/** * Subtitle menu and track management. * Handles sidecar, embedded, and user-chosen subtitle files. */ import { api } from './api'; import { library, cb } from './store'; // ---- DOM refs ---- let player: HTMLVideoElement; let subsBtn: HTMLElement; let subsMenu: HTMLElement; let subtitleTrackEl: HTMLTrackElement | null = null; let subsMenuOpen = false; export function initSubtitles(): void { player = document.getElementById('player') as HTMLVideoElement; subsBtn = document.getElementById('subsBtn')!; subsMenu = document.getElementById('subsMenu')!; subsBtn.setAttribute('aria-haspopup', 'true'); subsBtn.setAttribute('aria-expanded', 'false'); subsBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (!library) return; if (subsMenuOpen) closeSubsMenu(); else await openSubsMenu(); }); subsBtn.addEventListener('keydown', (e) => { if (e.key === 'Escape' && subsMenuOpen) { closeSubsMenu(); subsBtn.focus(); } }); window.addEventListener('click', () => { if (subsMenuOpen) closeSubsMenu(); }); subsMenu.addEventListener('click', (e) => e.stopPropagation()); } export function ensureSubtitleTrack(): HTMLTrackElement { if (!subtitleTrackEl) { subtitleTrackEl = document.createElement('track'); subtitleTrackEl.kind = 'subtitles'; subtitleTrackEl.label = 'Subtitles'; subtitleTrackEl.srclang = 'en'; subtitleTrackEl.default = true; player.appendChild(subtitleTrackEl); } return subtitleTrackEl; } export async function refreshSubtitles(): Promise { ensureSubtitleTrack(); try { const res = await api.getCurrentSubtitle(); if (res && res.ok && res.has) { subtitleTrackEl!.src = res.url!; subtitleTrackEl!.label = res.label || 'Subtitles'; setTimeout(() => { try { if (player.textTracks && player.textTracks.length > 0) { for (const tt of Array.from(player.textTracks)) { if (tt.kind === 'subtitles') tt.mode = 'showing'; } } } catch (_) {} }, 50); cb.notify?.('Subtitles loaded.'); } else { subtitleTrackEl!.src = ''; } } catch (_) {} } export function applySubtitle(url: string, label: string): void { ensureSubtitleTrack(); subtitleTrackEl!.src = url; subtitleTrackEl!.label = label || 'Subtitles'; setTimeout(() => { try { if (player.textTracks && player.textTracks.length > 0) { for (const tt of Array.from(player.textTracks)) { if (tt.kind === 'subtitles') tt.mode = 'showing'; } } } catch (_) {} }, 50); } export function clearSubtitles(): void { try { if (player.textTracks && player.textTracks.length > 0) { for (const tt of Array.from(player.textTracks)) { tt.mode = 'hidden'; } } if (subtitleTrackEl) subtitleTrackEl.src = ''; } catch (_) {} } export function closeSubsMenu(): void { subsMenuOpen = false; subsMenu?.classList.remove('show'); subsBtn?.setAttribute('aria-expanded', 'false'); } function menuItemKeyHandler(e: KeyboardEvent): void { const item = e.currentTarget as HTMLElement; if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); let next = item.nextElementSibling as HTMLElement | null; while (next && !next.classList.contains('subsMenuItem')) next = next.nextElementSibling as HTMLElement | null; if (next) next.focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); let prev = item.previousElementSibling as HTMLElement | null; while (prev && !prev.classList.contains('subsMenuItem')) prev = prev.previousElementSibling as HTMLElement | null; if (prev) prev.focus(); } else if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); closeSubsMenu(); subsBtn.focus(); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); item.click(); } } export async function openSubsMenu(): Promise { if (!library) return; subsMenu.innerHTML = ''; try { const available = await api.getAvailableSubtitles(); if (available && available.ok) { // Sidecar subtitle files if (available.sidecar && available.sidecar.length > 0) { const header = document.createElement('div'); header.className = 'subsMenuHeader'; header.textContent = 'External Files'; subsMenu.appendChild(header); for (const sub of available.sidecar) { const item = document.createElement('div'); item.className = 'subsMenuItem'; item.setAttribute('role', 'menuitem'); item.tabIndex = -1; item.addEventListener('keydown', menuItemKeyHandler); item.innerHTML = ` ${sub.label} ${sub.format}`; item.onclick = async () => { closeSubsMenu(); const res = await api.loadSidecarSubtitle(sub.path); if (res && res.ok && res.url) { applySubtitle(res.url, res.label!); cb.notify?.('Subtitles loaded.'); } else { cb.notify?.(res?.error || 'Failed to load subtitle'); } }; subsMenu.appendChild(item); } } // Embedded subtitle tracks if (available.embedded && available.embedded.length > 0) { if (available.sidecar && available.sidecar.length > 0) { const div = document.createElement('div'); div.className = 'subsDivider'; subsMenu.appendChild(div); } const header = document.createElement('div'); header.className = 'subsMenuHeader'; header.textContent = 'Embedded Tracks'; subsMenu.appendChild(header); for (const track of available.embedded) { const item = document.createElement('div'); item.className = 'subsMenuItem embedded'; item.setAttribute('role', 'menuitem'); item.tabIndex = -1; item.addEventListener('keydown', menuItemKeyHandler); item.innerHTML = ` ${track.label} ${track.codec}`; item.onclick = async () => { closeSubsMenu(); const res = await api.extractEmbeddedSubtitle(track.index); if (res && res.ok && res.url) { applySubtitle(res.url, res.label!); cb.notify?.('Embedded subtitle loaded.'); } else { cb.notify?.(res?.error || 'Failed to extract subtitle'); } }; subsMenu.appendChild(item); } } // Divider if ((available.sidecar && available.sidecar.length > 0) || (available.embedded && available.embedded.length > 0)) { const div = document.createElement('div'); div.className = 'subsDivider'; subsMenu.appendChild(div); } } } catch (_) {} // "Load from file" option const loadItem = document.createElement('div'); loadItem.className = 'subsMenuItem'; loadItem.setAttribute('role', 'menuitem'); loadItem.tabIndex = -1; loadItem.addEventListener('keydown', menuItemKeyHandler); loadItem.innerHTML = ' Load from file...'; loadItem.onclick = async () => { closeSubsMenu(); try { const res = await api.chooseSubtitleFile(); if (res && res.ok && res.url) { applySubtitle(res.url, res.label!); cb.notify?.('Subtitles loaded.'); } else if (res && res.error) { cb.notify?.(res.error); } } catch (_) {} }; subsMenu.appendChild(loadItem); // "Disable" option const disableItem = document.createElement('div'); disableItem.className = 'subsMenuItem'; disableItem.setAttribute('role', 'menuitem'); disableItem.tabIndex = -1; disableItem.addEventListener('keydown', menuItemKeyHandler); disableItem.innerHTML = ' Disable subtitles'; disableItem.onclick = () => { closeSubsMenu(); try { if (player.textTracks) { for (const tt of Array.from(player.textTracks)) { tt.mode = 'hidden'; } } cb.notify?.('Subtitles disabled.'); } catch (_) {} }; subsMenu.appendChild(disableItem); subsMenu.classList.add('show'); subsMenuOpen = true; subsBtn.setAttribute('aria-expanded', 'true'); const first = subsMenu.querySelector('.subsMenuItem') as HTMLElement | null; if (first) first.focus(); }