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.
255 lines
8.5 KiB
TypeScript
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();
|
|
}
|