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">
|
||||
<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>←</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
76
src/main.ts
76
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<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 ----
|
||||
|
||||
171
src/player.ts
171
src/player.ts
@@ -16,11 +16,15 @@ let seekFill: HTMLElement;
|
||||
let volSlider: HTMLInputElement & { dragging?: boolean };
|
||||
let volFill: HTMLElement;
|
||||
let volTooltip: HTMLElement;
|
||||
let volMuteBtn: HTMLElement;
|
||||
let volIcon: HTMLElement;
|
||||
let playPauseBtn: HTMLElement;
|
||||
let ppIcon: HTMLElement;
|
||||
let prevBtn: HTMLElement;
|
||||
let nextBtn: HTMLElement;
|
||||
let fsBtn: HTMLElement;
|
||||
let pipBtn: HTMLElement;
|
||||
let pipIcon: HTMLElement;
|
||||
let timeNow: HTMLElement;
|
||||
let timeDur: HTMLElement;
|
||||
let speedBtn: HTMLElement;
|
||||
@@ -30,6 +34,20 @@ let speedMenu: HTMLElement;
|
||||
let videoOverlay: HTMLElement;
|
||||
let overlayIcon: HTMLElement;
|
||||
let overlayIconI: HTMLElement;
|
||||
let seekFeedbackEl: HTMLElement;
|
||||
let errorOverlay: HTMLElement;
|
||||
let errorNextBtn: HTMLElement;
|
||||
|
||||
// ---- Mute state ----
|
||||
let muted = false;
|
||||
let lastVolume = 1.0;
|
||||
|
||||
// ---- Seek feedback state ----
|
||||
let seekFeedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekAccum = 0;
|
||||
|
||||
// ---- Double-click state ----
|
||||
let clickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function getPlayer(): HTMLVideoElement { return player; }
|
||||
|
||||
@@ -40,11 +58,15 @@ export function initPlayer(): void {
|
||||
volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
|
||||
volFill = document.getElementById('volFill')!;
|
||||
volTooltip = document.getElementById('volTooltip')!;
|
||||
volMuteBtn = document.getElementById('volMuteBtn')!;
|
||||
volIcon = document.getElementById('volIcon')!;
|
||||
playPauseBtn = document.getElementById('playPauseBtn')!;
|
||||
ppIcon = document.getElementById('ppIcon')!;
|
||||
prevBtn = document.getElementById('prevBtn')!;
|
||||
nextBtn = document.getElementById('nextBtn')!;
|
||||
fsBtn = document.getElementById('fsBtn')!;
|
||||
pipBtn = document.getElementById('pipBtn')!;
|
||||
pipIcon = document.getElementById('pipIcon')!;
|
||||
timeNow = document.getElementById('timeNow')!;
|
||||
timeDur = document.getElementById('timeDur')!;
|
||||
speedBtn = document.getElementById('speedBtn')!;
|
||||
@@ -56,22 +78,54 @@ export function initPlayer(): void {
|
||||
videoOverlay = document.getElementById('videoOverlay')!;
|
||||
overlayIcon = document.getElementById('overlayIcon')!;
|
||||
overlayIconI = document.getElementById('overlayIconI')!;
|
||||
seekFeedbackEl = document.getElementById('seekFeedback')!;
|
||||
errorOverlay = document.getElementById('errorOverlay')!;
|
||||
errorNextBtn = document.getElementById('errorNextBtn')!;
|
||||
|
||||
// --- Play/Pause ---
|
||||
playPauseBtn.onclick = togglePlay;
|
||||
player.addEventListener('click', (e) => { e.preventDefault(); togglePlay(); });
|
||||
|
||||
// --- Prev / Next ---
|
||||
prevBtn.onclick = () => nextPrev(-1);
|
||||
nextBtn.onclick = () => nextPrev(+1);
|
||||
|
||||
// --- Fullscreen ---
|
||||
fsBtn.onclick = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await player.requestFullscreen();
|
||||
} catch (_) {}
|
||||
};
|
||||
fsBtn.onclick = () => toggleFullscreen();
|
||||
|
||||
// --- Picture-in-Picture ---
|
||||
if (pipBtn) {
|
||||
if (!document.pictureInPictureEnabled) {
|
||||
pipBtn.style.display = 'none';
|
||||
} else {
|
||||
pipBtn.onclick = async () => {
|
||||
try {
|
||||
if (document.pictureInPictureElement) await document.exitPictureInPicture();
|
||||
else await player.requestPictureInPicture();
|
||||
} catch (_) {}
|
||||
};
|
||||
player.addEventListener('enterpictureinpicture', () => {
|
||||
pipIcon.className = 'fa-solid fa-down-left-and-up-right-to-center';
|
||||
pipBtn.setAttribute('aria-label', 'Exit picture-in-picture');
|
||||
});
|
||||
player.addEventListener('leavepictureinpicture', () => {
|
||||
pipIcon.className = 'fa-solid fa-up-right-from-square';
|
||||
pipBtn.setAttribute('aria-label', 'Enter picture-in-picture');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mute button ---
|
||||
if (volMuteBtn) {
|
||||
volMuteBtn.onclick = (e) => { e.stopPropagation(); toggleMute(); };
|
||||
}
|
||||
|
||||
// --- Video error state ---
|
||||
player.addEventListener('error', () => {
|
||||
if (errorOverlay) errorOverlay.style.display = 'flex';
|
||||
});
|
||||
if (errorNextBtn) {
|
||||
errorNextBtn.onclick = () => nextPrev(+1);
|
||||
}
|
||||
|
||||
// --- Seek bar ---
|
||||
seek.addEventListener('input', () => {
|
||||
@@ -108,6 +162,7 @@ export function initPlayer(): void {
|
||||
player.addEventListener('loadedmetadata', async () => {
|
||||
const d = player.duration || 0;
|
||||
timeDur.textContent = d ? fmtTime(d) : '00:00';
|
||||
if (errorOverlay) errorOverlay.style.display = 'none';
|
||||
await cb.refreshCurrentVideoMeta?.();
|
||||
});
|
||||
|
||||
@@ -120,10 +175,12 @@ export function initPlayer(): void {
|
||||
|
||||
volSlider.addEventListener('input', () => {
|
||||
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
|
||||
if (muted && v > 0) { muted = false; }
|
||||
setSuppressTick(true); player.volume = v; setSuppressTick(false);
|
||||
if (library) (library as any).folder_volume = v;
|
||||
cb.updateInfoPanel?.();
|
||||
updateVolFill();
|
||||
updateVolumeIcon();
|
||||
// Update tooltip position
|
||||
if (volTooltip && volTooltip.classList.contains('show')) {
|
||||
volTooltip.textContent = Math.round(v * 100) + '%';
|
||||
@@ -187,7 +244,7 @@ export function initPlayer(): void {
|
||||
window.addEventListener('click', () => { closeSpeedMenu(); });
|
||||
speedMenu.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// --- Video overlay ---
|
||||
// --- Video overlay (single-click = play/pause, double-click = fullscreen) ---
|
||||
if (videoOverlay) {
|
||||
const videoWrap = videoOverlay.parentElement!;
|
||||
videoWrap.addEventListener('mouseenter', () => {
|
||||
@@ -205,10 +262,19 @@ export function initPlayer(): void {
|
||||
videoOverlay.style.pointerEvents = 'auto';
|
||||
videoOverlay.style.cursor = 'pointer';
|
||||
videoOverlay.addEventListener('click', () => {
|
||||
if (player.paused) player.play();
|
||||
else player.pause();
|
||||
overlayIcon.classList.add('pulse');
|
||||
setTimeout(() => overlayIcon.classList.remove('pulse'), 400);
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = null;
|
||||
toggleFullscreen();
|
||||
return;
|
||||
}
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null;
|
||||
if (player.paused) player.play();
|
||||
else player.pause();
|
||||
overlayIcon.classList.add('pulse');
|
||||
setTimeout(() => overlayIcon.classList.remove('pulse'), 400);
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,6 +357,87 @@ export function getVideoDuration(): number | null {
|
||||
export function isPlaying(): boolean { return player ? !player.paused && !player.ended : false; }
|
||||
export function isVolDragging(): boolean { return !!volSlider?.dragging; }
|
||||
|
||||
// ---- Mute ----
|
||||
|
||||
export function toggleMute(): void {
|
||||
if (muted) {
|
||||
muted = false;
|
||||
const v = lastVolume > 0 ? lastVolume : 1.0;
|
||||
player.volume = v;
|
||||
volSlider.value = String(v);
|
||||
} else {
|
||||
lastVolume = player.volume || 1.0;
|
||||
muted = true;
|
||||
player.volume = 0;
|
||||
volSlider.value = '0';
|
||||
}
|
||||
updateVolFill();
|
||||
updateVolumeIcon();
|
||||
if (library) (library as any).folder_volume = player.volume;
|
||||
cb.updateInfoPanel?.();
|
||||
}
|
||||
|
||||
export function updateVolumeIcon(): void {
|
||||
if (!volIcon || !volMuteBtn) return;
|
||||
const v = player.volume;
|
||||
if (muted || v === 0) {
|
||||
volIcon.className = 'fa-solid fa-volume-xmark';
|
||||
volMuteBtn.setAttribute('aria-label', 'Unmute');
|
||||
volMuteBtn.closest('.miniCtl')?.classList.add('muted');
|
||||
} else if (v < 0.5) {
|
||||
volIcon.className = 'fa-solid fa-volume-low';
|
||||
volMuteBtn.setAttribute('aria-label', 'Mute');
|
||||
volMuteBtn.closest('.miniCtl')?.classList.remove('muted');
|
||||
} else {
|
||||
volIcon.className = 'fa-solid fa-volume-high';
|
||||
volMuteBtn.setAttribute('aria-label', 'Mute');
|
||||
volMuteBtn.closest('.miniCtl')?.classList.remove('muted');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Fullscreen ----
|
||||
|
||||
export async function toggleFullscreen(): Promise<void> {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await player.requestFullscreen();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ---- Seek feedback ----
|
||||
|
||||
export function showSeekFeedback(delta: number): void {
|
||||
seekAccum += delta;
|
||||
const sign = seekAccum >= 0 ? '+' : '\u2212';
|
||||
seekFeedbackEl.textContent = `${sign}${Math.abs(seekAccum)}s`;
|
||||
seekFeedbackEl.classList.add('show');
|
||||
if (seekFeedbackTimer) clearTimeout(seekFeedbackTimer);
|
||||
seekFeedbackTimer = setTimeout(() => {
|
||||
seekFeedbackEl.classList.remove('show');
|
||||
seekAccum = 0;
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// ---- Speed cycle ----
|
||||
|
||||
export function cycleSpeed(delta: number): void {
|
||||
const current = player.playbackRate;
|
||||
let idx = SPEEDS.findIndex(s => Math.abs(s - current) < 0.01);
|
||||
if (idx === -1) idx = SPEEDS.indexOf(1.0);
|
||||
idx = clamp(idx + delta, 0, SPEEDS.length - 1);
|
||||
const r = SPEEDS[idx];
|
||||
player.playbackRate = r;
|
||||
if (library) (library as any).folder_rate = r;
|
||||
speedBtnText.textContent = `${r.toFixed(2)}x`;
|
||||
updateSpeedIcon(r);
|
||||
buildSpeedMenu(r);
|
||||
cb.updateInfoPanel?.();
|
||||
cb.notify?.(`Speed: ${r}x`);
|
||||
setSuppressTick(true);
|
||||
if (library) { api.setFolderRate(r).catch(() => {}); }
|
||||
setSuppressTick(false);
|
||||
}
|
||||
|
||||
/** Load a video by index and handle the onloadedmetadata callback. */
|
||||
export async function loadVideoSrc(
|
||||
idx: number,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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
105
src/ui.ts
@@ -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)';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user