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 *.key
# written by the admin node manager, not part of the source # written by the admin node manager, not part of the source
backend/nodes.json backend/nodes.json
# administrator-edited settings (brand, transcode ladder) saved at runtime
backend/settings.json
# MediaMTX binaries and archives are fetched, not committed # MediaMTX binaries and archives are fetched, not committed
mediamtx/mediamtx mediamtx/mediamtx
+4
View File
@@ -21,6 +21,7 @@ from routes.settings_route import router as settings_router
from config import settings from config import settings
from services.load_balancer import lb from services.load_balancer import lb
from services.node_store import load_nodes from services.node_store import load_nodes
from services.settings_store import load_settings
async def _origin_paths() -> dict[str, dict]: async def _origin_paths() -> dict[str, dict]:
@@ -150,6 +151,9 @@ async def monitor_origin():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
load_nodes() 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() await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True) os.makedirs(settings.hls_runtime_dir, exist_ok=True)
# Bring the media origin up before the transcoders: they immediately open RTMP # 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 pydantic import BaseModel
from typing import Optional from typing import Optional
from config import settings from config import settings
from services.settings_store import save_settings
from routes.auth import require_auth from routes.auth import require_auth
from services.load_balancer import lb from services.load_balancer import lb
from services.multicast_detector import multicast_detector 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 settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None: if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled 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} return {"ok": True}
+5
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from config import settings from config import settings
from routes.auth import require_auth from routes.auth import require_auth
from services.settings_store import save_settings
router = APIRouter(prefix="/api/settings", tags=["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 settings.brand_tagline = body.brand_tagline
if body.brand_logo_url is not None: if body.brand_logo_url is not None:
settings.brand_logo_url = body.brand_logo_url 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} return {"ok": True}
@@ -88,6 +92,7 @@ async def update_transcode_profiles(
settings.transcode_profiles = normalized settings.transcode_profiles = normalized
settings.qualities = next(iter(normalized.values())) settings.qualities = next(iter(normalized.values()))
save_settings()
return {"ok": True, "profiles": normalized} 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 Hls from 'hls.js'
import { Loader2, Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react' import { Loader2, Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react'
import type { Chapter, LineInfo, QualityInfo } from '@/api/streams' import type { Chapter, LineInfo, QualityInfo } from '@/api/streams'
export interface VideoPlayerHandle { export interface VideoPlayerHandle {
seek: (seconds: number) => void seek: (seconds: number) => void
/** Move the playhead by a delta, used by the keyboard shortcuts. */
seekBy: (seconds: number) => void
play: () => Promise<void> play: () => Promise<void>
pause: () => void pause: () => void
getVideo: () => HTMLVideoElement | null getVideo: () => HTMLVideoElement | null
@@ -24,6 +26,12 @@ interface VideoPlayerProps {
onError?: (message: string) => void 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) { function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return '00:00' if (!Number.isFinite(seconds)) return '00:00'
const value = Math.max(0, Math.floor(seconds)) 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 [volume, setVolume] = useState(1)
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = 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 // 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. // 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. // `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 [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null) 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 // Refresh the source-option label from what is actually decoding. A component
// function so both the once-registered media listeners and the source-switching // function so both the once-registered media listeners and the source-switching
// effect can call it. // effect can call it.
@@ -176,6 +215,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
} }
video.currentTime = target video.currentTime = target
}, },
seekBy,
async play() { async play() {
await videoRef.current?.play() await videoRef.current?.play()
}, },
@@ -186,7 +226,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
return videoRef.current return videoRef.current
}, },
}), }),
[], [seekBy],
) )
// 基础事件监听 // 基础事件监听
@@ -317,6 +357,33 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
} }
}, [playing]) }, [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(() => { useEffect(() => {
const video = videoRef.current const video = videoRef.current
@@ -342,21 +409,11 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
video.load() video.load()
void attemptAutoplay(video) void attemptAutoplay(video)
} else if (live) { } else if (live) {
const line = lines.find((item) => item.node_id === activeLine) // Resolved outside the effect so that re-renders with fresh prop arrays do
// "自动" resolves to the rendition the owner's ladder picked. Each quality URL // not look like a source change; see liveSource above.
// is a single-rendition media playlist, so this label is the only thing const source = liveSource
// selecting a rendition — falling through to line.url would request a master sourceMetaRef.current = { isSource: liveSourceIsSource }
// playlist that the transcoder never writes (the line's own url is bare const hasQualities = qualities.length > 0
// /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 }
const onFallbackLoaded = () => { const onFallbackLoaded = () => {
setLoading(false) setLoading(false)
sourceBitrateRef.current = 0 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 // level names cannot override it. This only covers a fallback playlist whose
// rendition list we never received. // rendition list we never received.
hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => { 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) => { hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) { if (data.fatal) {
@@ -435,7 +492,11 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
hlsRef.current?.destroy() hlsRef.current?.destroy()
hlsRef.current = null 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 togglePlayback = () => {
const video = videoRef.current const video = videoRef.current
@@ -452,6 +513,70 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
if (!live) video.pause() 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 toggleMute = () => {
const video = videoRef.current const video = videoRef.current
if (!video) return if (!video) return
@@ -464,15 +589,6 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
if (video) video.currentTime = value 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 qualityOptions = qualities.map((quality) => {
const meta = const meta =
@@ -651,6 +767,23 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
className="h-1 w-16 cursor-pointer" className="h-1 w-16 cursor-pointer"
/> />
</label> </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" /> <span className="flex-1" />
{live && lines.length > 1 && ( {live && lines.length > 1 && (
+1 -1
View File
@@ -452,7 +452,7 @@ export default function WatchPage() {
> >
<span>{chapter.title}</span> <span>{chapter.title}</span>
<time className="tabular-nums text-muted-foreground"> <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> </time>
</button> </button>
))} ))}