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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in new issue
Block a user