diff --git a/src/index.html b/src/index.html index 635ce71..bb0c4f3 100644 --- a/src/index.html +++ b/src/index.html @@ -91,6 +91,12 @@
+
+ + @@ -129,7 +135,7 @@
- +
@@ -162,6 +168,7 @@
+
@@ -171,6 +178,8 @@

Notes

+ +
@@ -187,6 +196,7 @@

Info

+
@@ -237,7 +247,14 @@

Playlist

+
+
+ + + +
+
@@ -260,6 +277,24 @@
+ + diff --git a/src/main.ts b/src/main.ts index 55d3d0e..01060a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { // ---- 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 ---- diff --git a/src/player.ts b/src/player.ts index c5c5d8c..f7c2725 100644 --- a/src/player.ts +++ b/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 | null = null; +let seekAccum = 0; + +// ---- Double-click state ---- +let clickTimer: ReturnType | 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 { + 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, diff --git a/src/playlist.ts b/src/playlist.ts index f093b6c..f7340cd 100644 --- a/src/playlist.ts +++ b/src/playlist.ts @@ -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); } diff --git a/src/styles/components.css b/src/styles/components.css index 7c42f22..d763485 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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); +} diff --git a/src/styles/panels.css b/src/styles/panels.css index 90ac4e1..7dfb66d 100644 --- a/src/styles/panels.css +++ b/src/styles/panels.css @@ -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;} diff --git a/src/styles/player.css b/src/styles/player.css index afe386a..b7adf3a 100644 --- a/src/styles/player.css +++ b/src/styles/player.css @@ -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;} diff --git a/src/styles/playlist.css b/src/styles/playlist.css index 61e70c4..00474c6 100644 --- a/src/styles/playlist.css +++ b/src/styles/playlist.css @@ -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);} diff --git a/src/ui.ts b/src/ui.ts index 340accd..2aa0de3 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -68,6 +68,8 @@ let saveZoomTimer: ReturnType | null = null; let noteSaveTimer: ReturnType | null = null; let notesSavedTimer: ReturnType | null = null; let recentOpen = false; +let resetConfirmTimer: ReturnType | 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 { async function savePrefsPatch(patch: Record): Promise { 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)'; + } +}