/** * 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 | 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 { 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 = ''; 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 = ''; 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); }