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 98011cf604
commit 715c3c713a
9 changed files with 660 additions and 22 deletions
+104 -1
View File
@@ -68,6 +68,8 @@ let saveZoomTimer: ReturnType<typeof setTimeout> | null = null;
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
let recentOpen = false;
let resetConfirmTimer: ReturnType<typeof setTimeout> | null = null;
let resetConfirming = false;
// ---- Player ref for position info ----
let player: HTMLVideoElement;
@@ -279,9 +281,22 @@ export function initUI(): void {
} catch (_) {}
});
// --- Reset progress ---
// --- Reset progress (two-click confirmation) ---
resetProgBtn.addEventListener('click', async () => {
if (!library) return;
if (!resetConfirming) {
resetConfirming = true;
resetProgBtn.classList.add('confirming');
const icon = resetProgBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-exclamation-triangle';
resetProgBtn.setAttribute('aria-label', 'Confirm reset progress');
resetConfirmTimer = setTimeout(() => {
cancelResetConfirm();
}, 3000);
return;
}
// Second click — actually reset
cancelResetConfirm();
try {
const res = await api.resetWatchProgress();
if (res && res.ok) {
@@ -293,6 +308,7 @@ export function initUI(): void {
}
} catch (_) {}
});
resetProgBtn.addEventListener('blur', () => { if (resetConfirming) cancelResetConfirm(); });
// --- Open folder / Recent menu ---
chooseBtn.onclick = async () => {
@@ -348,6 +364,63 @@ export function initUI(): void {
} catch (_) {}
}, 350);
});
// --- Timestamp insertion ---
const insertTimestampBtn = document.getElementById('insertTimestamp');
if (insertTimestampBtn) {
insertTimestampBtn.onclick = () => {
const t = player?.currentTime || 0;
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
const stamp = `[${m}:${String(s).padStart(2, '0')}] `;
const pos = notesBox.selectionStart ?? notesBox.value.length;
const before = notesBox.value.substring(0, pos);
const after = notesBox.value.substring(pos);
notesBox.value = before + stamp + after;
notesBox.focus();
const newPos = pos + stamp.length;
notesBox.setSelectionRange(newPos, newPos);
// Trigger note save
notesBox.dispatchEvent(new Event('input'));
};
}
// --- Collapsible dock panes ---
const notesHeader = document.getElementById('notesHeader');
const infoHeader = document.getElementById('infoHeader');
if (notesHeader) {
notesHeader.style.cursor = 'pointer';
notesHeader.setAttribute('aria-expanded', 'true');
notesHeader.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.timestampBtn')) return;
toggleDockPane(notesHeader, 'notes_collapsed');
});
notesHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDockPane(notesHeader, 'notes_collapsed');
}
});
notesHeader.tabIndex = 0;
}
if (infoHeader) {
infoHeader.style.cursor = 'pointer';
infoHeader.setAttribute('aria-expanded', 'true');
infoHeader.addEventListener('click', () => {
toggleDockPane(infoHeader, 'info_collapsed');
});
infoHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDockPane(infoHeader, 'info_collapsed');
}
});
infoHeader.tabIndex = 0;
}
// Restore collapsed state from prefs
if (prefs?.notes_collapsed) collapsePane(notesHeader!, true);
if (prefs?.info_collapsed) collapsePane(infoHeader!, true);
}
// ---- Exported functions ----
@@ -633,3 +706,33 @@ export async function openRecentMenu(): Promise<void> {
async function savePrefsPatch(patch: Record<string, unknown>): Promise<void> {
await api.setPrefs(patch);
}
function cancelResetConfirm(): void {
resetConfirming = false;
if (resetConfirmTimer) { clearTimeout(resetConfirmTimer); resetConfirmTimer = null; }
resetProgBtn.classList.remove('confirming');
const icon = resetProgBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-clock-rotate-left';
resetProgBtn.setAttribute('aria-label', 'Reset progress');
}
function toggleDockPane(header: HTMLElement, prefKey: string): void {
const pane = header.closest('.dockPane');
if (!pane) return;
const isCollapsed = pane.classList.toggle('collapsed');
header.setAttribute('aria-expanded', String(!isCollapsed));
const chevron = header.querySelector('.dockChevron') as HTMLElement | null;
if (chevron) chevron.style.transform = isCollapsed ? 'rotate(-90deg)' : '';
savePrefsPatch({ [prefKey]: isCollapsed });
}
function collapsePane(header: HTMLElement, collapsed: boolean): void {
const pane = header.closest('.dockPane');
if (!pane) return;
if (collapsed) {
pane.classList.add('collapsed');
header.setAttribute('aria-expanded', 'false');
const chevron = header.querySelector('.dockChevron') as HTMLElement | null;
if (chevron) chevron.style.transform = 'rotate(-90deg)';
}
}