fix(studio): fit preview reset to composition dimensions (#1085)

* fix(studio): fit preview reset to composition dimensions

* fix(core): keep runtime root resolution explicit

* fix(studio): resume playback after keep-playing seek
This commit is contained in:
Miguel Ángel
2026-05-26 23:44:39 -04:00
committed by GitHub
parent 3cd6cd6a1c
commit 3a24aed9bc
7 changed files with 265 additions and 187 deletions
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readCompositionSizeFromDocument } from "./composition-probe.js";
describe("readCompositionSizeFromDocument", () => {
it("reads dimensions from the composition root", () => {
const doc = document.implementation.createHTMLDocument();
doc.body.innerHTML =
'<div data-composition-id="main" data-width="1080" data-height="1920"></div>';
expect(readCompositionSizeFromDocument(doc)).toEqual({ width: 1080, height: 1920 });
});
it("falls back to plain data-width/data-height compositions", () => {
const doc = document.implementation.createHTMLDocument();
doc.body.innerHTML = '<div class="clip" data-width="1080" data-height="1920"></div>';
expect(readCompositionSizeFromDocument(doc)).toEqual({ width: 1080, height: 1920 });
});
it("ignores invalid dimensions", () => {
const doc = document.implementation.createHTMLDocument();
doc.body.innerHTML = '<div data-width="0" data-height="1920"></div>';
expect(readCompositionSizeFromDocument(doc)).toBeNull();
});
});
+22 -8
View File
@@ -36,6 +36,24 @@ export interface ProbeCallbacks {
onRuntimeInjected?: () => void;
}
function readPositiveDimension(value: string | null): number | null {
if (value === null) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export function readCompositionSizeFromDocument(
doc: Document | null | undefined,
): { width: number; height: number } | null {
const root =
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
doc?.querySelector("[data-width][data-height]");
if (!root) return null;
const width = readPositiveDimension(root.getAttribute("data-width"));
const height = readPositiveDimension(root.getAttribute("data-height"));
return width !== null && height !== null ? { width, height } : null;
}
export class CompositionProbe {
private _interval: ReturnType<typeof setInterval> | null = null;
private _runtimeInjected = false;
@@ -45,6 +63,7 @@ export class CompositionProbe {
private readonly _callbacks: ProbeCallbacks,
) {}
// fallow-ignore-next-line unused-class-member
get runtimeInjected(): boolean {
return this._runtimeInjected;
}
@@ -55,6 +74,7 @@ export class CompositionProbe {
this._runtimeInjected = false;
let attempts = 0;
// fallow-ignore-next-line complexity
this._interval = setInterval(() => {
attempts++;
try {
@@ -89,14 +109,7 @@ export class CompositionProbe {
if (adapter && adapter.getDuration() > 0) {
this.stop();
const doc = this._iframe.contentDocument;
let compositionSize: { width: number; height: number } | null = null;
const root = doc?.querySelector("[data-composition-id]");
if (root) {
const w = parseInt(root.getAttribute("data-width") || "0", 10);
const h = parseInt(root.getAttribute("data-height") || "0", 10);
if (w > 0 && h > 0) compositionSize = { width: w, height: h };
}
const compositionSize = readCompositionSizeFromDocument(this._iframe.contentDocument);
this._callbacks.onReady({
duration: adapter.getDuration(),
@@ -135,6 +148,7 @@ export class CompositionProbe {
}
}
// fallow-ignore-next-line unused-class-member
resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
return this._resolveDirectTimelineAdapterFromWindow(win);
}
@@ -3,7 +3,7 @@
import React, { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview";
import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -133,6 +133,22 @@ describe("getPreviewPlayerKey", () => {
});
});
describe("resolvePreviewStageSize", () => {
it("fits portrait composition dimensions by height in a narrow viewport", () => {
expect(resolvePreviewStageSize(512, 402, { width: 1080, height: 1920 }, undefined)).toEqual({
width: 217.125,
height: 386,
});
});
it("uses composition dimensions ahead of the legacy portrait fallback", () => {
expect(resolvePreviewStageSize(512, 402, { width: 1920, height: 1080 }, true)).toEqual({
width: 496,
height: 279,
});
});
});
describe("NLEPreview", () => {
beforeEach(() => {
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { Player } from "../../player";
import {
DEFAULT_PREVIEW_ZOOM,
@@ -14,7 +14,7 @@ import {
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
interface NLEPreviewProps {
projectId: string;
iframeRef: Ref<HTMLIFrameElement>;
iframeRef: RefObject<HTMLIFrameElement | null>;
onIframeLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
@@ -37,6 +37,11 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
const ZOOM_SETTLE_MS = 200;
const PREVIEW_STAGE_INSET_PX = 16;
interface PreviewCompositionSize {
width: number;
height: number;
}
function isPreviewAtFit(state: PreviewZoomState): boolean {
return (
Math.abs(state.zoomPercent - 100) < 0.5 &&
@@ -56,14 +61,41 @@ function loadInitialZoom(): PreviewZoomState {
: DEFAULT_PREVIEW_ZOOM;
}
function resolvePreviewStageSize(
// fallow-ignore-next-line complexity
function readPreviewCompositionSize(
iframe: HTMLIFrameElement | null,
): PreviewCompositionSize | null {
try {
const doc = iframe?.contentDocument;
const root =
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
doc?.querySelector("[data-width][data-height]");
if (!root) return null;
const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
return null;
}
return { width, height };
} catch {
return null;
}
}
export function resolvePreviewStageSize(
viewportWidth: number,
viewportHeight: number,
compositionSize: PreviewCompositionSize | null,
portrait: boolean | undefined,
): { width: number; height: number } {
const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
const aspectRatio = portrait ? 9 / 16 : 16 / 9;
const aspectRatio =
compositionSize && compositionSize.width > 0 && compositionSize.height > 0
? compositionSize.width / compositionSize.height
: portrait
? 9 / 16
: 16 / 9;
if (availableWidth === 0 || availableHeight === 0) {
return { width: 0, height: 0 };
@@ -95,10 +127,12 @@ export const NLEPreview = memo(function NLEPreview({
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
const viewportRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
useEffect(() => {
onStageRef?.(stageRef);
}, [onStageRef]);
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
@@ -127,14 +161,29 @@ export const NLEPreview = memo(function NLEPreview({
const updateStageSize = () => {
const rect = viewport.getBoundingClientRect();
setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait));
setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait));
};
updateStageSize();
const observer = new ResizeObserver(updateStageSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [portrait]);
}, [compositionSize, portrait]);
const updateCompositionSizeFromPreview = useCallback(() => {
const next = readPreviewCompositionSize(previewIframeRef.current);
setCompositionSize((prev) =>
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
);
}, []);
const setPreviewIframeRef = useCallback(
(node: HTMLIFrameElement | null) => {
previewIframeRef.current = node;
iframeRef.current = node;
},
[iframeRef],
);
const stageSizeRef = useRef(stageSize);
stageSizeRef.current = stageSize;
@@ -403,10 +452,11 @@ export const NLEPreview = memo(function NLEPreview({
)}
<Player
key={activeKey}
ref={iframeRef}
ref={setPreviewIframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {
updateCompositionSizeFromPreview();
onIframeLoad();
applyInitialZoom();
}}
@@ -25,6 +25,20 @@ function TimelinePlayerHarness({
return null;
}
function renderTimelinePlayerHarness() {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
});
if (!api) throw new Error("useTimelinePlayer did not mount");
return { api, root };
}
afterEach(() => {
document.body.innerHTML = "";
resetPlayerStore();
@@ -35,19 +49,25 @@ function attachIframeAdapter(
options: {
postMessage?: (message: unknown, targetOrigin: string) => void;
timelines?: Record<string, unknown>;
duration?: number;
} = {},
) {
const iframe = document.createElement("iframe");
let currentTime = 0;
let playing = false;
const adapter = {
play: () => {},
pause: () => {},
play: vi.fn(() => {
playing = true;
}),
pause: vi.fn(() => {
playing = false;
}),
seek: (time: number) => {
currentTime = time;
},
getTime: () => currentTime,
getDuration: () => 30,
isPlaying: () => false,
getDuration: () => options.duration ?? 30,
isPlaying: () => playing,
};
Object.defineProperty(iframe, "contentWindow", {
value: {
@@ -71,90 +91,77 @@ function attachIframeAdapter(
return adapter;
}
function renderAttachedTimelinePlayer() {
const { api, root } = renderTimelinePlayerHarness();
const adapter = attachIframeAdapter(api);
return { api, root, adapter };
}
function setStorePlaying() {
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
}
function seekWithAct(
api: ReturnType<typeof useTimelinePlayer>,
time: number,
options?: { keepPlaying?: boolean },
) {
act(() => {
api.seek(time, options);
});
}
function unmountWithAct(root: ReturnType<typeof createRoot>) {
act(() => {
root.unmount();
});
}
function expectStorePlaybackState(
root: ReturnType<typeof createRoot>,
expected: { isPlaying: boolean; currentTime: number },
) {
expect(usePlayerStore.getState().isPlaying).toBe(expected.isPlaying);
expect(usePlayerStore.getState().currentTime).toBe(expected.currentTime);
unmountWithAct(root);
}
describe("useTimelinePlayer seek hydration", () => {
it("keeps an external seek request until the iframe adapter is ready", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const observedTimes: number[] = [];
const unsubscribe = liveTime.subscribe((time) => {
observedTimes.push(time);
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
const { api, root } = renderTimelinePlayerHarness();
act(() => {
usePlayerStore.getState().requestSeek(4.2);
});
expect(api).not.toBeNull();
expect(usePlayerStore.getState().currentTime).toBe(0);
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
const iframe = document.createElement("iframe");
let currentTime = 0;
const adapter = {
play: () => {},
pause: () => {},
seek: (time: number) => {
currentTime = time;
},
getTime: () => currentTime,
getDuration: () => 30,
isPlaying: () => false,
};
Object.defineProperty(iframe, "contentWindow", {
value: {
__player: adapter,
postMessage: () => {},
scrollTo: () => {},
addEventListener: () => {},
removeEventListener: () => {},
},
configurable: true,
});
Object.defineProperty(iframe, "contentDocument", {
value: document.implementation.createHTMLDocument("preview"),
configurable: true,
});
const adapter = attachIframeAdapter(api);
act(() => {
api!.iframeRef.current = iframe;
api!.onIframeLoad();
});
expect(currentTime).toBe(4.2);
expect(adapter.getTime()).toBe(4.2);
expect(usePlayerStore.getState().currentTime).toBe(4.2);
expect(usePlayerStore.getState().timelineReady).toBe(true);
expect(observedTimes).toContain(4.2);
act(() => {
root.unmount();
});
unmountWithAct(root);
unsubscribe();
});
});
describe("useTimelinePlayer audio controls (#835)", () => {
it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { api, root } = renderTimelinePlayerHarness();
const postMessage = vi.fn();
const timeScale = vi.fn();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!, {
attachIframeAdapter(api, {
postMessage,
timelines: {
root: { timeScale },
@@ -202,24 +209,14 @@ describe("useTimelinePlayer audio controls (#835)", () => {
"*",
);
act(() => {
root.unmount();
});
unmountWithAct(root);
});
it("keeps explicit Studio mute active at 1x", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { api, root } = renderTimelinePlayerHarness();
const postMessage = vi.fn();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!, { postMessage });
attachIframeAdapter(api, { postMessage });
postMessage.mockClear();
act(() => {
@@ -235,95 +232,50 @@ describe("useTimelinePlayer audio controls (#835)", () => {
"*",
);
act(() => {
root.unmount();
});
unmountWithAct(root);
});
});
describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
it("default seek() clears isPlaying when the store reports playing", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { api, root } = renderAttachedTimelinePlayer();
setStorePlaying();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!);
seekWithAct(api, 5);
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
act(() => {
api!.seek(5);
});
expect(usePlayerStore.getState().isPlaying).toBe(false);
expect(usePlayerStore.getState().currentTime).toBe(5);
act(() => {
root.unmount();
});
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
});
it("seek(time, { keepPlaying: true }) preserves isPlaying=true so A/E shortcuts don't pause the timeline", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { api, root, adapter } = renderAttachedTimelinePlayer();
setStorePlaying();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!);
seekWithAct(api, 5, { keepPlaying: true });
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
act(() => {
api!.seek(5, { keepPlaying: true });
});
expect(usePlayerStore.getState().isPlaying).toBe(true);
expect(usePlayerStore.getState().currentTime).toBe(5);
act(() => {
root.unmount();
});
expect(adapter.play).toHaveBeenCalledTimes(1);
expectStorePlaybackState(root, { isPlaying: true, currentTime: 5 });
});
it("seek(time, { keepPlaying: true }) from paused state stays paused (no spurious resume)", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!);
const { api, root } = renderAttachedTimelinePlayer();
expect(usePlayerStore.getState().isPlaying).toBe(false);
act(() => {
api!.seek(5, { keepPlaying: true });
});
seekWithAct(api, 5, { keepPlaying: true });
expect(usePlayerStore.getState().isPlaying).toBe(false);
expect(usePlayerStore.getState().currentTime).toBe(5);
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
});
act(() => {
root.unmount();
});
it("seek(time, { keepPlaying: true }) restarts playback when the iframe adapter was paused", () => {
const { api, root, adapter } = renderAttachedTimelinePlayer();
setStorePlaying();
expect(adapter.isPlaying()).toBe(false);
seekWithAct(api, 0, { keepPlaying: true });
expect(adapter.play).toHaveBeenCalledTimes(1);
expect(adapter.isPlaying()).toBe(true);
expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
});
});
@@ -4,19 +4,16 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
// Re-export public API consumed by tests and external modules.
// All of these were previously defined in this file; they now live in focused
// sub-modules but are re-exported here so existing import sites don't change.
export type { ClipManifestClip } from "../lib/playbackTypes";
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
export {
getTimelineElementSelector,
readTimelineDurationFromDocument,
parseTimelineFromDOM,
buildStandaloneRootTimelineElement,
createTimelineElementFromManifestClip,
findTimelineDomNodeForClip,
buildStandaloneRootTimelineElement,
getTimelineElementSelector,
mergeTimelineElementsPreservingDowngrades,
parseTimelineFromDOM,
readTimelineDurationFromDocument,
resolveStandaloneRootCompositionSrc,
resolveIframe,
} from "../lib/timelineDOM";
@@ -43,10 +40,7 @@ import {
shouldMutePreviewAudio,
} from "../lib/timelineIframeHelpers";
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
export function useTimelinePlayer() {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -65,8 +59,6 @@ export function useTimelinePlayer() {
adapter: PlaybackAdapter;
} | null>(null);
// ZERO store subscriptions — this hook never causes re-renders.
// All reads use getState() (point-in-time), all writes use the stable setters.
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
usePlayerStore.getState();
@@ -383,8 +375,6 @@ export function useTimelinePlayer() {
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
const seek = useCallback(
(time: number, options?: { keepPlaying?: boolean }) => {
// Reverse shuttle is always stopped: the RAF reverse tick can't survive
// a seek anyway, so `keepPlaying` only preserves forward playback.
const wasReverseShuttle = shuttleDirectionRef.current === "backward";
stopReverseLoop();
const adapter = getAdapter();
@@ -394,10 +384,27 @@ export function useTimelinePlayer() {
}
const duration = Math.max(0, adapter.getDuration());
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
const keepPlaying = options?.keepPlaying === true;
const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
keepPlaying,
wasReverseShuttle,
storeWasPlaying: usePlayerStore.getState().isPlaying,
duration,
nextTime,
});
adapter.seek(nextTime, options);
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
if (!options?.keepPlaying || wasReverseShuttle) {
if (shouldResumeAfterSeek) {
stopRAFLoop();
applyPlaybackRate(usePlayerStore.getState().playbackRate);
applyPreviewAudioState();
adapter.play();
setIsPlaying(true);
shuttleDirectionRef.current = "forward";
shuttleSpeedIndexRef.current = 0;
startRAFLoop();
} else if (shouldStopAfterSeek({ keepPlaying, wasReverseShuttle })) {
stopRAFLoop();
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
shuttleDirectionRef.current = null;
@@ -410,14 +417,16 @@ export function useTimelinePlayer() {
pendingSeekRef,
setCurrentTime,
setIsPlaying,
startRAFLoop,
stopRAFLoop,
stopReverseLoop,
applyPlaybackRate,
applyPreviewAudioState,
shuttleDirectionRef,
shuttleSpeedIndexRef,
],
);
// Handle seek requests from outside the player loop (e.g. LayersPanel).
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
@@ -480,12 +489,8 @@ export function useTimelinePlayer() {
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
// Listen for timeline messages from the iframe runtime.
// The runtime sends this AFTER all external compositions load,
// so we get the complete clip list (not just the first few).
const handleMessage = (e: MessageEvent) => {
const data = e.data;
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
const ourIframe = iframeRef.current;
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
return;
@@ -499,10 +504,6 @@ export function useTimelinePlayer() {
processTimelineMessageRef.current(manifest);
}
}
// Enrich only when the timeline has settled — skip during the window
// right after a "timeline" message to avoid the enrichment adding
// elements that fight with the manifest's authoritative element list,
// causing duration oscillation.
const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
if (msSinceTimeline > 500) {
enrichMissingCompositionsRef.current();
@@ -535,7 +536,6 @@ export function useTimelinePlayer() {
}
};
// Pause video when tab loses focus
const handleVisibilityChange = () => {
if (document.hidden && usePlayerStore.getState().isPlaying) {
const adapter = getAdapterRef.current?.();
@@ -564,7 +564,6 @@ export function useTimelinePlayer() {
};
});
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
const resetPlayer = useCallback(() => {
stopRAFLoop();
stopReverseLoop();
@@ -0,0 +1,21 @@
export function shouldResumeForwardPlaybackAfterSeek(input: {
keepPlaying: boolean;
wasReverseShuttle: boolean;
storeWasPlaying: boolean;
duration: number;
nextTime: number;
}): boolean {
return (
input.keepPlaying &&
!input.wasReverseShuttle &&
input.storeWasPlaying &&
(input.duration <= 0 || input.nextTime < input.duration)
);
}
export function shouldStopAfterSeek(input: {
keepPlaying: boolean;
wasReverseShuttle: boolean;
}): boolean {
return !input.keepPlaying || input.wasReverseShuttle;
}