/** * 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'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; // ---- 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 notesBackdrop: HTMLElement; let notesSaved: HTMLElement; let nowTitle: HTMLElement; let nowSub: HTMLElement; let overallBar: HTMLElement; let overallPct: HTMLElement; let toast: HTMLElement; let toastMsg: HTMLElement; let infoGridEl: HTMLElement; let winMinBtn: HTMLElement; let winMaxBtn: HTMLElement; let winCloseBtn: 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; let resetConfirmTimer: ReturnType | null = null; let resetConfirming = 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')!; chooseDropBtn.setAttribute('aria-haspopup', 'true'); chooseDropBtn.setAttribute('aria-expanded', 'false'); recentMenu = document.getElementById('recentMenu')!; refreshBtn = document.getElementById('refreshBtn')!; resetProgBtn = document.getElementById('resetProgBtn')!; notesBox = document.getElementById('notesBox') as HTMLTextAreaElement; notesSaved = document.getElementById('notesSaved')!; // Create notes highlight backdrop for timestamp coloring notesBackdrop = document.createElement('div'); notesBackdrop.className = 'notesBackdrop'; notesBackdrop.setAttribute('aria-hidden', 'true'); notesBox.parentElement!.insertBefore(notesBackdrop, notesBox); // Scroll sync between textarea and backdrop notesBox.addEventListener('scroll', () => { notesBackdrop.scrollTop = notesBox.scrollTop; notesBackdrop.scrollLeft = notesBox.scrollLeft; }); // Forward hover/focus state to backdrop for visual effects notesBox.addEventListener('mouseenter', () => notesBackdrop.classList.add('hover')); notesBox.addEventListener('mouseleave', () => notesBackdrop.classList.remove('hover')); notesBox.addEventListener('focus', () => notesBackdrop.classList.add('focus')); notesBox.addEventListener('blur', () => notesBackdrop.classList.remove('focus')); // Show pointer cursor when hovering over timestamps notesBox.addEventListener('mousemove', (e) => { notesBox.style.pointerEvents = 'none'; const el = document.elementFromPoint(e.clientX, e.clientY); notesBox.style.pointerEvents = ''; notesBox.style.cursor = (el && el.classList.contains('tsLink')) ? 'pointer' : ''; }); 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; // --- Window controls --- winMinBtn = document.getElementById('winMinBtn')!; winMaxBtn = document.getElementById('winMaxBtn')!; winCloseBtn = document.getElementById('winCloseBtn')!; const appWindow = getCurrentWebviewWindow(); winMinBtn.onclick = () => appWindow.minimize(); winMaxBtn.onclick = () => appWindow.toggleMaximize(); winCloseBtn.onclick = () => appWindow.close(); // Update maximize icon based on window state const updateMaxIcon = async () => { try { const maximized = await appWindow.isMaximized(); const icon = winMaxBtn.querySelector('i')!; icon.className = maximized ? 'fa-solid fa-clone' : 'fa-solid fa-square'; } catch (_) {} }; appWindow.onResized(() => { updateMaxIcon(); }); updateMaxIcon(); 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.tabIndex = 0; divider.setAttribute('role', 'separator'); divider.setAttribute('aria-orientation', 'vertical'); divider.setAttribute('aria-label', 'Resize panels'); divider.addEventListener('mousedown', (e) => { draggingDivider = true; document.body.style.userSelect = 'none'; e.preventDefault(); }); divider.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02; const current = prefs?.split_ratio || 0.62; prefs!.split_ratio = applySplit(current + delta); savePrefsPatch({ split_ratio: prefs!.split_ratio }); } }); dockDivider.tabIndex = 0; dockDivider.setAttribute('role', 'separator'); dockDivider.setAttribute('aria-orientation', 'vertical'); dockDivider.setAttribute('aria-label', 'Resize dock panes'); dockDivider.addEventListener('mousedown', (e) => { draggingDockDivider = true; document.body.style.userSelect = 'none'; e.preventDefault(); }); dockDivider.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02; const current = prefs?.dock_ratio || 0.62; prefs!.dock_ratio = applyDockSplit(current + delta); savePrefsPatch({ dock_ratio: prefs!.dock_ratio }); } }); 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 (two-click confirmation) --- resetProgBtn.addEventListener('click', async () => { if (!library) return; if (!resetConfirming) { resetConfirming = true; resetProgBtn.classList.add('confirming'); const icon = resetProgBtn.querySelector('i'); if (icon) icon.className = 'fa-solid fa-exclamation-triangle'; resetProgBtn.setAttribute('aria-label', 'Confirm reset progress'); resetConfirmTimer = setTimeout(() => { cancelResetConfirm(); }, 3000); return; } // Second click — actually reset cancelResetConfirm(); 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 (_) {} }); resetProgBtn.addEventListener('blur', () => { if (resetConfirming) cancelResetConfirm(); }); // --- 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(); }; chooseDropBtn.addEventListener('keydown', (e) => { if (e.key === 'Escape' && recentOpen) { closeRecentMenu(); chooseDropBtn.focus(); } }); 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', () => { updateNotesHighlight(); 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); }); // --- Timestamp insertion --- const insertTimestampBtn = document.getElementById('insertTimestamp'); if (insertTimestampBtn) { insertTimestampBtn.onclick = () => { const t = player?.currentTime || 0; const m = Math.floor(t / 60); const s = Math.floor(t % 60); const stamp = `[${m}:${String(s).padStart(2, '0')}] `; const pos = notesBox.selectionStart ?? notesBox.value.length; const before = notesBox.value.substring(0, pos); const after = notesBox.value.substring(pos); notesBox.value = before + stamp + after; notesBox.focus(); const newPos = pos + stamp.length; notesBox.setSelectionRange(newPos, newPos); // Trigger note save notesBox.dispatchEvent(new Event('input')); }; } // --- Clickable timestamps in notes --- notesBox.addEventListener('click', () => { const pos = notesBox.selectionStart; if (pos === null || pos === undefined) return; // Only act on single click without selection if (notesBox.selectionStart !== notesBox.selectionEnd) return; const text = notesBox.value; // Match [H:MM:SS] or [M:SS] patterns const re = /\[(\d{1,3}):(\d{2})(?::(\d{2}))?\]/g; let match; while ((match = re.exec(text)) !== null) { const start = match.index; const end = start + match[0].length; if (pos >= start && pos <= end) { let totalSeconds: number; if (match[3] !== undefined) { // [H:MM:SS] totalSeconds = parseInt(match[1], 10) * 3600 + parseInt(match[2], 10) * 60 + parseInt(match[3], 10); } else { // [M:SS] totalSeconds = parseInt(match[1], 10) * 60 + parseInt(match[2], 10); } if (player && Number.isFinite(player.duration) && player.duration > 0) { player.currentTime = clamp(totalSeconds, 0, player.duration); cb.notify?.(`Jumped to ${match[0]}`); } break; } } }); // --- Collapsible dock panes --- const notesHeader = document.getElementById('notesHeader'); const infoHeader = document.getElementById('infoHeader'); if (notesHeader) { notesHeader.style.cursor = 'pointer'; notesHeader.setAttribute('aria-expanded', 'true'); notesHeader.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('.timestampBtn')) return; toggleDockPane(notesHeader, 'notes_collapsed'); }); notesHeader.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDockPane(notesHeader, 'notes_collapsed'); } }); notesHeader.tabIndex = 0; } if (infoHeader) { infoHeader.style.cursor = 'pointer'; infoHeader.setAttribute('aria-expanded', 'true'); infoHeader.addEventListener('click', () => { toggleDockPane(infoHeader, 'info_collapsed'); }); infoHeader.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDockPane(infoHeader, 'info_collapsed'); } }); infoHeader.tabIndex = 0; } // Restore collapsed state from prefs if (prefs?.notes_collapsed) collapsePane(notesHeader!, true); if (prefs?.info_collapsed) collapsePane(infoHeader!, true); } // ---- 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 = '-'; const pb = overallBar.parentElement; if (pb) pb.setAttribute('aria-valuenow', '0'); return; } if (library.overall_progress === null || library.overall_progress === undefined) { overallBar.style.width = '0%'; overallPct.textContent = '-'; const pb = overallBar.parentElement; if (pb) pb.setAttribute('aria-valuenow', '0'); return; } const p = clamp(library.overall_progress, 0, 100); overallBar.style.width = `${p.toFixed(1)}%`; overallPct.textContent = `${p.toFixed(1)}%`; const progressBarEl = overallBar.parentElement; if (progressBarEl) progressBarEl.setAttribute('aria-valuenow', String(Math.round(p))); } 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((f: any) => `${f.name}: ${f.finished}/${f.total}`).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 = ''; updateNotesHighlight(); return; } try { const res = await api.getNote(it.fid); notesBox.value = (res && res.ok) ? (res.note || '') : ''; } catch (_) { notesBox.value = ''; } updateNotesHighlight(); } 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'; chooseDropBtn?.setAttribute('aria-expanded', 'false'); } 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.setAttribute('role', 'menuitem'); row.tabIndex = -1; 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('button'); removeBtn.className = 'dropRemove'; removeBtn.setAttribute('aria-label', `Remove ${it.name}`); 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); }; row.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); const next = row.nextElementSibling as HTMLElement | null; if (next && next.classList.contains('dropItem')) next.focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); const prev = row.previousElementSibling as HTMLElement | null; if (prev && prev.classList.contains('dropItem')) prev.focus(); } else if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); closeRecentMenu(); chooseDropBtn.focus(); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); row.click(); } }); recentMenu.appendChild(row); } } positionRecentMenu(); recentMenu.style.display = 'block'; recentOpen = true; chooseDropBtn.setAttribute('aria-expanded', 'true'); const first = recentMenu.querySelector('.dropItem') as HTMLElement | null; if (first) first.focus(); } catch (_) { closeRecentMenu(); } } // ---- Private helpers ---- async function savePrefsPatch(patch: Record): Promise { await api.setPrefs(patch); } function cancelResetConfirm(): void { resetConfirming = false; if (resetConfirmTimer) { clearTimeout(resetConfirmTimer); resetConfirmTimer = null; } resetProgBtn.classList.remove('confirming'); const icon = resetProgBtn.querySelector('i'); if (icon) icon.className = 'fa-solid fa-clock-rotate-left'; resetProgBtn.setAttribute('aria-label', 'Reset progress'); } function toggleDockPane(header: HTMLElement, prefKey: string): void { const pane = header.closest('.dockPane'); if (!pane) return; const isCollapsed = pane.classList.toggle('collapsed'); header.setAttribute('aria-expanded', String(!isCollapsed)); const chevron = header.querySelector('.dockChevron') as HTMLElement | null; if (chevron) chevron.style.transform = isCollapsed ? 'rotate(-90deg)' : ''; savePrefsPatch({ [prefKey]: isCollapsed }); } function updateNotesHighlight(): void { if (!notesBackdrop) return; const text = notesBox.value; const escaped = text .replace(/&/g, '&') .replace(//g, '>'); const highlighted = escaped.replace( /\[(\d{1,3}:\d{2}(?::\d{2})?)\]/g, '[$1]' ); // Trailing newline matches textarea's extra line space notesBackdrop.innerHTML = highlighted + '\n'; } function collapsePane(header: HTMLElement, collapsed: boolean): void { const pane = header.closest('.dockPane'); if (!pane) return; if (collapsed) { pane.classList.add('collapsed'); header.setAttribute('aria-expanded', 'false'); const chevron = header.querySelector('.dockChevron') as HTMLElement | null; if (chevron) chevron.style.transform = 'rotate(-90deg)'; } }