mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 04:26:36 +08:00
优化播放
This commit is contained in:
@@ -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> 的错误描述非常有限,把详细原因
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user