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.
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
/**
|
|
* 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 = `http://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"/>
|
|
`;
|
|
}
|
|
}
|