feat: keyboard seek, playback speed, and persist edited settings

Player
- Arrow keys seek by 5s, Shift for a 30s jump on long recordings, J/L for +-30s,
  Space toggles playback, and up/down adjust volume. Dragging a scrubber across a
  multi-hour replay is unusable — one pixel is tens of seconds — so keys are the
  practical way to move around a long recording.
- The shortcuts ignore key events from inputs, so typing a chapter time in the
  replay editor no longer scrubs the video.
- Add a playback speed selector. It is a replay tool: speeding a live stream up
  would only push it further ahead of the encoder's buffer.

Replay editor
- Stop playback restarting on every edit. The player's source effect depended on
  the `lines` and `qualities` arrays, and callers such as the replay editor pass
  fresh literals on every render, so adding a chapter or typing a title was
  treated as a source change and reloaded the stream from the beginning. The
  effect now depends on the resolved source URL instead of the arrays it came
  from. Measured: adding three chapters leaves the media requests unchanged and
  the playhead advancing.

Settings persistence
- Settings edited in the UI were applied to the in-memory settings object and
  never written down, so the brand, transcode ladder and load-balancing strategy
  silently reverted to the config.py defaults on every restart. They are now
  saved to backend/settings.json (atomic temp-file replace) and re-applied at
  startup, with only known keys accepted so a hand-edited file cannot inject
  arbitrary attributes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-29 12:44:33 +08:00
1 parent cdf591f933
commit 8e61dc111c
7 files changed
+232 -29

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ media/
*.key
# written by the admin node manager, not part of the source
backend/nodes.json
# administrator-edited settings (brand, transcode ladder) saved at runtime
backend/settings.json
# MediaMTX binaries and archives are fetched, not committed
mediamtx/mediamtx
+4
View File
@@ -21,6 +21,7 @@ from routes.settings_route import router as settings_router
from config import settings
from services.load_balancer import lb
from services.node_store import load_nodes
from services.settings_store import load_settings
async def _origin_paths() -> dict[str, dict]:
@@ -150,6 +151,9 @@ async def monitor_origin():
@asynccontextmanager
async def lifespan(app: FastAPI):
load_nodes()
# Must run before anything reads settings: it applies the administrator's saved
# overrides (brand, transcode ladder) on top of the config defaults.
load_settings()
await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True)
# Bring the media origin up before the transcoders: they immediately open RTMP
+4
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from config import settings
from services.settings_store import save_settings
from routes.auth import require_auth
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
@@ -83,6 +84,9 @@ async def update_strategy(body: StrategyUpdate, _: bool = Depends(require_auth))
settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled
# Persisted for the same reason as the brand: in-memory only meant a restart
# silently restored the defaults.
save_settings()
return {"ok": True}
+5
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from typing import Optional
from config import settings
from routes.auth import require_auth
from services.settings_store import save_settings
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -46,6 +47,9 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)):
settings.brand_tagline = body.brand_tagline
if body.brand_logo_url is not None:
settings.brand_logo_url = body.brand_logo_url
# Without this the change lives only in memory and the next restart silently
# reverts the platform back to the config.py defaults.
save_settings()
return {"ok": True}
@@ -88,6 +92,7 @@ async def update_transcode_profiles(
settings.transcode_profiles = normalized
settings.qualities = next(iter(normalized.values()))
save_settings()
return {"ok": True, "profiles": normalized}
+55
View File
@@ -0,0 +1,55 @@
"""Persist the settings an administrator edits in the UI.
config.py holds the defaults, but anything changed at runtime has to survive a
restart — otherwise editing the brand or the transcode ladder appears to work and
is silently reverted the next time the backend starts. Writes are atomic (temp
file + replace) so a crash mid-save cannot leave a half-written file that fails
to parse on boot.
"""
import json
from pathlib import Path
from config import settings
STORE_PATH = Path(__file__).resolve().parent.parent / "settings.json"
# Settings the UI may change, with the attribute they map to on Settings.
_MANAGED = (
"brand_name",
"brand_tagline",
"brand_logo_url",
"load_balance_strategy",
"multicast_enabled",
"transcode_profiles",
"qualities",
)
def load_settings() -> None:
"""Apply saved overrides on top of the config defaults. Never raises."""
if not STORE_PATH.exists():
return
try:
saved = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
# A corrupt file must not stop the server from booting; the defaults from
# config.py are still usable.
return
if not isinstance(saved, dict):
return
for key, value in saved.items():
# Only known keys, so a stale or hand-edited file cannot inject arbitrary
# attributes onto the settings object.
if key in _MANAGED and value is not None:
setattr(settings, key, value)
def save_settings() -> None:
payload = {key: getattr(settings, key) for key in _MANAGED}
temporary = STORE_PATH.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
temporary.replace(STORE_PATH)
+161 -28
View File
@@ -1,10 +1,12 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import Hls from 'hls.js'
import { Loader2, Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react'
import type { Chapter, LineInfo, QualityInfo } from '@/api/streams'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
/** Move the playhead by a delta, used by the keyboard shortcuts. */
seekBy: (seconds: number) => void
play: () => Promise<void>
pause: () => void
getVideo: () => HTMLVideoElement | null
@@ -24,6 +26,12 @@ interface VideoPlayerProps {
onError?: (message: string) => void
}
// A replay can run for hours, so dragging a scrubber to a precise offset is
// hopeless. Arrow keys jump by a fixed step and are the primary way to navigate;
// the multipliers make it bearable to cross a long recording.
const SEEK_STEP_SECONDS = 5
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return '00:00'
const value = Math.max(0, Math.floor(seconds))
@@ -68,6 +76,8 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
const [volume, setVolume] = useState(1)
const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
// Playback rate. Replays are often watched faster; live has no use for it.
const [speed, setSpeed] = useState(1)
// Autoplay gate: a browser that refuses to start playback on its own can only
// be unblocked by a real click, so the prompt has to be impossible to miss.
// `ready` keeps it hidden until there is actually something to play.
@@ -88,6 +98,35 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
const [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null)
// Jump the playhead by a delta, clamped to the media.
//
// Clamped rather than rejected so a jump that runs past an end lands on the
// boundary and still visibly does something, instead of being refused by the
// browser and appearing to be a dead key.
const seekBy = useCallback((delta: number) => {
const video = videoRef.current
if (!video) return
const upper = Number.isFinite(video.duration) && video.duration > 0
? video.duration
: video.currentTime + delta
video.currentTime = Math.min(Math.max(0, video.currentTime + delta), upper)
}, [])
const changeVolume = (value: number) => {
const video = videoRef.current
if (!video) return
video.volume = value
video.muted = value === 0
if (value > 0) setMutedAttempt(false)
setVolume(value)
}
const changeSpeed = (rate: number) => {
const video = videoRef.current
setSpeed(rate)
if (video) video.playbackRate = rate
}
// Refresh the source-option label from what is actually decoding. A component
// function so both the once-registered media listeners and the source-switching
// effect can call it.
@@ -176,6 +215,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
}
video.currentTime = target
},
seekBy,
async play() {
await videoRef.current?.play()
},
@@ -186,7 +226,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
return videoRef.current
},
}),
[],
[seekBy],
)
// 基础事件监听
@@ -317,6 +357,33 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
}
}, [playing])
// The exact playlist to load, resolved from the line/quality selection.
//
// Derived here as a *value* rather than read from `lines` inside the effect,
// because the effect tears down and rebuilds the media source whenever its
// dependencies change. Callers such as the replay editor pass fresh array
// literals on every render (`lines={[]}`), so depending on the arrays meant any
// re-render — typing a chapter title, adding a chapter — was treated as a source
// change and restarted playback from the beginning. A string cannot do that.
const liveSource = useMemo(() => {
if (!live) return ''
const line = lines.find((item) => item.node_id === activeLine)
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find((item) => item.label === label)
// Every quality URL is a single-rendition playlist, so this label is the only
// thing selecting a rendition; line.url is a bare /hls/<key>/index.m3u8 master
// that the transcoder never writes.
return quality?.url || line?.qualities?.[0]?.url || line?.url || line?.unicast_fallback || ''
}, [live, lines, activeLine, activeQuality, autoQuality])
// Whether this label is the source ("原画") rendition, which is advertised
// without dimensions and measured from the media instead.
const liveSourceIsSource = useMemo(() => {
if (!live) return false
const label = activeQuality === 'auto' ? autoQuality : activeQuality
return qualities.some((item) => (!item.width || !item.height) && item.label === label)
}, [live, qualities, activeQuality, autoQuality])
// 切换源并自动播放
useEffect(() => {
const video = videoRef.current
@@ -342,21 +409,11 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
video.load()
void attemptAutoplay(video)
} else if (live) {
const line = lines.find((item) => item.node_id === activeLine)
// "自动" resolves to the rendition the owner's ladder picked. Each quality URL
// is a single-rendition media playlist, so this label is the only thing
// selecting a rendition — falling through to line.url would request a master
// playlist that the transcoder never writes (the line's own url is bare
// /hls/<key>/index.m3u8, which only MediaMTX itself could have served).
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find((item) => item.label === label)
const source = quality?.url || line?.qualities?.[0]?.url || line?.url || line?.unicast_fallback
// The source rendition is the one advertised without dimensions, so its
// metadata is measured from the media rather than read off the API.
const sourceLabel = qualities.find((item) => !item.width || !item.height)?.label
// Every quality URL points at a single-rendition playlist, so no level list
// exists to name a level from the manifest; the selection decides it.
sourceMetaRef.current = { isSource: Boolean(sourceLabel) && label === sourceLabel }
// Resolved outside the effect so that re-renders with fresh prop arrays do
// not look like a source change; see liveSource above.
const source = liveSource
sourceMetaRef.current = { isSource: liveSourceIsSource }
const hasQualities = qualities.length > 0
const onFallbackLoaded = () => {
setLoading(false)
sourceBitrateRef.current = 0
@@ -406,7 +463,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
// level names cannot override it. This only covers a fallback playlist whose
// rendition list we never received.
hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => {
if (data.details.live && !qualities.length) onFallbackLoaded()
if (data.details.live && !hasQualities) onFallbackLoaded()
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
@@ -435,7 +492,11 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
hlsRef.current?.destroy()
hlsRef.current = null
}
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl, attemptAutoplay, measureSource])
// `liveSource` stands in for the line/quality selection (it is derived from
// them), and `qualities.length` stands in for the array itself — depending on
// `lines`/`qualities` directly would restart playback on every re-render of a
// caller that passes fresh literals.
}, [live, liveSource, liveSourceIsSource, onError, replayUrl, attemptAutoplay, measureSource, qualities.length])
const togglePlayback = () => {
const video = videoRef.current
@@ -452,6 +513,70 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
if (!live) video.pause()
}
// Keyboard navigation.
//
// Dragging a scrubber is unusable on a multi-hour replay: one pixel is tens of
// seconds, so a precise position cannot be hit at all. Arrow keys move by a
// fixed step, with Shift for a coarse jump, which is the practical way to
// navigate a long recording.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Never fight a form control: the replay editor has chapter title and time
// inputs, and typing a number there must not scrub the video.
const target = event.target as HTMLElement | null
const tag = target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return
if (event.metaKey || event.ctrlKey || event.altKey) return
// Anything longer than this in an hour-long video is the difference between
// useful and useless, so scale the coarse jump with the duration.
const duration = videoRef.current?.duration
const fine = SEEK_STEP_SECONDS
const coarse = Number.isFinite(duration) && (duration ?? 0) > 600 ? 30 : SEEK_STEP_SECONDS * 2
const step = event.shiftKey ? coarse : fine
switch (event.key) {
case 'ArrowLeft':
seekBy(-step)
setControlsVisible(true)
break
case 'ArrowRight':
seekBy(step)
setControlsVisible(true)
break
case ' ': {
// Space scrolls the page by default, which is never what is wanted.
event.preventDefault()
togglePlayback()
break
}
case 'j':
case 'J':
seekBy(-coarse)
break
case 'l':
case 'L':
seekBy(coarse)
break
case 'ArrowUp':
event.preventDefault()
changeVolume(Math.min(1, (videoRef.current?.volume ?? 1) + 0.1))
break
case 'ArrowDown':
event.preventDefault()
changeVolume(Math.max(0, (videoRef.current?.volume ?? 1) - 0.1))
break
default:
return
}
scheduleHide()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [seekBy, scheduleHide])
const toggleMute = () => {
const video = videoRef.current
if (!video) return
@@ -464,15 +589,6 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
if (video) video.currentTime = value
}
const changeVolume = (value: number) => {
const video = videoRef.current
if (!video) return
video.volume = value
video.muted = value === 0
if (value > 0) setMutedAttempt(false)
setVolume(value)
}
// 原画 是发布方原始流:分辨率与码率只有正在播放它时才测得到,所以先按"原始画质"占位。
const qualityOptions = qualities.map((quality) => {
const meta =
@@ -651,6 +767,23 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
className="h-1 w-16 cursor-pointer"
/>
</label>
{/* Playback rate is a replay tool: speeding a live stream up would just
race it further ahead of the encoder's buffer. */}
{!live && (
<select
aria-label="播放速度"
value={speed}
onChange={(e) => changeSpeed(Number(e.target.value))}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs tabular-nums"
>
{SPEED_OPTIONS.map((rate) => (
<option key={rate} value={rate} className="bg-zinc-900">
{rate}×
</option>
))}
</select>
)}
<span className="flex-1" />
{live && lines.length > 1 && (
+1 -1
View File
@@ -452,7 +452,7 @@ export default function WatchPage() {
>
<span>{chapter.title}</span>
<time className="tabular-nums text-muted-foreground">
{Math.floor(chapter.time / 60)}:{String(Math.round(chapter.time % 60)).padStart(2, '0')}
{(() => { const t = Math.round(chapter.time); return `${String(Math.floor(t / 3600)).padStart(2, '0')}:${String(Math.floor((t % 3600) / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`; })()}
</time>
</button>
))}