/** * UI controls - zoom, split ratios, topbar, recent menu, info panel, * notes, toast notifications, and reset/reload buttons. */ import { api } from './api'; import type { VideoItem } from './types'; import { library, currentIndex, prefs, setPrefs, clamp, fmtTime, fmtBytes, fmtDate, fmtBitrate, currentItem, cb, } from './store'; // ---- DOM refs ---- let contentGrid: HTMLElement; let divider: HTMLElement; let dockGrid: HTMLElement; let dockDivider: HTMLElement; let zoomOutBtn: HTMLElement; let zoomInBtn: HTMLElement; let zoomResetBtn: HTMLElement; let onTopChk: HTMLInputElement; let autoplayChk: HTMLInputElement; let chooseBtn: HTMLElement; let chooseDropBtn: HTMLElement; let recentMenu: HTMLElement; let refreshBtn: HTMLElement; let resetProgBtn: HTMLElement; let notesBox: HTMLTextAreaElement; let notesSaved: HTMLElement; let nowTitle: HTMLElement; let nowSub: HTMLElement; let overallBar: HTMLElement; let overallPct: HTMLElement; let toast: HTMLElement; let toastMsg: HTMLElement; let infoGridEl: HTMLElement; // Info panel elements let infoFolder: HTMLElement; let infoNext: HTMLElement; let infoStruct: HTMLElement; let infoTitle: HTMLElement; let infoRel: HTMLElement; let infoPos: HTMLElement; let infoFileBits: HTMLElement; let infoVidBits: HTMLElement; let infoAudBits: HTMLElement; let infoSubsBits: HTMLElement; let infoFinished: HTMLElement; let infoRemaining: HTMLElement; let infoEta: HTMLElement; let infoVolume: HTMLElement; let infoSpeed: HTMLElement; let infoKnown: HTMLElement; let infoTop: HTMLElement; // ---- State ---- let toastTimer: ReturnType | null = null; let draggingDivider = false; let draggingDockDivider = false; let saveSplitTimer: ReturnType | null = null; let saveDockTimer: ReturnType | null = null; let saveZoomTimer: ReturnType | null = null; let noteSaveTimer: ReturnType | null = null; let notesSavedTimer: ReturnType | null = null; let recentOpen = false; // ---- Player ref for position info ---- let player: HTMLVideoElement; export function initUI(): void { contentGrid = document.getElementById('contentGrid')!; divider = document.getElementById('divider')!; dockGrid = document.getElementById('dockGrid')!; dockDivider = document.getElementById('dockDivider')!; zoomOutBtn = document.getElementById('zoomOutBtn')!; zoomInBtn = document.getElementById('zoomInBtn')!; zoomResetBtn = document.getElementById('zoomResetBtn')!; onTopChk = document.getElementById('onTopChk') as HTMLInputElement; autoplayChk = document.getElementById('autoplayChk') as HTMLInputElement; chooseBtn = document.getElementById('chooseBtn')!; chooseDropBtn = document.getElementById('chooseDropBtn')!; recentMenu = document.getElementById('recentMenu')!; refreshBtn = document.getElementById('refreshBtn')!; resetProgBtn = document.getElementById('resetProgBtn')!; notesBox = document.getElementById('notesBox') as HTMLTextAreaElement; notesSaved = document.getElementById('notesSaved')!; nowTitle = document.getElementById('nowTitle')!; nowSub = document.getElementById('nowSub')!; overallBar = document.getElementById('overallBar')!; overallPct = document.getElementById('overallPct')!; toast = document.getElementById('toast')!; toastMsg = document.getElementById('toastMsg')!; infoGridEl = document.getElementById('infoGrid')!; player = document.getElementById('player') as HTMLVideoElement; infoFolder = document.getElementById('infoFolder')!; infoNext = document.getElementById('infoNext')!; infoStruct = document.getElementById('infoStruct')!; infoTitle = document.getElementById('infoTitle')!; infoRel = document.getElementById('infoRel')!; infoPos = document.getElementById('infoPos')!; infoFileBits = document.getElementById('infoFileBits')!; infoVidBits = document.getElementById('infoVidBits')!; infoAudBits = document.getElementById('infoAudBits')!; infoSubsBits = document.getElementById('infoSubsBits')!; infoFinished = document.getElementById('infoFinished')!; infoRemaining = document.getElementById('infoRemaining')!; infoEta = document.getElementById('infoEta')!; infoVolume = document.getElementById('infoVolume')!; infoSpeed = document.getElementById('infoSpeed')!; infoKnown = document.getElementById('infoKnown')!; infoTop = document.getElementById('infoTop')!; // --- Info panel scroll fades --- if (infoGridEl) { const updateInfoFades = () => { const atTop = infoGridEl.scrollTop < 5; const atBottom = infoGridEl.scrollTop + infoGridEl.clientHeight >= infoGridEl.scrollHeight - 5; infoGridEl.classList.toggle('at-top', atTop); infoGridEl.classList.toggle('at-bottom', atBottom); }; infoGridEl.addEventListener('scroll', updateInfoFades); setTimeout(updateInfoFades, 100); setTimeout(updateInfoFades, 500); } // --- Divider drag --- divider.addEventListener('mousedown', (e) => { draggingDivider = true; document.body.style.userSelect = 'none'; e.preventDefault(); }); dockDivider.addEventListener('mousedown', (e) => { draggingDockDivider = true; document.body.style.userSelect = 'none'; e.preventDefault(); }); window.addEventListener('mouseup', async () => { if (draggingDivider) { draggingDivider = false; document.body.style.userSelect = ''; if (prefs && typeof prefs.split_ratio === 'number') { await savePrefsPatch({ split_ratio: prefs.split_ratio }); } } if (draggingDockDivider) { draggingDockDivider = false; document.body.style.userSelect = ''; if (prefs && typeof prefs.dock_ratio === 'number') { await savePrefsPatch({ dock_ratio: prefs.dock_ratio }); } } }); window.addEventListener('mousemove', (e) => { if (draggingDivider) { const rect = contentGrid.getBoundingClientRect(); const x = e.clientX - rect.left; if (!prefs) setPrefs({}); prefs!.split_ratio = applySplit(x / rect.width); if (saveSplitTimer) clearTimeout(saveSplitTimer); saveSplitTimer = setTimeout(() => { if (prefs) savePrefsPatch({ split_ratio: prefs.split_ratio }); }, 400); } if (draggingDockDivider) { const rect = dockGrid.getBoundingClientRect(); const x = e.clientX - rect.left; if (!prefs) setPrefs({}); prefs!.dock_ratio = applyDockSplit(x / rect.width); if (saveDockTimer) clearTimeout(saveDockTimer); saveDockTimer = setTimeout(() => { if (prefs) savePrefsPatch({ dock_ratio: prefs.dock_ratio }); }, 400); } }); // --- Zoom controls --- zoomOutBtn.onclick = () => { prefs!.ui_zoom = applyZoom(Number(prefs?.ui_zoom || 1.0) - 0.1); if (saveZoomTimer) clearTimeout(saveZoomTimer); saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120); if (recentOpen) positionRecentMenu(); }; zoomInBtn.onclick = () => { prefs!.ui_zoom = applyZoom(Number(prefs?.ui_zoom || 1.0) + 0.1); if (saveZoomTimer) clearTimeout(saveZoomTimer); saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120); if (recentOpen) positionRecentMenu(); }; zoomResetBtn.onclick = () => { prefs!.ui_zoom = applyZoom(1.0); if (saveZoomTimer) clearTimeout(saveZoomTimer); saveZoomTimer = setTimeout(() => savePrefsPatch({ ui_zoom: prefs!.ui_zoom }), 120); if (recentOpen) positionRecentMenu(); }; // --- On Top toggle --- onTopChk.addEventListener('change', async () => { const enabled = !!onTopChk.checked; try { await api.setAlwaysOnTop(enabled); prefs!.always_on_top = enabled; await savePrefsPatch({ always_on_top: enabled }); notify(enabled ? 'On top enabled.' : 'On top disabled.'); } catch (_) { onTopChk.checked = !!prefs?.always_on_top; } }); // --- Autoplay toggle --- autoplayChk.addEventListener('change', async () => { if (!library) return; const enabled = !!autoplayChk.checked; try { const res = await api.setFolderAutoplay(enabled); if (res && res.ok) { (library as any).folder_autoplay = enabled; updateInfoPanel(); notify(enabled ? 'Autoplay: ON' : 'Autoplay: OFF'); } } catch (_) {} }); // --- Reset progress --- resetProgBtn.addEventListener('click', async () => { if (!library) return; try { const res = await api.resetWatchProgress(); if (res && res.ok) { notify('Progress reset for this folder.'); const info = await api.getLibrary(); if (info && info.ok) { await cb.onLibraryLoaded?.(info, false); } } } catch (_) {} }); // --- Open folder / Recent menu --- chooseBtn.onclick = async () => { closeRecentMenu(); const info = await api.selectFolder(); if (!info || !info.ok) return; await cb.onLibraryLoaded?.(info, true); notify('Folder loaded.'); }; chooseDropBtn.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); if (recentOpen) closeRecentMenu(); else await openRecentMenu(); }; window.addEventListener('resize', () => { if (recentOpen) positionRecentMenu(); }); window.addEventListener('scroll', () => { if (recentOpen) positionRecentMenu(); }, true); window.addEventListener('click', () => { if (recentOpen) closeRecentMenu(); }); recentMenu.addEventListener('click', (e) => e.stopPropagation()); // --- Reload --- refreshBtn.onclick = async () => { const info = await api.getLibrary(); if (!info || !info.ok) return; await cb.onLibraryLoaded?.(info, false); notify('Reloaded.'); }; // --- Notes --- notesBox.addEventListener('input', () => { if (!library) return; const it = currentItem(); if (!it) return; if (noteSaveTimer) clearTimeout(noteSaveTimer); if (notesSavedTimer) clearTimeout(notesSavedTimer); if (notesSaved) notesSaved.classList.remove('show'); noteSaveTimer = setTimeout(async () => { try { await api.setNote(it.fid, notesBox.value || ''); if (notesSaved) { notesSaved.classList.add('show'); notesSavedTimer = setTimeout(() => { notesSaved.classList.remove('show'); }, 2000); } } catch (_) {} }, 350); }); } // ---- Exported functions ---- export function applyZoom(z: number): number { const zoom = clamp(Number(z || 1.0), 0.75, 2.0); document.documentElement.style.setProperty('--zoom', String(zoom)); if (zoomResetBtn) zoomResetBtn.textContent = `${Math.round(zoom * 100)}%`; document.body.getBoundingClientRect(); // force reflow return zoom; } export function applySplit(ratio: number): number { const r = clamp(Number(ratio || 0.62), 0.35, 0.80); contentGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`; return r; } export function applyDockSplit(ratio: number): number { const r = clamp(Number(ratio || 0.62), 0.35, 0.80); dockGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`; return r; } export function notify(msg: string): void { if (!toastMsg || !toast) return; toastMsg.textContent = msg; toast.classList.add('show'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.classList.remove('show'); }, 2600); } export function updateNowHeader(it: VideoItem | null): void { nowTitle.textContent = it ? (it.title || it.name) : 'No video loaded'; nowSub.textContent = it ? it.relpath : '-'; } export function updateOverall(): void { if (!library) { overallBar.style.width = '0%'; overallPct.textContent = '-'; return; } if (library.overall_progress === null || library.overall_progress === undefined) { overallBar.style.width = '0%'; overallPct.textContent = '-'; return; } const p = clamp(library.overall_progress, 0, 1); overallBar.style.width = `${(p * 100).toFixed(1)}%`; overallPct.textContent = `${(p * 100).toFixed(1)}%`; } export function updateInfoPanel(): void { const it = currentItem(); infoFolder.textContent = library?.folder || '-'; infoNext.textContent = library?.next_up ? library.next_up.title : '-'; infoStruct.textContent = library ? (library.has_subdirs ? 'Subfolders detected' : 'Flat folder') : '-'; infoTitle.textContent = it?.title || '-'; infoRel.textContent = it?.relpath || '-'; const t = player?.currentTime || 0; const d = player && Number.isFinite(player.duration) ? player.duration : 0; infoPos.textContent = d > 0 ? `${fmtTime(t)} / ${fmtTime(d)}` : fmtTime(t); if (library) { infoFinished.textContent = `${library.finished_count ?? 0}`; infoRemaining.textContent = `${library.remaining_count ?? 0}`; infoEta.textContent = (library.remaining_seconds_known != null) ? fmtTime(library.remaining_seconds_known) : '-'; infoKnown.textContent = `${library.durations_known || 0}/${library.count || 0}`; infoTop.textContent = (library.top_folders || []).map(([n, c]: [string, number]) => `${n}:${c}`).join(' \u2022 ') || '-'; infoVolume.textContent = `${Math.round(clamp(Number(library.folder_volume ?? 1), 0, 1) * 100)}%`; infoSpeed.textContent = `${Number(library.folder_rate ?? 1).toFixed(2)}x`; } else { infoFinished.textContent = '-'; infoRemaining.textContent = '-'; infoEta.textContent = '-'; infoKnown.textContent = '-'; infoTop.textContent = '-'; infoVolume.textContent = '-'; infoSpeed.textContent = '-'; } } export async function refreshCurrentVideoMeta(): Promise { const it = currentItem(); if (!it) { infoFileBits.textContent = '-'; infoVidBits.textContent = '-'; infoAudBits.textContent = '-'; infoSubsBits.textContent = '-'; return; } try { const res = await api.getCurrentVideoMeta(); if (!res || !res.ok) { infoFileBits.textContent = '-'; infoVidBits.textContent = '-'; infoAudBits.textContent = '-'; infoSubsBits.textContent = '-'; return; } const b = res.basic || ({} as any); const p = res.probe || null; const ffFound = !!res.ffprobe_found; const bits: string[] = []; if (b.ext) bits.push(String(b.ext).toUpperCase()); if (b.size) bits.push(fmtBytes(b.size)); if (b.mtime) bits.push(`modified ${fmtDate(b.mtime)}`); if (b.folder) bits.push(`folder ${b.folder}`); infoFileBits.textContent = bits.join(' \u2022 ') || '-'; if (p) { const v: string[] = []; if (p.v_codec) v.push(String(p.v_codec).toUpperCase()); if (p.width && p.height) v.push(`${p.width}\u00d7${p.height}`); if (p.fps) v.push(`${Number(p.fps).toFixed(2)} fps`); if (p.pix_fmt) v.push(p.pix_fmt); const vb = fmtBitrate(p.v_bitrate || 0); if (vb) v.push(vb); infoVidBits.textContent = v.join(' \u2022 ') || '-'; const a: string[] = []; if (p.a_codec) a.push(String(p.a_codec).toUpperCase()); if (p.channels) a.push(`${p.channels} ch`); if (p.sample_rate) a.push(`${(Number(p.sample_rate) / 1000).toFixed(1)} kHz`); const ab = fmtBitrate(p.a_bitrate || 0); if (ab) a.push(ab); infoAudBits.textContent = a.join(' \u2022 ') || '-'; const subs = p.subtitle_tracks || []; if (subs.length > 0) { const subInfo = subs.map(s => { const lang = s.language?.toUpperCase() || ''; const title = s.title || ''; return title || lang || s.codec || 'Track'; }).join(', '); infoSubsBits.textContent = `${subs.length} embedded (${subInfo})`; } else if (it.has_sub) { infoSubsBits.textContent = 'External file loaded'; } else { infoSubsBits.textContent = 'None'; } } else { infoVidBits.textContent = ffFound ? '(ffprobe available, metadata not read for this file)' : '(ffprobe not found)'; infoAudBits.textContent = '-'; infoSubsBits.textContent = it.has_sub ? 'External file loaded' : '-'; } } catch (_) { infoFileBits.textContent = '-'; infoVidBits.textContent = '-'; infoAudBits.textContent = '-'; infoSubsBits.textContent = '-'; } } export async function loadNoteForCurrent(): Promise { const it = currentItem(); if (!it) { notesBox.value = ''; return; } try { const res = await api.getNote(it.fid); notesBox.value = (res && res.ok) ? (res.note || '') : ''; } catch (_) { notesBox.value = ''; } } export function setOnTopChecked(v: boolean): void { onTopChk.checked = v; } export function setAutoplayChecked(v: boolean): void { autoplayChk.checked = v; } // ---- Recent menu ---- function ensureDropdownPortal(): void { try { if (recentMenu && recentMenu.parentElement !== document.body) { document.body.appendChild(recentMenu); } recentMenu.classList.add('dropdownPortal'); } catch (_) {} } function positionRecentMenu(): void { const r = chooseDropBtn.getBoundingClientRect(); const zoom = clamp(Number(prefs?.ui_zoom || 1), 0.75, 2.0); const baseW = 460, baseH = 360; const effW = baseW * zoom, effH = baseH * zoom; const left = clamp(r.right - effW, 10, window.innerWidth - effW - 10); const top = clamp(r.bottom + 8, 10, window.innerHeight - effH - 10); recentMenu.style.left = `${left}px`; recentMenu.style.top = `${top}px`; recentMenu.style.width = `${baseW}px`; recentMenu.style.maxHeight = `${baseH}px`; } export function closeRecentMenu(): void { recentOpen = false; recentMenu.style.display = 'none'; } export async function openRecentMenu(): Promise { ensureDropdownPortal(); try { const res = await api.getRecents(); recentMenu.innerHTML = ''; if (!res || !res.ok || !res.items || res.items.length === 0) { const div = document.createElement('div'); div.className = 'dropEmpty'; div.textContent = 'No recent folders yet.'; recentMenu.appendChild(div); } else { for (const it of res.items) { const row = document.createElement('div'); row.className = 'dropItem'; row.dataset.tooltip = it.name; row.dataset.tooltipDesc = it.path; const icon = document.createElement('div'); icon.className = 'dropIcon'; icon.innerHTML = ''; const name = document.createElement('div'); name.className = 'dropName'; name.textContent = it.name; const removeBtn = document.createElement('div'); removeBtn.className = 'dropRemove'; removeBtn.innerHTML = ''; removeBtn.onclick = async (e) => { e.stopPropagation(); try { await api.removeRecent(it.path); row.remove(); if (recentMenu.querySelectorAll('.dropItem').length === 0) { const div = document.createElement('div'); div.className = 'dropEmpty'; div.textContent = 'No recent folders yet.'; recentMenu.innerHTML = ''; recentMenu.appendChild(div); } } catch (_) {} }; row.appendChild(icon); row.appendChild(name); row.appendChild(removeBtn); row.onclick = async () => { closeRecentMenu(); const info = await api.openFolderPath(it.path); if (!info || !info.ok) { notify('Folder not available.'); return; } await cb.onLibraryLoaded?.(info, true); }; recentMenu.appendChild(row); } } positionRecentMenu(); recentMenu.style.display = 'block'; recentOpen = true; } catch (_) { closeRecentMenu(); } } // ---- Private helpers ---- async function savePrefsPatch(patch: Record): Promise { await api.setPrefs(patch); }