Rename from TutorialDock to TutorialVault. Remove legacy Python app and scripts. Fix video playback, subtitles, metadata display, window state persistence, and auto-download of ffmpeg/ffprobe on first run. Bundle fonts via npm instead of runtime download.
259 lines
7.3 KiB
TypeScript
259 lines
7.3 KiB
TypeScript
/**
|
|
* TutorialDock frontend — boot sequence, tick loop, global wiring.
|
|
* Orchestrates all modules and holds cross-module callbacks.
|
|
*/
|
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
|
import '@fontsource/sora/500.css';
|
|
import '@fontsource/sora/600.css';
|
|
import '@fontsource/sora/700.css';
|
|
import '@fontsource/sora/800.css';
|
|
import '@fontsource/manrope/400.css';
|
|
import '@fontsource/manrope/500.css';
|
|
import '@fontsource/manrope/600.css';
|
|
import '@fontsource/manrope/700.css';
|
|
import '@fontsource/manrope/800.css';
|
|
import '@fontsource/ibm-plex-mono/400.css';
|
|
import '@fontsource/ibm-plex-mono/500.css';
|
|
import '@fontsource/ibm-plex-mono/600.css';
|
|
import './styles/main.css';
|
|
import './styles/player.css';
|
|
import './styles/playlist.css';
|
|
import './styles/panels.css';
|
|
import './styles/components.css';
|
|
import './styles/animations.css';
|
|
|
|
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();
|