feat: add 15 UI enhancements

1. Mute toggle (M key + volume icon click)
2. Fullscreen shortcut (F key)
3. Seek feedback overlay (±5s flash with accumulation)
4. Playlist search/filter with clear button
5. Scroll-to-current button (IntersectionObserver)
6. Picture-in-Picture button
7. Timestamp insertion in notes
8. Keyboard shortcut help panel (? key)
9. Playback speed shortcuts ([ and ] keys)
10. Reset progress two-click confirmation
11. Video load error state overlay
12. Double-click video to fullscreen
13. Playlist stats in header (count + done)
14. Mini progress bars per playlist item
15. Collapsible dock panes with chevron icons

All enhancements are WCAG 2.2 AAA compliant with proper
aria-labels, aria-live regions, focus-visible states,
keyboard accessibility, and 44x44 touch targets.
This commit is contained in:
Your Name
2026-02-19 17:01:01 +02:00
parent 0054654fee
commit e0059fd3d5
9 changed files with 660 additions and 22 deletions

View File

@@ -16,11 +16,15 @@ 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;
@@ -30,6 +34,20 @@ 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; }
@@ -40,11 +58,15 @@ export function initPlayer(): void {
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')!;
@@ -56,22 +78,54 @@ export function initPlayer(): void {
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;
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 (_) {}
};
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', () => {
@@ -108,6 +162,7 @@ export function initPlayer(): void {
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?.();
});
@@ -120,10 +175,12 @@ export function initPlayer(): void {
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) + '%';
@@ -187,7 +244,7 @@ export function initPlayer(): void {
window.addEventListener('click', () => { closeSpeedMenu(); });
speedMenu.addEventListener('click', (e) => e.stopPropagation());
// --- Video overlay ---
// --- Video overlay (single-click = play/pause, double-click = fullscreen) ---
if (videoOverlay) {
const videoWrap = videoOverlay.parentElement!;
videoWrap.addEventListener('mouseenter', () => {
@@ -205,10 +262,19 @@ export function initPlayer(): void {
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);
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);
});
}
@@ -291,6 +357,87 @@ export function getVideoDuration(): number | 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(() => {}); }
setSuppressTick(false);
}
/** Load a video by index and handle the onloadedmetadata callback. */
export async function loadVideoSrc(
idx: number,