支持高级字幕(ass,ssa)渲染

This commit is contained in:
mtvpls
2026-06-13 20:34:26 +08:00
parent e20d5bf77e
commit c45fdded59
8 changed files with 212 additions and 21 deletions
+192 -21
View File
@@ -121,14 +121,29 @@ interface SearchCachePayload {
updatedAt: number;
}
type CustomSubtitleEngine = 'native' | 'jassub';
interface CustomSubtitleState {
name: string;
url: string;
format: string;
episodeIndex: number;
engine: CustomSubtitleEngine;
url?: string;
content?: string;
}
interface JassubSubtitleInstance {
setTrack?: (content: string) => void | Promise<void>;
setTrackByUrl?: (url: string) => void | Promise<void>;
freeTrack?: () => void | Promise<void>;
destroy?: () => void | Promise<void>;
}
const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
const JASSUB_ASSET_BASE = '/assets/jassub';
const JASSUB_CJK_FONT_FAMILY = 'noto sans cjk sc';
const JASSUB_CJK_FONT_URL = `${JASSUB_ASSET_BASE}/NotoSansCJK-Regular.ttc`;
const ADVANCED_SUBTITLE_FORMATS = new Set(['ass', 'ssa']);
const PLAY_SHORTCUT_GROUPS = [
{
title: '播放控制',
@@ -1933,16 +1948,43 @@ function PlayPageClient() {
fontSize: typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em',
});
const revokeCustomSubtitle = () => {
if (customSubtitleRef.current) {
URL.revokeObjectURL(customSubtitleRef.current.url);
customSubtitleRef.current = null;
const getSubtitleFileExtension = (fileName: string) => {
return fileName.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
};
const isAdvancedSubtitleFormat = (format: string) => {
return ADVANCED_SUBTITLE_FORMATS.has(format.toLowerCase());
};
const getJassubSubtitleInstance = (): JassubSubtitleInstance | null => {
return artPlayerRef.current?.plugins?.artplayerPluginJassub?.instance || null;
};
const clearJassubSubtitle = () => {
try {
getJassubSubtitleInstance()?.freeTrack?.();
} catch (error) {
console.warn('[Subtitle] 清理高级字幕失败:', error);
}
};
const revokeCustomSubtitle = () => {
const customSubtitle = customSubtitleRef.current;
if (customSubtitle?.engine === 'native' && customSubtitle.url) {
URL.revokeObjectURL(customSubtitle.url);
}
if (customSubtitle?.engine === 'jassub') {
clearJassubSubtitle();
}
customSubtitleRef.current = null;
};
const switchSubtitle = (url: string, label: string) => {
if (!artPlayerRef.current) return;
clearJassubSubtitle();
artPlayerRef.current.subtitle.switch(url, {
name: label,
type: 'vtt',
@@ -1953,6 +1995,66 @@ function PlayPageClient() {
currentSubtitleLabelRef.current = label;
};
const closeSubtitle = () => {
if (!artPlayerRef.current) return;
artPlayerRef.current.subtitle.show = false;
clearJassubSubtitle();
currentSubtitleLabelRef.current = '关闭';
};
const ensureJassubSubtitleInstance = async (
initialContent: string
): Promise<{ instance: JassubSubtitleInstance; created: boolean }> => {
const existingInstance = getJassubSubtitleInstance();
if (existingInstance) {
return { instance: existingInstance, created: false };
}
if (!artPlayerRef.current) {
throw new Error('播放器尚未就绪');
}
const JassubPluginModule = await import('artplayer-plugin-jassub');
const artplayerPluginJassub =
((JassubPluginModule as any).default || JassubPluginModule) as any;
artPlayerRef.current.plugins.add(
artplayerPluginJassub({
subContent: initialContent,
workerUrl: `${JASSUB_ASSET_BASE}/jassub-worker.js`,
wasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker.wasm`,
modernWasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker-modern.wasm`,
availableFonts: {
[JASSUB_CJK_FONT_FAMILY]: JASSUB_CJK_FONT_URL,
'liberation sans': `${JASSUB_ASSET_BASE}/default.woff2`,
},
fallbackFont: JASSUB_CJK_FONT_FAMILY,
})
);
const instance = getJassubSubtitleInstance();
if (!instance) {
throw new Error('高级字幕渲染器初始化失败');
}
return { instance, created: true };
};
const switchAdvancedSubtitle = async (content: string, label: string) => {
if (!artPlayerRef.current) return;
artPlayerRef.current.subtitle.show = false;
const { instance, created } = await ensureJassubSubtitleInstance(content);
// 新建实例时 subContent 已作为初始轨道传入;复用实例时需要显式切轨。
if (!created) {
await instance.setTrack?.(content);
}
currentSubtitleLabelRef.current = label;
};
const removeSubtitleSetting = () => {
try {
artPlayerRef.current?.setting.remove('subtitle-selector');
@@ -1978,6 +2080,7 @@ function PlayPageClient() {
...sourceSubtitles.map((sub: any) => ({
html: sub.label,
action: 'switch',
engine: 'native',
url: sub.url,
})),
...(customSubtitle
@@ -1985,7 +2088,9 @@ function PlayPageClient() {
{
html: `本地:${customSubtitle.name}`,
action: 'switch',
engine: customSubtitle.engine,
url: customSubtitle.url,
content: customSubtitle.content,
},
]
: []),
@@ -2001,8 +2106,7 @@ function PlayPageClient() {
}
if (item.action === 'close') {
artPlayerRef.current.subtitle.show = false;
currentSubtitleLabelRef.current = '关闭';
closeSubtitle();
return item.html;
}
@@ -2011,6 +2115,18 @@ function PlayPageClient() {
return currentSubtitleLabelRef.current;
}
if (item.engine === 'jassub' && item.content) {
void switchAdvancedSubtitle(item.content, item.html).catch((error) => {
console.warn('[Subtitle] 高级字幕切换失败:', error);
setToast({
message: error instanceof Error ? error.message : '高级字幕切换失败',
type: 'error',
onClose: () => setToast(null),
});
});
return item.html;
}
if (item.url) {
switchSubtitle(item.url, item.html);
return item.html;
@@ -2022,6 +2138,41 @@ function PlayPageClient() {
});
};
const loadNativeCustomSubtitle = async (file: File) => {
const convertedSubtitle = await convertSubtitleFileToVttObjectUrl(file);
revokeCustomSubtitle();
customSubtitleRef.current = {
...convertedSubtitle,
engine: 'native',
episodeIndex: currentEpisodeIndexRef.current,
};
switchSubtitle(
convertedSubtitle.url,
`本地:${convertedSubtitle.name}`
);
updateSubtitleSetting();
return convertedSubtitle;
};
const loadAdvancedCustomSubtitle = async (file: File, format: string) => {
const content = await file.text();
revokeCustomSubtitle();
customSubtitleRef.current = {
name: file.name,
format,
engine: 'jassub',
content,
episodeIndex: currentEpisodeIndexRef.current,
};
await switchAdvancedSubtitle(content, `本地:${file.name}`);
updateSubtitleSetting();
};
const handleCustomSubtitleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
@@ -2030,29 +2181,49 @@ function PlayPageClient() {
if (!file) return;
const extension = getSubtitleFileExtension(file.name);
try {
const convertedSubtitle = await convertSubtitleFileToVttObjectUrl(file);
revokeCustomSubtitle();
if (isAdvancedSubtitleFormat(extension)) {
await loadAdvancedCustomSubtitle(file, extension);
setToast({
message: `已加载高级字幕:${file.name}`,
type: 'success',
onClose: () => setToast(null),
});
return;
}
customSubtitleRef.current = {
...convertedSubtitle,
episodeIndex: currentEpisodeIndexRef.current,
};
switchSubtitle(
convertedSubtitle.url,
`本地:${convertedSubtitle.name}`
);
updateSubtitleSetting();
const convertedSubtitle = await loadNativeCustomSubtitle(file);
setToast({
message: `已加载本地字幕:${convertedSubtitle.name}`,
type: 'success',
onClose: () => setToast(null),
});
} catch (error) {
console.warn('[Subtitle] 自定义字幕加载失败:', error);
let displayError = error;
if (isAdvancedSubtitleFormat(extension)) {
console.warn('[Subtitle] 高级字幕加载失败,尝试降级为普通字幕:', displayError);
try {
const convertedSubtitle = await loadNativeCustomSubtitle(file);
setToast({
message: `高级字幕渲染失败,已降级为普通字幕:${convertedSubtitle.name}`,
type: 'info',
duration: 5000,
onClose: () => setToast(null),
});
return;
} catch (fallbackError) {
console.warn('[Subtitle] 高级字幕降级加载失败:', fallbackError);
displayError = fallbackError;
}
}
console.warn('[Subtitle] 自定义字幕加载失败:', displayError);
setToast({
message: error instanceof Error ? error.message : '字幕加载失败',
message: displayError instanceof Error ? displayError.message : '字幕加载失败',
type: 'error',
onClose: () => setToast(null),
});