Files
tutorialvault/src/subtitles.ts
Your Name 52934d15d6 a11y: bring UI to WCAG 2.2 AAA compliance
Semantic HTML: lang attr, landmarks (header/main/region/complementary),
heading hierarchy (h1-h3), dl/dt/dd for info panel.

ARIA: labels on all icon buttons, aria-hidden on decorative icons,
progressbar role with dynamic aria-valuenow, aria-haspopup/expanded
on all menu triggers, role=listbox/option on playlist, aria-selected,
computed aria-labels on playlist rows.

Contrast: raised --textMuted/--textDim/--icon to AAA 7:1 ratios.

Focus: global :focus-visible outline, slider thumb glow, menu item
highlight, switch focus-within, row focus styles.

Target sizes: 44x44 hit areas on zoom/window/remove buttons via
::before pseudo-elements.

Keyboard: playlist arrow nav + Enter/Space activate + Alt+Arrow
reorder with live region announcements + move buttons. Speed menu,
subtitles menu, and recent menu all keyboard-navigable with
Arrow/Enter/Space/Escape. Dividers resizable via Arrow keys.

Dynamic document.title updates on video/folder load.
2026-02-19 16:35:19 +02:00

255 lines
8.5 KiB
TypeScript

/**
* 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<void> {
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<void> {
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 = `<i class="fa-solid fa-file-lines" aria-hidden="true"></i> ${sub.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${sub.format}</span>`;
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 = `<i class="fa-solid fa-film" aria-hidden="true"></i> ${track.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${track.codec}</span>`;
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 = '<i class="fa-solid fa-file-import" aria-hidden="true"></i> 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 = '<i class="fa-solid fa-xmark" aria-hidden="true"></i> 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();
}