fix: scope studio playback shortcuts

This commit is contained in:
Miguel Ángel
2026-04-28 22:32:23 -04:00
parent a45f900af7
commit 9dc17ae30d
4 changed files with 142 additions and 2 deletions
@@ -3,6 +3,7 @@ import {
buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc,
shouldIgnorePlaybackShortcutEvent,
shouldIgnorePlaybackShortcutTarget,
} from "./useTimelinePlayer";
@@ -12,6 +13,20 @@ function mockTargetMatching(selectorNeedle: string): EventTarget {
} as unknown as EventTarget;
}
function mockKeyboardEvent(
code: string,
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
code,
target: mockTargetMatching("[data-missing]"),
...overrides,
};
}
describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => {
expect(
@@ -115,3 +130,46 @@ describe("shouldIgnorePlaybackShortcutTarget", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
});
});
describe("shouldIgnorePlaybackShortcutEvent", () => {
it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
expect(
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
).toBe(true);
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
true,
);
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
true,
);
});
it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
expect(
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
).toBe(true);
expect(
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
).toBe(true);
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
false,
);
});
it("allows Arrow frame shortcuts when captions are not selected", () => {
expect(
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
isCaptionEditMode: true,
selectedCaptionSegmentCount: 0,
}),
).toBe(false);
expect(
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
isCaptionEditMode: false,
selectedCaptionSegmentCount: 1,
}),
).toBe(false);
});
});
@@ -2,6 +2,7 @@ import { useRef, useCallback } from "react";
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
import { useCaptionStore } from "../../captions/store";
interface PlaybackAdapter {
play: () => void;
@@ -107,6 +108,7 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
}
const SHUTTLE_SPEEDS = [1, 2, 4] as const;
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
"input",
"textarea",
@@ -137,6 +139,32 @@ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null):
);
}
interface PlaybackShortcutCaptionState {
isCaptionEditMode: boolean;
selectedCaptionSegmentCount: number;
}
type PlaybackShortcutEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
>;
export function shouldIgnorePlaybackShortcutEvent(
event: PlaybackShortcutEvent,
captionState: PlaybackShortcutCaptionState = {
isCaptionEditMode: false,
selectedCaptionSegmentCount: 0,
},
): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return true;
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
return (
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
captionState.isCaptionEditMode &&
captionState.selectedCaptionSegmentCount > 0
);
}
/**
* Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -698,7 +726,15 @@ export function useTimelinePlayer() {
const handlePlaybackKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if (shouldIgnorePlaybackShortcutTarget(e.target)) return;
const captionState = useCaptionStore.getState();
if (
shouldIgnorePlaybackShortcutEvent(e, {
isCaptionEditMode: captionState.isEditMode,
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
})
) {
return;
}
pressedCodesRef.current.add(e.code);
if (e.code === "Space") {
e.preventDefault();