Semantic HTML: lang attr, landmarks (header/main/region/complementary), heading hierarchy (h1-h3), dl/dt/dd for info panel. ARIA: labels on all icon buttons, aria-hidden on decorative icons, progressbar role with dynamic aria-valuenow, aria-haspopup/expanded on all menu triggers, role=listbox/option on playlist, aria-selected, computed aria-labels on playlist rows. Contrast: raised --textMuted/--textDim/--icon to AAA 7:1 ratios. Focus: global :focus-visible outline, slider thumb glow, menu item highlight, switch focus-within, row focus styles. Target sizes: 44x44 hit areas on zoom/window/remove buttons via ::before pseudo-elements. Keyboard: playlist arrow nav + Enter/Space activate + Alt+Arrow reorder with live region announcements + move buttons. Speed menu, subtitles menu, and recent menu all keyboard-navigable with Arrow/Enter/Space/Escape. Dividers resizable via Arrow keys. Dynamic document.title updates on video/folder load.
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
/**
|
|
* Playlist rendering — list items, tree SVG connectors, custom scrollbar,
|
|
* and drag-and-drop reorder.
|
|
*/
|
|
import { api } from './api';
|
|
import type { VideoItem } from './types';
|
|
import {
|
|
library, currentIndex, setCurrentIndex,
|
|
clamp, fmtTime, cb, currentItem, computeResumeTime,
|
|
setLibrary,
|
|
} from './store';
|
|
|
|
// ---- DOM refs ----
|
|
let listEl: HTMLElement;
|
|
let emptyHint: HTMLElement;
|
|
let listScrollbar: HTMLElement;
|
|
let listScrollbarThumb: HTMLElement;
|
|
|
|
// ---- Drag state ----
|
|
let dragFromIndex: number | null = null;
|
|
let dropTargetIndex: number | null = null;
|
|
let dropAfter = false;
|
|
|
|
/** True while a drag-reorder is in progress. Used to guard against renderList during drag. */
|
|
export function isDragging(): boolean { return dragFromIndex !== null; }
|
|
|
|
// ---- Scrollbar state ----
|
|
let scrollbarHideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let scrollbarDragging = false;
|
|
let scrollbarDragStartY = 0;
|
|
let scrollbarDragStartScrollTop = 0;
|
|
|
|
// ---- Scroll fades ----
|
|
let updateListFades: () => void = () => {};
|
|
|
|
export function initPlaylist(): void {
|
|
listEl = document.getElementById('list')!;
|
|
listEl.setAttribute('role', 'listbox');
|
|
listEl.setAttribute('aria-label', 'Playlist');
|
|
emptyHint = document.getElementById('emptyHint')!;
|
|
listScrollbar = document.getElementById('listScrollbar')!;
|
|
listScrollbarThumb = document.getElementById('listScrollbarThumb')!;
|
|
|
|
// Scrollbar drag handlers
|
|
if (listScrollbarThumb) {
|
|
listScrollbarThumb.style.pointerEvents = 'auto';
|
|
listScrollbar.style.pointerEvents = 'auto';
|
|
|
|
const startDrag = (e: MouseEvent | TouchEvent) => {
|
|
e.preventDefault();
|
|
scrollbarDragging = true;
|
|
scrollbarDragStartY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
scrollbarDragStartScrollTop = listEl.scrollTop;
|
|
listScrollbar.classList.add('active');
|
|
document.body.style.userSelect = 'none';
|
|
};
|
|
|
|
const doDrag = (e: MouseEvent | TouchEvent) => {
|
|
if (!scrollbarDragging) return;
|
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
const deltaY = clientY - scrollbarDragStartY;
|
|
const trackHeight = listEl.clientHeight - 24;
|
|
const thumbHeight = Math.max(24, listEl.clientHeight * (listEl.clientHeight / listEl.scrollHeight));
|
|
const scrollableTrack = trackHeight - thumbHeight;
|
|
const maxScroll = listEl.scrollHeight - listEl.clientHeight;
|
|
if (scrollableTrack > 0) {
|
|
const scrollDelta = (deltaY / scrollableTrack) * maxScroll;
|
|
listEl.scrollTop = scrollbarDragStartScrollTop + scrollDelta;
|
|
}
|
|
};
|
|
|
|
const endDrag = () => {
|
|
if (scrollbarDragging) {
|
|
scrollbarDragging = false;
|
|
document.body.style.userSelect = '';
|
|
if (scrollbarHideTimer) clearTimeout(scrollbarHideTimer);
|
|
scrollbarHideTimer = setTimeout(() => { listScrollbar.classList.remove('active'); }, 1200);
|
|
}
|
|
};
|
|
|
|
listScrollbarThumb.addEventListener('mousedown', startDrag as any);
|
|
listScrollbarThumb.addEventListener('touchstart', startDrag as any);
|
|
window.addEventListener('mousemove', doDrag as any);
|
|
window.addEventListener('touchmove', doDrag as any);
|
|
window.addEventListener('mouseup', endDrag);
|
|
window.addEventListener('touchend', endDrag);
|
|
}
|
|
|
|
// Allow internal DnD on the list container
|
|
if (listEl) {
|
|
listEl.addEventListener('dragenter', (e) => { e.preventDefault(); });
|
|
listEl.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
listEl.addEventListener('drop', (e) => { e.preventDefault(); });
|
|
}
|
|
|
|
if (listEl) {
|
|
updateListFades = () => {
|
|
const atTop = listEl.scrollTop < 5;
|
|
const atBottom = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 5;
|
|
listEl.classList.toggle('at-top', atTop);
|
|
listEl.classList.toggle('at-bottom', atBottom);
|
|
updateScrollbar();
|
|
if (listScrollbar && !scrollbarDragging) {
|
|
listScrollbar.classList.add('active');
|
|
if (scrollbarHideTimer) clearTimeout(scrollbarHideTimer);
|
|
scrollbarHideTimer = setTimeout(() => { listScrollbar.classList.remove('active'); }, 1200);
|
|
}
|
|
};
|
|
listEl.addEventListener('scroll', updateListFades);
|
|
setTimeout(updateListFades, 100);
|
|
setTimeout(updateListFades, 500);
|
|
}
|
|
|
|
// Live region for reorder announcements
|
|
const liveRegion = document.createElement('div');
|
|
liveRegion.id = 'playlistLive';
|
|
liveRegion.setAttribute('aria-live', 'polite');
|
|
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);
|
|
}
|
|
|
|
export function updateScrollbar(): void {
|
|
if (!listEl || !listScrollbarThumb) return;
|
|
const scrollHeight = listEl.scrollHeight;
|
|
const clientHeight = listEl.clientHeight;
|
|
if (scrollHeight <= clientHeight) {
|
|
listScrollbar.style.display = 'none';
|
|
return;
|
|
}
|
|
listScrollbar.style.display = 'block';
|
|
const scrollRatio = clientHeight / scrollHeight;
|
|
const thumbHeight = Math.max(24, clientHeight * scrollRatio);
|
|
const maxScroll = scrollHeight - clientHeight;
|
|
const scrollTop = listEl.scrollTop;
|
|
const trackHeight = clientHeight - 24;
|
|
const thumbTop = maxScroll > 0 ? (scrollTop / maxScroll) * (trackHeight - thumbHeight) : 0;
|
|
listScrollbarThumb.style.height = thumbHeight + 'px';
|
|
listScrollbarThumb.style.top = thumbTop + 'px';
|
|
}
|
|
|
|
function clearDropIndicators(): void {
|
|
listEl.querySelectorAll('.row').forEach(r => r.classList.remove('drop-before', 'drop-after'));
|
|
}
|
|
|
|
async function reorderPlaylistByGap(fromIdx: number, targetIdx: number, after: boolean): Promise<void> {
|
|
if (!library || !library.items) return;
|
|
const base = library.items.slice().sort((a, b) => a.index - b.index).map(x => x.fid);
|
|
if (fromIdx < 0 || fromIdx >= base.length) return;
|
|
if (targetIdx < 0 || targetIdx >= base.length) return;
|
|
|
|
const moving = base[fromIdx];
|
|
base.splice(fromIdx, 1);
|
|
|
|
let insertAt = targetIdx;
|
|
if (fromIdx < targetIdx) insertAt -= 1;
|
|
if (after) insertAt += 1;
|
|
insertAt = clamp(insertAt, 0, base.length);
|
|
base.splice(insertAt, 0, moving);
|
|
|
|
try {
|
|
const res = await api.setOrder(base);
|
|
if (res && res.ok) {
|
|
const info = await api.getLibrary();
|
|
if (info && info.ok) {
|
|
setLibrary(info);
|
|
setCurrentIndex(info.current_index || 0);
|
|
renderList();
|
|
cb.updateOverall?.();
|
|
cb.updateInfoPanel?.();
|
|
await cb.refreshCurrentVideoMeta?.();
|
|
}
|
|
}
|
|
} catch (err) { console.error('reorderPlaylistByGap failed:', err); }
|
|
}
|
|
|
|
export function renderTreeSvg(it: VideoItem): SVGSVGElement {
|
|
const depth = Number(it.depth || 0);
|
|
const pipes = Array.isArray(it.pipes) ? it.pipes : [];
|
|
const isLast = !!it.is_last;
|
|
const hasPrev = !!it.has_prev_in_parent;
|
|
|
|
const unit = 14;
|
|
const pad = 8;
|
|
const height = 28;
|
|
const mid = Math.round(height / 2);
|
|
const extend = 20;
|
|
const width = pad + Math.max(1, depth) * unit + 18;
|
|
|
|
const ns = 'http://www.w3.org/2000/svg';
|
|
const svg = document.createElementNS(ns, 'svg');
|
|
svg.setAttribute('class', 'treeSvg');
|
|
svg.setAttribute('width', String(width));
|
|
svg.setAttribute('height', String(height));
|
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
svg.style.overflow = 'visible';
|
|
|
|
const xCol = (j: number) => pad + j * unit + 0.5;
|
|
|
|
for (let j = 0; j < depth - 1; j++) {
|
|
if (pipes[j]) {
|
|
const ln = document.createElementNS(ns, 'line');
|
|
ln.setAttribute('x1', String(xCol(j)));
|
|
ln.setAttribute('y1', String(-extend));
|
|
ln.setAttribute('x2', String(xCol(j)));
|
|
ln.setAttribute('y2', String(height + extend));
|
|
svg.appendChild(ln);
|
|
}
|
|
}
|
|
|
|
if (depth <= 0) {
|
|
const c = document.createElementNS(ns, 'circle');
|
|
c.setAttribute('cx', String(pad + 3));
|
|
c.setAttribute('cy', String(mid));
|
|
c.setAttribute('r', '3.2');
|
|
c.setAttribute('opacity', '0.40');
|
|
svg.appendChild(c);
|
|
return svg;
|
|
}
|
|
|
|
const parentCol = depth - 1;
|
|
const px = xCol(parentCol);
|
|
|
|
if (hasPrev || !isLast) {
|
|
const vln = document.createElementNS(ns, 'line');
|
|
vln.setAttribute('x1', String(px));
|
|
vln.setAttribute('y1', String(hasPrev ? -extend : mid));
|
|
vln.setAttribute('x2', String(px));
|
|
vln.setAttribute('y2', String(isLast ? mid : String(height + extend)));
|
|
svg.appendChild(vln);
|
|
}
|
|
|
|
const hx1 = px;
|
|
const hx2 = px + unit;
|
|
const h = document.createElementNS(ns, 'line');
|
|
h.setAttribute('x1', String(hx1));
|
|
h.setAttribute('y1', String(mid));
|
|
h.setAttribute('x2', String(hx2));
|
|
h.setAttribute('y2', String(mid));
|
|
svg.appendChild(h);
|
|
|
|
const node = document.createElementNS(ns, 'circle');
|
|
node.setAttribute('cx', String(hx2));
|
|
node.setAttribute('cy', String(mid));
|
|
node.setAttribute('r', '3.4');
|
|
svg.appendChild(node);
|
|
|
|
return svg;
|
|
}
|
|
|
|
export function renderList(): void {
|
|
listEl.innerHTML = '';
|
|
if (!library || !library.items || library.items.length === 0) {
|
|
emptyHint.style.display = 'block';
|
|
return;
|
|
}
|
|
emptyHint.style.display = 'none';
|
|
|
|
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];
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
|
|
row.draggable = true;
|
|
row.dataset.index = String(it.index);
|
|
row.setAttribute('role', 'option');
|
|
row.setAttribute('aria-selected', it.index === currentIndex ? 'true' : 'false');
|
|
row.tabIndex = 0;
|
|
|
|
// Computed aria-label
|
|
const durStr = it.duration ? `${fmtTime(it.watched || 0)} / ${fmtTime(it.duration)}` : `${fmtTime(it.watched || 0)} watched`;
|
|
const statusStr = it.index === currentIndex ? 'Now playing' : it.finished ? 'Done' : '';
|
|
row.setAttribute('aria-label', `${String(displayIndex + 1).padStart(padN, '0')}. ${it.title || it.name} - ${durStr}${statusStr ? ' - ' + statusStr : ''}`);
|
|
|
|
row.onclick = () => {
|
|
if (dragFromIndex !== null) return;
|
|
cb.loadIndex?.(it.index, computeResumeTime(it), true);
|
|
};
|
|
|
|
row.addEventListener('keydown', (e) => {
|
|
const key = e.key;
|
|
if (key === 'Enter' || key === ' ') {
|
|
e.preventDefault(); e.stopPropagation();
|
|
cb.loadIndex?.(it.index, computeResumeTime(it), true);
|
|
} else if (key === 'ArrowDown' && !e.altKey) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
const next = row.nextElementSibling as HTMLElement | null;
|
|
if (next && next.classList.contains('row')) next.focus();
|
|
} else if (key === 'ArrowUp' && !e.altKey) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
const prev = row.previousElementSibling as HTMLElement | null;
|
|
if (prev && prev.classList.contains('row')) prev.focus();
|
|
} else if (e.altKey && key === 'ArrowDown' && displayIndex < library!.items!.length - 1) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
reorderPlaylistByGap(it.index, library!.items![displayIndex + 1].index, true).then(() => {
|
|
setTimeout(() => {
|
|
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
|
|
if (moved) moved.focus();
|
|
const lr = document.getElementById('playlistLive');
|
|
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
|
|
}, 100);
|
|
});
|
|
} else if (e.altKey && key === 'ArrowUp' && displayIndex > 0) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
reorderPlaylistByGap(it.index, library!.items![displayIndex - 1].index, false).then(() => {
|
|
setTimeout(() => {
|
|
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
|
|
if (moved) moved.focus();
|
|
const lr = document.getElementById('playlistLive');
|
|
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
|
|
}, 100);
|
|
});
|
|
}
|
|
});
|
|
|
|
row.addEventListener('dragstart', (e) => {
|
|
dragFromIndex = Number(row.dataset.index);
|
|
row.classList.add('dragging');
|
|
e.dataTransfer!.effectAllowed = 'move';
|
|
try { e.dataTransfer!.setData('text/plain', String(dragFromIndex)); } catch (_) {}
|
|
});
|
|
|
|
row.addEventListener('dragend', async () => {
|
|
row.classList.remove('dragging');
|
|
if (dragFromIndex !== null && dropTargetIndex !== null) {
|
|
await reorderPlaylistByGap(dragFromIndex, dropTargetIndex, dropAfter);
|
|
}
|
|
dragFromIndex = null; dropTargetIndex = null; dropAfter = false;
|
|
clearDropIndicators();
|
|
});
|
|
|
|
row.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
|
|
row.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer!.dropEffect = 'move';
|
|
const rect = row.getBoundingClientRect();
|
|
const y = e.clientY - rect.top;
|
|
const after = y > rect.height / 2;
|
|
dropTargetIndex = Number(row.dataset.index);
|
|
dropAfter = after;
|
|
clearDropIndicators();
|
|
row.classList.add(after ? 'drop-after' : 'drop-before');
|
|
});
|
|
|
|
row.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); });
|
|
|
|
const left = document.createElement('div');
|
|
left.className = 'left';
|
|
|
|
const num = document.createElement('div');
|
|
num.className = 'numBadge';
|
|
num.textContent = String(displayIndex + 1).padStart(padN, '0');
|
|
left.appendChild(num);
|
|
|
|
if (tree) left.appendChild(renderTreeSvg(it));
|
|
|
|
const textWrap = document.createElement('div');
|
|
textWrap.className = 'textWrap';
|
|
|
|
const name = document.createElement('div');
|
|
name.className = 'name';
|
|
name.textContent = it.title || it.name;
|
|
|
|
const small = document.createElement('div');
|
|
small.className = 'small';
|
|
const d = it.duration, w = it.watched || 0;
|
|
const note = it.note_len ? ' \u2022 note' : '';
|
|
const sub = it.has_sub ? ' \u2022 subs' : '';
|
|
small.textContent = (d ? `${fmtTime(w)} / ${fmtTime(d)}` : `${fmtTime(w)} watched`) + note + sub + ` - ${it.relpath}`;
|
|
|
|
textWrap.appendChild(name);
|
|
textWrap.appendChild(small);
|
|
left.appendChild(textWrap);
|
|
|
|
const tag = document.createElement('div');
|
|
tag.className = 'tag';
|
|
if (it.index === currentIndex) { tag.classList.add('now'); tag.textContent = 'Now'; }
|
|
else if (it.finished) { tag.classList.add('done'); tag.textContent = 'Done'; }
|
|
else { tag.classList.add('hidden'); tag.textContent = ''; }
|
|
|
|
// Move buttons for keyboard reorder alternative
|
|
const moveWrap = document.createElement('div');
|
|
moveWrap.className = 'moveWrap';
|
|
|
|
if (displayIndex > 0) {
|
|
const moveUp = document.createElement('button');
|
|
moveUp.className = 'moveBtn';
|
|
moveUp.setAttribute('aria-label', 'Move up');
|
|
moveUp.innerHTML = '<i class="fa-solid fa-chevron-up" aria-hidden="true"></i>';
|
|
moveUp.tabIndex = -1;
|
|
moveUp.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
reorderPlaylistByGap(it.index, library!.items![displayIndex - 1].index, false).then(() => {
|
|
const lr = document.getElementById('playlistLive');
|
|
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
|
|
});
|
|
});
|
|
moveWrap.appendChild(moveUp);
|
|
}
|
|
|
|
if (displayIndex < library!.items!.length - 1) {
|
|
const moveDown = document.createElement('button');
|
|
moveDown.className = 'moveBtn';
|
|
moveDown.setAttribute('aria-label', 'Move down');
|
|
moveDown.innerHTML = '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
|
|
moveDown.tabIndex = -1;
|
|
moveDown.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
reorderPlaylistByGap(it.index, library!.items![displayIndex + 1].index, true).then(() => {
|
|
const lr = document.getElementById('playlistLive');
|
|
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
|
|
});
|
|
});
|
|
moveWrap.appendChild(moveDown);
|
|
}
|
|
|
|
row.appendChild(left);
|
|
row.appendChild(moveWrap);
|
|
row.appendChild(tag);
|
|
listEl.appendChild(row);
|
|
}
|
|
setTimeout(updateListFades, 50);
|
|
}
|