支持高级字幕(ass,ssa)渲染
This commit is contained in:
+192
-21
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user