feat: implement all frontend TypeScript modules
Create player.ts (video controls, seek, volume, speed, overlay), playlist.ts (list rendering, tree SVG, drag-and-drop reorder, scrollbar), subtitles.ts (subtitle menu, track management, sidecar/embedded), ui.ts (zoom, splits, info panel, notes, toasts, recent menu), tooltips.ts (zoom-aware tooltip system with delays), store.ts (shared state and utility functions), and main.ts (boot sequence, tick loop, keyboard shortcuts). All modules compile with strict TypeScript. Vite build produces 34KB JS + 41KB CSS. 115 Rust tests pass.
This commit is contained in:
206
src/subtitles.ts
Normal file
206
src/subtitles.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!library) return;
|
||||
if (subsMenuOpen) closeSubsMenu();
|
||||
else await openSubsMenu();
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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.innerHTML = `<i class="fa-solid fa-file-lines"></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.innerHTML = `<i class="fa-solid fa-film"></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.innerHTML = '<i class="fa-solid fa-file-import"></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.innerHTML = '<i class="fa-solid fa-xmark"></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;
|
||||
}
|
||||
Reference in New Issue
Block a user