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:
171
src/player.ts
171
src/player.ts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user