diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 87633ac..87cc50c 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -60,6 +60,10 @@ import { recommendationCacheKeys, setRecommendationCache, } from '@/lib/recommendations/cache'; +import { + convertSubtitleFileToVttObjectUrl, + CUSTOM_SUBTITLE_ACCEPT, +} from '@/lib/subtitle-converter'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; @@ -116,6 +120,13 @@ interface SearchCachePayload { updatedAt: number; } +interface CustomSubtitleState { + name: string; + url: string; + format: string; + episodeIndex: number; +} + function PlayPageClient() { const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080'; const router = useRouter(); @@ -1782,6 +1793,9 @@ function PlayPageClient() { const artPlayerRef = useRef(null); const artRef = useRef(null); + const customSubtitleInputRef = useRef(null); + const customSubtitleRef = useRef(null); + const currentSubtitleLabelRef = useRef('关闭'); // Wake Lock 相关 const wakeLockRef = useRef(null); @@ -1803,6 +1817,137 @@ function PlayPageClient() { // 工具函数(Utils) // ----------------------------------------------------------------------------- + const getSubtitleStyle = () => ({ + color: '#fff', + fontSize: typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em', + }); + + const revokeCustomSubtitle = () => { + if (customSubtitleRef.current) { + URL.revokeObjectURL(customSubtitleRef.current.url); + customSubtitleRef.current = null; + } + }; + + const switchSubtitle = (url: string, label: string) => { + if (!artPlayerRef.current) return; + + artPlayerRef.current.subtitle.switch(url, { + name: label, + type: 'vtt', + style: getSubtitleStyle(), + encoding: 'utf-8', + }); + artPlayerRef.current.subtitle.show = true; + currentSubtitleLabelRef.current = label; + }; + + const removeSubtitleSetting = () => { + try { + artPlayerRef.current?.setting.remove('subtitle-selector'); + } catch (e) { + // 忽略错误,可能设置项不存在 + } + }; + + const updateSubtitleSetting = () => { + if (!artPlayerRef.current) return; + + const sourceSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || []; + const customSubtitle = + customSubtitleRef.current?.episodeIndex === currentEpisodeIndexRef.current + ? customSubtitleRef.current + : null; + + removeSubtitleSetting(); + + const subtitleOptions = [ + { html: '关闭', action: 'close' }, + { html: '上传本地字幕', action: 'upload' }, + ...sourceSubtitles.map((sub: any) => ({ + html: sub.label, + action: 'switch', + url: sub.url, + })), + ...(customSubtitle + ? [ + { + html: `本地:${customSubtitle.name}`, + action: 'switch', + url: customSubtitle.url, + }, + ] + : []), + ]; + + artPlayerRef.current.setting.add({ + name: 'subtitle-selector', + html: '字幕', + selector: subtitleOptions, + onSelect: function (item: any) { + if (!artPlayerRef.current) { + return currentSubtitleLabelRef.current; + } + + if (item.action === 'close') { + artPlayerRef.current.subtitle.show = false; + currentSubtitleLabelRef.current = '关闭'; + return item.html; + } + + if (item.action === 'upload') { + customSubtitleInputRef.current?.click(); + return currentSubtitleLabelRef.current; + } + + if (item.url) { + switchSubtitle(item.url, item.html); + return item.html; + } + + return currentSubtitleLabelRef.current; + }, + default: currentSubtitleLabelRef.current, + }); + }; + + const handleCustomSubtitleFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + event.target.value = ''; + + if (!file) return; + + try { + const convertedSubtitle = await convertSubtitleFileToVttObjectUrl(file); + revokeCustomSubtitle(); + + customSubtitleRef.current = { + ...convertedSubtitle, + episodeIndex: currentEpisodeIndexRef.current, + }; + + switchSubtitle( + convertedSubtitle.url, + `本地:${convertedSubtitle.name}` + ); + updateSubtitleSetting(); + setToast({ + message: `已加载本地字幕:${convertedSubtitle.name}`, + type: 'success', + onClose: () => setToast(null), + }); + } catch (error) { + console.warn('[Subtitle] 自定义字幕加载失败:', error); + setToast({ + message: error instanceof Error ? error.message : '字幕加载失败', + type: 'error', + onClose: () => setToast(null), + }); + } + }; + // 判断剧集状态 const getSeriesStatus = (detail: SearchResult | null): 'completed' | 'ongoing' | 'unknown' => { if (!detail) return 'unknown'; @@ -2953,6 +3098,8 @@ function PlayPageClient() { // 清理播放器资源的统一函数 const cleanupPlayer = async () => { + revokeCustomSubtitle(); + // 清除刷新定时器 clearRefreshTimer(); @@ -4342,62 +4489,18 @@ function PlayPageClient() { useEffect(() => { if (!artPlayerRef.current || !detail) return; + revokeCustomSubtitle(); const currentSubtitles = detail.subtitles?.[currentEpisodeIndex] || []; - const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; // 如果有字幕,更新播放器字幕 if (currentSubtitles.length > 0) { - artPlayerRef.current.subtitle.switch(currentSubtitles[0].url, { - type: 'vtt', - style: { - color: '#fff', - fontSize: savedSubtitleSize, - }, - encoding: 'utf-8', - }); - - // 移除旧的字幕设置,添加新的 - try { - artPlayerRef.current.setting.remove('subtitle-selector'); - } catch (e) { - // 忽略错误,可能设置项不存在 - } - - const subtitleOptions = [ - { html: '关闭', url: '' }, - ...currentSubtitles.map((sub: any) => ({ - html: sub.label, - url: sub.url, - })), - ]; - - artPlayerRef.current.setting.add({ - name: 'subtitle-selector', - html: '字幕', - selector: subtitleOptions, - onSelect: function (item: any) { - if (artPlayerRef.current) { - if (item.url === '') { - artPlayerRef.current.subtitle.show = false; - } else { - artPlayerRef.current.subtitle.switch(item.url, { - name: item.html, - }); - artPlayerRef.current.subtitle.show = true; - } - } - return item.html; - }, - }); + switchSubtitle(currentSubtitles[0].url, currentSubtitles[0].label); } else { - // 没有字幕时,隐藏字幕并移除字幕设置 artPlayerRef.current.subtitle.show = false; - try { - artPlayerRef.current.setting.remove('subtitle-selector'); - } catch (e) { - // 忽略错误,可能设置项不存在 - } + currentSubtitleLabelRef.current = '关闭'; } + + updateSubtitleSetting(); }, [detail, currentEpisodeIndex]); const getSourceSwitchResumeTime = async ( @@ -6167,6 +6270,7 @@ function PlayPageClient() { // 获取当前集的字幕 const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || []; const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; + currentSubtitleLabelRef.current = currentSubtitles[0]?.label || '关闭'; artPlayerRef.current = new Artplayer({ container: artRef.current!, @@ -7298,40 +7402,8 @@ function PlayPageClient() { applyProgressThumbConfig(); - // 添加字幕切换功能 - const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || []; - if (currentSubtitles.length > 0 && artPlayerRef.current) { - const subtitleOptions = [ - { - html: '关闭', - url: '', - }, - ...currentSubtitles.map((sub: any) => ({ - html: sub.label, - url: sub.url, - })), - ]; - - artPlayerRef.current.setting.add({ - html: '字幕', - selector: subtitleOptions, - onSelect: function (item: any) { - if (artPlayerRef.current) { - if (item.url === '') { - // 关闭字幕 - artPlayerRef.current.subtitle.show = false; - } else { - // 切换字幕 - artPlayerRef.current.subtitle.switch(item.url, { - name: item.html, - }); - artPlayerRef.current.subtitle.show = true; - } - } - return item.html; - }, - }); - } + // 添加字幕切换和本地字幕上传功能 + updateSubtitleSetting(); // 添加字幕大小设置 if (artPlayerRef.current) { @@ -9919,6 +9991,14 @@ function PlayPageClient() { {/* Toast通知 */} {toast && } + + {/* 下载选集面板 */} \s*(\d{1,2}:)?\d{2}:\d{2}[,.]\d{1,3}/; + +export async function convertSubtitleFileToVttObjectUrl( + file: File +): Promise { + const extension = getSubtitleExtension(file.name); + if (!extension) { + throw new Error('不支持的字幕格式'); + } + + const text = await file.text(); + const vtt = convertSubtitleTextToVtt(text, extension); + const blob = new Blob([vtt], { type: 'text/vtt;charset=utf-8' }); + + return { + name: file.name, + url: URL.createObjectURL(blob), + format: extension, + }; +} + +export function convertSubtitleTextToVtt(text: string, format: string): string { + const normalizedFormat = format.toLowerCase().replace(/^\./, ''); + const normalizedText = stripBom(text).replace(/\r\n?/g, '\n'); + + switch (normalizedFormat) { + case 'vtt': + return normalizeVtt(normalizedText); + case 'srt': + return convertSrtToVtt(normalizedText); + case 'ass': + case 'ssa': + return convertAssToVtt(normalizedText); + case 'ttml': + case 'dfxp': + case 'xml': + return convertTtmlToVtt(normalizedText); + case 'sbv': + return convertSbvToVtt(normalizedText); + case 'sub': + return convertSubToVtt(normalizedText); + case 'lrc': + return convertLrcToVtt(normalizedText); + default: + throw new Error('不支持的字幕格式'); + } +} + +function getSubtitleExtension(fileName: string): string | null { + const match = fileName.toLowerCase().match(/\.([a-z0-9]+)$/); + const extension = match?.[1] || ''; + + return CUSTOM_SUBTITLE_ACCEPT.split(',') + .map((item) => item.replace('.', '')) + .includes(extension) + ? extension + : null; +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function normalizeVtt(text: string): string { + const body = text.replace(/^WEBVTT[^\n]*(\n|$)/i, '').trim(); + return `WEBVTT\n\n${body}\n`; +} + +function convertSrtToVtt(text: string): string { + return `WEBVTT\n\n${text + .replace(/(\d{2}:\d{2}:\d{2}),(\d{1,3})/g, (_, time, ms) => { + return `${time}.${ms.padEnd(3, '0').slice(0, 3)}`; + }) + .trim()}\n`; +} + +function convertAssToVtt(text: string): string { + const formatFields = getAssFormatFields(text); + const events = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^Dialogue:/i.test(line)) + .map((line) => parseAssDialogue(line, formatFields)) + .filter((cue): cue is VttCue => Boolean(cue)); + + return cuesToVtt(events); +} + +function getAssFormatFields(text: string): string[] { + const eventSection = text.split(/\n\s*\[Events\]\s*\n/i)[1] || text; + const formatLine = eventSection + .split('\n') + .find((line) => /^Format:/i.test(line.trim())); + + if (!formatLine) { + return ['Layer', 'Start', 'End', 'Style', 'Name', 'MarginL', 'MarginR', 'MarginV', 'Effect', 'Text']; + } + + return formatLine + .replace(/^Format:\s*/i, '') + .split(',') + .map((field) => field.trim()); +} + +interface VttCue { + start: string; + end: string; + text: string; +} + +function parseAssDialogue(line: string, fields: string[]): VttCue | null { + const payload = line.replace(/^Dialogue:\s*/i, ''); + const textIndex = fields.findIndex((field) => /^text$/i.test(field)); + const startIndex = fields.findIndex((field) => /^start$/i.test(field)); + const endIndex = fields.findIndex((field) => /^end$/i.test(field)); + + if (textIndex < 0 || startIndex < 0 || endIndex < 0) { + return null; + } + + const parts = splitAssCsv(payload, fields.length); + const start = assTimeToVtt(parts[startIndex]); + const end = assTimeToVtt(parts[endIndex]); + const text = cleanAssText(parts[textIndex]); + + if (!start || !end || !text) { + return null; + } + + return { start, end, text }; +} + +function splitAssCsv(value: string, expectedParts: number): string[] { + const parts = value.split(','); + if (parts.length <= expectedParts) { + return parts.map((part) => part.trim()); + } + + return [ + ...parts.slice(0, expectedParts - 1).map((part) => part.trim()), + parts.slice(expectedParts - 1).join(',').trim(), + ]; +} + +function cleanAssText(text: string): string { + return decodeHtmlEntities( + text + .replace(/\{[^}]*\}/g, '') + .replace(/\\[Nn]/g, '\n') + .replace(/\\h/g, ' ') + .trim() + ); +} + +function assTimeToVtt(value: string): string | null { + const match = value.trim().match(/^(\d+):(\d{2}):(\d{2})[.](\d{1,2})$/); + if (!match) return null; + + return `${match[1].padStart(2, '0')}:${match[2]}:${match[3]}.${match[4] + .padEnd(3, '0') + .slice(0, 3)}`; +} + +function convertTtmlToVtt(text: string): string { + const cues: VttCue[] = []; + const paragraphPattern = /]*)>([\s\S]*?)<\/p>/gi; + let match: RegExpExecArray | null; + + while ((match = paragraphPattern.exec(text))) { + const attrs = match[1]; + const start = readXmlTime(attrs, ['begin', 'start']); + const end = readXmlTime(attrs, ['end']); + const duration = readXmlTime(attrs, ['dur']); + const cueText = decodeHtmlEntities( + match[2] + .replace(//gi, '\n') + .replace(/<[^>]+>/g, '') + .trim() + ); + + if (!start || !cueText) continue; + + cues.push({ + start, + end: end || addVttTimes(start, duration || '00:00:03.000'), + text: cueText, + }); + } + + return cuesToVtt(cues); +} + +function readXmlTime(attrs: string, names: string[]): string | null { + for (const name of names) { + const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i')); + if (match) { + return normalizeTimestamp(match[1]); + } + } + + return null; +} + +function convertSbvToVtt(text: string): string { + const cues: VttCue[] = []; + const blocks = text.split(/\n{2,}/); + + for (const block of blocks) { + const lines = block.split('\n').map((line) => line.trim()).filter(Boolean); + const timing = lines[0]; + + if (!timing || !timing.includes(',')) continue; + + const [startRaw, endRaw] = timing.split(',').map((part) => part.trim()); + const start = normalizeTimestamp(startRaw); + const end = normalizeTimestamp(endRaw); + const cueText = lines.slice(1).join('\n').trim(); + + if (start && end && cueText) { + cues.push({ start, end, text: cueText }); + } + } + + return cuesToVtt(cues); +} + +function convertSubToVtt(text: string): string { + if (/^\{\d+\}\{\d+\}/m.test(text)) { + return convertMicroDvdSubToVtt(text); + } + + if (WEBVTT_TIMESTAMP_PATTERN.test(text)) { + return convertSrtToVtt(text); + } + + return convertSbvToVtt(text); +} + +function convertMicroDvdSubToVtt(text: string): string { + const frameRate = 25; + const cues = text + .split('\n') + .map((line) => { + const match = line.match(/^\{(\d+)\}\{(\d+)\}(.*)$/); + if (!match) return null; + + const start = secondsToVttTime(Number(match[1]) / frameRate); + const end = secondsToVttTime(Number(match[2]) / frameRate); + const cueText = decodeHtmlEntities(match[3].replace(/\|/g, '\n').replace(/\{[^}]*\}/g, '').trim()); + + return cueText ? { start, end, text: cueText } : null; + }) + .filter((cue): cue is VttCue => Boolean(cue)); + + return cuesToVtt(cues); +} + +function convertLrcToVtt(text: string): string { + const entries = text + .split('\n') + .flatMap((line) => { + const timeMatches = Array.from(line.matchAll(/\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g)); + const cueText = line.replace(/\[[^\]]+\]/g, '').trim(); + + return timeMatches.map((match) => ({ + seconds: + Number(match[1]) * 60 + + Number(match[2]) + + Number((match[3] || '0').padEnd(3, '0').slice(0, 3)) / 1000, + text: cueText, + })); + }) + .filter((entry) => entry.text) + .sort((a, b) => a.seconds - b.seconds); + + const cues = entries.map((entry, index) => ({ + start: secondsToVttTime(entry.seconds), + end: secondsToVttTime(entries[index + 1]?.seconds || entry.seconds + 3), + text: entry.text, + })); + + return cuesToVtt(cues); +} + +function cuesToVtt(cues: VttCue[]): string { + if (cues.length === 0) { + throw new Error('未识别到有效字幕内容'); + } + + return `WEBVTT\n\n${cues + .map((cue) => `${cue.start} --> ${cue.end}\n${cue.text}`) + .join('\n\n')}\n`; +} + +function normalizeTimestamp(value: string): string | null { + const trimmed = value.trim().replace(',', '.'); + const offsetMatch = trimmed.match(/^(\d+(?:\.\d+)?)s$/i); + if (offsetMatch) { + return secondsToVttTime(Number(offsetMatch[1])); + } + + const match = trimmed.match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?$/); + if (!match) return null; + + const hours = match[1] || '0'; + const minutes = match[2]; + const seconds = match[3]; + const millis = (match[4] || '0').padEnd(3, '0').slice(0, 3); + + return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:${seconds}.${millis}`; +} + +function addVttTimes(start: string, duration: string): string { + return secondsToVttTime(vttTimeToSeconds(start) + vttTimeToSeconds(duration)); +} + +function vttTimeToSeconds(value: string): number { + const match = value.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/); + if (!match) return 0; + + return ( + Number(match[1]) * 3600 + + Number(match[2]) * 60 + + Number(match[3]) + + Number(match[4]) / 1000 + ); +} + +function secondsToVttTime(totalSeconds: number): string { + const safeSeconds = Math.max(0, totalSeconds); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const seconds = Math.floor(safeSeconds % 60); + const millis = Math.round((safeSeconds - Math.floor(safeSeconds)) * 1000); + + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String( + seconds + ).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'"); +}