Files
MajoorWaldi-ComfyUI-Majoor-…/js/components/VideoControls.js
T

2183 lines
80 KiB
JavaScript

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 <video> element.
* Must never throw (viewer stability).
*
* Viewer variant adds Nuke-style range + frame controls (in/out, step, loop/once).
*
* @param {HTMLMediaElement} video
* @param {{variant?: 'viewer'|'preview'|'viewerbar', mediaKind?: 'video'|'audio', hostEl?: HTMLElement, fullscreenEl?: HTMLElement, initialFps?: number, initialFrameCount?: number, initialPlaybackRate?: number}} opts
* @returns {{controlsEl: HTMLElement|null, destroy: () => void, setMediaInfo?: (info: {fps?: number, frameCount?: number}) => void, setPlaybackRate?: (rate:number)=>number, getPlaybackRate?: ()=>number, adjustPlaybackRate?: (delta:number)=>number, togglePlay?: ()=>boolean, stepFrames?: (direction:number)=>boolean}}
*/
export function mountVideoControls(video, opts = {}) {
try {
const variant = _resolveVideoControlsVariant(opts);
const mediaKind = String(opts?.mediaKind || "video").toLowerCase();
const isAudioMedia = mediaKind === "audio";
const isViewerBar = variant === "viewerbar";
const advanced = variant !== "preview" && !isAudioMedia;
// Volume slider is toggled from the sound icon to keep the bar compact.
const showVolumeSlider = true;
const initialFps = _resolveInitialFps(opts);
const hostEl = opts?.hostEl || video?.parentElement;
if (!video || !hostEl) return { controlsEl: null, destroy: noop };
// ---------------------------------------------------------------------
// Preview variant: minimal play/pause overlay + continuous looping
// ---------------------------------------------------------------------
if (variant === "preview") {
return _mountPreviewControls(video, hostEl);
}
// We manage looping ourselves (Loop/Once + in/out range); disable native looping to avoid conflicts.
try {
video.loop = false;
} catch (e) {
console.debug?.(e);
}
safeCall(() => hostEl.classList?.add("mjr-video-host"));
safeCall(() => video.classList?.add("mjr-video-el"));
safeCall(() => {
const cs = window.getComputedStyle?.(hostEl);
if (cs?.position === "static") hostEl.style.position = "relative";
});
const controls = document.createElement("div");
controls.className = `mjr-video-controls mjr-video-controls--${variant}`;
if (isViewerBar) controls.classList.add("mjr-video-controls--modern");
controls.setAttribute("role", "group");
controls.setAttribute(
"aria-label",
isAudioMedia
? t("video.audioControls", "Audio controls")
: t("video.controls", "Video controls"),
);
const rowTop = document.createElement("div");
rowTop.className = "mjr-video-row mjr-video-row--top";
const rowBottom = document.createElement("div");
rowBottom.className = "mjr-video-row mjr-video-row--bottom";
controls.appendChild(rowTop);
controls.appendChild(rowBottom);
const seekWrap = document.createElement("div");
seekWrap.className = "mjr-video-seek-wrap";
const seek = document.createElement("input");
seek.className = "mjr-video-range mjr-video-range--seek";
seek.type = "range";
seek.min = "0";
seek.max = String(SEEK_RANGE_MAX);
seek.step = "1";
seek.value = "0";
seek.setAttribute("aria-label", t("video.seek", "Seek"));
seek.title = isAudioMedia
? t("video.seekThroughAudio", "Seek through audio")
: t("video.seekThrough", "Seek through video");
const seekOverlay = document.createElement("div");
seekOverlay.className = "mjr-video-seek-overlay";
// Seekbar range zones (more reliable than styling <input type="range"> across browsers).
// Only used in advanced mode (viewer/viewerbar).
let seekZones = null;
let leftTrimZone = null;
let selectedZone = null;
let rightTrimZone = null;
if (advanced) {
seekZones = document.createElement("div");
seekZones.className = "mjr-video-seek-zones";
leftTrimZone = document.createElement("div");
leftTrimZone.className = "mjr-video-seek-zone mjr-video-seek-zone--leftTrim";
selectedZone = document.createElement("div");
selectedZone.className = "mjr-video-seek-zone mjr-video-seek-zone--selected";
rightTrimZone = document.createElement("div");
rightTrimZone.className = "mjr-video-seek-zone mjr-video-seek-zone--rightTrim";
seekZones.appendChild(leftTrimZone);
seekZones.appendChild(selectedZone);
seekZones.appendChild(rightTrimZone);
}
const tickBar = document.createElement("div");
tickBar.className = "mjr-video-seek-ticks";
const labelsBar = document.createElement("div");
labelsBar.className = "mjr-video-seek-labels";
const inMark = document.createElement("div");
inMark.className = "mjr-video-seek-mark mjr-video-seek-mark--in";
const outMark = document.createElement("div");
outMark.className = "mjr-video-seek-mark mjr-video-seek-mark--out";
const playhead = document.createElement("div");
playhead.className = "mjr-video-seek-playhead";
const playheadLabel = document.createElement("div");
playheadLabel.className = "mjr-video-seek-playhead-label";
seekOverlay.appendChild(tickBar);
seekOverlay.appendChild(labelsBar);
seekOverlay.appendChild(playhead);
seekOverlay.appendChild(playheadLabel);
// Draggable I/O handles (bigger hit area than the thin markers).
const inHandle = document.createElement("div");
inHandle.className = "mjr-video-seek-handle mjr-video-seek-handle--in";
inHandle.title = t("video.dragSetIn", "Drag to set In");
inHandle.setAttribute("aria-label", t("video.dragSetIn", "Drag to set In"));
const outHandle = document.createElement("div");
outHandle.className = "mjr-video-seek-handle mjr-video-seek-handle--out";
outHandle.title = t("video.dragSetOut", "Drag to set Out");
outHandle.setAttribute("aria-label", t("video.dragSetOut", "Drag to set Out"));
seekWrap.appendChild(seek);
if (seekZones) seekWrap.appendChild(seekZones);
seekWrap.appendChild(seekOverlay);
// Marks are separate from the non-interactive overlay so they can be dragged directly.
seekWrap.appendChild(inMark);
seekWrap.appendChild(outMark);
seekWrap.appendChild(inHandle);
seekWrap.appendChild(outHandle);
const timeLabel = document.createElement("span");
timeLabel.className = "mjr-video-time";
timeLabel.textContent = "0:00 / 0:00";
timeLabel.title = t("video.currentTimeTotal", "Current time / Total duration");
const rangeCountLabel = document.createElement("span");
rangeCountLabel.className = "mjr-video-range-count";
rangeCountLabel.textContent = "";
try {
rangeCountLabel.style.display = "none";
} catch (e) {
console.debug?.(e);
}
const timeGroup = document.createElement("div");
timeGroup.className = "mjr-video-timegroup";
timeGroup.appendChild(timeLabel);
if (advanced) timeGroup.appendChild(rangeCountLabel);
const frameLabel = document.createElement("span");
frameLabel.className = "mjr-video-frame";
frameLabel.textContent = "F: 0";
frameLabel.title = t("video.currentFrame", "Current frame number");
const playBtn = createBtn(
"mjr-video-btn--play",
t("btn.play", "Play"),
t("video.playPauseSpace", "Play/Pause (Space)"),
);
const prevFrameBtn = createBtn(
"mjr-video-btn--step",
"<",
t("video.stepBack", "Step back"),
);
const nextFrameBtn = createBtn(
"mjr-video-btn--step",
">",
t("video.stepForward", "Step forward"),
);
const toInBtn = createBtn(
"mjr-video-btn--jump mjr-video-btn--in",
"|<",
t("video.goToIn", "Go to In"),
);
const toOutBtn = createBtn(
"mjr-video-btn--jump mjr-video-btn--out",
">|",
t("video.goToOut", "Go to Out"),
);
const setInBtn = createBtn(
"mjr-video-btn--mark mjr-video-btn--in",
"I",
t("video.setInFromCurrent", "Set In from current frame"),
);
const setOutBtn = createBtn(
"mjr-video-btn--mark mjr-video-btn--out",
"O",
t("video.setOutFromCurrent", "Set Out from current frame"),
);
const loopIcon = createIconBtn(
"mjr-video-btn--toggle",
"pi-refresh",
t("video.loopPlaybackInRange", "Loop playback in range"),
t("video.loop", "Loop"),
);
const loopBtn = loopIcon.btn;
const inInput = createNumberInput("mjr-video-num--in", {
min: 0,
step: 1,
value: 0,
title: t("video.inFrame", "In frame"),
ariaLabel: t("video.inFrame", "In frame"),
widthPx: 72,
});
const outInput = createNumberInput("mjr-video-num--out", {
min: 0,
step: 1,
value: 0,
title: t("video.outFrame", "Out frame"),
ariaLabel: t("video.outFrame", "Out frame"),
widthPx: 72,
});
const stepInput = createNumberInput("mjr-video-num--step", {
min: 1,
step: 1,
value: 1,
title: t("video.frameIncrement", "Frame increment"),
ariaLabel: t("video.frameIncrement", "Frame increment"),
widthPx: 56,
});
const fpsInput = createNumberInput("mjr-video-num--fps", {
min: 1,
step: 0.001,
value: normalizeVideoFps(initialFps || 30),
title: t("video.fpsStepping", "FPS (used for frame stepping)"),
ariaLabel: t("video.fps", "FPS"),
widthPx: 56,
});
const speedSelect = document.createElement("select");
speedSelect.className = "mjr-video-num mjr-video-num--speed";
speedSelect.title = t("video.playbackSpeed", "Playback speed");
speedSelect.setAttribute("aria-label", t("video.playbackSpeed", "Playback speed"));
speedSelect.style.width = "74px";
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
for (const rate of SPEED_OPTIONS) {
const opt = document.createElement("option");
opt.value = String(rate);
opt.textContent = `${rate}x`;
speedSelect.appendChild(opt);
}
const muteIcon = createIconBtn(
"mjr-video-btn--mute",
"pi-volume-up",
t("video.mute", "Mute"),
t("video.mute", "Mute"),
);
const muteBtn = muteIcon.btn;
const volumeWrap = document.createElement("div");
volumeWrap.className = "mjr-video-volume-wrap";
volumeWrap.style.cssText = "display:none; align-items:center; position:relative;";
let volume = null;
if (showVolumeSlider) {
volume = document.createElement("input");
volume.className = "mjr-video-range mjr-video-range--volume";
volume.type = "range";
volume.min = "0";
volume.max = "1";
volume.step = "0.02";
volume.value = String(clamp01(Number(video.volume) || 0));
volume.setAttribute("aria-label", t("video.volume", "Volume"));
volume.title = t("video.volume", "Volume");
try {
volume.style.width = "120px";
} catch (e) {
console.debug?.(e);
}
volumeWrap.appendChild(volume);
}
const inGroup = document.createElement("div");
inGroup.className = "mjr-video-group mjr-video-group--in";
const inLabel = document.createElement("span");
inLabel.textContent = "In";
inLabel.title = t("video.resetInToStart", "Reset In to start");
inLabel.style.cssText = "cursor:pointer; user-select:none;";
if (advanced) {
inGroup.appendChild(inLabel);
inGroup.appendChild(inInput);
}
const outGroup = document.createElement("div");
outGroup.className = "mjr-video-group mjr-video-group--out";
const outLabel = document.createElement("span");
outLabel.textContent = "Out";
outLabel.title = t("video.resetOutToEnd", "Reset Out to end");
outLabel.style.cssText = "cursor:pointer; user-select:none;";
if (advanced) {
outGroup.appendChild(outLabel);
outGroup.appendChild(outInput);
}
const leftAdjustGroup = document.createElement("div");
leftAdjustGroup.className = "mjr-video-group mjr-video-group--adjust-left";
if (advanced) {
// Requested: keep current frame next to the FPS controls in the bottom row.
leftAdjustGroup.appendChild(setInBtn);
leftAdjustGroup.appendChild(document.createTextNode(t("video.step", "Step")));
leftAdjustGroup.appendChild(stepInput);
leftAdjustGroup.appendChild(document.createTextNode(t("video.fps", "FPS")));
leftAdjustGroup.appendChild(fpsInput);
leftAdjustGroup.appendChild(frameLabel);
}
const rightAdjustGroup = document.createElement("div");
rightAdjustGroup.className = "mjr-video-group mjr-video-group--adjust-right";
if (advanced) {
rightAdjustGroup.appendChild(timeGroup);
rightAdjustGroup.appendChild(loopBtn);
}
const speedGroup = document.createElement("div");
speedGroup.className = "mjr-video-group mjr-video-group--speed";
speedGroup.appendChild(document.createTextNode(t("video.speed", "Speed")));
speedGroup.appendChild(speedSelect);
// Bottom row: center transport, right-side extra controls
const bottomLeft = document.createElement("div");
bottomLeft.className = "mjr-video-bottom mjr-video-bottom--left";
const transport = document.createElement("div");
transport.className = "mjr-video-transport";
const bottomRight = document.createElement("div");
bottomRight.className = "mjr-video-bottom mjr-video-bottom--right";
if (!isAudioMedia) {
transport.appendChild(toInBtn);
transport.appendChild(prevFrameBtn);
}
transport.appendChild(playBtn);
if (!isAudioMedia) {
transport.appendChild(nextFrameBtn);
transport.appendChild(toOutBtn);
}
if (advanced) bottomLeft.appendChild(leftAdjustGroup);
if (advanced) bottomRight.appendChild(rightAdjustGroup);
bottomRight.appendChild(speedGroup);
bottomRight.appendChild(muteBtn);
if (advanced) bottomRight.appendChild(setOutBtn);
if (volume) bottomRight.appendChild(volumeWrap);
if (isViewerBar) {
const timelineStrip = document.createElement("div");
timelineStrip.className = "mjr-video-bar-timeline";
if (advanced) timelineStrip.appendChild(inGroup);
timelineStrip.appendChild(seekWrap);
if (advanced) timelineStrip.appendChild(outGroup);
const actionsStrip = document.createElement("div");
actionsStrip.className = "mjr-video-bar-actions";
const actionsLeft = document.createElement("div");
actionsLeft.className = "mjr-video-bar-side mjr-video-bar-side--left";
if (advanced) actionsLeft.appendChild(leftAdjustGroup);
const actionsCenter = document.createElement("div");
actionsCenter.className = "mjr-video-bar-center";
actionsCenter.appendChild(transport);
const actionsRight = document.createElement("div");
actionsRight.className = "mjr-video-bar-side mjr-video-bar-side--right";
if (advanced) actionsRight.appendChild(rightAdjustGroup);
actionsRight.appendChild(speedGroup);
actionsRight.appendChild(muteBtn);
if (advanced) actionsRight.appendChild(setOutBtn);
if (volume) actionsRight.appendChild(volumeWrap);
actionsStrip.appendChild(actionsLeft);
actionsStrip.appendChild(actionsCenter);
actionsStrip.appendChild(actionsRight);
controls.replaceChildren(timelineStrip, actionsStrip);
} else {
if (advanced) rowTop.appendChild(frameLabel);
if (advanced) rowTop.appendChild(inGroup);
rowTop.appendChild(seekWrap);
if (advanced) rowTop.appendChild(outGroup);
rowTop.appendChild(timeGroup);
rowBottom.appendChild(bottomLeft);
rowBottom.appendChild(transport);
rowBottom.appendChild(bottomRight);
}
// Ensure the controls overlay never triggers viewer panning/zoom handlers.
const stop = (e) => {
try {
e.stopPropagation?.();
} catch (e) {
console.debug?.(e);
}
};
const preventStop = (e) => {
try {
e.preventDefault?.();
} catch (e) {
console.debug?.(e);
}
stop(e);
};
const unsubs = [];
const timersAC = (() => {
try {
return new AbortController();
} catch {
return {
signal: { aborted: false, addEventListener: noop, removeEventListener: noop },
abort: noop,
};
}
})();
unsubs.push(() => {
try {
timersAC.abort();
} catch (e) {
console.debug?.(e);
}
});
// Must NOT run in capture phase, otherwise it blocks pointerdown on child controls
// like the draggable In/Out handles. We stop bubbling instead, and rely on the
// window-capture guards below to prevent viewer pan/zoom handlers.
unsubs.push(safeAddListener(controls, "pointerdown", stop));
unsubs.push(safeAddListener(controls, "dblclick", preventStop, { capture: true }));
unsubs.push(
safeAddListener(controls, "wheel", preventStop, { capture: true, passive: false }),
);
// We intentionally do NOT stop pointer events at the window capture phase, because that would
// prevent child controls (like draggable In/Out handles) from receiving pointerdown.
// Pan/zoom suppression is handled in `viewer/panzoom.js` by ignoring events originating inside
// `.mjr-video-controls`.
unsubs.push(
safeAddListener(
window,
"dblclick",
(e) => {
try {
if (controls.contains?.(e?.target)) preventStop(e);
} catch (e) {
console.debug?.(e);
}
},
{ capture: true },
),
);
unsubs.push(
safeAddListener(
window,
"wheel",
(e) => {
try {
if (controls.contains?.(e?.target)) preventStop(e);
} catch (e) {
console.debug?.(e);
}
},
{ capture: true, passive: false },
),
);
const state = {
fps: normalizeVideoFps(initialFps || 30),
step: 1,
inFrame: null,
outFrame: null,
frameCount: null,
loop: advanced,
pingpong: false,
once: false,
playbackRate: Math.max(0.25, Math.min(2, Number(opts?.initialPlaybackRate) || 1)),
_seeking: false,
_ppReverse: false,
_ppRafId: null,
_userInteracted: false,
};
// Auto-unmute once the user interacts with the video controls.
// This bypasses autoplay restrictions: most browsers allow unmuted playback after a user gesture.
const tryAutoUnmute = () => {
if (state._userInteracted) return;
state._userInteracted = true;
try {
// Only unmute if the video is currently muted due to autoplay restrictions
// (not if user explicitly muted via the mute button).
if (video.muted) {
video.muted = false;
updateVolumeUI?.();
}
} catch (e) {
console.debug?.(e);
}
};
let _stepFlashCancel = null;
const flashFrameStep = () => {
if (!advanced) return;
try {
frameLabel.classList.add("is-step");
try {
_stepFlashCancel?.();
} catch (e) {
console.debug?.(e);
}
_stepFlashCancel = setAbortableTimeout(timersAC.signal, STEP_FLASH_MS, () => {
try {
frameLabel.classList.remove("is-step");
} catch (e) {
console.debug?.(e);
}
});
} catch (e) {
console.debug?.(e);
}
};
unsubs.push(() => {
try {
_stepFlashCancel?.();
} catch (e) {
console.debug?.(e);
}
_stepFlashCancel = null;
try {
frameLabel?.classList?.remove?.("is-step");
} catch (e) {
console.debug?.(e);
}
});
const setToggleBtn = (btn, on) => {
try {
if (!btn) return;
if (on) btn.classList.add("is-on");
else btn.classList.remove("is-on");
} catch (e) {
console.debug?.(e);
}
};
const setPlaybackRate = (nextRate) => {
try {
const n = Number(nextRate);
if (!Number.isFinite(n) || n <= 0) return state.playbackRate;
const rate = Math.max(0.25, Math.min(2, Math.round(n * 100) / 100));
state.playbackRate = rate;
try {
video.playbackRate = rate;
} catch (e) {
console.debug?.(e);
}
try {
if (!speedSelect.matches?.(":focus")) speedSelect.value = String(rate);
} catch (e) {
console.debug?.(e);
}
return rate;
} catch {
return state.playbackRate;
}
};
const applyLoopOnceUI = () => {
try {
setToggleBtn(loopBtn, Boolean(state.loop || state.pingpong));
try {
if (loopIcon?.icon) {
if (state.pingpong) {
loopIcon.icon.className = "pi pi-sort-alt";
loopBtn.title = t(
"video.pingpongPlayback",
"Ping-pong playback (forward then reverse)",
);
} else {
loopIcon.icon.className = "pi pi-refresh";
loopBtn.title = t(
"video.loopPlaybackInRange",
"Loop playback in range",
);
}
}
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
};
const durationFrames = () => {
try {
const override = Number(state.frameCount);
if (Number.isFinite(override) && override > 0)
return Math.max(1, Math.floor(override));
const d = Number(video?.duration);
const fps = normalizeVideoFps(state.fps, 30);
if (!Number.isFinite(d) || d <= 0) return 0;
return Math.max(0, Math.floor(d * fps));
} catch {
return 0;
}
};
const currentFrame = (timeOverride = null) => {
try {
const sourceTime = timeOverride == null ? video?.currentTime : timeOverride;
const t = Number(sourceTime);
const fps = normalizeVideoFps(state.fps, 30);
if (!Number.isFinite(t) || t < 0) return 0;
// Use floor rather than round so the frame counter matches the
// currently displayed frame instead of jumping ahead mid-frame.
return Math.max(0, Math.floor((t * fps) + 1e-6));
} catch {
return 0;
}
};
const frameToTime = (frame) => {
const fps = normalizeVideoFps(state.fps, 30);
const f = Math.max(0, Number(frame) || 0);
return f / fps;
};
const normalizeRange = () => {
try {
const maxF = durationFrames();
if (maxF <= 0) return;
const inF = state.inFrame == null ? 0 : clamp(state.inFrame, 0, maxF);
const outF = state.outFrame == null ? maxF : clamp(state.outFrame, 0, maxF);
if (outF < inF) {
state.inFrame = outF;
state.outFrame = inF;
} else {
state.inFrame = inF;
state.outFrame = outF;
}
} catch (e) {
console.debug?.(e);
}
};
const getEffectiveInOut = () => {
try {
const maxF = durationFrames();
const inF = state.inFrame == null ? 0 : clamp(state.inFrame, 0, maxF);
const outF = state.outFrame == null ? maxF : clamp(state.outFrame, 0, maxF);
return { inF, outF, maxF };
} catch {
return { inF: 0, outF: 0, maxF: 0 };
}
};
const setPlayLabel = () => {
try {
const isPlaying = !video?.paused || state._ppReverse;
playBtn.textContent = isPlaying
? t("video.pause", "Pause")
: t("video.play", "Play");
} catch (e) {
console.debug?.(e);
}
};
const setMuteLabel = () => {
try {
const muted = Boolean(video?.muted) || (Number(video?.volume) || 0) <= 0.001;
try {
muteIcon.icon.className = `pi ${muted ? "pi-volume-off" : "pi-volume-up"}`;
} catch (e) {
console.debug?.(e);
}
const muteLabel = muted ? t("video.unmute", "Unmute") : t("video.mute", "Mute");
muteBtn.title = muteLabel;
muteBtn.setAttribute("aria-label", muteLabel);
} catch (e) {
console.debug?.(e);
}
};
const updateTimeUI = (timeOverride = null) => {
try {
const d = Number(video?.duration);
const sourceTime = timeOverride == null ? video?.currentTime : timeOverride;
const t = Number(sourceTime);
const durationOk = Number.isFinite(d) && d > 0;
timeLabel.textContent = `${formatTime(t)} / ${durationOk ? formatTime(d) : "0:00"}`;
seek.disabled = !durationOk;
if (durationOk) {
const p = clamp01((t || 0) / d);
const v = Math.round(p * 1000);
if (!Number.isNaN(v) && !seek.matches?.(":active")) seek.value = String(v);
try {
playhead.style.left = `${p * 100}%`;
} catch (e) {
console.debug?.(e);
}
} else {
seek.value = "0";
try {
playhead.style.left = "0%";
} catch (e) {
console.debug?.(e);
}
}
if (advanced) {
const maxF = durationFrames();
const cf = currentFrame(t);
frameLabel.textContent = `F: ${cf} / ${maxF}`;
try {
// Show current frame value on the ruler, anchored to the playhead.
const durOk = Number.isFinite(d) && d > 0;
if (durOk) {
const p = clamp01((t || 0) / d);
playheadLabel.style.left = `${p * 100}%`;
playheadLabel.textContent = String(cf);
playheadLabel.style.display = "";
} else {
playheadLabel.style.display = "none";
}
} catch (e) {
console.debug?.(e);
}
if (!inInput.matches?.(":focus")) inInput.value = String(state.inFrame ?? 0);
if (!outInput.matches?.(":focus"))
outInput.value = String(state.outFrame ?? maxF);
// Show selected playback range frame count near timecode (only when trimmed).
try {
const { inF, outF, maxF: mx } = getEffectiveInOut();
const fullRange = inF <= 0 && outF >= mx;
const count = Math.max(0, Math.floor(outF) - Math.floor(inF) + 1);
if (!fullRange && mx > 0) {
rangeCountLabel.textContent = `R: ${count}f`;
rangeCountLabel.style.display = "";
} else {
rangeCountLabel.style.display = "none";
}
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
};
const updateSeekRangeStyle = () => {
if (!advanced) return;
try {
const { inF, outF, maxF } = getEffectiveInOut();
if (!Number.isFinite(maxF) || maxF <= 0) return;
const inPct = clamp01(inF / maxF) * 100;
const outPct = clamp01(outF / maxF) * 100;
const fullRange = inF <= 0 && outF >= maxF;
// Prefer overlay zones over range background for cross-browser reliability.
try {
seek.style.background = "";
} catch (e) {
console.debug?.(e);
}
try {
const inP = clamp01(inPct / 100) * 100;
const outP = clamp01(outPct / 100) * 100;
const selLeft = Math.min(inP, outP);
const selRight = Math.max(inP, outP);
if (seekZones && leftTrimZone && selectedZone && rightTrimZone) {
// 0..In (leftTrim) is intentionally kept visually neutral (see CSS).
leftTrimZone.style.left = "0%";
leftTrimZone.style.width = `${selLeft}%`;
selectedZone.style.left = `${selLeft}%`;
selectedZone.style.width = `${Math.max(0, selRight - selLeft)}%`;
rightTrimZone.style.left = `${selRight}%`;
rightTrimZone.style.width = `${Math.max(0, 100 - selRight)}%`;
try {
seekZones.classList.toggle("is-trimmed", !fullRange);
seekZones.classList.toggle("is-fullrange", fullRange);
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
try {
inMark.style.left = `${inPct}%`;
outMark.style.left = `${outPct}%`;
} catch (e) {
console.debug?.(e);
}
try {
inHandle.style.left = `${inPct}%`;
outHandle.style.left = `${outPct}%`;
} catch (e) {
console.debug?.(e);
}
// Nuke-like tick ruler. Clamp tick density so we don't create thousands of ticks for long clips.
try {
const targetTicks = MAX_MINOR_TICKS; // max minor ticks
const minFramesPerTick = Math.max(1, Math.floor(maxF / targetTicks));
const framesPerTick = Math.max(
minFramesPerTick,
Math.floor(Number(state.step) || 1),
);
const minorPct = (framesPerTick / maxF) * 100;
const majorPct = minorPct * 10;
if (Number.isFinite(minorPct) && minorPct > 0.02) {
const minor = `repeating-linear-gradient(to right, rgba(255,255,255,0.16) 0, rgba(255,255,255,0.16) 1px, transparent 1px, transparent ${minorPct}%)`;
const major = `repeating-linear-gradient(to right, rgba(255,255,255,0.28) 0, rgba(255,255,255,0.28) 1px, transparent 1px, transparent ${majorPct}%)`;
tickBar.style.backgroundImage = `${major}, ${minor}`;
} else {
tickBar.style.backgroundImage = "";
}
// Frame number labels (major ticks). Keep count small to avoid overlap.
const rebuildLabels = () => {
try {
const key = `${maxF}|${framesPerTick}`;
if (labelsBar?.dataset?.mjrLabelKey === key) return;
labelsBar.dataset.mjrLabelKey = key;
} catch (e) {
console.debug?.(e);
}
try {
labelsBar.replaceChildren();
} catch (e) {
console.debug?.(e);
}
const MAX_LABELS = 22;
let majorFrames = Math.max(1, framesPerTick * 10);
try {
while (majorFrames > 0 && Math.ceil(maxF / majorFrames) > MAX_LABELS)
majorFrames *= 2;
} catch (e) {
console.debug?.(e);
}
const makeLabel = (frame) => {
const s = document.createElement("span");
s.className = "mjr-video-seek-label";
const pct = clamp01(frame / maxF) * 100;
s.style.left = `${pct}%`;
s.textContent = String(Math.floor(frame));
return s;
};
try {
labelsBar.appendChild(makeLabel(0));
} catch (e) {
console.debug?.(e);
}
for (let f = majorFrames; f < maxF; f += majorFrames) {
try {
labelsBar.appendChild(makeLabel(f));
} catch (e) {
console.debug?.(e);
}
}
try {
labelsBar.appendChild(makeLabel(maxF));
} catch (e) {
console.debug?.(e);
}
};
rebuildLabels();
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
};
const applyRangeChange = ({ prefer = null } = {}) => {
if (!advanced) return;
try {
normalizeRange();
const { inF, outF } = getEffectiveInOut();
const cf = currentFrame();
if (prefer === "in") {
goToFrame(inF);
} else if (prefer === "out") {
if (cf > outF) goToFrame(outF);
} else {
// If outside the range, clamp into it (Nuke-like).
if (cf < inF) goToFrame(inF);
else if (cf > outF) goToFrame(outF);
}
} catch (e) {
console.debug?.(e);
}
};
const resetInToStart = () => {
try {
state.inFrame = 0;
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "in" });
} catch (e) {
console.debug?.(e);
}
};
const resetOutToEnd = () => {
try {
const { maxF } = getEffectiveInOut();
state.outFrame = Math.max(0, Number(maxF) || 0);
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "out" });
} catch (e) {
console.debug?.(e);
}
};
const _cancelPingpongReverse = () => {
try {
if (state._ppRafId != null) {
cancelAnimationFrame(state._ppRafId);
state._ppRafId = null;
}
} catch (e) {
console.debug?.(e);
}
};
unsubs.push(_cancelPingpongReverse);
const _startPingpongReverse = () => {
try {
_cancelPingpongReverse();
state._ppReverse = true;
video.pause?.();
setPlayLabel();
const fps = normalizeVideoFps(state.fps, 30);
const rate = Math.max(0.25, Number(state.playbackRate) || 1);
const intervalMs = 1000 / (fps * rate);
let lastTime = performance.now();
const stepBack = (now) => {
try {
if (!state._ppReverse || !state.pingpong) {
state._ppReverse = false;
setPlayLabel();
return;
}
const dt = now - lastTime;
if (dt >= intervalMs) {
lastTime = now - (dt % intervalMs);
const { inF } = getEffectiveInOut();
const cf = currentFrame();
if (cf <= inF) {
// Reached start — switch to forward
state._ppReverse = false;
goToFrame(inF);
const p = video.play?.();
if (p && typeof p.catch === "function") p.catch(() => {});
setPlayLabel();
updateTimeUI();
return;
}
const stepSize = Math.max(1, Math.floor(Number(state.step) || 1));
goToFrame(cf - stepSize);
updateTimeUI();
}
state._ppRafId = requestAnimationFrame(stepBack);
} catch (e) {
console.debug?.(e);
state._ppReverse = false;
setPlayLabel();
}
};
state._ppRafId = requestAnimationFrame(stepBack);
} catch (e) {
console.debug?.(e);
state._ppReverse = false;
}
};
const resetPlayerAll = () => {
try {
const maxF = durationFrames();
state.inFrame = 0;
state.outFrame = maxF > 0 ? maxF : null;
state.step = 1;
state.loop = Boolean(advanced);
state.pingpong = false;
state._ppReverse = false;
_cancelPingpongReverse();
state.once = false;
setPlaybackRate(1);
try {
stepInput.value = "1";
} catch (e) {
console.debug?.(e);
}
try {
if (!speedSelect.matches?.(":focus")) speedSelect.value = "1";
} catch (e) {
console.debug?.(e);
}
normalizeRange();
applyLoopOnceUI();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "in" });
} catch (e) {
console.debug?.(e);
}
};
const updateVolumeUI = () => {
try {
const v = clamp01(Number(video?.volume) || 0);
try {
if (volume && !volume.matches?.(":active")) volume.value = String(v);
} catch (e) {
console.debug?.(e);
}
setMuteLabel();
} catch (e) {
console.debug?.(e);
}
};
const goToFrame = (frame) => {
try {
const { maxF } = getEffectiveInOut();
const f = clamp(frame, 0, maxF > 0 ? maxF : Infinity);
video.currentTime = frameToTime(f);
} catch (e) {
console.debug?.(e);
}
updateTimeUI();
};
const stepFrames = (direction) => {
tryAutoUnmute();
try {
const inc = Math.max(1, Math.floor(Number(state.step) || 1));
const { inF, outF } = getEffectiveInOut();
const cf = currentFrame();
let next = cf + direction * inc;
if (state.loop) {
if (next < inF) next = outF;
if (next > outF) next = inF;
} else {
next = clamp(next, inF, outF);
}
try {
video.pause?.();
} catch (e) {
console.debug?.(e);
}
goToFrame(next);
flashFrameStep();
} catch (e) {
console.debug?.(e);
}
};
const ensureInRangeBeforePlay = () => {
if (!advanced) return;
try {
normalizeRange();
const { inF, outF } = getEffectiveInOut();
const cf = currentFrame();
if (cf < inF || cf > outF) goToFrame(inF);
} catch (e) {
console.debug?.(e);
}
};
const togglePlay = () => {
tryAutoUnmute();
try {
if (state._ppReverse) {
// Stop pingpong reverse and pause
state._ppReverse = false;
_cancelPingpongReverse();
setPlayLabel();
return;
}
if (video.paused) {
ensureInRangeBeforePlay();
const p = video.play?.();
if (p && typeof p.catch === "function") p.catch(() => {});
} else {
video.pause?.();
}
} catch (e) {
console.debug?.(e);
}
setPlayLabel();
};
// Click on video toggles play/pause (common UX).
unsubs.push(
safeAddListener(video, "click", (e) => {
try {
if (e?.target !== video) return;
} catch (e) {
console.debug?.(e);
}
togglePlay();
}),
);
unsubs.push(
safeAddListener(playBtn, "click", (e) => {
stop(e);
togglePlay();
}),
);
unsubs.push(
safeAddListener(prevFrameBtn, "click", (e) => {
stop(e);
stepFrames(-1);
}),
);
unsubs.push(
safeAddListener(nextFrameBtn, "click", (e) => {
stop(e);
stepFrames(1);
}),
);
unsubs.push(
safeAddListener(toInBtn, "click", (e) => {
stop(e);
const { inF } = getEffectiveInOut();
goToFrame(inF);
flashFrameStep();
}),
);
unsubs.push(
safeAddListener(toOutBtn, "click", (e) => {
stop(e);
const { outF } = getEffectiveInOut();
goToFrame(outF);
flashFrameStep();
}),
);
// Seeking (range slider)
unsubs.push(
safeAddListener(seek, "pointerdown", () => {
state._seeking = true;
}),
);
unsubs.push(
safeAddListener(seek, "pointerup", () => {
state._seeking = false;
}),
);
unsubs.push(
safeAddListener(seek, "input", (e) => {
stop(e);
tryAutoUnmute();
try {
const d = Number(video?.duration);
if (!Number.isFinite(d) || d <= 0) return;
const v = Number(seek.value);
const p = clamp01((Number.isFinite(v) ? v : 0) / 1000);
video.currentTime = p * d;
} catch (e) {
console.debug?.(e);
}
updateTimeUI();
}),
);
if (advanced) {
unsubs.push(
safeAddListener(setInBtn, "click", (e) => {
stop(e);
state.inFrame = currentFrame();
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "in" });
}),
);
unsubs.push(
safeAddListener(setOutBtn, "click", (e) => {
stop(e);
state.outFrame = currentFrame();
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "out" });
}),
);
unsubs.push(
safeAddListener(inInput, "change", (e) => {
stop(e);
try {
const v = Number(inInput.value);
state.inFrame = Number.isFinite(v) ? Math.max(0, Math.floor(v)) : null;
normalizeRange();
} catch (e) {
console.debug?.(e);
}
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "in" });
}),
);
unsubs.push(
safeAddListener(outInput, "change", (e) => {
stop(e);
try {
const v = Number(outInput.value);
state.outFrame = Number.isFinite(v) ? Math.max(0, Math.floor(v)) : null;
normalizeRange();
} catch (e) {
console.debug?.(e);
}
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "out" });
}),
);
unsubs.push(
safeAddListener(stepInput, "change", (e) => {
stop(e);
try {
state.step = Math.max(1, Math.floor(Number(stepInput.value) || 1));
stepInput.value = String(state.step);
} catch (e) {
console.debug?.(e);
}
}),
);
unsubs.push(
safeAddListener(fpsInput, "change", (e) => {
stop(e);
try {
state.fps = normalizeVideoFps(fpsInput.value, 30);
fpsInput.value = String(state.fps);
normalizeRange();
} catch (e) {
console.debug?.(e);
}
updateTimeUI();
updateSeekRangeStyle();
}),
);
unsubs.push(
safeAddListener(loopBtn, "click", (e) => {
stop(e);
// Cycle: off → loop → pingpong → off
if (!state.loop && !state.pingpong) {
state.loop = true;
state.pingpong = false;
} else if (state.loop && !state.pingpong) {
state.loop = false;
state.pingpong = true;
} else {
state.loop = false;
state.pingpong = false;
}
if (state.loop || state.pingpong) state.once = false;
// Cancel any active pingpong reverse animation
if (!state.pingpong) {
state._ppReverse = false;
_cancelPingpongReverse();
}
applyLoopOnceUI();
}),
);
unsubs.push(
safeAddListener(inLabel, "click", (e) => {
stop(e);
resetInToStart();
}),
);
unsubs.push(
safeAddListener(outLabel, "click", (e) => {
stop(e);
resetOutToEnd();
}),
);
unsubs.push(
safeAddListener(rangeCountLabel, "click", (e) => {
stop(e);
resetPlayerAll();
}),
);
try {
rangeCountLabel.title = t("video.resetPlayerControls", "Reset player controls");
rangeCountLabel.style.cursor = "pointer";
rangeCountLabel.style.userSelect = "none";
} catch (e) {
console.debug?.(e);
}
}
unsubs.push(
safeAddListener(muteBtn, "click", (e) => {
stop(e);
try {
video.muted = !video.muted;
if (volumeWrap) {
volumeWrap.style.display = video.muted ? "none" : "inline-flex";
}
} catch (e) {
console.debug?.(e);
}
updateVolumeUI();
}),
);
unsubs.push(
safeAddListener(muteBtn, "contextmenu", (e) => {
preventStop(e);
try {
if (!volumeWrap) return;
const open = volumeWrap.style.display !== "none";
volumeWrap.style.display = open ? "none" : "inline-flex";
} catch (e) {
console.debug?.(e);
}
updateVolumeUI();
}),
);
unsubs.push(
safeAddListener(
window,
"pointerdown",
(e) => {
try {
if (!volumeWrap || volumeWrap.style.display === "none") return;
if (muteBtn.contains?.(e?.target) || volumeWrap.contains?.(e?.target))
return;
volumeWrap.style.display = "none";
} catch (e) {
console.debug?.(e);
}
},
{ capture: true },
),
);
if (volume) {
unsubs.push(
safeAddListener(volume, "input", (e) => {
stop(e);
try {
const v = clamp01(Number(volume.value) || 0);
video.volume = v;
if (v > 0.001) video.muted = false;
} catch (e) {
console.debug?.(e);
}
updateVolumeUI();
}),
);
}
unsubs.push(
safeAddListener(speedSelect, "change", (e) => {
stop(e);
try {
setPlaybackRate(Number(speedSelect.value) || 1);
} catch (e) {
console.debug?.(e);
}
}),
);
unsubs.push(
safeAddListener(video, "ratechange", () => {
try {
setPlaybackRate(Number(video.playbackRate) || state.playbackRate || 1);
} catch (e) {
console.debug?.(e);
}
}),
);
const enforceRange = () => {
if (!advanced) return;
try {
if (state._seeking) return;
if (video?.paused) return;
const { inF, outF, maxF } = getEffectiveInOut();
if (maxF <= 0) return;
// Only enforce when the user has a non-full range selected.
const fullRange = inF <= 0 && outF >= maxF;
if (fullRange && !state.loop && !state.pingpong && !state.once) return;
// During pingpong reverse phase, don't enforce normal range logic
if (state._ppReverse) return;
const cf = currentFrame();
const eps = Math.max(1, Math.floor(Number(state.step) || 1));
if (cf >= outF - eps) {
if (state.pingpong) {
_startPingpongReverse();
return;
} else if (state.loop) {
goToFrame(inF);
try {
const p = video.play?.();
if (p && typeof p.catch === "function") p.catch(() => {});
} catch (e) {
console.debug?.(e);
}
} else if (state.once) {
try {
video.pause?.();
} catch (e) {
console.debug?.(e);
}
goToFrame(outF);
} else {
// Default behavior when a range is set: stop at Out (Nuke-like playback range).
try {
video.pause?.();
} catch (e) {
console.debug?.(e);
}
goToFrame(outF);
}
} else if (cf < inF) {
goToFrame(inF);
}
} catch (e) {
console.debug?.(e);
}
};
const playbackUiSync = {
rafId: null,
rvfcId: null,
};
const cancelPlaybackUiSync = () => {
try {
if (
playbackUiSync.rvfcId != null
&& typeof video?.cancelVideoFrameCallback === "function"
) {
video.cancelVideoFrameCallback(playbackUiSync.rvfcId);
}
} catch (e) {
console.debug?.(e);
}
playbackUiSync.rvfcId = null;
try {
if (playbackUiSync.rafId != null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(playbackUiSync.rafId);
}
} catch (e) {
console.debug?.(e);
}
playbackUiSync.rafId = null;
};
const tickPlaybackUiSync = (_now = 0, meta = null) => {
playbackUiSync.rafId = null;
playbackUiSync.rvfcId = null;
try {
safeCall(updateTimeUI, meta?.mediaTime);
safeCall(enforceRange);
} catch (e) {
console.debug?.(e);
}
const shouldContinue = Boolean(state._ppReverse) || !video?.paused;
if (!shouldContinue || timersAC.signal?.aborted) return;
try {
if (typeof video?.requestVideoFrameCallback === "function" && !state._ppReverse) {
playbackUiSync.rvfcId = video.requestVideoFrameCallback(tickPlaybackUiSync);
return;
}
} catch (e) {
console.debug?.(e);
}
try {
if (typeof requestAnimationFrame === "function") {
playbackUiSync.rafId = requestAnimationFrame((nextNow) => {
tickPlaybackUiSync(nextNow, { mediaTime: Number(video?.currentTime) || 0 });
});
}
} catch (e) {
console.debug?.(e);
}
};
const startPlaybackUiSync = () => {
cancelPlaybackUiSync();
const shouldContinue = Boolean(state._ppReverse) || !video?.paused;
if (!shouldContinue || timersAC.signal?.aborted) return;
tickPlaybackUiSync(0, { mediaTime: Number(video?.currentTime) || 0 });
};
unsubs.push(cancelPlaybackUiSync);
// Keep UI synced with media events.
unsubs.push(
safeAddListener(video, "play", () => {
safeCall(setPlayLabel);
startPlaybackUiSync();
}),
);
for (const ev of ["pause", "ended"]) {
unsubs.push(
safeAddListener(video, ev, () => {
cancelPlaybackUiSync();
safeCall(setPlayLabel);
safeCall(updateTimeUI);
}),
);
}
for (const ev of ["timeupdate", "loadedmetadata", "durationchange", "seeked"]) {
unsubs.push(safeAddListener(video, ev, () => safeCall(updateTimeUI)));
}
unsubs.push(safeAddListener(video, "timeupdate", enforceRange));
// `timeupdate` can be too coarse to reliably catch the last frames of short clips.
// Ensure looping works even when the video reaches its natural end.
unsubs.push(
safeAddListener(
video,
"ended",
() => {
if (!advanced) return;
try {
const { inF, outF, maxF } = getEffectiveInOut();
const fullRange = inF <= 0 && outF >= maxF;
if (state.pingpong && !state._ppReverse) {
_startPingpongReverse();
return;
}
if (!state.loop && !fullRange) return;
goToFrame(inF);
try {
const p = video.play?.();
if (p && typeof p.catch === "function") p.catch(() => {});
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
},
{ passive: true },
),
);
if (advanced) {
unsubs.push(
safeAddListener(video, "mjr:frameStep", () => {
safeCall(flashFrameStep);
}),
);
}
if (advanced) {
unsubs.push(
safeAddListener(video, "loadedmetadata", () => {
try {
const maxF = durationFrames();
if (maxF > 0 && state.inFrame == null && state.outFrame == null) {
state.inFrame = 0;
state.outFrame = maxF;
normalizeRange();
}
} catch (e) {
console.debug?.(e);
}
updateSeekRangeStyle();
}),
);
unsubs.push(
safeAddListener(video, "durationchange", () => safeCall(updateSeekRangeStyle)),
);
}
for (const ev of ["volumechange"]) {
unsubs.push(safeAddListener(video, ev, () => safeCall(updateVolumeUI)));
}
// Initial sync
try {
state.fps = normalizeVideoFps(fpsInput.value, 30);
state.step = Math.max(1, Math.floor(Number(stepInput.value) || 1));
normalizeRange();
applyLoopOnceUI();
setPlaybackRate(state.playbackRate);
} catch (e) {
console.debug?.(e);
}
safeCall(setPlayLabel);
safeCall(updateTimeUI);
safeCall(updateSeekRangeStyle);
safeCall(updateVolumeUI);
try {
if (!video?.paused || state._ppReverse) {
startPlaybackUiSync();
}
} catch (e) {
console.debug?.(e);
}
const setMediaInfo = (info = {}) => {
let oldMax = 0;
let hadAutoFullOut = false;
try {
oldMax = Math.max(0, durationFrames());
hadAutoFullOut = oldMax > 0 && state.outFrame != null && state.outFrame >= oldMax - 1;
} catch (e) {
console.debug?.(e);
}
try {
const fps = Number(info?.fps);
if (Number.isFinite(fps) && fps > 0) {
state.fps = normalizeVideoFps(fps, state.fps || 30);
try {
if (!fpsInput?.matches?.(":focus")) fpsInput.value = String(state.fps);
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
try {
const fc = Number(info?.frameCount);
state.frameCount = Number.isFinite(fc) && fc > 0 ? Math.floor(fc) : null;
} catch {
state.frameCount = null;
}
try {
const nextMax = Math.max(0, durationFrames());
// If the out-point was previously pinned to the media's full extent,
// keep that behavior when later metadata reveals a larger frame range.
if (hadAutoFullOut && nextMax > oldMax + 0.5) {
state.outFrame = null;
}
normalizeRange();
applyLoopOnceUI();
updateTimeUI();
updateSeekRangeStyle();
} catch (e) {
console.debug?.(e);
}
};
// Best-effort: initialize from caller-provided media info (metadata/fps hints).
try {
if (advanced) {
const initFps = Number(opts?.initialFps);
const initFrames = Number(opts?.initialFrameCount);
if (Number.isFinite(initFps) || Number.isFinite(initFrames)) {
setMediaInfo({ fps: initFps, frameCount: initFrames });
}
}
} catch (e) {
console.debug?.(e);
}
// Drag In/Out handles on the seek bar.
if (advanced) {
const drag = { active: false, which: null, pointerId: null, ac: null, captureEl: null };
const frameFromClientX = (clientX) => {
try {
const rect = seekWrap.getBoundingClientRect();
const x = clamp((Number(clientX) || 0) - rect.left, 0, rect.width || 1);
const pct = rect.width > 0 ? x / rect.width : 0;
const { maxF } = getEffectiveInOut();
return clamp(Math.round(pct * maxF), 0, maxF);
} catch {
return 0;
}
};
const startDrag = (e, which) => {
preventStop(e);
try {
drag.ac?.abort?.();
} catch (e) {
console.debug?.(e);
}
drag.ac = null;
drag.active = true;
drag.which = which;
drag.pointerId = e.pointerId;
// Capture on the actual handle so move/up events still fire even if the pointer leaves the seek bar.
// Some browsers are picky about capturing on non-target elements.
try {
drag.captureEl = e.currentTarget || null;
} catch {
drag.captureEl = null;
}
try {
drag.captureEl?.setPointerCapture?.(e.pointerId);
} catch (e) {
console.debug?.(e);
}
try {
seekWrap.setPointerCapture?.(e.pointerId);
} catch (e) {
console.debug?.(e);
}
try {
const ac = new AbortController();
drag.ac = ac;
// Use document-level listeners during active drag for robustness.
window.addEventListener("pointermove", moveDrag, {
passive: false,
capture: true,
signal: ac.signal,
});
window.addEventListener("pointerup", endDrag, {
passive: false,
capture: true,
signal: ac.signal,
});
window.addEventListener("pointercancel", endDrag, {
passive: false,
capture: true,
signal: ac.signal,
});
} catch (e) {
console.debug?.(e);
}
const f = frameFromClientX(e.clientX);
if (which === "in") state.inFrame = f;
else state.outFrame = f;
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: which });
};
const moveDrag = (e) => {
if (!drag.active) return;
preventStop(e);
const f = frameFromClientX(e.clientX);
if (drag.which === "in") state.inFrame = f;
else state.outFrame = f;
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
};
const endDrag = (e) => {
if (!drag.active) return;
preventStop(e);
drag.active = false;
try {
seekWrap.releasePointerCapture?.(drag.pointerId);
} catch (e) {
console.debug?.(e);
}
try {
drag.captureEl?.releasePointerCapture?.(drag.pointerId);
} catch (e) {
console.debug?.(e);
}
drag.captureEl = null;
drag.pointerId = null;
try {
applyRangeChange({ prefer: drag.which });
} catch (e) {
console.debug?.(e);
}
try {
drag.ac?.abort?.();
} catch (e) {
console.debug?.(e);
}
drag.ac = null;
};
unsubs.push(
safeAddListener(inHandle, "pointerdown", (e) => startDrag(e, "in"), {
passive: false,
}),
);
unsubs.push(
safeAddListener(outHandle, "pointerdown", (e) => startDrag(e, "out"), {
passive: false,
}),
);
// Allow dragging the thin In/Out markers too (Nuke-like).
unsubs.push(
safeAddListener(inMark, "pointerdown", (e) => startDrag(e, "in"), {
passive: false,
}),
);
unsubs.push(
safeAddListener(outMark, "pointerdown", (e) => startDrag(e, "out"), {
passive: false,
}),
);
unsubs.push(safeAddListener(seekWrap, "pointermove", moveDrag, { passive: false }));
unsubs.push(safeAddListener(seekWrap, "pointerup", endDrag, { passive: false }));
unsubs.push(safeAddListener(seekWrap, "pointercancel", endDrag, { passive: false }));
}
safeCall(() => hostEl.appendChild(controls));
return {
controlsEl: controls,
setMediaInfo,
setPlaybackRate: (rate) => {
try {
return setPlaybackRate(rate);
} catch {
return state.playbackRate || 1;
}
},
getPlaybackRate: () => {
try {
return Number(state.playbackRate) || 1;
} catch {
return 1;
}
},
adjustPlaybackRate: (delta) => {
try {
const d = Number(delta);
if (!Number.isFinite(d)) return state.playbackRate || 1;
return setPlaybackRate((Number(state.playbackRate) || 1) + d);
} catch {
return state.playbackRate || 1;
}
},
togglePlay: () => {
try {
togglePlay();
return true;
} catch {
return false;
}
},
stepFrames: (direction) => {
try {
stepFrames(direction);
return true;
} catch {
return false;
}
},
setInPoint: () => {
if (!advanced) return false;
try {
state.inFrame = currentFrame();
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "in" });
return true;
} catch {
return false;
}
},
setOutPoint: () => {
if (!advanced) return false;
try {
state.outFrame = currentFrame();
normalizeRange();
updateTimeUI();
updateSeekRangeStyle();
applyRangeChange({ prefer: "out" });
return true;
} catch {
return false;
}
},
goToIn: () => {
if (!advanced) return false;
try {
const { inF } = getEffectiveInOut();
goToFrame(inF);
flashFrameStep();
return true;
} catch {
return false;
}
},
goToOut: () => {
if (!advanced) return false;
try {
const { outF } = getEffectiveInOut();
goToFrame(outF);
flashFrameStep();
return true;
} catch {
return false;
}
},
destroy: () => {
for (const u of unsubs) safeCall(u);
safeCall(() => controls.remove());
},
};
} catch {
return { controlsEl: null, destroy: noop };
}
}