Files
tutorialvault/src/player.ts
Your Name c0a8eca955 fix: WCAG AAA contrast compliance, speed menu z-index, custom app icon
- Fix all text colors to meet WCAG 2.2 AAA 7:1 contrast ratios against
  dark backgrounds (--textMuted, --textDim, hover states across playlist,
  player, panels, tooltips)
- Fix speed menu rendering behind seek bar by correcting z-index stacking
  context (.controls z-index:10, .miniCtl z-index:3, .seek z-index:2)
- Replace default Tauri icons with custom TutorialVault icon across all
  required sizes (32-512px PNGs, ICO, ICNS, Windows Square logos)
- Update README: Fraunces → Bricolage Grotesque font reference
- Add collapsible dock pane persistence and keyboard-adjustable dividers
2026-02-19 18:23:38 +02:00

596 lines
20 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 volMuteBtn: HTMLElement;
let volIcon: HTMLElement;
let playPauseBtn: HTMLElement;
let ppIcon: HTMLElement;
let prevBtn: HTMLElement;
let nextBtn: HTMLElement;
let fsBtn: HTMLElement;
let pipBtn: HTMLElement;
let pipIcon: 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;
let seekFeedbackEl: HTMLElement;
let errorOverlay: HTMLElement;
let errorNextBtn: HTMLElement;
// ---- Mute state ----
let muted = false;
let lastVolume = 1.0;
// ---- Seek feedback state ----
let seekFeedbackTimer: ReturnType<typeof setTimeout> | null = null;
let seekAccum = 0;
// ---- Double-click state ----
let clickTimer: ReturnType<typeof setTimeout> | null = null;
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')!;
volMuteBtn = document.getElementById('volMuteBtn')!;
volIcon = document.getElementById('volIcon')!;
playPauseBtn = document.getElementById('playPauseBtn')!;
ppIcon = document.getElementById('ppIcon')!;
prevBtn = document.getElementById('prevBtn')!;
nextBtn = document.getElementById('nextBtn')!;
fsBtn = document.getElementById('fsBtn')!;
pipBtn = document.getElementById('pipBtn')!;
pipIcon = document.getElementById('pipIcon')!;
timeNow = document.getElementById('timeNow')!;
timeDur = document.getElementById('timeDur')!;
speedBtn = document.getElementById('speedBtn')!;
speedBtn.setAttribute('aria-haspopup', 'true');
speedBtn.setAttribute('aria-expanded', 'false');
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')!;
seekFeedbackEl = document.getElementById('seekFeedback')!;
errorOverlay = document.getElementById('errorOverlay')!;
errorNextBtn = document.getElementById('errorNextBtn')!;
// --- Play/Pause ---
playPauseBtn.onclick = togglePlay;
// --- Prev / Next ---
prevBtn.onclick = () => nextPrev(-1);
nextBtn.onclick = () => nextPrev(+1);
// --- Fullscreen ---
fsBtn.onclick = () => toggleFullscreen();
// --- Picture-in-Picture ---
if (pipBtn) {
if (!document.pictureInPictureEnabled) {
pipBtn.style.display = 'none';
} else {
pipBtn.onclick = async () => {
try {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else await player.requestPictureInPicture();
} catch (_) {}
};
player.addEventListener('enterpictureinpicture', () => {
pipIcon.className = 'fa-solid fa-down-left-and-up-right-to-center';
pipBtn.setAttribute('aria-label', 'Exit picture-in-picture');
});
player.addEventListener('leavepictureinpicture', () => {
pipIcon.className = 'fa-solid fa-up-right-from-square';
pipBtn.setAttribute('aria-label', 'Enter picture-in-picture');
});
}
}
// --- Mute button ---
if (volMuteBtn) {
volMuteBtn.onclick = (e) => { e.stopPropagation(); toggleMute(); };
}
// --- Video error state ---
player.addEventListener('error', () => {
if (errorOverlay) errorOverlay.style.display = 'flex';
});
if (errorNextBtn) {
errorNextBtn.onclick = () => nextPrev(+1);
}
// --- 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();
// Update active row's mini progress bar in real time
const activeRow = document.querySelector('.row.active');
if (activeRow && d > 0) {
const bar = activeRow.querySelector('.rowProgress') as HTMLElement;
if (bar) bar.style.width = clamp((t / d) * 100, 0, 100) + '%';
}
}
cb.updateInfoPanel?.();
});
player.addEventListener('loadedmetadata', async () => {
const d = player.duration || 0;
timeDur.textContent = d ? fmtTime(d) : '00:00';
if (errorOverlay) errorOverlay.style.display = 'none';
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);
if (muted && v > 0) { muted = false; }
setSuppressTick(true); player.volume = v; setSuppressTick(false);
if (library) (library as any).folder_volume = v;
cb.updateInfoPanel?.();
updateVolFill();
updateVolumeIcon();
// 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();
const first = speedMenu.querySelector('[role="menuitem"]') as HTMLElement | null;
if (first) first.focus();
}
});
speedBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeSpeedMenu(); speedBtn.focus(); }
});
window.addEventListener('click', () => { closeSpeedMenu(); });
speedMenu.addEventListener('click', (e) => e.stopPropagation());
// --- Video overlay (single-click = play/pause, double-click = fullscreen) ---
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 (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
toggleFullscreen();
return;
}
clickTimer = setTimeout(() => {
clickTimer = null;
if (player.paused) player.play();
else player.pause();
overlayIcon.classList.add('pulse');
setTimeout(() => overlayIcon.classList.remove('pulse'), 400);
}, 250);
});
}
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';
playPauseBtn.setAttribute('aria-label', (player.paused || player.ended) ? 'Play' : '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; }
// ---- Mute ----
export function toggleMute(): void {
if (muted) {
muted = false;
const v = lastVolume > 0 ? lastVolume : 1.0;
player.volume = v;
volSlider.value = String(v);
} else {
lastVolume = player.volume || 1.0;
muted = true;
player.volume = 0;
volSlider.value = '0';
}
updateVolFill();
updateVolumeIcon();
if (library) (library as any).folder_volume = player.volume;
cb.updateInfoPanel?.();
}
export function updateVolumeIcon(): void {
if (!volIcon || !volMuteBtn) return;
const v = player.volume;
if (muted || v === 0) {
volIcon.className = 'fa-solid fa-volume-xmark';
volMuteBtn.setAttribute('aria-label', 'Unmute');
volMuteBtn.closest('.miniCtl')?.classList.add('muted');
} else if (v < 0.5) {
volIcon.className = 'fa-solid fa-volume-low';
volMuteBtn.setAttribute('aria-label', 'Mute');
volMuteBtn.closest('.miniCtl')?.classList.remove('muted');
} else {
volIcon.className = 'fa-solid fa-volume-high';
volMuteBtn.setAttribute('aria-label', 'Mute');
volMuteBtn.closest('.miniCtl')?.classList.remove('muted');
}
}
// ---- Fullscreen ----
export async function toggleFullscreen(): Promise<void> {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await player.requestFullscreen();
} catch (_) {}
}
// ---- Seek feedback ----
export function showSeekFeedback(delta: number): void {
seekAccum += delta;
const sign = seekAccum >= 0 ? '+' : '\u2212';
seekFeedbackEl.textContent = `${sign}${Math.abs(seekAccum)}s`;
seekFeedbackEl.classList.add('show');
if (seekFeedbackTimer) clearTimeout(seekFeedbackTimer);
seekFeedbackTimer = setTimeout(() => {
seekFeedbackEl.classList.remove('show');
seekAccum = 0;
}, 600);
}
// ---- Speed cycle ----
export function cycleSpeed(delta: number): void {
const current = player.playbackRate;
let idx = SPEEDS.findIndex(s => Math.abs(s - current) < 0.01);
if (idx === -1) idx = SPEEDS.indexOf(1.0);
idx = clamp(idx + delta, 0, SPEEDS.length - 1);
const r = SPEEDS[idx];
player.playbackRate = r;
if (library) (library as any).folder_rate = r;
speedBtnText.textContent = `${r.toFixed(2)}x`;
updateSpeedIcon(r);
buildSpeedMenu(r);
cb.updateInfoPanel?.();
cb.notify?.(`Speed: ${r}x`);
setSuppressTick(true);
if (library) { api.setFolderRate(r).catch(() => {}).finally(() => setSuppressTick(false)); }
else { setSuppressTick(false); }
}
/** 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');
speedBtn?.setAttribute('aria-expanded', 'false');
}
export function openSpeedMenu(): void {
speedMenu?.classList.add('show');
speedBtn?.setAttribute('aria-expanded', 'true');
}
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');
row.tabIndex = -1;
row.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
const next = row.nextElementSibling as HTMLElement | null;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
closeSpeedMenu();
speedBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.stopPropagation();
row.click();
}
});
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 = () => {
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) { api.setFolderRate(r).catch(() => {}).finally(() => setSuppressTick(false)); }
else { 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 = -150;
needleColor = 'rgba(100,180,255,.9)';
} else if (rate < 1.0) {
const t = (rate - 0.5) / 0.5;
needleAngle = -150 + t * 150;
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 * 150;
needleColor = `rgba(255,${Math.round(255 - t * 115)},${Math.round(255 - t * 155)},0.9)`;
} else {
needleAngle = 150;
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"/>
`;
}
}