- 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
596 lines
20 KiB
TypeScript
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"/>
|
|
`;
|
|
}
|
|
}
|