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:
@@ -91,6 +91,12 @@
|
|||||||
<div class="overlayIcon" id="overlayIcon">
|
<div class="overlayIcon" id="overlayIcon">
|
||||||
<i class="fa-solid fa-play" id="overlayIconI"></i>
|
<i class="fa-solid fa-play" id="overlayIconI"></i>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +135,7 @@
|
|||||||
<div class="stripDivider"></div>
|
<div class="stripDivider"></div>
|
||||||
|
|
||||||
<div class="miniCtl" data-tooltip="Volume" data-tooltip-desc="Adjust volume (saved per folder)">
|
<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="volWrap">
|
||||||
<div class="volTrack">
|
<div class="volTrack">
|
||||||
<div class="volFill" id="volFill"></div>
|
<div class="volFill" id="volFill"></div>
|
||||||
@@ -162,6 +168,7 @@
|
|||||||
<div class="stripDivider"></div>
|
<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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,6 +178,8 @@
|
|||||||
<div class="dockInner">
|
<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.">
|
<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>
|
<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>
|
||||||
<div class="notesArea">
|
<div class="notesArea">
|
||||||
<textarea class="notes" id="notesBox" aria-label="Notes for current video" placeholder="Write timestamps, TODOs, reminders…"></textarea>
|
<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="dockInner">
|
||||||
<div class="dockHeader" id="infoHeader" data-tooltip="Info" data-tooltip-desc="Metadata and progress info for the current folder and video. Updates automatically.">
|
<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>
|
<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>
|
||||||
<div class="infoGrid" id="infoGrid">
|
<div class="infoGrid" id="infoGrid">
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
@@ -237,7 +247,14 @@
|
|||||||
<div class="panel" role="region" aria-label="Playlist">
|
<div class="panel" role="region" aria-label="Playlist">
|
||||||
<div class="panelHeader" style="align-items:center;">
|
<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>
|
<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 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>
|
||||||
<div class="listWrap">
|
<div class="listWrap">
|
||||||
<div class="list" id="list"></div>
|
<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 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>←</kbd> <kbd>→</kbd></div><div class="shortcutDesc">Seek ±5 seconds</div>
|
||||||
|
<div class="shortcutKey"><kbd>↑</kbd> <kbd>↓</kbd></div><div class="shortcutDesc">Volume ±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+↑</kbd> <kbd>Alt+↓</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>
|
<script type="module" src="/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
76
src/main.ts
76
src/main.ts
@@ -32,6 +32,7 @@ import {
|
|||||||
updateSeekFill, updateVolFill, updateVideoOverlay, updateSpeedIcon,
|
updateSeekFill, updateVolFill, updateVideoOverlay, updateSpeedIcon,
|
||||||
setVolume, setPlaybackRate, buildSpeedMenu, isPlaying, getVideoTime,
|
setVolume, setPlaybackRate, buildSpeedMenu, isPlaying, getVideoTime,
|
||||||
getVideoDuration, getPlayer, isVolDragging,
|
getVideoDuration, getPlayer, isVolDragging,
|
||||||
|
toggleMute, toggleFullscreen, showSeekFeedback, cycleSpeed,
|
||||||
} from './player';
|
} from './player';
|
||||||
|
|
||||||
import { initPlaylist, renderList, isDragging } from './playlist';
|
import { initPlaylist, renderList, isDragging } from './playlist';
|
||||||
@@ -209,8 +210,37 @@ async function tick(): Promise<void> {
|
|||||||
|
|
||||||
// ---- Keyboard shortcuts ----
|
// ---- 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 {
|
function initKeyboard(): void {
|
||||||
window.addEventListener('keydown', (e) => {
|
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
|
// Don't capture when typing in textarea/input
|
||||||
const tag = (e.target as HTMLElement).tagName;
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
@@ -218,18 +248,24 @@ function initKeyboard(): void {
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const player = getPlayer();
|
{ const player = getPlayer();
|
||||||
if (player.paused || player.ended) player.play();
|
if (player.paused || player.ended) player.play();
|
||||||
else player.pause();
|
else player.pause();
|
||||||
updatePlayPauseIcon();
|
updatePlayPauseIcon(); }
|
||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
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;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault();
|
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;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -247,8 +283,36 @@ function initKeyboard(): void {
|
|||||||
setVolume(p.volume);
|
setVolume(p.volume);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
break;
|
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 ----
|
// ---- Start ----
|
||||||
|
|||||||
171
src/player.ts
171
src/player.ts
@@ -16,11 +16,15 @@ let seekFill: HTMLElement;
|
|||||||
let volSlider: HTMLInputElement & { dragging?: boolean };
|
let volSlider: HTMLInputElement & { dragging?: boolean };
|
||||||
let volFill: HTMLElement;
|
let volFill: HTMLElement;
|
||||||
let volTooltip: HTMLElement;
|
let volTooltip: HTMLElement;
|
||||||
|
let volMuteBtn: HTMLElement;
|
||||||
|
let volIcon: HTMLElement;
|
||||||
let playPauseBtn: HTMLElement;
|
let playPauseBtn: HTMLElement;
|
||||||
let ppIcon: HTMLElement;
|
let ppIcon: HTMLElement;
|
||||||
let prevBtn: HTMLElement;
|
let prevBtn: HTMLElement;
|
||||||
let nextBtn: HTMLElement;
|
let nextBtn: HTMLElement;
|
||||||
let fsBtn: HTMLElement;
|
let fsBtn: HTMLElement;
|
||||||
|
let pipBtn: HTMLElement;
|
||||||
|
let pipIcon: HTMLElement;
|
||||||
let timeNow: HTMLElement;
|
let timeNow: HTMLElement;
|
||||||
let timeDur: HTMLElement;
|
let timeDur: HTMLElement;
|
||||||
let speedBtn: HTMLElement;
|
let speedBtn: HTMLElement;
|
||||||
@@ -30,6 +34,20 @@ let speedMenu: HTMLElement;
|
|||||||
let videoOverlay: HTMLElement;
|
let videoOverlay: HTMLElement;
|
||||||
let overlayIcon: HTMLElement;
|
let overlayIcon: HTMLElement;
|
||||||
let overlayIconI: 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; }
|
export function getPlayer(): HTMLVideoElement { return player; }
|
||||||
|
|
||||||
@@ -40,11 +58,15 @@ export function initPlayer(): void {
|
|||||||
volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
|
volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
|
||||||
volFill = document.getElementById('volFill')!;
|
volFill = document.getElementById('volFill')!;
|
||||||
volTooltip = document.getElementById('volTooltip')!;
|
volTooltip = document.getElementById('volTooltip')!;
|
||||||
|
volMuteBtn = document.getElementById('volMuteBtn')!;
|
||||||
|
volIcon = document.getElementById('volIcon')!;
|
||||||
playPauseBtn = document.getElementById('playPauseBtn')!;
|
playPauseBtn = document.getElementById('playPauseBtn')!;
|
||||||
ppIcon = document.getElementById('ppIcon')!;
|
ppIcon = document.getElementById('ppIcon')!;
|
||||||
prevBtn = document.getElementById('prevBtn')!;
|
prevBtn = document.getElementById('prevBtn')!;
|
||||||
nextBtn = document.getElementById('nextBtn')!;
|
nextBtn = document.getElementById('nextBtn')!;
|
||||||
fsBtn = document.getElementById('fsBtn')!;
|
fsBtn = document.getElementById('fsBtn')!;
|
||||||
|
pipBtn = document.getElementById('pipBtn')!;
|
||||||
|
pipIcon = document.getElementById('pipIcon')!;
|
||||||
timeNow = document.getElementById('timeNow')!;
|
timeNow = document.getElementById('timeNow')!;
|
||||||
timeDur = document.getElementById('timeDur')!;
|
timeDur = document.getElementById('timeDur')!;
|
||||||
speedBtn = document.getElementById('speedBtn')!;
|
speedBtn = document.getElementById('speedBtn')!;
|
||||||
@@ -56,22 +78,54 @@ export function initPlayer(): void {
|
|||||||
videoOverlay = document.getElementById('videoOverlay')!;
|
videoOverlay = document.getElementById('videoOverlay')!;
|
||||||
overlayIcon = document.getElementById('overlayIcon')!;
|
overlayIcon = document.getElementById('overlayIcon')!;
|
||||||
overlayIconI = document.getElementById('overlayIconI')!;
|
overlayIconI = document.getElementById('overlayIconI')!;
|
||||||
|
seekFeedbackEl = document.getElementById('seekFeedback')!;
|
||||||
|
errorOverlay = document.getElementById('errorOverlay')!;
|
||||||
|
errorNextBtn = document.getElementById('errorNextBtn')!;
|
||||||
|
|
||||||
// --- Play/Pause ---
|
// --- Play/Pause ---
|
||||||
playPauseBtn.onclick = togglePlay;
|
playPauseBtn.onclick = togglePlay;
|
||||||
player.addEventListener('click', (e) => { e.preventDefault(); togglePlay(); });
|
|
||||||
|
|
||||||
// --- Prev / Next ---
|
// --- Prev / Next ---
|
||||||
prevBtn.onclick = () => nextPrev(-1);
|
prevBtn.onclick = () => nextPrev(-1);
|
||||||
nextBtn.onclick = () => nextPrev(+1);
|
nextBtn.onclick = () => nextPrev(+1);
|
||||||
|
|
||||||
// --- Fullscreen ---
|
// --- Fullscreen ---
|
||||||
fsBtn.onclick = async () => {
|
fsBtn.onclick = () => toggleFullscreen();
|
||||||
try {
|
|
||||||
if (document.fullscreenElement) await document.exitFullscreen();
|
// --- Picture-in-Picture ---
|
||||||
else await player.requestFullscreen();
|
if (pipBtn) {
|
||||||
} catch (_) {}
|
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 bar ---
|
||||||
seek.addEventListener('input', () => {
|
seek.addEventListener('input', () => {
|
||||||
@@ -108,6 +162,7 @@ export function initPlayer(): void {
|
|||||||
player.addEventListener('loadedmetadata', async () => {
|
player.addEventListener('loadedmetadata', async () => {
|
||||||
const d = player.duration || 0;
|
const d = player.duration || 0;
|
||||||
timeDur.textContent = d ? fmtTime(d) : '00:00';
|
timeDur.textContent = d ? fmtTime(d) : '00:00';
|
||||||
|
if (errorOverlay) errorOverlay.style.display = 'none';
|
||||||
await cb.refreshCurrentVideoMeta?.();
|
await cb.refreshCurrentVideoMeta?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,10 +175,12 @@ export function initPlayer(): void {
|
|||||||
|
|
||||||
volSlider.addEventListener('input', () => {
|
volSlider.addEventListener('input', () => {
|
||||||
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
|
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
|
||||||
|
if (muted && v > 0) { muted = false; }
|
||||||
setSuppressTick(true); player.volume = v; setSuppressTick(false);
|
setSuppressTick(true); player.volume = v; setSuppressTick(false);
|
||||||
if (library) (library as any).folder_volume = v;
|
if (library) (library as any).folder_volume = v;
|
||||||
cb.updateInfoPanel?.();
|
cb.updateInfoPanel?.();
|
||||||
updateVolFill();
|
updateVolFill();
|
||||||
|
updateVolumeIcon();
|
||||||
// Update tooltip position
|
// Update tooltip position
|
||||||
if (volTooltip && volTooltip.classList.contains('show')) {
|
if (volTooltip && volTooltip.classList.contains('show')) {
|
||||||
volTooltip.textContent = Math.round(v * 100) + '%';
|
volTooltip.textContent = Math.round(v * 100) + '%';
|
||||||
@@ -187,7 +244,7 @@ export function initPlayer(): void {
|
|||||||
window.addEventListener('click', () => { closeSpeedMenu(); });
|
window.addEventListener('click', () => { closeSpeedMenu(); });
|
||||||
speedMenu.addEventListener('click', (e) => e.stopPropagation());
|
speedMenu.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
// --- Video overlay ---
|
// --- Video overlay (single-click = play/pause, double-click = fullscreen) ---
|
||||||
if (videoOverlay) {
|
if (videoOverlay) {
|
||||||
const videoWrap = videoOverlay.parentElement!;
|
const videoWrap = videoOverlay.parentElement!;
|
||||||
videoWrap.addEventListener('mouseenter', () => {
|
videoWrap.addEventListener('mouseenter', () => {
|
||||||
@@ -205,10 +262,19 @@ export function initPlayer(): void {
|
|||||||
videoOverlay.style.pointerEvents = 'auto';
|
videoOverlay.style.pointerEvents = 'auto';
|
||||||
videoOverlay.style.cursor = 'pointer';
|
videoOverlay.style.cursor = 'pointer';
|
||||||
videoOverlay.addEventListener('click', () => {
|
videoOverlay.addEventListener('click', () => {
|
||||||
if (player.paused) player.play();
|
if (clickTimer) {
|
||||||
else player.pause();
|
clearTimeout(clickTimer);
|
||||||
overlayIcon.classList.add('pulse');
|
clickTimer = null;
|
||||||
setTimeout(() => overlayIcon.classList.remove('pulse'), 400);
|
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 isPlaying(): boolean { return player ? !player.paused && !player.ended : false; }
|
||||||
export function isVolDragging(): boolean { return !!volSlider?.dragging; }
|
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. */
|
/** Load a video by index and handle the onloadedmetadata callback. */
|
||||||
export async function loadVideoSrc(
|
export async function loadVideoSrc(
|
||||||
idx: number,
|
idx: number,
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ let listEl: HTMLElement;
|
|||||||
let emptyHint: HTMLElement;
|
let emptyHint: HTMLElement;
|
||||||
let listScrollbar: HTMLElement;
|
let listScrollbar: HTMLElement;
|
||||||
let listScrollbarThumb: 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 ----
|
// ---- Drag state ----
|
||||||
let dragFromIndex: number | null = null;
|
let dragFromIndex: number | null = null;
|
||||||
@@ -118,6 +125,42 @@ export function initPlaylist(): void {
|
|||||||
liveRegion.setAttribute('aria-atomic', 'true');
|
liveRegion.setAttribute('aria-atomic', 'true');
|
||||||
liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
|
liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
|
||||||
document.body.appendChild(liveRegion);
|
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 {
|
export function updateScrollbar(): void {
|
||||||
@@ -252,6 +295,7 @@ export function renderList(): void {
|
|||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (!library || !library.items || library.items.length === 0) {
|
if (!library || !library.items || library.items.length === 0) {
|
||||||
emptyHint.style.display = 'block';
|
emptyHint.style.display = 'block';
|
||||||
|
if (plistStats) plistStats.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emptyHint.style.display = 'none';
|
emptyHint.style.display = 'none';
|
||||||
@@ -259,8 +303,34 @@ export function renderList(): void {
|
|||||||
const tree = !!library.has_subdirs;
|
const tree = !!library.has_subdirs;
|
||||||
const padN = String(library.items.length).length;
|
const padN = String(library.items.length).length;
|
||||||
|
|
||||||
for (let displayIndex = 0; displayIndex < library.items.length; displayIndex++) {
|
// Search filter
|
||||||
const it = library.items[displayIndex];
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
|
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
|
||||||
@@ -422,10 +492,34 @@ export function renderList(): void {
|
|||||||
moveWrap.appendChild(moveDown);
|
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(left);
|
||||||
row.appendChild(moveWrap);
|
row.appendChild(moveWrap);
|
||||||
row.appendChild(tag);
|
row.appendChild(tag);
|
||||||
listEl.appendChild(row);
|
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);
|
setTimeout(updateListFades, 50);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,3 +147,78 @@
|
|||||||
.subsMenuItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
|
.subsMenuItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
|
||||||
.speedItem:focus-visible{background:var(--surfaceHover); padding-left:14px; outline:none;}
|
.speedItem:focus-visible{background:var(--surfaceHover); padding-left:14px; outline:none;}
|
||||||
.dropItem:focus-visible{background:var(--surfaceHover); padding-left:16px; 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,3 +169,24 @@ dl.kv dt,dl.kv dd{margin:0; padding:0;}
|
|||||||
}
|
}
|
||||||
.dockDivider:hover::after{opacity:.50; height:60px;}
|
.dockDivider:hover::after{opacity:.50; height:60px;}
|
||||||
.dockDivider:active::after{opacity:.65;}
|
.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;}
|
||||||
|
|||||||
@@ -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);}
|
.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);}
|
.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);}
|
.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;}
|
||||||
|
|||||||
@@ -117,3 +117,47 @@
|
|||||||
.moveBtn:active{transform:scale(.9); transition-duration:.08s;}
|
.moveBtn:active{transform:scale(.9); transition-duration:.08s;}
|
||||||
.moveBtn .fa{font-size:9px; color:var(--iconStrong)!important; opacity:.7;}
|
.moveBtn .fa{font-size:9px; color:var(--iconStrong)!important; opacity:.7;}
|
||||||
.moveBtn:hover .fa{opacity:1;}
|
.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
105
src/ui.ts
@@ -68,6 +68,8 @@ let saveZoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let recentOpen = false;
|
let recentOpen = false;
|
||||||
|
let resetConfirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let resetConfirming = false;
|
||||||
|
|
||||||
// ---- Player ref for position info ----
|
// ---- Player ref for position info ----
|
||||||
let player: HTMLVideoElement;
|
let player: HTMLVideoElement;
|
||||||
@@ -279,9 +281,22 @@ export function initUI(): void {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Reset progress ---
|
// --- Reset progress (two-click confirmation) ---
|
||||||
resetProgBtn.addEventListener('click', async () => {
|
resetProgBtn.addEventListener('click', async () => {
|
||||||
if (!library) return;
|
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 {
|
try {
|
||||||
const res = await api.resetWatchProgress();
|
const res = await api.resetWatchProgress();
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
@@ -293,6 +308,7 @@ export function initUI(): void {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
resetProgBtn.addEventListener('blur', () => { if (resetConfirming) cancelResetConfirm(); });
|
||||||
|
|
||||||
// --- Open folder / Recent menu ---
|
// --- Open folder / Recent menu ---
|
||||||
chooseBtn.onclick = async () => {
|
chooseBtn.onclick = async () => {
|
||||||
@@ -348,6 +364,63 @@ export function initUI(): void {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 350);
|
}, 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 ----
|
// ---- Exported functions ----
|
||||||
@@ -633,3 +706,33 @@ export async function openRecentMenu(): Promise<void> {
|
|||||||
async function savePrefsPatch(patch: Record<string, unknown>): Promise<void> {
|
async function savePrefsPatch(patch: Record<string, unknown>): Promise<void> {
|
||||||
await api.setPrefs(patch);
|
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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user