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:
Your Name
2026-02-19 11:44:48 +02:00
parent 8f2437fd13
commit 52e334ebfe
8 changed files with 1897 additions and 1 deletions

View File

@@ -1,3 +1,7 @@
/**
* TutorialDock frontend — boot sequence, tick loop, global wiring.
* Orchestrates all modules and holds cross-module callbacks.
*/
import './styles/main.css';
import './styles/player.css';
import './styles/playlist.css';
@@ -5,4 +9,237 @@ import './styles/panels.css';
import './styles/components.css';
import './styles/animations.css';
console.log('TutorialDock frontend loaded');
import { api } from './api';
import type { LibraryInfo } from './types';
import {
library, currentIndex, prefs, suppressTick, lastTick,
setLibrary, setCurrentIndex, setPrefs, setSuppressTick, setLastTick,
clamp, cb, currentItem,
} from './store';
import {
initPlayer, loadVideoSrc, updatePlayPauseIcon, updateTimeReadout,
updateSeekFill, updateVolFill, updateVideoOverlay, updateSpeedIcon,
setVolume, setPlaybackRate, buildSpeedMenu, isPlaying, getVideoTime,
getVideoDuration, getPlayer, isVolDragging,
} from './player';
import { initPlaylist, renderList } from './playlist';
import { initSubtitles, refreshSubtitles, clearSubtitles } from './subtitles';
import {
initUI, applyZoom, applySplit, applyDockSplit,
updateInfoPanel, updateOverall, updateNowHeader,
loadNoteForCurrent, refreshCurrentVideoMeta, notify,
setOnTopChecked, setAutoplayChecked,
} from './ui';
import { initTooltips } from './tooltips';
// ---- Wire cross-module callbacks ----
cb.loadIndex = loadIndex;
cb.renderList = renderList;
cb.updateInfoPanel = updateInfoPanel;
cb.updateOverall = updateOverall;
cb.notify = notify;
cb.refreshCurrentVideoMeta = refreshCurrentVideoMeta;
cb.onLibraryLoaded = onLibraryLoaded;
cb.buildSpeedMenu = buildSpeedMenu;
// ---- Boot ----
async function boot(): Promise<void> {
// Init all modules
initPlayer();
initPlaylist();
initSubtitles();
initUI();
initTooltips();
// Load prefs
const pres = await api.getPrefs();
const p: Record<string, any> = (pres && pres.ok) ? (pres.prefs as any || {}) : {};
setPrefs(p);
p.ui_zoom = applyZoom(p.ui_zoom || 1.0);
p.split_ratio = applySplit(p.split_ratio || 0.62);
p.dock_ratio = applyDockSplit(p.dock_ratio || 0.62);
setOnTopChecked(!!p.always_on_top);
await api.setPrefs({
ui_zoom: p.ui_zoom,
split_ratio: p.split_ratio,
dock_ratio: p.dock_ratio,
always_on_top: !!p.always_on_top,
});
// Load library
const info = await api.getLibrary();
if (info && info.ok) {
await onLibraryLoaded(info, true);
notify('Ready.');
return;
}
updateOverall();
updateTimeReadout();
updatePlayPauseIcon();
updateInfoPanel();
buildSpeedMenu(1.0);
notify('Open a folder to begin.');
}
// ---- onLibraryLoaded ----
async function onLibraryLoaded(info: LibraryInfo, startScan: boolean): Promise<void> {
setLibrary(info);
setCurrentIndex(info.current_index || 0);
clearSubtitles();
const player = getPlayer();
const v = clamp(Number(info.folder_volume ?? 1.0), 0, 1);
setSuppressTick(true);
player.volume = v;
setVolume(v);
setSuppressTick(false);
const r = clamp(Number(info.folder_rate ?? 1.0), 0.25, 3);
setPlaybackRate(r);
buildSpeedMenu(r);
setAutoplayChecked(!!info.folder_autoplay);
updateOverall();
renderList();
updateInfoPanel();
if (startScan) {
try { await api.startDurationScan(); } catch (_) {}
}
await loadIndex(currentIndex, Number(info.current_time || 0.0), true, false);
}
// ---- loadIndex ----
async function loadIndex(
idx: number,
timecode: number = 0.0,
pauseAfterLoad: boolean = true,
autoplayOnLoad: boolean = false,
): Promise<void> {
if (!library || !library.items || library.items.length === 0) return;
idx = Math.max(0, Math.min(idx, library.items.length - 1));
setCurrentIndex(idx);
const it = library.items[currentIndex] || null;
updateNowHeader(it);
await api.setCurrent(currentIndex, Number(timecode || 0.0));
renderList();
updateInfoPanel();
await loadNoteForCurrent();
await refreshCurrentVideoMeta();
await loadVideoSrc(idx, timecode, pauseAfterLoad, autoplayOnLoad, async () => {
await refreshSubtitles();
});
}
// ---- Tick loop ----
async function tick(): Promise<void> {
const now = Date.now();
if (now - lastTick < 950) return;
setLastTick(now);
if (library && !suppressTick) {
const t = getVideoTime();
const d = getVideoDuration();
const playing = isPlaying();
try { await api.tickProgress(currentIndex, t, d, playing); } catch (_) {}
}
if (now % 3000 < 1000) {
try {
const info = await api.getLibrary();
if (info && info.ok) {
const oldIndex = currentIndex;
const oldCount = library?.items?.length || 0;
setLibrary(info);
setCurrentIndex(info.current_index || currentIndex);
setAutoplayChecked(!!info.folder_autoplay);
const player = getPlayer();
const volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
const v = clamp(Number(info.folder_volume ?? player.volume ?? 1.0), 0, 1);
if (!isVolDragging() && Math.abs(v - Number(volSlider.value)) > 0.001) {
volSlider.value = String(v);
updateVolFill();
}
const r = clamp(Number(info.folder_rate ?? player.playbackRate ?? 1.0), 0.25, 3);
player.playbackRate = r;
updateSpeedIcon(r);
buildSpeedMenu(r);
updateOverall();
updateInfoPanel();
updateNowHeader(currentItem());
if (oldIndex !== currentIndex || oldCount !== (library?.items?.length || 0)) {
renderList();
}
}
} catch (_) {}
}
try { await api.saveWindowState(); } catch (_) {}
}
// ---- Keyboard shortcuts ----
function initKeyboard(): void {
window.addEventListener('keydown', (e) => {
// Don't capture when typing in textarea/input
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
switch (e.key) {
case ' ':
e.preventDefault();
const player = getPlayer();
if (player.paused || player.ended) player.play();
else player.pause();
updatePlayPauseIcon();
break;
case 'ArrowLeft':
e.preventDefault();
try { getPlayer().currentTime = Math.max(0, getPlayer().currentTime - 5); } catch (_) {}
break;
case 'ArrowRight':
e.preventDefault();
try { getPlayer().currentTime = Math.min(getPlayer().duration || 0, getPlayer().currentTime + 5); } catch (_) {}
break;
case 'ArrowUp':
e.preventDefault();
try {
const p = getPlayer();
p.volume = clamp(p.volume + 0.05, 0, 1);
setVolume(p.volume);
} catch (_) {}
break;
case 'ArrowDown':
e.preventDefault();
try {
const p = getPlayer();
p.volume = clamp(p.volume - 0.05, 0, 1);
setVolume(p.volume);
} catch (_) {}
break;
}
});
}
// ---- Start ----
initKeyboard();
setInterval(tick, 250);
boot();