diff --git a/static/player/subtitles/index.ts b/static/player/subtitles/index.ts new file mode 100644 index 0000000..55adbb9 --- /dev/null +++ b/static/player/subtitles/index.ts @@ -0,0 +1,76 @@ +import { SubtitleCue, SubtitleTrack } from '../types' +import { state } from '../state' +import { parseVtt } from './vtt' + +const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}` + +const subtitlesForMode = (): SubtitleTrack[] => { + const src = state.modeSources[state.currentMode] + if (!src?.subtitles) return [] + return src.subtitles + .map(t => ({ lang: (t.lang || 'unknown').toLowerCase(), label: t.lang || 'Unknown', url: proxyUrl(t.token) })) + .filter(t => t.url !== '') +} + +const hideSubtitleText = (): void => { + const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null + if (!el) return + el.textContent = '' + el.classList.remove('block') + el.classList.add('hidden') +} + +const loadSubtitle = async (url: string): Promise => { + try { + const res = await fetch(url) + if (!res.ok) return [] + return parseVtt(await res.text()) + } catch { return [] } +} + +export const updateSubtitleOptions = (): void => { + const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null + if (!select) return + state.currentSubtitleTracks = subtitlesForMode() + select.innerHTML = '' + + const none = document.createElement('option') + none.value = 'none' + none.textContent = 'Off' + select.appendChild(none) + select.value = 'none' + + state.currentSubtitleTracks.forEach((t, i) => { + const opt = document.createElement('option') + opt.value = String(i) + opt.textContent = t.label + select.appendChild(opt) + }) + + const wrapper = select.parentElement + wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0) + state.activeSubtitles = [] + hideSubtitleText() +} + +export const updateSubtitleRender = (time: number): void => { + const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null + if (!el) return + if (!state.activeSubtitles.length) { hideSubtitleText(); return } + + const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end) + if (!cue) { hideSubtitleText(); return } + + el.textContent = cue.text + el.classList.remove('hidden') +} + +export const setupSubtitles = (): void => { + const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null + select?.addEventListener('change', async () => { + if (select.value === 'none') { state.activeSubtitles = []; hideSubtitleText(); return } + const track = state.currentSubtitleTracks[Number(select.value)] + if (!track) { state.activeSubtitles = []; return } + state.activeSubtitles = await loadSubtitle(track.url) + }) +} diff --git a/static/player/subtitles/vtt.ts b/static/player/subtitles/vtt.ts new file mode 100644 index 0000000..2ebf1b1 --- /dev/null +++ b/static/player/subtitles/vtt.ts @@ -0,0 +1,38 @@ +export const parseVttTime = (raw: string): number => { + const parts = raw.trim().split(':') + if (parts.length < 2) return 0 + const secPart = parts.pop()! + const minPart = parts.pop()! + const hourPart = parts.pop() ?? '0' + return (Number(hourPart) * 3600) + (Number(minPart) * 60) + Number(secPart.replace(',', '.')) +} + +export const parseVttCue = (line: string, lines: string[], i: number) => { + if (!line.includes('-->')) return null + const [startRaw, endRaw] = line.split('-->') + const payload: string[] = [] + let j = i + 1 + while (j < lines.length && lines[j].trim() !== '') { + payload.push(lines[j]); j++ + } + const text = payload.join('\n').replace(/<[^>]+>/g, '').trim() + if (!text) return null + return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text } +} + +export const parseVtt = (text: string) => { + const lines = text.replace(/\r/g, '').split('\n') + const cues = [] + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) { + const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1) + if (cue) cues.push(cue); i++ + } else if (line.includes('-->')) { + const cue = parseVttCue(line, lines, i) + if (cue) cues.push(cue) + } + } + return cues +}