支持 ASS 字幕原样渲染与字幕显示设置

This commit is contained in:
truewhile
2026-09-11 19:35:08 +08:00
parent e840cbd2c9
commit 0f06bdf929
19 changed files with 874 additions and 76 deletions
+24
View File
@@ -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}
+197 -69
View File
@@ -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}