Files
tutorialvault/src/ui.ts
T
Your Name c0a8eca955 fix: WCAG AAA contrast compliance, speed menu z-index, custom app icon
- Fix all text colors to meet WCAG 2.2 AAA 7:1 contrast ratios against
  dark backgrounds (--textMuted, --textDim, hover states across playlist,
  player, panels, tooltips)
- Fix speed menu rendering behind seek bar by correcting z-index stacking
  context (.controls z-index:10, .miniCtl z-index:3, .seek z-index:2)
- Replace default Tauri icons with custom TutorialVault icon across all
  required sizes (32-512px PNGs, ICO, ICNS, Windows Square logos)
- Update README: Fraunces → Bricolage Grotesque font reference
- Add collapsible dock pane persistence and keyboard-adjustable dividers
2026-02-19 18:23:38 +02:00

814 lines
30 KiB
TypeScript

/**
* 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<typeof setTimeout> | null = null;
let draggingDivider = false;
let draggingDockDivider = false;
let saveSplitTimer: ReturnType<typeof setTimeout> | null = null;
let saveDockTimer: ReturnType<typeof setTimeout> | null = null;
let saveZoomTimer: ReturnType<typeof setTimeout> | null = null;
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
let recentOpen = false;
let resetConfirmTimer: ReturnType<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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 = '<i class="fa-solid fa-folder"></i>';
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 = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
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<string, unknown>): Promise<void> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const highlighted = escaped.replace(
/\[(\d{1,3}:\d{2}(?::\d{2})?)\]/g,
'<span class="tsLink">[$1]</span>'
);
// 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)';
}
}