优化播放

This commit is contained in:
truewhile
2026-09-08 09:24:29 +08:00
parent fdfb2ee987
commit 4d698d2757
12 changed files with 469 additions and 89 deletions
+6 -2
View File
@@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string {
// hlsURL returns the m3u8 playlist URL fed into hls.js.
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0): string {
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
@@ -155,7 +155,11 @@ export function hlsURL(mediaId: string, startSec = 0): string {
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
// requests must not cancel the active ffmpeg job back to t=0.
const bust = `&_seek=${Date.now()}`
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${bust}`
const subtitle =
subtitleStream !== undefined && subtitleStream >= 0
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
+3
View File
@@ -7,6 +7,9 @@ export interface SubtitleTrack {
path: string
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
stream_index?: number
}
export const subtitlesAPI = {
+13 -9
View File
@@ -30,6 +30,8 @@ function formatTime(s: number): string {
type PlayerControlsProps = {
videoRef: React.RefObject<HTMLVideoElement>
uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void
subs: SubtitleTrack[]
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
@@ -56,6 +58,8 @@ type PlayerControlsProps = {
export function PlayerControls({
videoRef,
uiVisible,
onUiVisibleChange,
subs,
subtitleIndex,
onSelectSubtitle,
@@ -88,7 +92,6 @@ export function PlayerControls({
const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
const [pip, setPip] = useState(false)
const [uiVisible, setUiVisible] = useState(true)
const [controlsHovered, setControlsHovered] = useState(false)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubValue, setScrubValue] = useState<number | null>(null)
@@ -159,14 +162,14 @@ export function PlayerControls({
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
setUiVisible(false)
onUiVisibleChange(false)
}
}, 3000)
}
}
const onMove = () => {
setUiVisible(true)
onUiVisibleChange(true)
resetTimer()
}
@@ -177,7 +180,7 @@ export function PlayerControls({
}
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
setUiVisible(false)
onUiVisibleChange(false)
}
const syncPlay = () => {
@@ -248,7 +251,7 @@ export function PlayerControls({
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef, knownDuration, streamOffset])
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
// Keep the scrubber max in sync when metadata duration arrives after mount.
useEffect(() => {
@@ -260,16 +263,16 @@ export function PlayerControls({
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
setUiVisible(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
} else {
const el = video()
if (el && !el.paused) {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
const togglePlay = () => {
const el = video()
@@ -301,7 +304,7 @@ export function PlayerControls({
const handleSeekStart = () => {
setIsScrubbing(true)
setUiVisible(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
@@ -361,6 +364,7 @@ export function PlayerControls({
onMouseEnter={() => setControlsHovered(true)}
onMouseLeave={() => setControlsHovered(false)}
onClick={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
{/* 上一集 */}
+54 -30
View File
@@ -33,23 +33,6 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
const SUBTITLE_STORAGE_KEY = 'mebox.subtitle'
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
// 默认 0(自动加载第一条字幕)。
function initialSubtitleIndex(): number {
try {
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
if (saved !== null && saved !== '') {
const n = parseInt(saved, 10)
if (Number.isFinite(n)) return n
}
} catch {
// ignore
}
return 0
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -63,13 +46,14 @@ export function PlayerPage() {
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
const [loadError, setLoadError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
const [resumePosition, setResumePosition] = useState(0)
const [initialSeekDone, setInitialSeekDone] = useState(false)
// HLS session source offset: playlist t=0 maps to this absolute second.
@@ -121,6 +105,7 @@ export function PlayerPage() {
.info()
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
.catch(() => setDirectOnly(false))
.finally(() => setDirectOnlyKnown(true))
}, [])
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
@@ -225,9 +210,8 @@ export function PlayerPage() {
if (cancelled) return
const list = tracks ?? []
setSubs(list)
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
// 越界时回退到第一条;无字幕则关闭。
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
// 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。
setSubtitleIndex(list.length > 0 ? 0 : -1)
})
.catch(() => {
if (cancelled) return
@@ -242,6 +226,9 @@ export function PlayerPage() {
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const burnedSubtitleStream =
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
const mediaRef = useRef(media)
mediaRef.current = media
useEffect(() => {
@@ -254,7 +241,7 @@ export function PlayerPage() {
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
const url = hlsURL(mediaId, hlsStartSec)
const url = hlsURL(mediaId, hlsStartSec, burnedSubtitleStream)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
@@ -323,7 +310,7 @@ export function PlayerPage() {
cancelled = true
teardownHls()
}
}, [hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, teardownHls])
}, [burnedSubtitleStream, hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, teardownHls])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
@@ -600,6 +587,31 @@ export function PlayerPage() {
const isDirectStream = isDirectStreamMedia(media)
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
useEffect(() => {
if (
!directOnlyKnown ||
directOnly ||
isDirectStream ||
selectedSubtitle?.delivery !== 'burn' ||
mode === 'hls'
) {
return
}
setHlsStartSec(ref.current?.currentTime || 0)
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
}, [
directOnly,
directOnlyKnown,
isDirectStream,
mode,
params,
selectedSubtitle?.delivery,
setParams,
])
const toggleMode = useCallback(() => {
if (isDirectStream) {
toast('该媒体为直连播放,无需且不支持转码')
@@ -646,15 +658,27 @@ export function PlayerPage() {
[hlsStartSec, mode],
)
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
const selectSubtitle = useCallback((index: number) => {
setSubtitleIndex(index)
try {
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
} catch {
// ignore
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const nextTrack = index >= 0 ? subs[index] : undefined
if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) {
toast.error('图片字幕需要开启 HLS 转码后才能显示')
return
}
}, [])
const burnChanged =
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
if (burnChanged && mode === 'hls' && ref.current) {
setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0))
}
setSubtitleIndex(index)
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
setHlsStartSec(ref.current?.currentTime || 0)
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
}
}, [directOnly, hlsStartSec, isDirectStream, mode, params, setParams, subs, subtitleIndex])
const handleVideoError = useCallback(() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
+49 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import type { PointerEvent, ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
@@ -85,6 +85,8 @@ export function PlayerVideoStage({
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
const [controlsVisible, setControlsVisible] = useState(true)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCueText, setActiveCueText] = useState<string>('')
@@ -123,13 +125,26 @@ export function PlayerVideoStage({
}
}, [videoRef, media])
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
// 桌面端点击直接切换播放;移动端控制栏隐藏时首次轻触只唤出控制栏,
// 控制栏已显示时再次轻触才切换播放/暂停。
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play()?.catch(() => undefined)
else video.pause()
}
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
}
const handleStageClick = () => {
if (revealControlsOnlyRef.current) {
revealControlsOnlyRef.current = false
setControlsVisible(true)
return
}
revealControlsOnlyRef.current = false
togglePlay()
}
const toggleFullscreen = () => {
const stage = stageRef.current
if (!stage) return
@@ -142,15 +157,22 @@ export function PlayerVideoStage({
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
useEffect(() => {
const video = videoRef.current
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
const selectedTrack = subs[subtitleIndex]
if (
!video ||
subs.length === 0 ||
subtitleIndex < 0 ||
!selectedTrack ||
selectedTrack.delivery === 'burn'
) {
setActiveCueText('')
return
}
const trackIdx = subtitleIndex
const updateCue = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
const selectedEl = trackEls[trackIdx]
const selectedEl = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
const tt = selectedEl?.track
if (!tt) {
setActiveCueText('')
@@ -160,13 +182,13 @@ export function PlayerVideoStage({
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
const texts: string[] = []
if (tt.activeCues && tt.activeCues.length > 0) {
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
for (let i = 0; i < tt.activeCues.length; i++) {
const cue = tt.activeCues[i] as VTTCue
if (cue && cue.text) texts.push(cue.text)
}
} else if (tt.cues && tt.cues.length > 0) {
const cur = video.currentTime
const cur = video.currentTime + (streamOffset ?? 0)
for (let i = 0; i < tt.cues.length; i++) {
const cue = tt.cues[i] as VTTCue
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
@@ -180,15 +202,18 @@ export function PlayerVideoStage({
const apply = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
if (trackEls.length === 0) return
trackEls.forEach((el, i) => {
trackEls.forEach((el) => {
const tt = el.track
if (tt) {
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
tt.mode =
el.dataset.subtitleIndex === String(subtitleIndex) ? 'hidden' : 'disabled'
}
})
const selected = trackEls[trackIdx]
const selected = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
if (!selected) return
const tt = selected.track
@@ -216,8 +241,9 @@ export function PlayerVideoStage({
video.removeEventListener('seeking', updateCue)
video.removeEventListener('seeked', updateCue)
video.removeEventListener('playing', updateCue)
const trackEls = Array.from(video.querySelectorAll('track'))
const selected = trackEls[trackIdx]
const selected = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
if (selected) {
selected.removeEventListener('load', updateCue)
if (selected.track) {
@@ -225,7 +251,7 @@ export function PlayerVideoStage({
}
}
}
}, [subtitleIndex, subs, videoRef, media])
}, [subtitleIndex, subs, videoRef, media, streamOffset])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
const isWiderThanStage =
@@ -251,7 +277,8 @@ export function PlayerVideoStage({
ref={stageRef}
data-player-stage
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
onClick={togglePlay}
onPointerDown={handleStagePointerDown}
onClick={handleStageClick}
onDoubleClick={toggleFullscreen}
>
{media ? (
@@ -267,16 +294,19 @@ export function PlayerVideoStage({
className="h-full w-full object-contain bg-black"
onError={onVideoError}
>
{subs.map((track, index) => (
{subs.map((track, index) =>
track.delivery === 'burn' ? null : (
<track
key={track.path}
data-subtitle-index={index}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={subtitleIndex === index}
/>
))}
),
)}
</video>
<DanmakuStage
key={media.id}
@@ -310,6 +340,8 @@ export function PlayerVideoStage({
</div>
<PlayerControls
videoRef={videoRef}
uiVisible={controlsVisible}
onUiVisibleChange={setControlsVisible}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}