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

View File

@@ -91,6 +91,12 @@
<div class="overlayIcon" id="overlayIcon">
<i class="fa-solid fa-play" id="overlayIconI"></i>
</div>
<div class="seekFeedback" id="seekFeedback" aria-live="assertive"></div>
</div>
<div class="errorOverlay" id="errorOverlay" role="alert" style="display:none;">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<div class="errorMsg">This file format may not be supported.<br>Try MP4 (H.264/AAC) or WebM.</div>
<button class="errorNextBtn" id="errorNextBtn">Try next</button>
</div>
</div>
@@ -129,7 +135,7 @@
<div class="stripDivider"></div>
<div class="miniCtl" data-tooltip="Volume" data-tooltip-desc="Adjust volume (saved per folder)">
<i class="fa-solid fa-volume-high" aria-hidden="true"></i>
<button class="volMuteBtn" id="volMuteBtn" aria-label="Mute"><i class="fa-solid fa-volume-high" id="volIcon" aria-hidden="true"></i></button>
<div class="volWrap">
<div class="volTrack">
<div class="volFill" id="volFill"></div>
@@ -162,6 +168,7 @@
<div class="stripDivider"></div>
<button class="iconBtn" id="fsBtn" aria-label="Toggle fullscreen" data-tooltip="Fullscreen" data-tooltip-desc="Toggle fullscreen mode"><i class="fa-solid fa-expand" aria-hidden="true"></i></button>
<button class="iconBtn" id="pipBtn" aria-label="Enter picture-in-picture" data-tooltip="PiP" data-tooltip-desc="Toggle picture-in-picture mode"><i class="fa-solid fa-up-right-from-square" id="pipIcon" aria-hidden="true"></i></button>
</div>
</div>
</div>
@@ -171,6 +178,8 @@
<div class="dockInner">
<div class="dockHeader" id="notesHeader" data-tooltip="Notes" data-tooltip-desc="Your notes are automatically saved for each video file. Write timestamps, TODOs, or reminders.">
<h3 class="dockTitle"><i class="fa-solid fa-note-sticky" aria-hidden="true"></i> Notes</h3>
<button class="timestampBtn" id="insertTimestamp" aria-label="Insert timestamp" data-tooltip="Timestamp" data-tooltip-desc="Insert current video timestamp at cursor position"><i class="fa-solid fa-clock" aria-hidden="true"></i></button>
<i class="fa-solid fa-chevron-down dockChevron" aria-hidden="true"></i>
</div>
<div class="notesArea">
<textarea class="notes" id="notesBox" aria-label="Notes for current video" placeholder="Write timestamps, TODOs, reminders…"></textarea>
@@ -187,6 +196,7 @@
<div class="dockInner">
<div class="dockHeader" id="infoHeader" data-tooltip="Info" data-tooltip-desc="Metadata and progress info for the current folder and video. Updates automatically.">
<h3 class="dockTitle"><i class="fa-solid fa-circle-info" aria-hidden="true"></i> Info</h3>
<i class="fa-solid fa-chevron-down dockChevron" aria-hidden="true"></i>
</div>
<div class="infoGrid" id="infoGrid">
<dl class="kv">
@@ -237,7 +247,14 @@
<div class="panel" role="region" aria-label="Playlist">
<div class="panelHeader" style="align-items:center;">
<h2 class="playlistHeader" id="plistHeader" data-tooltip="Playlist" data-tooltip-desc="Drag items to reorder. The blue line shows where it will drop."><i class="fa-solid fa-list" aria-hidden="true"></i> Playlist</h2>
<span class="plistStats" id="plistStats" aria-label="Playlist statistics"></span>
<div style="flex:1 1 auto;"></div>
<div class="plistSearchWrap">
<i class="fa-solid fa-magnifying-glass plistSearchIcon" aria-hidden="true"></i>
<input type="text" id="plistSearch" class="plistSearch" aria-label="Search playlist" placeholder="Filter...">
<button class="plistSearchClear" id="plistSearchClear" aria-label="Clear search" style="display:none;"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>
</div>
<button class="scrollToCurrent" id="scrollToCurrent" aria-label="Scroll to current video" style="display:none;" data-tooltip="Scroll to Current" data-tooltip-desc="Scroll the playing video into view"><i class="fa-solid fa-crosshairs" aria-hidden="true"></i></button>
</div>
<div class="listWrap">
<div class="list" id="list"></div>
@@ -260,6 +277,24 @@
<div class="tooltip" id="fancyTooltip"><div class="tooltip-title"></div><div class="tooltip-desc"></div></div>
<div id="shortcutHelp" class="shortcutHelp" role="dialog" aria-label="Keyboard shortcuts" style="display:none;">
<div class="shortcutHelpBackdrop"></div>
<div class="shortcutHelpPanel">
<h2 class="shortcutHelpTitle">Keyboard Shortcuts</h2>
<div class="shortcutGrid">
<div class="shortcutKey"><kbd>Space</kbd></div><div class="shortcutDesc">Play / Pause</div>
<div class="shortcutKey"><kbd>&#8592;</kbd> <kbd>&#8594;</kbd></div><div class="shortcutDesc">Seek &plusmn;5 seconds</div>
<div class="shortcutKey"><kbd>&#8593;</kbd> <kbd>&#8595;</kbd></div><div class="shortcutDesc">Volume &plusmn;5%</div>
<div class="shortcutKey"><kbd>M</kbd></div><div class="shortcutDesc">Mute / Unmute</div>
<div class="shortcutKey"><kbd>F</kbd></div><div class="shortcutDesc">Toggle fullscreen</div>
<div class="shortcutKey"><kbd>[</kbd> <kbd>]</kbd></div><div class="shortcutDesc">Decrease / Increase speed</div>
<div class="shortcutKey"><kbd>Alt+&#8593;</kbd> <kbd>Alt+&#8595;</kbd></div><div class="shortcutDesc">Reorder playlist item</div>
<div class="shortcutKey"><kbd>?</kbd></div><div class="shortcutDesc">This help</div>
</div>
<div class="shortcutHelpClose">Press <kbd>?</kbd> or <kbd>Esc</kbd> to close</div>
</div>
</div>
<script type="module" src="/main.ts"></script>
</body>
</html>

View File

@@ -32,6 +32,7 @@ import {
updateSeekFill, updateVolFill, updateVideoOverlay, updateSpeedIcon,
setVolume, setPlaybackRate, buildSpeedMenu, isPlaying, getVideoTime,
getVideoDuration, getPlayer, isVolDragging,
toggleMute, toggleFullscreen, showSeekFeedback, cycleSpeed,
} from './player';
import { initPlaylist, renderList, isDragging } from './playlist';
@@ -209,8 +210,37 @@ async function tick(): Promise<void> {
// ---- Keyboard shortcuts ----
// ---- Shortcut help dialog ----
let shortcutHelpOpen = false;
function toggleShortcutHelp(): void {
const el = document.getElementById('shortcutHelp');
if (!el) return;
shortcutHelpOpen = !shortcutHelpOpen;
if (shortcutHelpOpen) {
el.style.display = 'flex';
document.getElementById('zoomRoot')?.setAttribute('aria-hidden', 'true');
// Focus trap: focus the panel
const panel = el.querySelector('.shortcutHelpPanel') as HTMLElement | null;
if (panel) { panel.tabIndex = -1; panel.focus(); }
} else {
el.style.display = 'none';
document.getElementById('zoomRoot')?.removeAttribute('aria-hidden');
}
}
function initKeyboard(): void {
window.addEventListener('keydown', (e) => {
// Close shortcut help on Escape or ?
if (shortcutHelpOpen) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault();
toggleShortcutHelp();
}
return; // trap focus while dialog is open
}
// Don't capture when typing in textarea/input
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
@@ -218,18 +248,24 @@ function initKeyboard(): void {
switch (e.key) {
case ' ':
e.preventDefault();
const player = getPlayer();
if (player.paused || player.ended) player.play();
else player.pause();
updatePlayPauseIcon();
{ const player = getPlayer();
if (player.paused || player.ended) player.play();
else player.pause();
updatePlayPauseIcon(); }
break;
case 'ArrowLeft':
e.preventDefault();
try { getPlayer().currentTime = Math.max(0, getPlayer().currentTime - 5); } catch (_) {}
try {
getPlayer().currentTime = Math.max(0, getPlayer().currentTime - 5);
showSeekFeedback(-5);
} catch (_) {}
break;
case 'ArrowRight':
e.preventDefault();
try { getPlayer().currentTime = Math.min(getPlayer().duration || 0, getPlayer().currentTime + 5); } catch (_) {}
try {
getPlayer().currentTime = Math.min(getPlayer().duration || 0, getPlayer().currentTime + 5);
showSeekFeedback(+5);
} catch (_) {}
break;
case 'ArrowUp':
e.preventDefault();
@@ -247,8 +283,36 @@ function initKeyboard(): void {
setVolume(p.volume);
} catch (_) {}
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
toggleMute();
break;
case '[':
e.preventDefault();
cycleSpeed(-1);
break;
case ']':
e.preventDefault();
cycleSpeed(+1);
break;
case '?':
e.preventDefault();
toggleShortcutHelp();
break;
}
});
// Backdrop click closes shortcut help
const helpEl = document.getElementById('shortcutHelp');
if (helpEl) {
helpEl.querySelector('.shortcutHelpBackdrop')?.addEventListener('click', () => {
if (shortcutHelpOpen) toggleShortcutHelp();
});
}
}
// ---- Start ----

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,

View File

@@ -15,6 +15,13 @@ let listEl: HTMLElement;
let emptyHint: HTMLElement;
let listScrollbar: HTMLElement;
let listScrollbarThumb: HTMLElement;
let plistSearch: HTMLInputElement;
let plistSearchClear: HTMLElement;
let plistStats: HTMLElement;
let scrollToCurrentBtn: HTMLElement;
// ---- Scroll-to-current observer ----
let activeRowObserver: IntersectionObserver | null = null;
// ---- Drag state ----
let dragFromIndex: number | null = null;
@@ -118,6 +125,42 @@ export function initPlaylist(): void {
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
document.body.appendChild(liveRegion);
// --- Playlist search ---
plistSearch = document.getElementById('plistSearch') as HTMLInputElement;
plistSearchClear = document.getElementById('plistSearchClear')!;
plistStats = document.getElementById('plistStats')!;
if (plistSearch) {
plistSearch.addEventListener('input', () => {
plistSearchClear.style.display = plistSearch.value ? 'flex' : 'none';
renderList();
});
plistSearch.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
plistSearch.value = '';
plistSearchClear.style.display = 'none';
renderList();
}
});
}
if (plistSearchClear) {
plistSearchClear.onclick = () => {
plistSearch.value = '';
plistSearchClear.style.display = 'none';
plistSearch.focus();
renderList();
};
}
// --- Scroll-to-current button ---
scrollToCurrentBtn = document.getElementById('scrollToCurrent')!;
if (scrollToCurrentBtn) {
scrollToCurrentBtn.onclick = () => {
const activeRow = listEl.querySelector('.row.active') as HTMLElement | null;
if (activeRow) activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
}
}
export function updateScrollbar(): void {
@@ -252,6 +295,7 @@ export function renderList(): void {
listEl.innerHTML = '';
if (!library || !library.items || library.items.length === 0) {
emptyHint.style.display = 'block';
if (plistStats) plistStats.textContent = '';
return;
}
emptyHint.style.display = 'none';
@@ -259,8 +303,34 @@ export function renderList(): void {
const tree = !!library.has_subdirs;
const padN = String(library.items.length).length;
for (let displayIndex = 0; displayIndex < library.items.length; displayIndex++) {
const it = library.items[displayIndex];
// Search filter
const filterText = plistSearch?.value?.trim().toLowerCase() || '';
const allItems = library.items;
const filteredItems: { it: typeof allItems[0]; displayIndex: number }[] = [];
for (let i = 0; i < allItems.length; i++) {
const it = allItems[i];
if (filterText) {
const haystack = `${it.title || ''} ${it.name || ''} ${it.relpath || ''}`.toLowerCase();
if (!haystack.includes(filterText)) continue;
}
filteredItems.push({ it, displayIndex: i });
}
// Stats
const totalCount = allItems.length;
const doneCount = allItems.filter(it => it.finished).length;
if (plistStats) {
if (filterText) {
plistStats.textContent = `${filteredItems.length} of ${totalCount}`;
} else {
plistStats.textContent = `${totalCount} videos \u00b7 ${doneCount} done`;
}
}
// Disconnect old observer
if (activeRowObserver) { activeRowObserver.disconnect(); activeRowObserver = null; }
for (const { it, displayIndex } of filteredItems) {
const row = document.createElement('div');
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
@@ -422,10 +492,34 @@ export function renderList(): void {
moveWrap.appendChild(moveDown);
}
// Mini progress bar (Enhancement 14)
if (it.duration && it.duration > 0) {
const rowProgress = document.createElement('div');
rowProgress.className = 'rowProgress';
rowProgress.setAttribute('aria-hidden', 'true');
const pct = clamp(((it.watched || 0) / it.duration) * 100, 0, 100);
rowProgress.style.width = pct + '%';
if (it.finished) rowProgress.classList.add('done');
row.appendChild(rowProgress);
}
row.appendChild(left);
row.appendChild(moveWrap);
row.appendChild(tag);
listEl.appendChild(row);
}
// Scroll-to-current observer
const activeRow = listEl.querySelector('.row.active') as HTMLElement | null;
if (activeRow && scrollToCurrentBtn) {
activeRowObserver = new IntersectionObserver((entries) => {
const visible = entries[0]?.isIntersecting;
scrollToCurrentBtn.style.display = visible ? 'none' : 'inline-flex';
}, { root: listEl, threshold: 0.5 });
activeRowObserver.observe(activeRow);
} else if (scrollToCurrentBtn) {
scrollToCurrentBtn.style.display = 'none';
}
setTimeout(updateListFades, 50);
}

View File

@@ -147,3 +147,78 @@
.subsMenuItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
.speedItem:focus-visible{background:var(--surfaceHover); padding-left:14px; outline:none;}
.dropItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
/* Keyboard shortcut help dialog */
.shortcutHelp{
position:fixed;
inset:0;
z-index:999999;
display:flex;
align-items:center;
justify-content:center;
}
.shortcutHelpBackdrop{
position:absolute;
inset:0;
background:rgba(0,0,0,.55);
}
.shortcutHelpPanel{
position:relative;
z-index:1;
padding:28px 32px;
border-radius:var(--r);
background:rgba(18,21,30,.97);
border:1px solid rgba(140,160,210,.12);
box-shadow:var(--shadow);
max-width:420px;
width:90%;
}
.shortcutHelpTitle{
font-family:var(--brand);
font-weight:700;
font-size:16px;
margin:0 0 20px;
color:rgba(235,240,252,.95);
letter-spacing:-.01em;
}
.shortcutGrid{
display:grid;
grid-template-columns:auto 1fr;
gap:10px 20px;
align-items:baseline;
}
.shortcutKey{
text-align:right;
white-space:nowrap;
}
.shortcutKey kbd{
display:inline-block;
padding:3px 8px;
border-radius:4px;
background:rgba(140,165,220,.08);
border:1px solid rgba(140,165,220,.12);
font-family:var(--mono);
font-size:12px;
color:rgba(200,212,238,.88);
line-height:1;
}
.shortcutDesc{
font-size:13px;
color:rgba(200,212,238,.78);
}
.shortcutHelpClose{
margin-top:20px;
text-align:center;
font-size:11px;
color:var(--textDim);
}
.shortcutHelpClose kbd{
display:inline-block;
padding:2px 6px;
border-radius:3px;
background:rgba(140,165,220,.06);
border:1px solid rgba(140,165,220,.10);
font-family:var(--mono);
font-size:11px;
color:rgba(170,182,210,.70);
}

View File

@@ -169,3 +169,24 @@ dl.kv dt,dl.kv dd{margin:0; padding:0;}
}
.dockDivider:hover::after{opacity:.50; height:60px;}
.dockDivider:active::after{opacity:.65;}
/* Timestamp button */
.timestampBtn{border:none; background:transparent; padding:0; margin:0 0 0 auto; cursor:pointer; display:flex; align-items:center; justify-content:center; width:28px; height:28px; border-radius:var(--r3); transition:all .2s var(--ease-bounce);}
.timestampBtn:hover{background:var(--surface-3); transform:scale(1.1);}
.timestampBtn:active{transform:scale(.9); transition-duration:.08s;}
.timestampBtn .fa{font-size:12px; color:var(--iconStrong)!important; opacity:.75; transition:opacity .15s ease;}
.timestampBtn:hover .fa{opacity:1;}
.timestampBtn:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:1px;}
/* Dock chevron */
.dockChevron{font-size:10px; color:var(--textDim); transition:transform .25s var(--ease-bounce), color .2s ease; margin-left:4px; flex:0 0 auto;}
.dockHeader:hover .dockChevron{color:var(--textMuted);}
/* Collapsible dock pane */
.dockPane.collapsed{flex:0 0 auto !important;}
.dockPane.collapsed .notesArea,
.dockPane.collapsed .infoGrid{display:none;}
/* Reset confirm state */
.toolbarBtn.confirming{background:rgba(255,70,70,.14);}
.toolbarBtn.confirming .fa{color:rgba(255,160,100,.9)!important;}

View File

@@ -246,3 +246,58 @@ video::cue{
.vol:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 1px 4px rgba(0,0,0,.25);}
.seek:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 2px 6px rgba(0,0,0,.30);}
.vol:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 1px 4px rgba(0,0,0,.25);}
/* Mute button */
.volMuteBtn{border:none; background:transparent; padding:0; margin:0; cursor:pointer; display:flex; align-items:center; justify-content:center; width:14px; height:14px; flex:0 0 auto;}
.volMuteBtn .fa{font-size:14px; color:var(--iconStrong)!important; opacity:.95; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
.volMuteBtn:hover .fa{transform:scale(1.15); opacity:1;}
.volMuteBtn:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:2px; border-radius:3px;}
.miniCtl.muted{opacity:.5;}
.miniCtl.muted .volFill{opacity:.3;}
/* Seek feedback overlay */
.seekFeedback{
position:absolute;
top:50%; left:50%;
transform:translate(-50%,-50%);
font-family:var(--mono);
font-size:28px;
font-weight:700;
color:#fff;
text-shadow:0 2px 8px rgba(0,0,0,.7);
opacity:0;
transition:opacity .15s ease;
pointer-events:none;
z-index:6;
}
.seekFeedback.show{opacity:1;}
/* Error overlay */
.errorOverlay{
position:absolute;
inset:0;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
gap:16px;
background:rgba(15,17,23,.88);
z-index:10;
}
.errorOverlay>.fa{font-size:42px; color:rgba(255,180,100,.85);}
.errorMsg{font-size:14px; color:rgba(218,225,240,.85); text-align:center; line-height:1.5; max-width:320px;}
.errorNextBtn{
border:none;
background:var(--surface-3);
color:var(--text);
padding:10px 20px;
border-radius:var(--r2);
font-size:13px;
font-weight:600;
cursor:pointer;
min-width:44px;
min-height:44px;
transition:background .2s ease;
}
.errorNextBtn:hover{background:var(--surface-4);}
.errorNextBtn:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:2px;}

View File

@@ -117,3 +117,47 @@
.moveBtn:active{transform:scale(.9); transition-duration:.08s;}
.moveBtn .fa{font-size:9px; color:var(--iconStrong)!important; opacity:.7;}
.moveBtn:hover .fa{opacity:1;}
/* Playlist stats */
.plistStats{font-family:var(--mono); font-size:11px; color:var(--textMuted); letter-spacing:.02em; white-space:nowrap; flex:0 0 auto;}
/* Playlist search */
.plistSearchWrap{display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--r2); background:var(--surface-0); border:1px solid transparent; transition:border-color .2s ease, background .2s ease; flex:0 1 180px; min-width:0;}
.plistSearchWrap:focus-within{border-color:rgba(136,164,196,.25); background:rgba(140,165,220,.04);}
.plistSearchIcon{font-size:11px; color:var(--textDim); flex:0 0 auto; transition:color .2s ease;}
.plistSearchWrap:focus-within .plistSearchIcon{color:var(--iconStrong);}
.plistSearch{border:none; background:transparent; color:var(--text); font-size:12px; font-family:var(--sans); outline:none; width:100%; min-width:0; padding:2px 0;}
.plistSearch::placeholder{color:var(--textDim); font-size:11px;}
.plistSearchClear{border:none; background:transparent; color:var(--textMuted); cursor:pointer; padding:0; display:flex; align-items:center; justify-content:center; width:20px; height:20px; flex:0 0 auto; transition:color .2s ease;}
.plistSearchClear:hover{color:var(--text);}
.plistSearchClear:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:1px; border-radius:3px;}
.plistSearchClear .fa{font-size:10px;}
/* Scroll-to-current button */
.scrollToCurrent{
width:36px; height:36px;
border-radius:var(--r2);
border:none;
background:var(--surface-2);
display:inline-flex; align-items:center; justify-content:center;
cursor:pointer;
flex:0 0 auto;
transition:all .2s var(--ease-bounce);
}
.scrollToCurrent:hover{background:var(--surface-3); transform:translateY(-1px);}
.scrollToCurrent:active{transform:scale(.9); transition-duration:.08s;}
.scrollToCurrent .fa{font-size:13px; color:var(--iconStrong)!important; opacity:.9;}
.scrollToCurrent:hover .fa{opacity:1;}
.scrollToCurrent:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:2px;}
/* Mini progress bar per row */
.rowProgress{
position:absolute;
bottom:0; left:0;
height:2px;
background:var(--accent);
border-radius:0 1px 0 0;
transition:width .3s ease;
pointer-events:none;
}
.rowProgress.done{background:var(--success);}

105
src/ui.ts
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)';
}
}