mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
支持 ASS 字幕原样渲染与字幕显示设置
This commit is contained in:
@@ -18,6 +18,14 @@ import {
|
||||
normalizeSubtitleChineseMode,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import {
|
||||
loadSubtitlePosition,
|
||||
loadSubtitleStyle,
|
||||
saveSubtitlePosition,
|
||||
saveSubtitleStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
@@ -63,6 +71,8 @@ export function PlayerPage() {
|
||||
useState<SubtitleChineseMode>(() =>
|
||||
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
|
||||
)
|
||||
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
|
||||
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
@@ -878,6 +888,16 @@ export function PlayerPage() {
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
|
||||
|
||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||
setSubtitlePosition(nextPosition)
|
||||
saveSubtitlePosition(nextPosition)
|
||||
}, [])
|
||||
|
||||
const changeSubtitleStyle = useCallback((nextStyle: SubtitleStylePreset) => {
|
||||
setSubtitleStyle(nextStyle)
|
||||
saveSubtitleStyle(nextStyle)
|
||||
}, [])
|
||||
|
||||
const danmakuAutoTitle =
|
||||
danmakuInfo?.animeTitle ||
|
||||
media?.original_name?.trim() ||
|
||||
@@ -910,6 +930,10 @@ export function PlayerPage() {
|
||||
onSelectSubtitle={selectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={changeSubtitleChineseMode}
|
||||
subtitlePosition={subtitlePosition}
|
||||
onSubtitlePositionChange={changeSubtitlePosition}
|
||||
subtitleStyle={subtitleStyle}
|
||||
onSubtitleStyleChange={changeSubtitleStyle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
@@ -8,55 +8,126 @@ import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
import {
|
||||
subtitleTextStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleCue = {
|
||||
startTime: number
|
||||
endTime: number
|
||||
type SubtitleRenderGroup = {
|
||||
key: string
|
||||
text: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
function parseVTTTimestamp(value: string): number {
|
||||
const parts = value.trim().replace(',', '.').split(':')
|
||||
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
|
||||
const seconds = Number(parts.pop())
|
||||
const minutes = Number(parts.pop())
|
||||
const hours = parts.length > 0 ? Number(parts.pop()) : 0
|
||||
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: SubtitleCue[] = []
|
||||
for (const cue of cues) {
|
||||
const key = `${cue.startTime}\u0000${cue.endTime}\u0000${JSON.stringify(cue.settings)}\u0000${cue.text}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
unique.push(cue)
|
||||
}
|
||||
// WebVTT can contain more than two simultaneous tracks after ASS conversion.
|
||||
// Keep the last two visual blocks, matching the previous bilingual behavior.
|
||||
return unique.slice(-2)
|
||||
}
|
||||
|
||||
function parseWebVTTCues(body: string): SubtitleCue[] {
|
||||
const blocks = body
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
const cues: SubtitleCue[] = []
|
||||
function parsePercent(value: string | undefined): number | null {
|
||||
if (!value || !value.endsWith('%')) return null
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? Math.min(100, Math.max(0, parsed)) : null
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
const timingIndex = lines.findIndex((line) => line.includes('-->'))
|
||||
if (timingIndex < 0) continue
|
||||
function cueLineBottom(value: string | undefined): number | null {
|
||||
const percent = parsePercent(value)
|
||||
if (percent !== null) return Math.min(92, Math.max(2, 100 - percent))
|
||||
if (value === undefined) return null
|
||||
const line = Number.parseFloat(value)
|
||||
if (!Number.isFinite(line)) return null
|
||||
if (line < 0) return Math.min(92, Math.max(2, 6 - Math.abs(line) * 2))
|
||||
return Math.min(92, Math.max(2, 7 + line * 8))
|
||||
}
|
||||
|
||||
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
|
||||
const startTime = parseVTTTimestamp(rawStart)
|
||||
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
|
||||
const text = lines.slice(timingIndex + 1).join('\n').trim()
|
||||
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
|
||||
cues.push({ startTime, endTime, text })
|
||||
}
|
||||
function cueTextAlign(cue: SubtitleCue): CSSProperties['textAlign'] {
|
||||
const align = cue.settings.align
|
||||
if (align === 'left' || align === 'start') return 'left'
|
||||
if (align === 'right' || align === 'end') return 'right'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
function cuePlacementStyle(cue: SubtitleCue, position: SubtitlePosition): CSSProperties {
|
||||
const style: CSSProperties = {
|
||||
maxWidth: `${Math.min(cue.settings.size ?? 92, 94)}%`,
|
||||
textAlign: cueTextAlign(cue),
|
||||
}
|
||||
|
||||
return cues
|
||||
if (cue.settings.vertical) {
|
||||
style.writingMode = cue.settings.vertical === 'lr' ? 'vertical-lr' : 'vertical-rl'
|
||||
}
|
||||
|
||||
if (position !== 'auto') {
|
||||
style.left = '50%'
|
||||
style.textAlign = 'center'
|
||||
if (position === 'bottom') {
|
||||
style.bottom = '5%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
} else if (position === 'lower') {
|
||||
style.bottom = '13%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
} else if (position === 'middle') {
|
||||
style.top = '50%'
|
||||
style.transform = 'translate(-50%, -50%)'
|
||||
} else {
|
||||
style.top = '7%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
const explicitPosition = cue.settings.position
|
||||
const align = cue.settings.align
|
||||
if (explicitPosition !== undefined) {
|
||||
if (align === 'end' || align === 'right') {
|
||||
style.right = `${100 - explicitPosition}%`
|
||||
} else {
|
||||
style.left = `${explicitPosition}%`
|
||||
if (align !== 'start' && align !== 'left') style.transform = 'translateX(-50%)'
|
||||
}
|
||||
} else {
|
||||
style.left = '50%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
}
|
||||
|
||||
const bottom = cueLineBottom(cue.settings.line)
|
||||
if (bottom !== null) style.bottom = `${bottom}%`
|
||||
else style.bottom = '7%'
|
||||
return style
|
||||
}
|
||||
|
||||
function uniqueSubtitleTexts(texts: string[]): string[] {
|
||||
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
|
||||
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
|
||||
// simultaneous blocks for bilingual subtitles; use libass if full ASS
|
||||
// typesetting support is added later.
|
||||
return unique.slice(-2)
|
||||
function buildSubtitleRenderGroups(
|
||||
cues: SubtitleCue[],
|
||||
position: SubtitlePosition,
|
||||
): SubtitleRenderGroup[] {
|
||||
const groups = new Map<string, SubtitleRenderGroup>()
|
||||
for (const cue of cues) {
|
||||
const style = cuePlacementStyle(cue, position)
|
||||
const key = JSON.stringify(style)
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
if (!existing.text.split('\n').includes(cue.text)) {
|
||||
existing.text += `\n${cue.text}`
|
||||
}
|
||||
continue
|
||||
}
|
||||
groups.set(key, { key, text: cue.text, style })
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
@@ -70,6 +141,10 @@ type PlayerVideoStageProps = {
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
subtitlePosition: SubtitlePosition
|
||||
onSubtitlePositionChange: (position: SubtitlePosition) => void
|
||||
subtitleStyle: SubtitleStylePreset
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
@@ -110,6 +185,10 @@ export function PlayerVideoStage({
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
subtitlePosition,
|
||||
onSubtitlePositionChange,
|
||||
subtitleStyle,
|
||||
onSubtitleStyleChange,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
@@ -144,7 +223,7 @@ export function PlayerVideoStage({
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const revealControlsOnlyRef = useRef(false)
|
||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||
const [subtitleChineseConverter, setSubtitleChineseConverter] =
|
||||
useState<(text: string) => string>(() => (text: string) => text)
|
||||
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
||||
@@ -155,6 +234,14 @@ export function PlayerVideoStage({
|
||||
} | null>(null)
|
||||
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
||||
const [tracksArmed, setTracksArmed] = useState(false)
|
||||
// libass/WASM is optional at runtime. If it fails, ASS tracks fall back to
|
||||
// the server's WebVTT conversion so playback still has visible subtitles.
|
||||
const [assFallbackPath, setAssFallbackPath] = useState<string | null>(null)
|
||||
const activeSubtitleTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
setAssFallbackPath(null)
|
||||
}, [media?.id, activeSubtitleTrack?.path, subs])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
@@ -201,7 +288,10 @@ export function PlayerVideoStage({
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
|
||||
const useWebVTT =
|
||||
selectedTrack?.delivery === 'webvtt' ||
|
||||
(selectedTrack?.delivery === 'ass' && assFallbackPath === selectedTrack.path)
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || !useWebVTT) {
|
||||
setSubtitleTimeline(null)
|
||||
return
|
||||
}
|
||||
@@ -227,7 +317,7 @@ export function PlayerVideoStage({
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [media, subs, subtitleIndex])
|
||||
}, [assFallbackPath, media, subs, subtitleIndex])
|
||||
|
||||
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
||||
useEffect(() => {
|
||||
@@ -303,21 +393,22 @@ export function PlayerVideoStage({
|
||||
subs.length === 0 ||
|
||||
subtitleIndex < 0 ||
|
||||
!selectedTrack ||
|
||||
selectedTrack.delivery === 'burn'
|
||||
(selectedTrack.delivery !== 'webvtt' &&
|
||||
!(selectedTrack.delivery === 'ass' && assFallbackPath === selectedTrack.path))
|
||||
) {
|
||||
setActiveCueText('')
|
||||
setActiveCues([])
|
||||
return
|
||||
}
|
||||
|
||||
const updateCue = () => {
|
||||
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
||||
if (subtitleTimeline?.path === selectedTrack.path) {
|
||||
const texts = uniqueSubtitleTexts(
|
||||
subtitleTimeline.cues
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text),
|
||||
)
|
||||
setActiveCueText(subtitleChineseConverter(texts.join('\n')))
|
||||
const cues = uniqueSubtitleCues(
|
||||
subtitleTimeline.cues.filter(
|
||||
(cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime,
|
||||
),
|
||||
).map((cue) => ({ ...cue, text: subtitleChineseConverter(cue.text) }))
|
||||
setActiveCues(cues)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -326,17 +417,19 @@ export function PlayerVideoStage({
|
||||
)
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
setActiveCues([])
|
||||
return
|
||||
}
|
||||
|
||||
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
|
||||
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
|
||||
const texts: string[] = []
|
||||
const cues: SubtitleCue[] = []
|
||||
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)
|
||||
if (cue && cue.text) {
|
||||
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
|
||||
}
|
||||
}
|
||||
} else if (tt.cues && tt.cues.length > 0) {
|
||||
for (let i = 0; i < tt.cues.length; i++) {
|
||||
@@ -347,11 +440,16 @@ export function PlayerVideoStage({
|
||||
absoluteTime <= cue.endTime &&
|
||||
cue.text
|
||||
) {
|
||||
texts.push(cue.text)
|
||||
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
setActiveCueText(subtitleChineseConverter(uniqueSubtitleTexts(texts).join('\n')))
|
||||
setActiveCues(
|
||||
uniqueSubtitleCues(cues).map((cue) => ({
|
||||
...cue,
|
||||
text: subtitleChineseConverter(cue.text),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
@@ -415,6 +513,7 @@ export function PlayerVideoStage({
|
||||
subtitleTimeline,
|
||||
tracksArmed,
|
||||
subtitleChineseConverter,
|
||||
assFallbackPath,
|
||||
])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
@@ -423,6 +522,7 @@ export function PlayerVideoStage({
|
||||
? videoRatio > stageRect.width / stageRect.height
|
||||
: true
|
||||
|
||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const wrapperStyle = videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
@@ -459,8 +559,11 @@ export function PlayerVideoStage({
|
||||
onError={onVideoError}
|
||||
>
|
||||
{tracksArmed &&
|
||||
subs.map((track, index) =>
|
||||
track.delivery === 'burn' ? null : (
|
||||
subs.map((track, index) => {
|
||||
const renderAsFallback =
|
||||
track.delivery === 'ass' && assFallbackPath === track.path
|
||||
if (track.delivery !== 'webvtt' && !renderAsFallback) return null
|
||||
return (
|
||||
<track
|
||||
key={track.path}
|
||||
data-subtitle-index={index}
|
||||
@@ -469,8 +572,8 @@ export function PlayerVideoStage({
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
key={media.id}
|
||||
@@ -486,19 +589,40 @@ export function PlayerVideoStage({
|
||||
onLoaded={onDanmakuLoaded}
|
||||
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>
|
||||
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (
|
||||
<AssSubtitleStage
|
||||
key={`${media.id}:${assTrack.path}`}
|
||||
videoRef={videoRef}
|
||||
source={subtitlesAPI.assUrl(media.id, assTrack.path)}
|
||||
timeOffset={streamOffset}
|
||||
chineseMode={assTrack.source === 'external' ? subtitleChineseMode : 'original'}
|
||||
onError={(error) => {
|
||||
console.warn('libass subtitle rendering failed; falling back to WebVTT', error)
|
||||
setAssFallbackPath(assTrack.path)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* SRT/VTT 自定义字幕层:按 cue 位置对齐,并支持用户样式覆盖。 */}
|
||||
{activeCues.length > 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10">
|
||||
{buildSubtitleRenderGroups(activeCues, subtitlePosition).map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="absolute flex px-4"
|
||||
style={group.style}
|
||||
>
|
||||
<span
|
||||
className="inline-block whitespace-pre-line font-sans text-white select-none"
|
||||
style={{
|
||||
...subtitleTextStyle(subtitleStyle),
|
||||
fontSize: 'clamp(16px, 2.1vw, 30px)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{group.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -511,6 +635,10 @@ export function PlayerVideoStage({
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
|
||||
subtitlePosition={subtitlePosition}
|
||||
onSubtitlePositionChange={onSubtitlePositionChange}
|
||||
subtitleStyle={subtitleStyle}
|
||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
|
||||
Reference in New Issue
Block a user