import { noop, safeCall, safeAddListener } from "../utils/safeCall.js"; import { clamp, clamp01 } from "../features/viewer/state.js"; import { t } from "../app/i18n.js"; import { normalizeVideoFps } from "../utils/mediaFps.js"; const MAX_MINOR_TICKS = 400; const SEEK_RANGE_MAX = 1000; const STEP_FLASH_MS = 220; function setAbortableTimeout(signal, ms, fn) { try { if (signal?.aborted) return noop; const id = setTimeout( () => { try { if (signal?.aborted) return; fn?.(); } catch (e) { console.debug?.(e); } }, Math.max(0, Math.floor(Number(ms) || 0)), ); const onAbort = () => { try { clearTimeout(id); } catch (e) { console.debug?.(e); } }; try { signal?.addEventListener?.("abort", onAbort, { once: true }); } catch (e) { console.debug?.(e); } return () => { try { clearTimeout(id); } catch (e) { console.debug?.(e); } try { signal?.removeEventListener?.("abort", onAbort); } catch (e) { console.debug?.(e); } }; } catch { return noop; } } function pad2(n) { const v = Math.floor(Number(n) || 0); return v < 10 ? `0${v}` : String(v); } function formatTime(seconds) { const s = Number(seconds); if (!Number.isFinite(s) || s < 0) return "0:00"; const total = Math.floor(s); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const sec = total % 60; if (h > 0) return `${h}:${pad2(m)}:${pad2(sec)}`; return `${m}:${pad2(sec)}`; } function createBtn(className, text, title) { const b = document.createElement("button"); b.type = "button"; b.className = `mjr-video-btn ${className || ""}`.trim(); if (title) b.title = title; try { b.setAttribute("aria-label", title || text || "Button"); } catch (e) { console.debug?.(e); } b.textContent = text; return b; } function createIconBtn(className, iconClass, title, ariaLabel) { const b = document.createElement("button"); b.type = "button"; b.className = `mjr-video-btn ${className || ""}`.trim(); if (title) b.title = title; try { b.setAttribute("aria-label", ariaLabel || title || "Button"); } catch (e) { console.debug?.(e); } const icon = document.createElement("span"); icon.className = `pi ${iconClass || ""}`.trim(); icon.setAttribute("aria-hidden", "true"); b.appendChild(icon); return { btn: b, icon }; } function createNumberInput(className, { min, max, step, value, title, ariaLabel, widthPx } = {}) { const i = document.createElement("input"); i.type = "number"; i.className = `mjr-video-num ${className || ""}`.trim(); if (title) i.title = title; if (ariaLabel) i.setAttribute("aria-label", ariaLabel); if (min != null) i.min = String(min); if (max != null) i.max = String(max); if (step != null) i.step = String(step); if (value != null) i.value = String(value); if (widthPx != null) i.style.width = `${widthPx}px`; return i; } function _resolveVideoControlsVariant(opts) { try { if (opts?.variant === "preview") return "preview"; if (opts?.variant === "viewerbar") return "viewerbar"; return "viewer"; } catch { return "viewer"; } } function _resolveInitialFps(opts) { try { const v = Number(opts?.initialFps); return Number.isFinite(v) && v > 0 ? v : null; } catch { return null; } } function _mountPreviewControls(video, hostEl) { const unsubs = []; try { // Preview thumbnails should loop continuously (muted). video.controls = false; video.loop = true; video.muted = true; video.playsInline = true; video.autoplay = true; } catch (e) { console.debug?.(e); } const controls = document.createElement("div"); controls.className = "mjr-video-controls mjr-video-controls--preview"; try { controls.setAttribute("role", "group"); controls.setAttribute("aria-label", t("video.previewControls", "Video preview controls")); } catch (e) { console.debug?.(e); } const btn = document.createElement("button"); btn.type = "button"; btn.className = "mjr-video-preview-btn"; btn.title = t("video.playPause", "Play/Pause"); try { btn.setAttribute("aria-label", t("video.playPause", "Play/Pause")); } catch (e) { console.debug?.(e); } const icon = document.createElement("span"); icon.className = "pi pi-play"; try { icon.setAttribute("aria-hidden", "true"); } catch (e) { console.debug?.(e); } btn.appendChild(icon); controls.appendChild(btn); const setIcon = () => { try { const paused = Boolean(video?.paused); icon.className = `pi ${paused ? "pi-play" : "pi-pause"}`; } catch (e) { console.debug?.(e); } }; const tryPlay = () => { try { const p = video.play?.(); if (p && typeof p.catch === "function") p.catch(() => {}); } catch (e) { console.debug?.(e); } }; const toggle = (e) => { try { e?.stopPropagation?.(); } catch (e) { console.debug?.(e); } try { if (video.paused) { tryPlay(); } else { video.pause?.(); } } catch (e) { console.debug?.(e); } setIcon(); }; try { hostEl.appendChild(controls); } catch (e) { console.debug?.(e); } // Best-effort autoplay. try { tryPlay(); } catch (e) { console.debug?.(e); } unsubs.push(safeAddListener(video, "loadedmetadata", () => tryPlay(), { passive: true })); unsubs.push(safeAddListener(video, "canplay", () => tryPlay(), { passive: true })); unsubs.push(safeAddListener(btn, "click", toggle)); unsubs.push(safeAddListener(video, "play", setIcon, { passive: true })); unsubs.push(safeAddListener(video, "pause", setIcon, { passive: true })); unsubs.push(safeAddListener(video, "ended", () => tryPlay(), { passive: true })); try { setIcon(); } catch (e) { console.debug?.(e); } return { controlsEl: controls, destroy: () => { try { for (const u of unsubs) safeCall(() => u?.()); } catch (e) { console.debug?.(e); } try { controls.remove?.(); } catch (e) { console.debug?.(e); } }, }; } /** * Mount custom video controls on top of a