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:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/
|
||||
ffmpeg.exe
|
||||
ffprobe.exe
|
||||
state/
|
||||
239
src/main.ts
239
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<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();
|
||||
|
||||
407
src/player.ts
Normal file
407
src/player.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Video playback controls — manages the <video> element, seek bar,
|
||||
* volume slider, play/pause, fullscreen, and video overlay.
|
||||
*/
|
||||
import { api } from './api';
|
||||
import {
|
||||
library, currentIndex, suppressTick,
|
||||
setSuppressTick, setSeeking, seeking,
|
||||
clamp, fmtTime, cb, currentItem, computeResumeTime,
|
||||
} from './store';
|
||||
|
||||
// ---- DOM refs ----
|
||||
let player: HTMLVideoElement;
|
||||
let seek: HTMLInputElement;
|
||||
let seekFill: HTMLElement;
|
||||
let volSlider: HTMLInputElement & { dragging?: boolean };
|
||||
let volFill: HTMLElement;
|
||||
let volTooltip: HTMLElement;
|
||||
let playPauseBtn: HTMLElement;
|
||||
let ppIcon: HTMLElement;
|
||||
let prevBtn: HTMLElement;
|
||||
let nextBtn: HTMLElement;
|
||||
let fsBtn: HTMLElement;
|
||||
let timeNow: HTMLElement;
|
||||
let timeDur: HTMLElement;
|
||||
let speedBtn: HTMLElement;
|
||||
let speedBtnText: HTMLElement;
|
||||
let speedIcon: SVGElement;
|
||||
let speedMenu: HTMLElement;
|
||||
let videoOverlay: HTMLElement;
|
||||
let overlayIcon: HTMLElement;
|
||||
let overlayIconI: HTMLElement;
|
||||
|
||||
export function getPlayer(): HTMLVideoElement { return player; }
|
||||
|
||||
export function initPlayer(): void {
|
||||
player = document.getElementById('player') as HTMLVideoElement;
|
||||
seek = document.getElementById('seek') as HTMLInputElement;
|
||||
seekFill = document.getElementById('seekFill')!;
|
||||
volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
|
||||
volFill = document.getElementById('volFill')!;
|
||||
volTooltip = document.getElementById('volTooltip')!;
|
||||
playPauseBtn = document.getElementById('playPauseBtn')!;
|
||||
ppIcon = document.getElementById('ppIcon')!;
|
||||
prevBtn = document.getElementById('prevBtn')!;
|
||||
nextBtn = document.getElementById('nextBtn')!;
|
||||
fsBtn = document.getElementById('fsBtn')!;
|
||||
timeNow = document.getElementById('timeNow')!;
|
||||
timeDur = document.getElementById('timeDur')!;
|
||||
speedBtn = document.getElementById('speedBtn')!;
|
||||
speedBtnText = document.getElementById('speedBtnText')!;
|
||||
speedIcon = document.getElementById('speedIcon') as unknown as SVGElement;
|
||||
speedMenu = document.getElementById('speedMenu')!;
|
||||
videoOverlay = document.getElementById('videoOverlay')!;
|
||||
overlayIcon = document.getElementById('overlayIcon')!;
|
||||
overlayIconI = document.getElementById('overlayIconI')!;
|
||||
|
||||
// --- Play/Pause ---
|
||||
playPauseBtn.onclick = togglePlay;
|
||||
player.addEventListener('click', (e) => { e.preventDefault(); togglePlay(); });
|
||||
|
||||
// --- Prev / Next ---
|
||||
prevBtn.onclick = () => nextPrev(-1);
|
||||
nextBtn.onclick = () => nextPrev(+1);
|
||||
|
||||
// --- Fullscreen ---
|
||||
fsBtn.onclick = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await player.requestFullscreen();
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// --- Seek bar ---
|
||||
seek.addEventListener('input', () => {
|
||||
setSeeking(true);
|
||||
const d = player.duration || 0;
|
||||
if (d > 0) {
|
||||
const t = (Number(seek.value) / 1000) * d;
|
||||
timeNow.textContent = fmtTime(t);
|
||||
}
|
||||
updateSeekFill();
|
||||
});
|
||||
|
||||
seek.addEventListener('change', () => {
|
||||
const d = player.duration || 0;
|
||||
if (d > 0) {
|
||||
const t = (Number(seek.value) / 1000) * d;
|
||||
try { player.currentTime = t; } catch (_) {}
|
||||
}
|
||||
setSeeking(false);
|
||||
updateSeekFill();
|
||||
});
|
||||
|
||||
// --- Video events ---
|
||||
player.addEventListener('timeupdate', () => {
|
||||
if (!seeking) {
|
||||
const d = player.duration || 0, t = player.currentTime || 0;
|
||||
if (d > 0) seek.value = String(Math.round((t / d) * 1000));
|
||||
updateSeekFill();
|
||||
updateTimeReadout();
|
||||
}
|
||||
cb.updateInfoPanel?.();
|
||||
});
|
||||
|
||||
player.addEventListener('loadedmetadata', async () => {
|
||||
const d = player.duration || 0;
|
||||
timeDur.textContent = d ? fmtTime(d) : '00:00';
|
||||
await cb.refreshCurrentVideoMeta?.();
|
||||
});
|
||||
|
||||
player.addEventListener('ended', () => nextPrev(+1));
|
||||
player.addEventListener('play', () => { updatePlayPauseIcon(); updateVideoOverlay(); });
|
||||
player.addEventListener('pause', () => { updatePlayPauseIcon(); updateVideoOverlay(); });
|
||||
|
||||
// --- Volume slider ---
|
||||
updateVolFill();
|
||||
|
||||
volSlider.addEventListener('input', () => {
|
||||
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
|
||||
setSuppressTick(true); player.volume = v; setSuppressTick(false);
|
||||
if (library) (library as any).folder_volume = v;
|
||||
cb.updateInfoPanel?.();
|
||||
updateVolFill();
|
||||
// Update tooltip position
|
||||
if (volTooltip && volTooltip.classList.contains('show')) {
|
||||
volTooltip.textContent = Math.round(v * 100) + '%';
|
||||
const sliderWidth = volSlider.offsetWidth;
|
||||
const thumbRadius = 7;
|
||||
const trackRange = sliderWidth - thumbRadius * 2;
|
||||
const thumbCenter = thumbRadius + v * trackRange;
|
||||
volTooltip.style.left = (10 + 14 + 10 + 8 + thumbCenter) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
const showVolTooltip = () => {
|
||||
volSlider.dragging = true;
|
||||
if (volTooltip) {
|
||||
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
|
||||
volTooltip.textContent = Math.round(v * 100) + '%';
|
||||
const sliderWidth = volSlider.offsetWidth;
|
||||
const thumbRadius = 7;
|
||||
const trackRange = sliderWidth - thumbRadius * 2;
|
||||
const thumbCenter = thumbRadius + v * trackRange;
|
||||
volTooltip.style.left = (10 + 14 + 10 + 8 + thumbCenter) + 'px';
|
||||
volTooltip.classList.add('show');
|
||||
}
|
||||
};
|
||||
volSlider.addEventListener('mousedown', showVolTooltip);
|
||||
volSlider.addEventListener('touchstart', showVolTooltip);
|
||||
|
||||
volSlider.addEventListener('change', async () => {
|
||||
volSlider.dragging = false;
|
||||
if (volTooltip) volTooltip.classList.remove('show');
|
||||
updateVolFill();
|
||||
if (!library) return;
|
||||
try {
|
||||
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
|
||||
await api.setFolderVolume(v);
|
||||
(library as any).folder_volume = v;
|
||||
cb.updateInfoPanel?.();
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
const hideVolTooltip = () => {
|
||||
volSlider.dragging = false;
|
||||
if (volTooltip) volTooltip.classList.remove('show');
|
||||
};
|
||||
window.addEventListener('mouseup', hideVolTooltip);
|
||||
window.addEventListener('touchend', hideVolTooltip);
|
||||
|
||||
// --- Speed menu ---
|
||||
speedBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (speedMenu.classList.contains('show')) closeSpeedMenu();
|
||||
else openSpeedMenu();
|
||||
});
|
||||
window.addEventListener('click', () => { closeSpeedMenu(); });
|
||||
speedMenu.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// --- Video overlay ---
|
||||
if (videoOverlay) {
|
||||
const videoWrap = videoOverlay.parentElement!;
|
||||
videoWrap.addEventListener('mouseenter', () => {
|
||||
if (!player.paused && overlayIcon) {
|
||||
overlayIconI.className = 'fa-solid fa-pause';
|
||||
overlayIcon.classList.add('pause');
|
||||
overlayIcon.classList.add('show');
|
||||
}
|
||||
});
|
||||
videoWrap.addEventListener('mouseleave', () => {
|
||||
if (!player.paused && overlayIcon) {
|
||||
overlayIcon.classList.remove('show');
|
||||
}
|
||||
});
|
||||
videoOverlay.style.pointerEvents = 'auto';
|
||||
videoOverlay.style.cursor = 'pointer';
|
||||
videoOverlay.addEventListener('click', () => {
|
||||
if (player.paused) player.play();
|
||||
else player.pause();
|
||||
overlayIcon.classList.add('pulse');
|
||||
setTimeout(() => overlayIcon.classList.remove('pulse'), 400);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(updateVideoOverlay, 100);
|
||||
}
|
||||
|
||||
// ---- Exported functions ----
|
||||
|
||||
export function togglePlay(): void {
|
||||
try {
|
||||
if (player.paused || player.ended) player.play();
|
||||
else player.pause();
|
||||
} catch (_) {}
|
||||
updatePlayPauseIcon();
|
||||
}
|
||||
|
||||
export function nextPrev(delta: number): void {
|
||||
if (!library || !library.items || library.items.length === 0) return;
|
||||
const newIdx = Math.max(0, Math.min(currentIndex + delta, library.items.length - 1));
|
||||
const it = library.items[newIdx];
|
||||
const auto = !!library.folder_autoplay;
|
||||
cb.loadIndex?.(newIdx, computeResumeTime(it), !auto, auto);
|
||||
}
|
||||
|
||||
export function updatePlayPauseIcon(): void {
|
||||
if (!ppIcon) return;
|
||||
ppIcon.className = (player.paused || player.ended) ? 'fa-solid fa-play' : 'fa-solid fa-pause';
|
||||
}
|
||||
|
||||
export function updateTimeReadout(): void {
|
||||
const t = player.currentTime || 0, d = player.duration || 0;
|
||||
timeNow.textContent = fmtTime(t);
|
||||
timeDur.textContent = d ? fmtTime(d) : '00:00';
|
||||
}
|
||||
|
||||
export function updateSeekFill(): void {
|
||||
if (seekFill) {
|
||||
const pct = (Number(seek.value) / 1000) * 100;
|
||||
seekFill.style.width = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateVolFill(): void {
|
||||
if (volFill && volSlider) {
|
||||
const pct = clamp(Number(volSlider.value || 1.0), 0, 1) * 100;
|
||||
volFill.style.width = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateVideoOverlay(): void {
|
||||
if (!overlayIcon || !overlayIconI) return;
|
||||
if (player.paused) {
|
||||
overlayIconI.className = 'fa-solid fa-play';
|
||||
overlayIcon.classList.remove('pause');
|
||||
overlayIcon.classList.add('show');
|
||||
} else {
|
||||
overlayIcon.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
export function setVolume(vol: number): void {
|
||||
const v = clamp(vol, 0, 1);
|
||||
player.volume = v;
|
||||
volSlider.value = String(v);
|
||||
updateVolFill();
|
||||
}
|
||||
|
||||
export function setPlaybackRate(rate: number): void {
|
||||
const r = clamp(rate, 0.25, 3);
|
||||
player.playbackRate = r;
|
||||
speedBtnText.textContent = `${r.toFixed(2)}x`;
|
||||
updateSpeedIcon(r);
|
||||
}
|
||||
|
||||
export function getVideoTime(): number { return player?.currentTime || 0; }
|
||||
export function getVideoDuration(): number | null {
|
||||
return player && Number.isFinite(player.duration) ? player.duration : null;
|
||||
}
|
||||
export function isPlaying(): boolean { return player ? !player.paused && !player.ended : false; }
|
||||
export function isVolDragging(): boolean { return !!volSlider?.dragging; }
|
||||
|
||||
/** Load a video by index and handle the onloadedmetadata callback. */
|
||||
export async function loadVideoSrc(
|
||||
idx: number,
|
||||
timecode: number = 0,
|
||||
pauseAfterLoad: boolean = true,
|
||||
autoplayOnLoad: boolean = false,
|
||||
onReady?: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!library || !library.items || library.items.length === 0) return;
|
||||
idx = Math.max(0, Math.min(idx, library.items.length - 1));
|
||||
|
||||
const keepVol = clamp(Number(library.folder_volume ?? player.volume ?? 1.0), 0, 1);
|
||||
const keepRate = clamp(Number(library.folder_rate ?? player.playbackRate ?? 1.0), 0.25, 3);
|
||||
|
||||
setSuppressTick(true);
|
||||
player.src = `tutdock://localhost/video/${idx}`;
|
||||
player.load();
|
||||
|
||||
player.onloadedmetadata = async () => {
|
||||
try { player.volume = keepVol; volSlider.value = String(keepVol); updateVolFill(); } catch (_) {}
|
||||
try { player.playbackRate = keepRate; } catch (_) {}
|
||||
speedBtnText.textContent = `${keepRate.toFixed(2)}x`;
|
||||
updateSpeedIcon(keepRate);
|
||||
cb.buildSpeedMenu?.(keepRate);
|
||||
|
||||
try { const t = Number(timecode || 0.0); if (t > 0) player.currentTime = t; } catch (_) {}
|
||||
|
||||
if (onReady) await onReady();
|
||||
|
||||
if (autoplayOnLoad) {
|
||||
try { await player.play(); } catch (_) {}
|
||||
} else if (pauseAfterLoad) {
|
||||
player.pause();
|
||||
}
|
||||
|
||||
setSuppressTick(false);
|
||||
updateTimeReadout();
|
||||
updatePlayPauseIcon();
|
||||
cb.updateInfoPanel?.();
|
||||
await cb.refreshCurrentVideoMeta?.();
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Speed menu ----
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
|
||||
export function closeSpeedMenu(): void { speedMenu?.classList.remove('show'); }
|
||||
export function openSpeedMenu(): void { speedMenu?.classList.add('show'); }
|
||||
|
||||
export function buildSpeedMenu(active: number): void {
|
||||
if (!speedMenu) return;
|
||||
speedMenu.innerHTML = '';
|
||||
for (const s of SPEEDS) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'speedItem' + (Math.abs(s - active) < 0.0001 ? ' active' : '');
|
||||
row.setAttribute('role', 'menuitem');
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.style.display = 'flex'; left.style.alignItems = 'center'; left.style.gap = '10px';
|
||||
const dot = document.createElement('div'); dot.className = 'dot';
|
||||
const txt = document.createElement('div');
|
||||
txt.textContent = `${s.toFixed(2)}x`;
|
||||
|
||||
left.appendChild(dot); left.appendChild(txt);
|
||||
row.appendChild(left);
|
||||
|
||||
row.onclick = async () => {
|
||||
closeSpeedMenu();
|
||||
const r = clamp(Number(s), 0.25, 3);
|
||||
player.playbackRate = r;
|
||||
if (library) (library as any).folder_rate = r;
|
||||
speedBtnText.textContent = `${r.toFixed(2)}x`;
|
||||
updateSpeedIcon(r);
|
||||
buildSpeedMenu(r);
|
||||
cb.updateInfoPanel?.();
|
||||
setSuppressTick(true);
|
||||
if (library) { try { await api.setFolderRate(r); } catch (_) {} }
|
||||
setSuppressTick(false);
|
||||
};
|
||||
speedMenu.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSpeedIcon(rate: number): void {
|
||||
if (!speedIcon) return;
|
||||
let needleAngle = 0;
|
||||
let needleColor = 'rgba(255,255,255,.85)';
|
||||
|
||||
if (rate <= 0.5) {
|
||||
needleAngle = -60;
|
||||
needleColor = 'rgba(100,180,255,.9)';
|
||||
} else if (rate < 1.0) {
|
||||
const t = (rate - 0.5) / 0.5;
|
||||
needleAngle = -60 + t * 60;
|
||||
needleColor = `rgba(${Math.round(100 + t * 155)},${Math.round(180 + t * 75)},${Math.round(255)},0.9)`;
|
||||
} else if (rate <= 1.0) {
|
||||
needleAngle = 0;
|
||||
needleColor = 'rgba(255,255,255,.85)';
|
||||
} else if (rate < 2.0) {
|
||||
const t = (rate - 1.0) / 1.0;
|
||||
needleAngle = t * 75;
|
||||
needleColor = `rgba(255,${Math.round(255 - t * 115)},${Math.round(255 - t * 155)},0.9)`;
|
||||
} else {
|
||||
needleAngle = 75;
|
||||
needleColor = 'rgba(255,140,100,.9)';
|
||||
}
|
||||
|
||||
const needleGroup = speedIcon.querySelector('.speed-needle') as SVGElement | null;
|
||||
const needleLine = speedIcon.querySelector('.speed-needle line') as SVGElement | null;
|
||||
if (needleGroup && needleLine) {
|
||||
needleGroup.style.transform = `rotate(${needleAngle}deg)`;
|
||||
needleLine.style.stroke = needleColor;
|
||||
} else {
|
||||
speedIcon.innerHTML = `
|
||||
<path d="M12 22C6.5 22 2 17.5 2 12S6.5 2 12 2s10 4.5 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".5"/>
|
||||
<path d="M12 22c5.5 0 10-4.5 10-10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".3"/>
|
||||
<g class="speed-needle" style="transform-origin:12px 13px; transform:rotate(${needleAngle}deg); transition:transform 0.8s cubic-bezier(.4,0,.2,1);">
|
||||
<line x1="12" y1="13" x2="12" y2="5" stroke="${needleColor}" stroke-width="2.5" stroke-linecap="round" style="transition:stroke 0.8s ease;"/>
|
||||
</g>
|
||||
<circle cx="12" cy="13" r="2" fill="currentColor" opacity=".7"/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
324
src/playlist.ts
Normal file
324
src/playlist.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Playlist rendering — list items, tree SVG connectors, custom scrollbar,
|
||||
* and drag-and-drop reorder.
|
||||
*/
|
||||
import { api } from './api';
|
||||
import type { VideoItem } from './types';
|
||||
import {
|
||||
library, currentIndex, setCurrentIndex,
|
||||
clamp, fmtTime, cb, currentItem, computeResumeTime,
|
||||
setLibrary,
|
||||
} from './store';
|
||||
|
||||
// ---- DOM refs ----
|
||||
let listEl: HTMLElement;
|
||||
let emptyHint: HTMLElement;
|
||||
let listScrollbar: HTMLElement;
|
||||
let listScrollbarThumb: HTMLElement;
|
||||
|
||||
// ---- Drag state ----
|
||||
let dragFromIndex: number | null = null;
|
||||
let dropTargetIndex: number | null = null;
|
||||
let dropAfter = false;
|
||||
|
||||
// ---- Scrollbar state ----
|
||||
let scrollbarHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollbarDragging = false;
|
||||
let scrollbarDragStartY = 0;
|
||||
let scrollbarDragStartScrollTop = 0;
|
||||
|
||||
// ---- Scroll fades ----
|
||||
let updateListFades: () => void = () => {};
|
||||
|
||||
export function initPlaylist(): void {
|
||||
listEl = document.getElementById('list')!;
|
||||
emptyHint = document.getElementById('emptyHint')!;
|
||||
listScrollbar = document.getElementById('listScrollbar')!;
|
||||
listScrollbarThumb = document.getElementById('listScrollbarThumb')!;
|
||||
|
||||
// Scrollbar drag handlers
|
||||
if (listScrollbarThumb) {
|
||||
listScrollbarThumb.style.pointerEvents = 'auto';
|
||||
listScrollbar.style.pointerEvents = 'auto';
|
||||
|
||||
const startDrag = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault();
|
||||
scrollbarDragging = true;
|
||||
scrollbarDragStartY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
scrollbarDragStartScrollTop = listEl.scrollTop;
|
||||
listScrollbar.classList.add('active');
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const doDrag = (e: MouseEvent | TouchEvent) => {
|
||||
if (!scrollbarDragging) return;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
const deltaY = clientY - scrollbarDragStartY;
|
||||
const trackHeight = listEl.clientHeight - 24;
|
||||
const thumbHeight = Math.max(24, listEl.clientHeight * (listEl.clientHeight / listEl.scrollHeight));
|
||||
const scrollableTrack = trackHeight - thumbHeight;
|
||||
const maxScroll = listEl.scrollHeight - listEl.clientHeight;
|
||||
if (scrollableTrack > 0) {
|
||||
const scrollDelta = (deltaY / scrollableTrack) * maxScroll;
|
||||
listEl.scrollTop = scrollbarDragStartScrollTop + scrollDelta;
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (scrollbarDragging) {
|
||||
scrollbarDragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
if (scrollbarHideTimer) clearTimeout(scrollbarHideTimer);
|
||||
scrollbarHideTimer = setTimeout(() => { listScrollbar.classList.remove('active'); }, 1200);
|
||||
}
|
||||
};
|
||||
|
||||
listScrollbarThumb.addEventListener('mousedown', startDrag as any);
|
||||
listScrollbarThumb.addEventListener('touchstart', startDrag as any);
|
||||
window.addEventListener('mousemove', doDrag as any);
|
||||
window.addEventListener('touchmove', doDrag as any);
|
||||
window.addEventListener('mouseup', endDrag);
|
||||
window.addEventListener('touchend', endDrag);
|
||||
}
|
||||
|
||||
if (listEl) {
|
||||
updateListFades = () => {
|
||||
const atTop = listEl.scrollTop < 5;
|
||||
const atBottom = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 5;
|
||||
listEl.classList.toggle('at-top', atTop);
|
||||
listEl.classList.toggle('at-bottom', atBottom);
|
||||
updateScrollbar();
|
||||
if (listScrollbar && !scrollbarDragging) {
|
||||
listScrollbar.classList.add('active');
|
||||
if (scrollbarHideTimer) clearTimeout(scrollbarHideTimer);
|
||||
scrollbarHideTimer = setTimeout(() => { listScrollbar.classList.remove('active'); }, 1200);
|
||||
}
|
||||
};
|
||||
listEl.addEventListener('scroll', updateListFades);
|
||||
setTimeout(updateListFades, 100);
|
||||
setTimeout(updateListFades, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateScrollbar(): void {
|
||||
if (!listEl || !listScrollbarThumb) return;
|
||||
const scrollHeight = listEl.scrollHeight;
|
||||
const clientHeight = listEl.clientHeight;
|
||||
if (scrollHeight <= clientHeight) {
|
||||
listScrollbar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
listScrollbar.style.display = 'block';
|
||||
const scrollRatio = clientHeight / scrollHeight;
|
||||
const thumbHeight = Math.max(24, clientHeight * scrollRatio);
|
||||
const maxScroll = scrollHeight - clientHeight;
|
||||
const scrollTop = listEl.scrollTop;
|
||||
const trackHeight = clientHeight - 24;
|
||||
const thumbTop = maxScroll > 0 ? (scrollTop / maxScroll) * (trackHeight - thumbHeight) : 0;
|
||||
listScrollbarThumb.style.height = thumbHeight + 'px';
|
||||
listScrollbarThumb.style.top = thumbTop + 'px';
|
||||
}
|
||||
|
||||
function clearDropIndicators(): void {
|
||||
listEl.querySelectorAll('.row').forEach(r => r.classList.remove('drop-before', 'drop-after'));
|
||||
}
|
||||
|
||||
async function reorderPlaylistByGap(fromIdx: number, targetIdx: number, after: boolean): Promise<void> {
|
||||
if (!library || !library.items) return;
|
||||
const base = library.items.slice().sort((a, b) => a.index - b.index).map(x => x.fid);
|
||||
if (fromIdx < 0 || fromIdx >= base.length) return;
|
||||
if (targetIdx < 0 || targetIdx >= base.length) return;
|
||||
|
||||
const moving = base[fromIdx];
|
||||
base.splice(fromIdx, 1);
|
||||
|
||||
let insertAt = targetIdx;
|
||||
if (fromIdx < targetIdx) insertAt -= 1;
|
||||
if (after) insertAt += 1;
|
||||
insertAt = clamp(insertAt, 0, base.length);
|
||||
base.splice(insertAt, 0, moving);
|
||||
|
||||
try {
|
||||
const res = await api.setOrder(base);
|
||||
if (res && res.ok) {
|
||||
const info = await api.getLibrary();
|
||||
if (info && info.ok) {
|
||||
setLibrary(info);
|
||||
setCurrentIndex(info.current_index || 0);
|
||||
renderList();
|
||||
cb.updateOverall?.();
|
||||
cb.updateInfoPanel?.();
|
||||
await cb.refreshCurrentVideoMeta?.();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function renderTreeSvg(it: VideoItem): SVGSVGElement {
|
||||
const depth = Number(it.depth || 0);
|
||||
const pipes = Array.isArray(it.pipes) ? it.pipes : [];
|
||||
const isLast = !!it.is_last;
|
||||
const hasPrev = !!it.has_prev_in_parent;
|
||||
|
||||
const unit = 14;
|
||||
const pad = 8;
|
||||
const height = 28;
|
||||
const mid = Math.round(height / 2);
|
||||
const extend = 20;
|
||||
const width = pad + Math.max(1, depth) * unit + 18;
|
||||
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('class', 'treeSvg');
|
||||
svg.setAttribute('width', String(width));
|
||||
svg.setAttribute('height', String(height));
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
svg.style.overflow = 'visible';
|
||||
|
||||
const xCol = (j: number) => pad + j * unit + 0.5;
|
||||
|
||||
for (let j = 0; j < depth - 1; j++) {
|
||||
if (pipes[j]) {
|
||||
const ln = document.createElementNS(ns, 'line');
|
||||
ln.setAttribute('x1', String(xCol(j)));
|
||||
ln.setAttribute('y1', String(-extend));
|
||||
ln.setAttribute('x2', String(xCol(j)));
|
||||
ln.setAttribute('y2', String(height + extend));
|
||||
svg.appendChild(ln);
|
||||
}
|
||||
}
|
||||
|
||||
if (depth <= 0) {
|
||||
const c = document.createElementNS(ns, 'circle');
|
||||
c.setAttribute('cx', String(pad + 3));
|
||||
c.setAttribute('cy', String(mid));
|
||||
c.setAttribute('r', '3.2');
|
||||
c.setAttribute('opacity', '0.40');
|
||||
svg.appendChild(c);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const parentCol = depth - 1;
|
||||
const px = xCol(parentCol);
|
||||
|
||||
if (hasPrev || !isLast) {
|
||||
const vln = document.createElementNS(ns, 'line');
|
||||
vln.setAttribute('x1', String(px));
|
||||
vln.setAttribute('y1', String(hasPrev ? -extend : mid));
|
||||
vln.setAttribute('x2', String(px));
|
||||
vln.setAttribute('y2', String(isLast ? mid : String(height + extend)));
|
||||
svg.appendChild(vln);
|
||||
}
|
||||
|
||||
const hx1 = px;
|
||||
const hx2 = px + unit;
|
||||
const h = document.createElementNS(ns, 'line');
|
||||
h.setAttribute('x1', String(hx1));
|
||||
h.setAttribute('y1', String(mid));
|
||||
h.setAttribute('x2', String(hx2));
|
||||
h.setAttribute('y2', String(mid));
|
||||
svg.appendChild(h);
|
||||
|
||||
const node = document.createElementNS(ns, 'circle');
|
||||
node.setAttribute('cx', String(hx2));
|
||||
node.setAttribute('cy', String(mid));
|
||||
node.setAttribute('r', '3.4');
|
||||
svg.appendChild(node);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function renderList(): void {
|
||||
listEl.innerHTML = '';
|
||||
if (!library || !library.items || library.items.length === 0) {
|
||||
emptyHint.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
emptyHint.style.display = 'none';
|
||||
|
||||
const tree = !!library.has_subdirs;
|
||||
const padN = String(library.items.length).length;
|
||||
|
||||
for (let displayIndex = 0; displayIndex < library.items.length; displayIndex++) {
|
||||
const it = library.items[displayIndex];
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
|
||||
row.draggable = true;
|
||||
row.dataset.index = String(it.index);
|
||||
|
||||
row.onclick = () => {
|
||||
if (dragFromIndex !== null) return;
|
||||
cb.loadIndex?.(it.index, computeResumeTime(it), true);
|
||||
};
|
||||
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
dragFromIndex = Number(row.dataset.index);
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
try { e.dataTransfer!.setData('text/plain', String(dragFromIndex)); } catch (_) {}
|
||||
});
|
||||
|
||||
row.addEventListener('dragend', async () => {
|
||||
row.classList.remove('dragging');
|
||||
if (dragFromIndex !== null && dropTargetIndex !== null) {
|
||||
await reorderPlaylistByGap(dragFromIndex, dropTargetIndex, dropAfter);
|
||||
}
|
||||
dragFromIndex = null; dropTargetIndex = null; dropAfter = false;
|
||||
clearDropIndicators();
|
||||
});
|
||||
|
||||
row.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
const rect = row.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const after = y > rect.height / 2;
|
||||
dropTargetIndex = Number(row.dataset.index);
|
||||
dropAfter = after;
|
||||
clearDropIndicators();
|
||||
row.classList.add(after ? 'drop-after' : 'drop-before');
|
||||
});
|
||||
|
||||
row.addEventListener('drop', (e) => e.preventDefault());
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'left';
|
||||
|
||||
const num = document.createElement('div');
|
||||
num.className = 'numBadge';
|
||||
num.textContent = String(displayIndex + 1).padStart(padN, '0');
|
||||
left.appendChild(num);
|
||||
|
||||
if (tree) left.appendChild(renderTreeSvg(it));
|
||||
|
||||
const textWrap = document.createElement('div');
|
||||
textWrap.className = 'textWrap';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'name';
|
||||
name.textContent = it.title || it.name;
|
||||
|
||||
const small = document.createElement('div');
|
||||
small.className = 'small';
|
||||
const d = it.duration, w = it.watched || 0;
|
||||
const note = it.note_len ? ' \u2022 note' : '';
|
||||
const sub = it.has_sub ? ' \u2022 subs' : '';
|
||||
small.textContent = (d ? `${fmtTime(w)} / ${fmtTime(d)}` : `${fmtTime(w)} watched`) + note + sub + ` - ${it.relpath}`;
|
||||
|
||||
textWrap.appendChild(name);
|
||||
textWrap.appendChild(small);
|
||||
left.appendChild(textWrap);
|
||||
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'tag';
|
||||
if (it.index === currentIndex) { tag.classList.add('now'); tag.textContent = 'Now'; }
|
||||
else if (it.finished) { tag.classList.add('done'); tag.textContent = 'Done'; }
|
||||
else { tag.classList.add('hidden'); tag.textContent = ''; }
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(tag);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
setTimeout(updateListFades, 50);
|
||||
}
|
||||
83
src/store.ts
Normal file
83
src/store.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared mutable state and pure utility functions.
|
||||
* All modules import from here — no circular dependencies.
|
||||
*/
|
||||
import type { LibraryInfo, VideoItem } from './types';
|
||||
|
||||
// ---- Shared mutable state ----
|
||||
export let library: LibraryInfo | null = null;
|
||||
export let currentIndex = 0;
|
||||
export let prefs: Record<string, any> | null = null;
|
||||
export let suppressTick = false;
|
||||
export let lastTick = 0;
|
||||
export let seeking = false;
|
||||
|
||||
export function setLibrary(lib: LibraryInfo | null) { library = lib; }
|
||||
export function setCurrentIndex(idx: number) { currentIndex = idx; }
|
||||
export function setPrefs(p: Record<string, any> | null) { prefs = p; }
|
||||
export function setSuppressTick(v: boolean) { suppressTick = v; }
|
||||
export function setLastTick(v: number) { lastTick = v; }
|
||||
export function setSeeking(v: boolean) { seeking = v; }
|
||||
|
||||
// ---- Cross-module callbacks (set by main.ts) ----
|
||||
export const cb = {
|
||||
loadIndex: null as ((idx: number, tc?: number, pause?: boolean, autoplay?: boolean) => Promise<void>) | null,
|
||||
renderList: null as (() => void) | null,
|
||||
updateInfoPanel: null as (() => void) | null,
|
||||
updateOverall: null as (() => void) | null,
|
||||
notify: null as ((msg: string) => void) | null,
|
||||
refreshCurrentVideoMeta: null as (() => Promise<void>) | null,
|
||||
onLibraryLoaded: null as ((info: LibraryInfo, startScan: boolean) => Promise<void>) | null,
|
||||
buildSpeedMenu: null as ((rate: number) => void) | null,
|
||||
};
|
||||
|
||||
// ---- Pure utility functions ----
|
||||
|
||||
export function clamp(n: number, a: number, b: number): number {
|
||||
return Math.max(a, Math.min(b, n));
|
||||
}
|
||||
|
||||
export function fmtTime(sec: number): string {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
n = Number(n || 0);
|
||||
if (!isFinite(n) || n <= 0) return '-';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
export function fmtDate(ts: number): string {
|
||||
if (!ts) return '-';
|
||||
try { return new Date(ts * 1000).toLocaleString(); } catch { return '-'; }
|
||||
}
|
||||
|
||||
export function fmtBitrate(bps: number): string | null {
|
||||
const n = Number(bps || 0);
|
||||
if (!isFinite(n) || n <= 0) return null;
|
||||
const kb = n / 1000.0;
|
||||
if (kb < 1000) return `${kb.toFixed(0)} kbps`;
|
||||
return `${(kb / 1000).toFixed(2)} Mbps`;
|
||||
}
|
||||
|
||||
export function currentItem(): VideoItem | null {
|
||||
if (!library || !library.items) return null;
|
||||
return library.items[currentIndex] || null;
|
||||
}
|
||||
|
||||
export function computeResumeTime(item: VideoItem | null): number {
|
||||
if (!item) return 0.0;
|
||||
if (item.finished) return 0.0;
|
||||
const pos = Number(item.pos || 0.0);
|
||||
const dur = Number(item.duration || 0.0);
|
||||
if (dur > 0) return clamp(pos, 0.0, Math.max(0.0, dur - 0.25));
|
||||
return Math.max(0.0, pos);
|
||||
}
|
||||
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;
|
||||
}
|
||||
92
src/tooltips.ts
Normal file
92
src/tooltips.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Fancy tooltip system with zoom-aware positioning.
|
||||
* Event delegation on [data-tooltip] elements with show/hide delays.
|
||||
*/
|
||||
|
||||
export function initTooltips(): void {
|
||||
const el = document.getElementById('fancyTooltip');
|
||||
if (!el) return;
|
||||
const fancyTooltip: HTMLElement = el;
|
||||
|
||||
const tooltipTitle = fancyTooltip.querySelector('.tooltip-title') as HTMLElement;
|
||||
const tooltipDesc = fancyTooltip.querySelector('.tooltip-desc') as HTMLElement;
|
||||
let showTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let _currentEl: Element | null = null;
|
||||
|
||||
function getZoom(): number {
|
||||
const z = getComputedStyle(document.documentElement).getPropertyValue('--zoom');
|
||||
return parseFloat(z) || 1;
|
||||
}
|
||||
|
||||
function positionTooltip(el: Element): void {
|
||||
const zoom = getZoom();
|
||||
const margin = 16;
|
||||
|
||||
fancyTooltip.style.transform = `scale(${zoom})`;
|
||||
fancyTooltip.style.transformOrigin = 'top left';
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const tipRect = fancyTooltip.getBoundingClientRect();
|
||||
const tipW = tipRect.width;
|
||||
const tipH = tipRect.height;
|
||||
|
||||
let left = rect.left + rect.width / 2 - tipW / 2;
|
||||
let top = rect.bottom + 8;
|
||||
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
|
||||
if (top + tipH > window.innerHeight - margin) {
|
||||
top = rect.top - tipH - 8;
|
||||
fancyTooltip.style.transformOrigin = 'bottom left';
|
||||
} else {
|
||||
fancyTooltip.style.transformOrigin = 'top left';
|
||||
}
|
||||
|
||||
if (top < margin) top = margin;
|
||||
|
||||
fancyTooltip.style.left = left + 'px';
|
||||
fancyTooltip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function showFancyTooltip(el: Element): void {
|
||||
const title = (el as HTMLElement).dataset.tooltip || '';
|
||||
const desc = (el as HTMLElement).dataset.tooltipDesc || '';
|
||||
if (!title) return;
|
||||
|
||||
if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
|
||||
|
||||
tooltipTitle.textContent = title;
|
||||
tooltipDesc.textContent = desc;
|
||||
positionTooltip(el);
|
||||
_currentEl = el;
|
||||
|
||||
if (fancyTooltip.classList.contains('visible')) return;
|
||||
|
||||
if (showTimeout) clearTimeout(showTimeout);
|
||||
showTimeout = setTimeout(() => {
|
||||
fancyTooltip.classList.add('visible');
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function hideFancyTooltip(): void {
|
||||
if (showTimeout) { clearTimeout(showTimeout); showTimeout = null; }
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
fancyTooltip.classList.remove('visible');
|
||||
_currentEl = null;
|
||||
}, 80);
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-tooltip]').forEach(el => {
|
||||
el.addEventListener('mouseenter', () => showFancyTooltip(el));
|
||||
el.addEventListener('mouseleave', hideFancyTooltip);
|
||||
el.addEventListener('mousedown', () => {
|
||||
if (showTimeout) clearTimeout(showTimeout);
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
fancyTooltip.classList.remove('visible');
|
||||
_currentEl = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
538
src/ui.ts
Normal file
538
src/ui.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* UI controls — zoom, split ratios, topbar, recent menu, info panel,
|
||||
* notes, toast notifications, and reset/reload buttons.
|
||||
*/
|
||||
import { api } from './api';
|
||||
import type { VideoItem } from './types';
|
||||
import {
|
||||
library, currentIndex, prefs, setPrefs,
|
||||
clamp, fmtTime, fmtBytes, fmtDate, fmtBitrate,
|
||||
currentItem, cb,
|
||||
} from './store';
|
||||
|
||||
// ---- DOM refs ----
|
||||
let contentGrid: HTMLElement;
|
||||
let divider: HTMLElement;
|
||||
let dockGrid: HTMLElement;
|
||||
let dockDivider: HTMLElement;
|
||||
let zoomOutBtn: HTMLElement;
|
||||
let zoomInBtn: HTMLElement;
|
||||
let zoomResetBtn: HTMLElement;
|
||||
let onTopChk: HTMLInputElement;
|
||||
let autoplayChk: HTMLInputElement;
|
||||
let chooseBtn: HTMLElement;
|
||||
let chooseDropBtn: HTMLElement;
|
||||
let recentMenu: HTMLElement;
|
||||
let refreshBtn: HTMLElement;
|
||||
let resetProgBtn: HTMLElement;
|
||||
let notesBox: HTMLTextAreaElement;
|
||||
let notesSaved: HTMLElement;
|
||||
let nowTitle: HTMLElement;
|
||||
let nowSub: HTMLElement;
|
||||
let overallBar: HTMLElement;
|
||||
let overallPct: HTMLElement;
|
||||
let toast: HTMLElement;
|
||||
let toastMsg: HTMLElement;
|
||||
let infoGridEl: HTMLElement;
|
||||
|
||||
// Info panel elements
|
||||
let infoFolder: HTMLElement;
|
||||
let infoNext: HTMLElement;
|
||||
let infoStruct: HTMLElement;
|
||||
let infoTitle: HTMLElement;
|
||||
let infoRel: HTMLElement;
|
||||
let infoPos: HTMLElement;
|
||||
let infoFileBits: HTMLElement;
|
||||
let infoVidBits: HTMLElement;
|
||||
let infoAudBits: HTMLElement;
|
||||
let infoSubsBits: HTMLElement;
|
||||
let infoFinished: HTMLElement;
|
||||
let infoRemaining: HTMLElement;
|
||||
let infoEta: HTMLElement;
|
||||
let infoVolume: HTMLElement;
|
||||
let infoSpeed: HTMLElement;
|
||||
let infoKnown: HTMLElement;
|
||||
let infoTop: HTMLElement;
|
||||
|
||||
// ---- State ----
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let draggingDivider = false;
|
||||
let draggingDockDivider = false;
|
||||
let saveSplitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveDockTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveZoomTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let recentOpen = false;
|
||||
|
||||
// ---- Player ref for position info ----
|
||||
let player: HTMLVideoElement;
|
||||
|
||||
export function initUI(): void {
|
||||
contentGrid = document.getElementById('contentGrid')!;
|
||||
divider = document.getElementById('divider')!;
|
||||
dockGrid = document.getElementById('dockGrid')!;
|
||||
dockDivider = document.getElementById('dockDivider')!;
|
||||
zoomOutBtn = document.getElementById('zoomOutBtn')!;
|
||||
zoomInBtn = document.getElementById('zoomInBtn')!;
|
||||
zoomResetBtn = document.getElementById('zoomResetBtn')!;
|
||||
onTopChk = document.getElementById('onTopChk') as HTMLInputElement;
|
||||
autoplayChk = document.getElementById('autoplayChk') as HTMLInputElement;
|
||||
chooseBtn = document.getElementById('chooseBtn')!;
|
||||
chooseDropBtn = document.getElementById('chooseDropBtn')!;
|
||||
recentMenu = document.getElementById('recentMenu')!;
|
||||
refreshBtn = document.getElementById('refreshBtn')!;
|
||||
resetProgBtn = document.getElementById('resetProgBtn')!;
|
||||
notesBox = document.getElementById('notesBox') as HTMLTextAreaElement;
|
||||
notesSaved = document.getElementById('notesSaved')!;
|
||||
nowTitle = document.getElementById('nowTitle')!;
|
||||
nowSub = document.getElementById('nowSub')!;
|
||||
overallBar = document.getElementById('overallBar')!;
|
||||
overallPct = document.getElementById('overallPct')!;
|
||||
toast = document.getElementById('toast')!;
|
||||
toastMsg = document.getElementById('toastMsg')!;
|
||||
infoGridEl = document.getElementById('infoGrid')!;
|
||||
player = document.getElementById('player') as HTMLVideoElement;
|
||||
|
||||
infoFolder = document.getElementById('infoFolder')!;
|
||||
infoNext = document.getElementById('infoNext')!;
|
||||
infoStruct = document.getElementById('infoStruct')!;
|
||||
infoTitle = document.getElementById('infoTitle')!;
|
||||
infoRel = document.getElementById('infoRel')!;
|
||||
infoPos = document.getElementById('infoPos')!;
|
||||
infoFileBits = document.getElementById('infoFileBits')!;
|
||||
infoVidBits = document.getElementById('infoVidBits')!;
|
||||
infoAudBits = document.getElementById('infoAudBits')!;
|
||||
infoSubsBits = document.getElementById('infoSubsBits')!;
|
||||
infoFinished = document.getElementById('infoFinished')!;
|
||||
infoRemaining = document.getElementById('infoRemaining')!;
|
||||
infoEta = document.getElementById('infoEta')!;
|
||||
infoVolume = document.getElementById('infoVolume')!;
|
||||
infoSpeed = document.getElementById('infoSpeed')!;
|
||||
infoKnown = document.getElementById('infoKnown')!;
|
||||
infoTop = document.getElementById('infoTop')!;
|
||||
|
||||
// --- Info panel scroll fades ---
|
||||
if (infoGridEl) {
|
||||
const updateInfoFades = () => {
|
||||
const atTop = infoGridEl.scrollTop < 5;
|
||||
const atBottom = infoGridEl.scrollTop + infoGridEl.clientHeight >= infoGridEl.scrollHeight - 5;
|
||||
infoGridEl.classList.toggle('at-top', atTop);
|
||||
infoGridEl.classList.toggle('at-bottom', atBottom);
|
||||
};
|
||||
infoGridEl.addEventListener('scroll', updateInfoFades);
|
||||
setTimeout(updateInfoFades, 100);
|
||||
setTimeout(updateInfoFades, 500);
|
||||
}
|
||||
|
||||
// --- Divider drag ---
|
||||
divider.addEventListener('mousedown', (e) => {
|
||||
draggingDivider = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
dockDivider.addEventListener('mousedown', (e) => {
|
||||
draggingDockDivider = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', async () => {
|
||||
if (draggingDivider) {
|
||||
draggingDivider = false;
|
||||
document.body.style.userSelect = '';
|
||||
if (prefs && typeof prefs.split_ratio === 'number') {
|
||||
await savePrefsPatch({ split_ratio: prefs.split_ratio });
|
||||
}
|
||||
}
|
||||
if (draggingDockDivider) {
|
||||
draggingDockDivider = false;
|
||||
document.body.style.userSelect = '';
|
||||
if (prefs && typeof prefs.dock_ratio === 'number') {
|
||||
await savePrefsPatch({ dock_ratio: prefs.dock_ratio });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (draggingDivider) {
|
||||
const rect = contentGrid.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
if (!prefs) setPrefs({});
|
||||
prefs!.split_ratio = applySplit(x / rect.width);
|
||||
if (saveSplitTimer) clearTimeout(saveSplitTimer);
|
||||
saveSplitTimer = setTimeout(() => {
|
||||
if (prefs) savePrefsPatch({ split_ratio: prefs.split_ratio });
|
||||
}, 400);
|
||||
}
|
||||
if (draggingDockDivider) {
|
||||
const rect = dockGrid.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
if (!prefs) setPrefs({});
|
||||
prefs!.dock_ratio = applyDockSplit(x / rect.width);
|
||||
if (saveDockTimer) clearTimeout(saveDockTimer);
|
||||
saveDockTimer = setTimeout(() => {
|
||||
if (prefs) savePrefsPatch({ dock_ratio: prefs.dock_ratio });
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Zoom controls ---
|
||||
zoomOutBtn.onclick = () => {
|
||||
prefs!.ui_zoom = applyZoom(Number(prefs?.ui_zoom || 1.0) - 0.1);
|
||||
if (saveZoomTimer) clearTimeout(saveZoomTimer);
|
||||
saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120);
|
||||
if (recentOpen) positionRecentMenu();
|
||||
};
|
||||
zoomInBtn.onclick = () => {
|
||||
prefs!.ui_zoom = applyZoom(Number(prefs?.ui_zoom || 1.0) + 0.1);
|
||||
if (saveZoomTimer) clearTimeout(saveZoomTimer);
|
||||
saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120);
|
||||
if (recentOpen) positionRecentMenu();
|
||||
};
|
||||
zoomResetBtn.onclick = () => {
|
||||
prefs!.ui_zoom = applyZoom(1.0);
|
||||
if (saveZoomTimer) clearTimeout(saveZoomTimer);
|
||||
saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120);
|
||||
if (recentOpen) positionRecentMenu();
|
||||
};
|
||||
|
||||
// --- On Top toggle ---
|
||||
onTopChk.addEventListener('change', async () => {
|
||||
const enabled = !!onTopChk.checked;
|
||||
try {
|
||||
await api.setAlwaysOnTop(enabled);
|
||||
prefs!.always_on_top = enabled;
|
||||
await savePrefsPatch({ always_on_top: enabled });
|
||||
notify(enabled ? 'On top enabled.' : 'On top disabled.');
|
||||
} catch (_) {
|
||||
onTopChk.checked = !!prefs?.always_on_top;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Autoplay toggle ---
|
||||
autoplayChk.addEventListener('change', async () => {
|
||||
if (!library) return;
|
||||
const enabled = !!autoplayChk.checked;
|
||||
try {
|
||||
const res = await api.setFolderAutoplay(enabled);
|
||||
if (res && res.ok) {
|
||||
(library as any).folder_autoplay = enabled;
|
||||
updateInfoPanel();
|
||||
notify(enabled ? 'Autoplay: ON' : 'Autoplay: OFF');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// --- Reset progress ---
|
||||
resetProgBtn.addEventListener('click', async () => {
|
||||
if (!library) return;
|
||||
try {
|
||||
const res = await api.resetWatchProgress();
|
||||
if (res && res.ok) {
|
||||
notify('Progress reset for this folder.');
|
||||
const info = await api.getLibrary();
|
||||
if (info && info.ok) {
|
||||
await cb.onLibraryLoaded?.(info, false);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// --- Open folder / Recent menu ---
|
||||
chooseBtn.onclick = async () => {
|
||||
closeRecentMenu();
|
||||
const info = await api.selectFolder();
|
||||
if (!info || !info.ok) return;
|
||||
await cb.onLibraryLoaded?.(info, true);
|
||||
notify('Folder loaded.');
|
||||
};
|
||||
|
||||
chooseDropBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (recentOpen) closeRecentMenu();
|
||||
else await openRecentMenu();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', () => { if (recentOpen) positionRecentMenu(); });
|
||||
window.addEventListener('scroll', () => { if (recentOpen) positionRecentMenu(); }, true);
|
||||
window.addEventListener('click', () => { if (recentOpen) closeRecentMenu(); });
|
||||
recentMenu.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// --- Reload ---
|
||||
refreshBtn.onclick = async () => {
|
||||
const info = await api.getLibrary();
|
||||
if (!info || !info.ok) return;
|
||||
await cb.onLibraryLoaded?.(info, false);
|
||||
notify('Reloaded.');
|
||||
};
|
||||
|
||||
// --- Notes ---
|
||||
notesBox.addEventListener('input', () => {
|
||||
if (!library) return;
|
||||
const it = currentItem();
|
||||
if (!it) return;
|
||||
if (noteSaveTimer) clearTimeout(noteSaveTimer);
|
||||
if (notesSavedTimer) clearTimeout(notesSavedTimer);
|
||||
if (notesSaved) notesSaved.classList.remove('show');
|
||||
noteSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
await api.setNote(it.fid, notesBox.value || '');
|
||||
if (notesSaved) {
|
||||
notesSaved.classList.add('show');
|
||||
notesSavedTimer = setTimeout(() => { notesSaved.classList.remove('show'); }, 2000);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Exported functions ----
|
||||
|
||||
export function applyZoom(z: number): number {
|
||||
const zoom = clamp(Number(z || 1.0), 0.75, 2.0);
|
||||
document.documentElement.style.setProperty('--zoom', String(zoom));
|
||||
if (zoomResetBtn) zoomResetBtn.textContent = `${Math.round(zoom * 100)}%`;
|
||||
document.body.getBoundingClientRect(); // force reflow
|
||||
return zoom;
|
||||
}
|
||||
|
||||
export function applySplit(ratio: number): number {
|
||||
const r = clamp(Number(ratio || 0.62), 0.35, 0.80);
|
||||
contentGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`;
|
||||
return r;
|
||||
}
|
||||
|
||||
export function applyDockSplit(ratio: number): number {
|
||||
const r = clamp(Number(ratio || 0.62), 0.35, 0.80);
|
||||
dockGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`;
|
||||
return r;
|
||||
}
|
||||
|
||||
export function notify(msg: string): void {
|
||||
if (!toastMsg || !toast) return;
|
||||
toastMsg.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toast.classList.remove('show'); }, 2600);
|
||||
}
|
||||
|
||||
export function updateNowHeader(it: VideoItem | null): void {
|
||||
nowTitle.textContent = it ? (it.title || it.name) : 'No video loaded';
|
||||
nowSub.textContent = it ? it.relpath : '-';
|
||||
}
|
||||
|
||||
export function updateOverall(): void {
|
||||
if (!library) { overallBar.style.width = '0%'; overallPct.textContent = '-'; return; }
|
||||
if (library.overall_progress === null || library.overall_progress === undefined) {
|
||||
overallBar.style.width = '0%'; overallPct.textContent = '-'; return;
|
||||
}
|
||||
const p = clamp(library.overall_progress, 0, 1);
|
||||
overallBar.style.width = `${(p * 100).toFixed(1)}%`;
|
||||
overallPct.textContent = `${(p * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function updateInfoPanel(): void {
|
||||
const it = currentItem();
|
||||
infoFolder.textContent = library?.folder || '-';
|
||||
infoNext.textContent = library?.next_up ? library.next_up.title : '-';
|
||||
infoStruct.textContent = library ? (library.has_subdirs ? 'Subfolders detected' : 'Flat folder') : '-';
|
||||
infoTitle.textContent = it?.title || '-';
|
||||
infoRel.textContent = it?.relpath || '-';
|
||||
|
||||
const t = player?.currentTime || 0;
|
||||
const d = player && Number.isFinite(player.duration) ? player.duration : 0;
|
||||
infoPos.textContent = d > 0 ? `${fmtTime(t)} / ${fmtTime(d)}` : fmtTime(t);
|
||||
|
||||
if (library) {
|
||||
infoFinished.textContent = `${library.finished_count ?? 0}`;
|
||||
infoRemaining.textContent = `${library.remaining_count ?? 0}`;
|
||||
infoEta.textContent = (library.remaining_seconds_known != null) ? fmtTime(library.remaining_seconds_known) : '-';
|
||||
infoKnown.textContent = `${library.durations_known || 0}/${library.count || 0}`;
|
||||
infoTop.textContent = (library.top_folders || []).map(([n, c]: [string, number]) => `${n}:${c}`).join(' \u2022 ') || '-';
|
||||
infoVolume.textContent = `${Math.round(clamp(Number(library.folder_volume ?? 1), 0, 1) * 100)}%`;
|
||||
infoSpeed.textContent = `${Number(library.folder_rate ?? 1).toFixed(2)}x`;
|
||||
} else {
|
||||
infoFinished.textContent = '-';
|
||||
infoRemaining.textContent = '-';
|
||||
infoEta.textContent = '-';
|
||||
infoKnown.textContent = '-';
|
||||
infoTop.textContent = '-';
|
||||
infoVolume.textContent = '-';
|
||||
infoSpeed.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshCurrentVideoMeta(): Promise<void> {
|
||||
const it = currentItem();
|
||||
if (!it) {
|
||||
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
|
||||
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.getCurrentVideoMeta();
|
||||
if (!res || !res.ok) {
|
||||
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
|
||||
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
|
||||
return;
|
||||
}
|
||||
const b = res.basic || ({} as any);
|
||||
const p = res.probe || null;
|
||||
const ffFound = !!res.ffprobe_found;
|
||||
|
||||
const bits: string[] = [];
|
||||
if (b.ext) bits.push(String(b.ext).toUpperCase());
|
||||
if (b.size) bits.push(fmtBytes(b.size));
|
||||
if (b.mtime) bits.push(`modified ${fmtDate(b.mtime)}`);
|
||||
if (b.folder) bits.push(`folder ${b.folder}`);
|
||||
infoFileBits.textContent = bits.join(' \u2022 ') || '-';
|
||||
|
||||
if (p) {
|
||||
const v: string[] = [];
|
||||
if (p.v_codec) v.push(String(p.v_codec).toUpperCase());
|
||||
if (p.width && p.height) v.push(`${p.width}\u00d7${p.height}`);
|
||||
if (p.fps) v.push(`${Number(p.fps).toFixed(2)} fps`);
|
||||
if (p.pix_fmt) v.push(p.pix_fmt);
|
||||
const vb = fmtBitrate(p.v_bitrate || 0); if (vb) v.push(vb);
|
||||
infoVidBits.textContent = v.join(' \u2022 ') || '-';
|
||||
|
||||
const a: string[] = [];
|
||||
if (p.a_codec) a.push(String(p.a_codec).toUpperCase());
|
||||
if (p.channels) a.push(`${p.channels} ch`);
|
||||
if (p.sample_rate) a.push(`${(Number(p.sample_rate) / 1000).toFixed(1)} kHz`);
|
||||
const ab = fmtBitrate(p.a_bitrate || 0); if (ab) a.push(ab);
|
||||
infoAudBits.textContent = a.join(' \u2022 ') || '-';
|
||||
|
||||
const subs = p.subtitle_tracks || [];
|
||||
if (subs.length > 0) {
|
||||
const subInfo = subs.map(s => {
|
||||
const lang = s.language?.toUpperCase() || '';
|
||||
const title = s.title || '';
|
||||
return title || lang || s.codec || 'Track';
|
||||
}).join(', ');
|
||||
infoSubsBits.textContent = `${subs.length} embedded (${subInfo})`;
|
||||
} else if (it.has_sub) {
|
||||
infoSubsBits.textContent = 'External file loaded';
|
||||
} else {
|
||||
infoSubsBits.textContent = 'None';
|
||||
}
|
||||
} else {
|
||||
infoVidBits.textContent = ffFound
|
||||
? '(ffprobe available, metadata not read for this file)'
|
||||
: '(ffprobe not found)';
|
||||
infoAudBits.textContent = '-';
|
||||
infoSubsBits.textContent = it.has_sub ? 'External file loaded' : '-';
|
||||
}
|
||||
} catch (_) {
|
||||
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
|
||||
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadNoteForCurrent(): Promise<void> {
|
||||
const it = currentItem();
|
||||
if (!it) { notesBox.value = ''; return; }
|
||||
try {
|
||||
const res = await api.getNote(it.fid);
|
||||
notesBox.value = (res && res.ok) ? (res.note || '') : '';
|
||||
} catch (_) { notesBox.value = ''; }
|
||||
}
|
||||
|
||||
export function setOnTopChecked(v: boolean): void { onTopChk.checked = v; }
|
||||
export function setAutoplayChecked(v: boolean): void { autoplayChk.checked = v; }
|
||||
|
||||
// ---- Recent menu ----
|
||||
|
||||
function ensureDropdownPortal(): void {
|
||||
try {
|
||||
if (recentMenu && recentMenu.parentElement !== document.body) {
|
||||
document.body.appendChild(recentMenu);
|
||||
}
|
||||
recentMenu.classList.add('dropdownPortal');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function positionRecentMenu(): void {
|
||||
const r = chooseDropBtn.getBoundingClientRect();
|
||||
const zoom = clamp(Number(prefs?.ui_zoom || 1), 0.75, 2.0);
|
||||
const baseW = 460, baseH = 360;
|
||||
const effW = baseW * zoom, effH = baseH * zoom;
|
||||
const left = clamp(r.right - effW, 10, window.innerWidth - effW - 10);
|
||||
const top = clamp(r.bottom + 8, 10, window.innerHeight - effH - 10);
|
||||
recentMenu.style.left = `${left}px`;
|
||||
recentMenu.style.top = `${top}px`;
|
||||
recentMenu.style.width = `${baseW}px`;
|
||||
recentMenu.style.maxHeight = `${baseH}px`;
|
||||
}
|
||||
|
||||
export function closeRecentMenu(): void {
|
||||
recentOpen = false;
|
||||
recentMenu.style.display = 'none';
|
||||
}
|
||||
|
||||
export async function openRecentMenu(): Promise<void> {
|
||||
ensureDropdownPortal();
|
||||
try {
|
||||
const res = await api.getRecents();
|
||||
recentMenu.innerHTML = '';
|
||||
if (!res || !res.ok || !res.items || res.items.length === 0) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dropEmpty';
|
||||
div.textContent = 'No recent folders yet.';
|
||||
recentMenu.appendChild(div);
|
||||
} else {
|
||||
for (const it of res.items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dropItem';
|
||||
row.dataset.tooltip = it.name;
|
||||
row.dataset.tooltipDesc = it.path;
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'dropIcon';
|
||||
icon.innerHTML = '<i class="fa-solid fa-folder"></i>';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'dropName';
|
||||
name.textContent = it.name;
|
||||
|
||||
const removeBtn = document.createElement('div');
|
||||
removeBtn.className = 'dropRemove';
|
||||
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
removeBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.removeRecent(it.path);
|
||||
row.remove();
|
||||
if (recentMenu.querySelectorAll('.dropItem').length === 0) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dropEmpty';
|
||||
div.textContent = 'No recent folders yet.';
|
||||
recentMenu.innerHTML = '';
|
||||
recentMenu.appendChild(div);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
row.appendChild(icon); row.appendChild(name); row.appendChild(removeBtn);
|
||||
row.onclick = async () => {
|
||||
closeRecentMenu();
|
||||
const info = await api.openFolderPath(it.path);
|
||||
if (!info || !info.ok) { notify('Folder not available.'); return; }
|
||||
await cb.onLibraryLoaded?.(info, true);
|
||||
};
|
||||
recentMenu.appendChild(row);
|
||||
}
|
||||
}
|
||||
positionRecentMenu();
|
||||
recentMenu.style.display = 'block';
|
||||
recentOpen = true;
|
||||
} catch (_) { closeRecentMenu(); }
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
async function savePrefsPatch(patch: Record<string, unknown>): Promise<void> {
|
||||
await api.setPrefs(patch);
|
||||
}
|
||||
Reference in New Issue
Block a user