From 8e61dc111cd67cbc60e68fd21e378b02246448e8 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 29 Sep 2026 12:44:33 +0800 Subject: [PATCH] feat: keyboard seek, playback speed, and persist edited settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 2 + backend/main.py | 4 + backend/routes/nodes.py | 4 + backend/routes/settings_route.py | 5 + backend/services/settings_store.py | 55 +++++ .../src/viewer/components/VideoPlayer.tsx | 189 +++++++++++++++--- frontend/src/viewer/pages/WatchPage.tsx | 2 +- 7 files changed, 232 insertions(+), 29 deletions(-) create mode 100644 backend/services/settings_store.py diff --git a/.gitignore b/.gitignore index d4f34ce..cd53f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/main.py b/backend/main.py index 2ba112c..a94802e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/routes/nodes.py b/backend/routes/nodes.py index 6cbe7b7..e1cf8c9 100644 --- a/backend/routes/nodes.py +++ b/backend/routes/nodes.py @@ -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} diff --git a/backend/routes/settings_route.py b/backend/routes/settings_route.py index ee1cd83..d7e5a0f 100644 --- a/backend/routes/settings_route.py +++ b/backend/routes/settings_route.py @@ -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} diff --git a/backend/services/settings_store.py b/backend/services/settings_store.py new file mode 100644 index 0000000..969aa69 --- /dev/null +++ b/backend/services/settings_store.py @@ -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) diff --git a/frontend/src/viewer/components/VideoPlayer.tsx b/frontend/src/viewer/components/VideoPlayer.tsx index 8a54aff..0027473 100644 --- a/frontend/src/viewer/components/VideoPlayer.tsx +++ b/frontend/src/viewer/components/VideoPlayer.tsx @@ -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 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(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(function Vid const [controlsVisible, setControlsVisible] = useState(true) const hideTimerRef = useRef(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(function Vid } video.currentTime = target }, + seekBy, async play() { await videoRef.current?.play() }, @@ -186,7 +226,7 @@ const VideoPlayer = forwardRef(function Vid return videoRef.current }, }), - [], + [seekBy], ) // 基础事件监听 @@ -317,6 +357,33 @@ const VideoPlayer = forwardRef(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//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(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//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(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(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(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(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(function Vid className="h-1 w-16 cursor-pointer" /> + + {/* Playback rate is a replay tool: speeding a live stream up would just + race it further ahead of the encoder's buffer. */} + {!live && ( + + )} {live && lines.length > 1 && ( diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx index 058817b..66da751 100644 --- a/frontend/src/viewer/pages/WatchPage.tsx +++ b/frontend/src/viewer/pages/WatchPage.tsx @@ -452,7 +452,7 @@ export default function WatchPage() { > {chapter.title} ))}