Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/studio/src/captions/components/CaptionOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, useState, useCallback, useRef } from "react";
import { useCaptionStore } from "../store";
import { useMountEffect } from "../../hooks/useMountEffect";
import { shouldHandleCaptionNudgeKey } from "../keyboard";

interface CaptionOverlayProps {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
Expand Down Expand Up @@ -329,7 +330,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
if (sel.size === 0 || !m) return;
const arrow = e.key;
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
if (!shouldHandleCaptionNudgeKey(e)) return;

e.preventDefault();
const step = e.shiftKey ? 10 : 1;
Expand Down
38 changes: 38 additions & 0 deletions packages/studio/src/captions/keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { shouldHandleCaptionNudgeKey } from "./keyboard";

function mockKeyboardEvent(
key: string,
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
key,
...overrides,
};
}

describe("shouldHandleCaptionNudgeKey", () => {
it("handles plain and Shift-modified arrow keys for caption nudging", () => {
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true);
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true);
});

it("ignores browser and app shortcut chords", () => {
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe(
false,
);
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe(
false,
);
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe(
false,
);
});

it("ignores non-arrow keys", () => {
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
});
});
8 changes: 8 additions & 0 deletions packages/studio/src/captions/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);

type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;

export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return false;
return CAPTION_NUDGE_KEYS.has(event.key);
}
6 changes: 5 additions & 1 deletion packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ export const NLEPreview = memo(function NLEPreview({

return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
<div
className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
tabIndex={0}
aria-label="Composition preview"
>
<Player
key={playerKey}
ref={iframeRef}
Expand Down
131 changes: 120 additions & 11 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
import { formatTime } from "../lib/time";
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";

const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
const SEEK_EDGE_SNAP_PX = 8;
type TimeDisplayMode = "time" | "frame";
const SHORTCUT_HINTS = [
{ key: "J", label: "Play backward" },
{ key: "K", label: "Stop playback" },
{ key: "L", label: "Play forward" },
{ key: "←/→", label: "Step one frame backward or forward" },
] as const;

export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
Expand Down Expand Up @@ -38,8 +45,12 @@ export const PlayerControls = memo(function PlayerControls({
const duration = usePlayerStore((s) => s.duration);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const playbackRate = usePlayerStore((s) => s.playbackRate);
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const [jumpFrame, setJumpFrame] = useState("");

const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null);
Expand All @@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
const timeDisplayModeRef = useRef(timeDisplayMode);
timeDisplayModeRef.current = timeDisplayMode;

const durationRef = useRef(duration);
durationRef.current = duration;
Expand All @@ -59,7 +72,10 @@ export const PlayerControls = memo(function PlayerControls({
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
if (timeDisplayRef.current) {
timeDisplayRef.current.textContent =
timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
}
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
};
const unsub = liveTime.subscribe(updateProgress);
Expand All @@ -82,6 +98,13 @@ export const PlayerControls = memo(function PlayerControls({
};
});

useEffect(() => {
if (!timeDisplayRef.current) return;
const t = currentTimeRef.current;
timeDisplayRef.current.textContent =
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
}, [duration, timeDisplayMode]);

useEffect(() => {
if (!showSpeedMenu) return;
const handleMouseDown = (e: MouseEvent) => {
Expand Down Expand Up @@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!timelineReady || duration <= 0) return;
const step = e.shiftKey ? 5 : 1;
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowLeft") {
e.preventDefault();
onSeek(Math.max(0, currentTimeRef.current - step));
onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
} else if (e.key === "ArrowRight") {
e.preventDefault();
onSeek(Math.min(duration, currentTimeRef.current + step));
onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
}
},
[timelineReady, duration, onSeek],
);

const commitJumpFrame = useCallback(() => {
const frame = Number.parseInt(jumpFrame, 10);
if (!Number.isFinite(frame) || duration <= 0) return;
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
}, [duration, jumpFrame, onSeek]);

const handleJumpSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
commitJumpFrame();
},
[commitJumpFrame],
);

const handleJumpKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return;
e.preventDefault();
commitJumpFrame();
},
[commitJumpFrame],
);

return (
<div
className="px-4 py-2 flex items-center gap-3"
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
style={{
borderTop: "1px solid rgba(255,255,255,0.04)",
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
Expand Down Expand Up @@ -236,12 +282,16 @@ export const PlayerControls = memo(function PlayerControls({

{/* Time display */}
<span
className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
style={{ color: "#A1A1AA" }}
>
<span ref={timeDisplayRef}>{formatTime(0)}</span>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
{timeDisplayMode === "time" ? (
<>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
</>
) : null}
</span>

{/* Seek bar — teal progress fill */}
Expand All @@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={0}
className="flex-1 h-6 flex items-center cursor-pointer group"
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
// `touch-action: none` tells the browser we're handling every
// pointer gesture on this element ourselves. Without it, iOS
// Safari consumes horizontal swipes for its own swipe-back-to-
Expand Down Expand Up @@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => setShowSpeedMenu((v) => !v)}
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
>
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
Expand Down Expand Up @@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
)}
</div>

<button
type="button"
onClick={() => setLoopEnabled(!loopEnabled)}
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
loopEnabled
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
}`}
title="Loop playback"
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
aria-pressed={loopEnabled}
>
Loop
</button>

<button
type="button"
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
title="Toggle time/frame display"
aria-label="Toggle time and frame display"
>
{timeDisplayMode === "time" ? "m:ss" : "frames"}
</button>

<form
onSubmit={handleJumpSubmit}
className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
>
<input
value={jumpFrame}
onChange={(e) => setJumpFrame(e.target.value)}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Jump to frame"
placeholder="frame"
className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
onKeyDown={handleJumpKeyDown}
onBlur={commitJumpFrame}
/>
</form>

<div
className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
>
{SHORTCUT_HINTS.map((shortcut) => (
<span
key={shortcut.key}
className="group relative rounded border border-neutral-800 px-1 py-0.5"
>
{shortcut.key}
<span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
{shortcut.label}
</span>
</span>
))}
</div>

{/* Timeline toggle */}
{onToggleTimeline !== undefined && (
<button
Expand Down
79 changes: 79 additions & 0 deletions packages/studio/src/player/hooks/useTimelinePlayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,30 @@ import {
buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc,
shouldIgnorePlaybackShortcutEvent,
shouldIgnorePlaybackShortcutTarget,
} from "./useTimelinePlayer";

function mockTargetMatching(selectorNeedle: string): EventTarget {
return {
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
} 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(
Expand Down Expand Up @@ -94,3 +116,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
});
});

describe("shouldIgnorePlaybackShortcutTarget", () => {
it("ignores focused toolbar buttons so Space can activate the button itself", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
});

it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
});

it("allows non-interactive preview targets to use playback shortcuts", () => {
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);
});
});
Loading
Loading