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:
1 parent
cdf591f933
commit
8e61dc111c
7 files changed
+232
-29
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user