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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user