feat: implement all frontend TypeScript modules

Create player.ts (video controls, seek, volume, speed, overlay),
playlist.ts (list rendering, tree SVG, drag-and-drop reorder, scrollbar),
subtitles.ts (subtitle menu, track management, sidecar/embedded),
ui.ts (zoom, splits, info panel, notes, toasts, recent menu),
tooltips.ts (zoom-aware tooltip system with delays),
store.ts (shared state and utility functions), and
main.ts (boot sequence, tick loop, keyboard shortcuts).

All modules compile with strict TypeScript. Vite build produces
34KB JS + 41KB CSS. 115 Rust tests pass.
This commit is contained in:
Your Name
2026-02-19 11:44:48 +02:00
parent 4e454084a8
commit a459efae45
8 changed files with 1897 additions and 1 deletions

324
src/playlist.ts Normal file
View File

@@ -0,0 +1,324 @@
/**
* 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;
// ---- 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')!;
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);
}
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);
}
}
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 (_) {}
}
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.onclick = () => {
if (dragFromIndex !== null) return;
cb.loadIndex?.(it.index, computeResumeTime(it), true);
};
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('dragover', (e) => {
e.preventDefault();
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());
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 = ''; }
row.appendChild(left);
row.appendChild(tag);
listEl.appendChild(row);
}
setTimeout(updateListFades, 50);
}