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

407
src/player.ts Normal file
View 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"/>
`;
}
}