mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
优化播放
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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">
|
||||
{/* 上一集 */}
|
||||
|
||||
@@ -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