mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
支持 ASS 字幕原样渲染与字幕显示设置
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import JASSUB from 'jassub'
|
||||
import jassubWorkerUrl from 'jassub/dist/worker/worker.js?worker&url'
|
||||
import jassubWasmUrl from 'jassub/dist/wasm/jassub-worker.wasm?url'
|
||||
import jassubModernWasmUrl from 'jassub/dist/wasm/jassub-worker-modern.wasm?url'
|
||||
import notoSansSCUrl from '../assets/fonts/noto-sans-sc-regular.woff2?url'
|
||||
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { convertASSContent } from '../utils/subtitleASS'
|
||||
|
||||
type AssSubtitleStageProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
source: string
|
||||
timeOffset?: number
|
||||
chineseMode?: SubtitleChineseMode
|
||||
onReady?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* libass-wasm subtitle layer for external and embedded ASS/SSA tracks. JASSUB
|
||||
* owns its canvas and attaches it next to the video, so this component itself
|
||||
* renders no visible DOM nodes.
|
||||
*/
|
||||
export function AssSubtitleStage({
|
||||
videoRef,
|
||||
source,
|
||||
timeOffset = 0,
|
||||
chineseMode = 'original',
|
||||
onReady,
|
||||
onError,
|
||||
}: AssSubtitleStageProps) {
|
||||
const instanceRef = useRef<JASSUB | null>(null)
|
||||
const timeOffsetRef = useRef(timeOffset)
|
||||
const callbacksRef = useRef({ onReady, onError })
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onReady, onError }
|
||||
}, [onReady, onError])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !source) return
|
||||
|
||||
let cancelled = false
|
||||
let instance: JASSUB | null = null
|
||||
const controller = new AbortController()
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [response, converter] = await Promise.all([
|
||||
fetch(source, { signal: controller.signal }),
|
||||
loadSubtitleChineseConverter(chineseMode).catch(() => (text: string) => text),
|
||||
])
|
||||
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
|
||||
const body = await response.text()
|
||||
if (!body.trim()) throw new Error('empty subtitle payload')
|
||||
if (cancelled) return
|
||||
|
||||
instance = new JASSUB({
|
||||
video,
|
||||
subContent: convertASSContent(body, converter),
|
||||
timeOffset: timeOffsetRef.current,
|
||||
workerUrl: jassubWorkerUrl,
|
||||
wasmUrl: jassubWasmUrl,
|
||||
modernWasmUrl: jassubModernWasmUrl,
|
||||
fonts: [notoSansSCUrl],
|
||||
defaultFont: 'Noto Sans SC',
|
||||
queryFonts: 'local',
|
||||
prescaleFactor: 1,
|
||||
})
|
||||
instanceRef.current = instance
|
||||
await instance.ready
|
||||
if (cancelled) {
|
||||
void instance.destroy().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
callbacksRef.current.onReady?.()
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
callbacksRef.current.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
instanceRef.current = null
|
||||
if (instance) void instance.destroy().catch(() => undefined)
|
||||
}
|
||||
// timeOffset is applied live below. Recreating the full libass worker on a
|
||||
// seek offset change would be unnecessarily expensive.
|
||||
}, [source, chineseMode, videoRef])
|
||||
|
||||
useEffect(() => {
|
||||
timeOffsetRef.current = timeOffset
|
||||
if (instanceRef.current) instanceRef.current.timeOffset = timeOffset
|
||||
}, [timeOffset])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||
import {
|
||||
SUBTITLE_POSITION_OPTIONS,
|
||||
SUBTITLE_STYLE_OPTIONS,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
@@ -39,6 +45,10 @@ type PlayerControlsProps = {
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
subtitlePosition: SubtitlePosition
|
||||
onSubtitlePositionChange: (position: SubtitlePosition) => void
|
||||
subtitleStyle: SubtitleStylePreset
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
@@ -68,6 +78,10 @@ export function PlayerControls({
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
subtitlePosition,
|
||||
onSubtitlePositionChange,
|
||||
subtitleStyle,
|
||||
onSubtitleStyleChange,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
@@ -361,7 +375,10 @@ export function PlayerControls({
|
||||
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const canConvertSelectedSubtitle =
|
||||
selectedSubtitle?.source === 'external' && selectedSubtitle.delivery === 'webvtt'
|
||||
selectedSubtitle?.source === 'external' &&
|
||||
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
|
||||
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
||||
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -455,7 +472,7 @@ export function PlayerControls({
|
||||
)}
|
||||
</button>
|
||||
{subtitleMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-44 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<div className="absolute bottom-11 right-0 z-30 max-h-[70vh] min-w-44 overflow-y-auto rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -481,10 +498,56 @@ export function PlayerControls({
|
||||
}`}
|
||||
title={track.label || track.lang}
|
||||
>
|
||||
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
|
||||
<span className="truncate">
|
||||
{track.label || track.lang || `字幕 ${index + 1}`}
|
||||
{track.delivery === 'ass' ? ' · ASS' : ''}
|
||||
</span>
|
||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||
</button>
|
||||
))}
|
||||
{canAdjustSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">字幕位置</p>
|
||||
<div className="flex flex-wrap gap-1 px-2 pb-1">
|
||||
{SUBTITLE_POSITION_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onSubtitlePositionChange(value)}
|
||||
className={`rounded-md px-2 py-1 text-[10px] transition ${
|
||||
subtitlePosition === value
|
||||
? 'bg-rose-500/20 text-rose-300'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">字幕样式</p>
|
||||
<div className="flex flex-wrap gap-1 px-2 pb-1">
|
||||
{SUBTITLE_STYLE_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onSubtitleStyleChange(value)}
|
||||
className={`rounded-md px-2 py-1 text-[10px] transition ${
|
||||
subtitleStyle === value
|
||||
? 'bg-rose-500/20 text-rose-300'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{usesOriginalASS && (
|
||||
<p className="mx-2 mt-1 border-t border-white/10 px-1 pt-2 text-[10px] leading-relaxed text-white/45">
|
||||
ASS/SSA 使用字幕文件自带的样式与位置,不做统一覆盖。
|
||||
</p>
|
||||
)}
|
||||
{canConvertSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
|
||||
|
||||
Reference in New Issue
Block a user