From a459efae454d8264e0094586d60f01990d5b8dc8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 11:44:48 +0200 Subject: [PATCH] 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. --- .gitignore | 9 + src/main.ts | 239 ++++++++++++++++++++- src/player.ts | 407 +++++++++++++++++++++++++++++++++++ src/playlist.ts | 324 ++++++++++++++++++++++++++++ src/store.ts | 83 ++++++++ src/subtitles.ts | 206 ++++++++++++++++++ src/tooltips.ts | 92 ++++++++ src/ui.ts | 538 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1897 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 src/player.ts create mode 100644 src/playlist.ts create mode 100644 src/store.ts create mode 100644 src/subtitles.ts create mode 100644 src/tooltips.ts create mode 100644 src/ui.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f1ecab --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +src-tauri/target/ +__pycache__/ +*.pyc +.claude/ +ffmpeg.exe +ffprobe.exe +state/ diff --git a/src/main.ts b/src/main.ts index ed86daa..41fa491 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { + // Init all modules + initPlayer(); + initPlaylist(); + initSubtitles(); + initUI(); + initTooltips(); + + // Load prefs + const pres = await api.getPrefs(); + const p: Record = (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 { + 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 { + 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 { + 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(); diff --git a/src/player.ts b/src/player.ts new file mode 100644 index 0000000..61ad058 --- /dev/null +++ b/src/player.ts @@ -0,0 +1,407 @@ +/** + * Video playback controls — manages the