mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 22:06:38 +08:00
优化字幕
优化字幕
This commit is contained in:
@@ -53,6 +53,8 @@ export function PlayerVideoStage({
|
|||||||
const stageRef = useRef<HTMLDivElement>(null)
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||||
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
|
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||||
|
|
||||||
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,51 +105,96 @@ export function PlayerVideoStage({
|
|||||||
else void stage.requestFullscreen?.()
|
else void stage.requestFullscreen?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把用户选择的字幕轨道应用到 <video> 的 textTracks(跨浏览器显式设置
|
// 自定义字幕驱动逻辑:
|
||||||
// mode;<track default> 只影响初始值,部分浏览器不会自动显示)。
|
// 把所选轨道设为 mode = 'hidden'(让浏览器在后台静默解析时间轴,但不渲染原生带黑底的字幕框),
|
||||||
//
|
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
|
||||||
// 通过 React 渲染的 <track> 元素定位而不是 textTracks 索引:hls.js 等
|
|
||||||
// 可能向 textTracks 注入内部轨道(如 CEA-608 captions),索引会错位。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || subs.length === 0) return
|
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
|
||||||
|
setActiveCueText('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||||
const delay = (fn: () => void, ms: number) => {
|
const delay = (fn: () => void, ms: number) => {
|
||||||
const id = setTimeout(fn, ms)
|
const id = setTimeout(fn, ms)
|
||||||
timers.add(id)
|
timers.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateCue = () => {
|
||||||
|
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||||
|
const selectedEl = trackEls[subtitleIndex]
|
||||||
|
const tt = selectedEl?.track
|
||||||
|
if (!tt) {
|
||||||
|
setActiveCueText('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tt.activeCues && tt.activeCues.length > 0) {
|
||||||
|
const texts: string[] = []
|
||||||
|
for (let i = 0; i < tt.activeCues.length; i++) {
|
||||||
|
const cue = tt.activeCues[i] as VTTCue
|
||||||
|
if (cue && cue.text) texts.push(cue.text)
|
||||||
|
}
|
||||||
|
setActiveCueText(texts.join('\n'))
|
||||||
|
} else {
|
||||||
|
setActiveCueText('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||||
if (trackEls.length === 0) return
|
if (trackEls.length === 0) return
|
||||||
trackEls.forEach((el, i) => {
|
trackEls.forEach((el, i) => {
|
||||||
const tt = el.track
|
const tt = el.track
|
||||||
if (tt) tt.mode = i === subtitleIndex ? 'showing' : 'disabled'
|
if (tt) {
|
||||||
|
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||||
|
tt.mode = i === subtitleIndex ? 'hidden' : 'disabled'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
// Chrome 把轨道从 disabled 切回 showing 时是异步重新拉取字幕;
|
|
||||||
// 若拉取因竞态没有发生(例如切换过程中视频重载),cues 会一直为空,
|
|
||||||
// 表现为「字幕已选中但不显示」。检测到空轨道后强制重载一次兜底。
|
|
||||||
const selected = trackEls[subtitleIndex]
|
const selected = trackEls[subtitleIndex]
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
|
|
||||||
|
const tt = selected.track
|
||||||
|
if (tt) {
|
||||||
|
tt.removeEventListener('cuechange', updateCue)
|
||||||
|
tt.addEventListener('cuechange', updateCue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 竞态兜底重载检测
|
||||||
delay(() => {
|
delay(() => {
|
||||||
const tt = selected.track
|
const curTt = selected.track
|
||||||
if (tt && tt.mode === 'showing' && (!tt.cues || tt.cues.length === 0)) {
|
if (curTt && curTt.mode === 'hidden' && (!curTt.cues || curTt.cues.length === 0)) {
|
||||||
const src = selected.getAttribute('src')
|
const src = selected.getAttribute('src')
|
||||||
if (src) {
|
if (src) {
|
||||||
selected.setAttribute('src', '')
|
selected.setAttribute('src', '')
|
||||||
selected.setAttribute('src', src)
|
selected.setAttribute('src', src)
|
||||||
tt.mode = 'showing'
|
curTt.mode = 'hidden'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1200)
|
}, 1200)
|
||||||
|
|
||||||
|
updateCue()
|
||||||
}
|
}
|
||||||
|
|
||||||
apply()
|
apply()
|
||||||
// 轨道元数据就绪后再应用一次,确保字幕真正可见
|
|
||||||
video.addEventListener('loadedmetadata', apply)
|
video.addEventListener('loadedmetadata', apply)
|
||||||
|
video.addEventListener('timeupdate', updateCue)
|
||||||
|
video.addEventListener('seeking', updateCue)
|
||||||
|
video.addEventListener('seeked', updateCue)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('loadedmetadata', apply)
|
video.removeEventListener('loadedmetadata', apply)
|
||||||
|
video.removeEventListener('timeupdate', updateCue)
|
||||||
|
video.removeEventListener('seeking', updateCue)
|
||||||
|
video.removeEventListener('seeked', updateCue)
|
||||||
timers.forEach(clearTimeout)
|
timers.forEach(clearTimeout)
|
||||||
|
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||||
|
const selected = trackEls[subtitleIndex]
|
||||||
|
if (selected?.track) {
|
||||||
|
selected.track.removeEventListener('cuechange', updateCue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [subtitleIndex, subs, videoRef])
|
}, [subtitleIndex, subs, videoRef])
|
||||||
|
|
||||||
@@ -213,6 +260,21 @@ export function PlayerVideoStage({
|
|||||||
onLoaded={onDanmakuLoaded}
|
onLoaded={onDanmakuLoaded}
|
||||||
onCandidates={onDanmakuCandidates}
|
onCandidates={onDanmakuCandidates}
|
||||||
/>
|
/>
|
||||||
|
{/* 自定义沉浸式字幕层:纯透明背景 + 柔和阴影,完全消除浏览器原生黑框 */}
|
||||||
|
{activeCueText ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-4 sm:bottom-6 md:bottom-8 z-10 flex justify-center text-center px-4">
|
||||||
|
<span
|
||||||
|
className="inline-block max-w-[92%] whitespace-pre-line text-center font-sans font-medium text-white text-base sm:text-lg md:text-xl lg:text-2xl select-none"
|
||||||
|
style={{
|
||||||
|
textShadow:
|
||||||
|
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
|
||||||
|
lineHeight: 1.35,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeCueText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<PlayerControls
|
<PlayerControls
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user