@hyperframes/studio 0.5.0-alpha.8 → 0.5.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2 -0
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/hooks/useTimelinePlayer.test.ts +79 -0
- package/src/player/hooks/useTimelinePlayer.ts +284 -16
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-peNJzL-4.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DKaNgV2Z.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.5.0-alpha.
|
|
36
|
-
"@hyperframes/player": "0.5.0-alpha.
|
|
35
|
+
"@hyperframes/core": "0.5.0-alpha.9",
|
|
36
|
+
"@hyperframes/player": "0.5.0-alpha.9"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.5.0-alpha.
|
|
50
|
+
"@hyperframes/producer": "0.5.0-alpha.9"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -2797,6 +2797,7 @@ export function StudioApp() {
|
|
|
2797
2797
|
selection={
|
|
2798
2798
|
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
2799
2799
|
}
|
|
2800
|
+
allowCanvasMovement={false}
|
|
2800
2801
|
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
2801
2802
|
onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
|
|
2802
2803
|
onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
|
|
@@ -2891,6 +2892,7 @@ export function StudioApp() {
|
|
|
2891
2892
|
onImportAssets={handleImportFiles}
|
|
2892
2893
|
fontAssets={fontAssets}
|
|
2893
2894
|
onImportFonts={handleImportFonts}
|
|
2895
|
+
allowLayoutDetach={false}
|
|
2894
2896
|
/>
|
|
2895
2897
|
) : (
|
|
2896
2898
|
<RenderQueue
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef } from "react";
|
|
2
2
|
import { useCaptionStore } from "../store";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import { shouldHandleCaptionNudgeKey } from "../keyboard";
|
|
4
5
|
|
|
5
6
|
interface CaptionOverlayProps {
|
|
6
7
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
@@ -329,7 +330,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
|
|
329
330
|
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
|
|
330
331
|
if (sel.size === 0 || !m) return;
|
|
331
332
|
const arrow = e.key;
|
|
332
|
-
if (!
|
|
333
|
+
if (!shouldHandleCaptionNudgeKey(e)) return;
|
|
333
334
|
|
|
334
335
|
e.preventDefault();
|
|
335
336
|
const step = e.shiftKey ? 10 : 1;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldHandleCaptionNudgeKey } from "./keyboard";
|
|
3
|
+
|
|
4
|
+
function mockKeyboardEvent(
|
|
5
|
+
key: string,
|
|
6
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
|
|
7
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
|
|
8
|
+
return {
|
|
9
|
+
altKey: false,
|
|
10
|
+
ctrlKey: false,
|
|
11
|
+
metaKey: false,
|
|
12
|
+
key,
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("shouldHandleCaptionNudgeKey", () => {
|
|
18
|
+
it("handles plain and Shift-modified arrow keys for caption nudging", () => {
|
|
19
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true);
|
|
20
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ignores browser and app shortcut chords", () => {
|
|
24
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe(
|
|
25
|
+
false,
|
|
26
|
+
);
|
|
27
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe(
|
|
28
|
+
false,
|
|
29
|
+
);
|
|
30
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe(
|
|
31
|
+
false,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ignores non-arrow keys", () => {
|
|
36
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
|
2
|
+
|
|
3
|
+
type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
|
|
4
|
+
|
|
5
|
+
export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
|
|
6
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
7
|
+
return CAPTION_NUDGE_KEYS.has(event.key);
|
|
8
|
+
}
|
|
@@ -14,6 +14,7 @@ interface OverlayRect {
|
|
|
14
14
|
interface DomEditOverlayProps {
|
|
15
15
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
16
16
|
selection: DomEditSelection | null;
|
|
17
|
+
allowCanvasMovement?: boolean;
|
|
17
18
|
onCanvasMouseDown: (
|
|
18
19
|
event: React.MouseEvent<HTMLDivElement>,
|
|
19
20
|
options?: { preferClipAncestor?: boolean },
|
|
@@ -125,6 +126,7 @@ interface BlockedMoveState {
|
|
|
125
126
|
export const DomEditOverlay = memo(function DomEditOverlay({
|
|
126
127
|
iframeRef,
|
|
127
128
|
selection,
|
|
129
|
+
allowCanvasMovement = true,
|
|
128
130
|
onCanvasMouseDown,
|
|
129
131
|
onCanvasDoubleClick,
|
|
130
132
|
onSelectedDoubleClick,
|
|
@@ -403,9 +405,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
403
405
|
top: overlayRect.top,
|
|
404
406
|
width: overlayRect.width,
|
|
405
407
|
height: overlayRect.height,
|
|
406
|
-
cursor: selection.capabilities.canMove ? "move" : "default",
|
|
408
|
+
cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
|
|
407
409
|
}}
|
|
408
410
|
onPointerDown={(e) => {
|
|
411
|
+
if (!allowCanvasMovement) return;
|
|
409
412
|
if (selection.capabilities.canMove) {
|
|
410
413
|
startGesture("drag", e);
|
|
411
414
|
return;
|
|
@@ -424,7 +427,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
424
427
|
onDoubleClick={onSelectedDoubleClick}
|
|
425
428
|
>
|
|
426
429
|
{/* Resize handle — bottom-right corner */}
|
|
427
|
-
{selection.capabilities.canResize && (
|
|
430
|
+
{allowCanvasMovement && selection.capabilities.canResize && (
|
|
428
431
|
<div
|
|
429
432
|
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
|
430
433
|
style={{ cursor: "se-resize", touchAction: "none" }}
|
|
@@ -64,6 +64,7 @@ interface PropertyPanelProps {
|
|
|
64
64
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
65
65
|
fontAssets?: ImportedFontAsset[];
|
|
66
66
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
67
|
+
allowLayoutDetach?: boolean;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const FIELD =
|
|
@@ -1984,6 +1985,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
1984
1985
|
onImportAssets,
|
|
1985
1986
|
fontAssets = [],
|
|
1986
1987
|
onImportFonts,
|
|
1988
|
+
allowLayoutDetach = true,
|
|
1987
1989
|
}: PropertyPanelProps) {
|
|
1988
1990
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
1989
1991
|
const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
|
|
@@ -2020,7 +2022,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2020
2022
|
<p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
|
|
2021
2023
|
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
2022
2024
|
The inspector is tuned for direct DOM edits with safer geometry controls, color picking,
|
|
2023
|
-
and cleaner
|
|
2025
|
+
and cleaner grouped layer controls.
|
|
2024
2026
|
</p>
|
|
2025
2027
|
</div>
|
|
2026
2028
|
);
|
|
@@ -2036,7 +2038,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2036
2038
|
const sourceLabel = element.id ? `#${element.id}` : element.selector;
|
|
2037
2039
|
const showEditableSections = element.capabilities.canEditStyles;
|
|
2038
2040
|
const disabledMoveReason =
|
|
2039
|
-
|
|
2041
|
+
allowLayoutDetach &&
|
|
2042
|
+
element.capabilities.reasonIfDisabled &&
|
|
2043
|
+
!element.capabilities.canDetachFromLayout
|
|
2040
2044
|
? element.capabilities.reasonIfDisabled
|
|
2041
2045
|
: null;
|
|
2042
2046
|
|
|
@@ -2131,7 +2135,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2131
2135
|
</button>
|
|
2132
2136
|
</div>
|
|
2133
2137
|
)}
|
|
2134
|
-
{element.capabilities.canDetachFromLayout && (
|
|
2138
|
+
{allowLayoutDetach && element.capabilities.canDetachFromLayout && (
|
|
2135
2139
|
<div className="mt-4 flex min-w-0 flex-wrap items-center justify-between gap-3 border-l border-amber-500/40 pl-3">
|
|
2136
2140
|
<div className="min-w-0 text-[11px] leading-5 text-neutral-400">
|
|
2137
2141
|
<div className="font-medium text-neutral-200">
|
|
@@ -67,7 +67,11 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
69
|
<div className="flex flex-col h-full min-h-0">
|
|
70
|
-
<div
|
|
70
|
+
<div
|
|
71
|
+
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
72
|
+
tabIndex={0}
|
|
73
|
+
aria-label="Composition preview"
|
|
74
|
+
>
|
|
71
75
|
{retiringKey && (
|
|
72
76
|
<Player
|
|
73
77
|
key={retiringKey}
|
|
@@ -4,11 +4,18 @@ import {
|
|
|
4
4
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
5
5
|
getTimelineToggleTitle,
|
|
6
6
|
} from "../../utils/timelineDiscovery";
|
|
7
|
-
import { formatTime } from "../lib/time";
|
|
7
|
+
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
|
8
8
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
9
9
|
|
|
10
10
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
11
11
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
12
|
+
type TimeDisplayMode = "time" | "frame";
|
|
13
|
+
const SHORTCUT_HINTS = [
|
|
14
|
+
{ key: "J", label: "Play backward" },
|
|
15
|
+
{ key: "K", label: "Stop playback" },
|
|
16
|
+
{ key: "L", label: "Play forward" },
|
|
17
|
+
{ key: "←/→", label: "Step one frame backward or forward" },
|
|
18
|
+
] as const;
|
|
12
19
|
|
|
13
20
|
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
14
21
|
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
|
|
@@ -38,8 +45,12 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
38
45
|
const duration = usePlayerStore((s) => s.duration);
|
|
39
46
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
40
47
|
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
48
|
+
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
|
|
41
49
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
50
|
+
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
42
51
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
52
|
+
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
|
53
|
+
const [jumpFrame, setJumpFrame] = useState("");
|
|
43
54
|
|
|
44
55
|
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
45
56
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
@@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
49
60
|
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
|
50
61
|
const isDraggingRef = useRef(false);
|
|
51
62
|
const currentTimeRef = useRef(0);
|
|
63
|
+
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
64
|
+
timeDisplayModeRef.current = timeDisplayMode;
|
|
52
65
|
|
|
53
66
|
const durationRef = useRef(duration);
|
|
54
67
|
durationRef.current = duration;
|
|
@@ -59,7 +72,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
59
72
|
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
|
|
60
73
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
61
74
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
62
|
-
if (timeDisplayRef.current)
|
|
75
|
+
if (timeDisplayRef.current) {
|
|
76
|
+
timeDisplayRef.current.textContent =
|
|
77
|
+
timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
|
|
78
|
+
}
|
|
63
79
|
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
|
|
64
80
|
};
|
|
65
81
|
const unsub = liveTime.subscribe(updateProgress);
|
|
@@ -82,6 +98,13 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
82
98
|
};
|
|
83
99
|
});
|
|
84
100
|
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!timeDisplayRef.current) return;
|
|
103
|
+
const t = currentTimeRef.current;
|
|
104
|
+
timeDisplayRef.current.textContent =
|
|
105
|
+
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
|
|
106
|
+
}, [duration, timeDisplayMode]);
|
|
107
|
+
|
|
85
108
|
useEffect(() => {
|
|
86
109
|
if (!showSpeedMenu) return;
|
|
87
110
|
const handleMouseDown = (e: MouseEvent) => {
|
|
@@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
190
213
|
const handleKeyDown = useCallback(
|
|
191
214
|
(e: React.KeyboardEvent) => {
|
|
192
215
|
if (!timelineReady || duration <= 0) return;
|
|
193
|
-
const step = e.shiftKey ?
|
|
216
|
+
const step = e.shiftKey ? 10 : 1;
|
|
194
217
|
if (e.key === "ArrowLeft") {
|
|
195
218
|
e.preventDefault();
|
|
196
|
-
onSeek(Math.max(0, currentTimeRef.current - step));
|
|
219
|
+
onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
|
|
197
220
|
} else if (e.key === "ArrowRight") {
|
|
198
221
|
e.preventDefault();
|
|
199
|
-
onSeek(Math.min(duration, currentTimeRef.current + step));
|
|
222
|
+
onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
|
|
200
223
|
}
|
|
201
224
|
},
|
|
202
225
|
[timelineReady, duration, onSeek],
|
|
203
226
|
);
|
|
204
227
|
|
|
228
|
+
const commitJumpFrame = useCallback(() => {
|
|
229
|
+
const frame = Number.parseInt(jumpFrame, 10);
|
|
230
|
+
if (!Number.isFinite(frame) || duration <= 0) return;
|
|
231
|
+
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
|
|
232
|
+
}, [duration, jumpFrame, onSeek]);
|
|
233
|
+
|
|
234
|
+
const handleJumpSubmit = useCallback(
|
|
235
|
+
(e: React.FormEvent) => {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
commitJumpFrame();
|
|
238
|
+
},
|
|
239
|
+
[commitJumpFrame],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const handleJumpKeyDown = useCallback(
|
|
243
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
244
|
+
if (e.key !== "Enter") return;
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
commitJumpFrame();
|
|
247
|
+
},
|
|
248
|
+
[commitJumpFrame],
|
|
249
|
+
);
|
|
250
|
+
|
|
205
251
|
return (
|
|
206
252
|
<div
|
|
207
|
-
className="px-4 py-2 flex items-center gap-
|
|
253
|
+
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
208
254
|
style={{
|
|
209
255
|
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
210
256
|
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
|
|
@@ -236,12 +282,16 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
236
282
|
|
|
237
283
|
{/* Time display */}
|
|
238
284
|
<span
|
|
239
|
-
className="font-mono text-[11px] tabular-nums flex-shrink-0
|
|
285
|
+
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
|
|
240
286
|
style={{ color: "#A1A1AA" }}
|
|
241
287
|
>
|
|
242
288
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
243
|
-
|
|
244
|
-
|
|
289
|
+
{timeDisplayMode === "time" ? (
|
|
290
|
+
<>
|
|
291
|
+
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
|
292
|
+
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
293
|
+
</>
|
|
294
|
+
) : null}
|
|
245
295
|
</span>
|
|
246
296
|
|
|
247
297
|
{/* Seek bar — teal progress fill */}
|
|
@@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
256
306
|
aria-valuemin={0}
|
|
257
307
|
aria-valuemax={Math.round(duration)}
|
|
258
308
|
aria-valuenow={0}
|
|
259
|
-
className="flex-1 h-6 flex items-center cursor-pointer group"
|
|
309
|
+
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
|
|
260
310
|
// `touch-action: none` tells the browser we're handling every
|
|
261
311
|
// pointer gesture on this element ourselves. Without it, iOS
|
|
262
312
|
// Safari consumes horizontal swipes for its own swipe-back-to-
|
|
@@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
292
342
|
<button
|
|
293
343
|
type="button"
|
|
294
344
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
295
|
-
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
345
|
+
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
296
346
|
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
|
297
347
|
>
|
|
298
348
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
@@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
329
379
|
)}
|
|
330
380
|
</div>
|
|
331
381
|
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
onClick={() => setLoopEnabled(!loopEnabled)}
|
|
385
|
+
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
|
|
386
|
+
loopEnabled
|
|
387
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
388
|
+
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
389
|
+
}`}
|
|
390
|
+
title="Loop playback"
|
|
391
|
+
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
392
|
+
aria-pressed={loopEnabled}
|
|
393
|
+
>
|
|
394
|
+
Loop
|
|
395
|
+
</button>
|
|
396
|
+
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
|
|
400
|
+
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"
|
|
401
|
+
title="Toggle time/frame display"
|
|
402
|
+
aria-label="Toggle time and frame display"
|
|
403
|
+
>
|
|
404
|
+
{timeDisplayMode === "time" ? "m:ss" : "frames"}
|
|
405
|
+
</button>
|
|
406
|
+
|
|
407
|
+
<form
|
|
408
|
+
onSubmit={handleJumpSubmit}
|
|
409
|
+
className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
|
|
410
|
+
>
|
|
411
|
+
<input
|
|
412
|
+
value={jumpFrame}
|
|
413
|
+
onChange={(e) => setJumpFrame(e.target.value)}
|
|
414
|
+
inputMode="numeric"
|
|
415
|
+
pattern="[0-9]*"
|
|
416
|
+
aria-label="Jump to frame"
|
|
417
|
+
placeholder="frame"
|
|
418
|
+
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"
|
|
419
|
+
onKeyDown={handleJumpKeyDown}
|
|
420
|
+
onBlur={commitJumpFrame}
|
|
421
|
+
/>
|
|
422
|
+
</form>
|
|
423
|
+
|
|
424
|
+
<div
|
|
425
|
+
className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
|
|
426
|
+
aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
|
|
427
|
+
>
|
|
428
|
+
{SHORTCUT_HINTS.map((shortcut) => (
|
|
429
|
+
<span
|
|
430
|
+
key={shortcut.key}
|
|
431
|
+
className="group relative rounded border border-neutral-800 px-1 py-0.5"
|
|
432
|
+
>
|
|
433
|
+
{shortcut.key}
|
|
434
|
+
<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">
|
|
435
|
+
{shortcut.label}
|
|
436
|
+
</span>
|
|
437
|
+
</span>
|
|
438
|
+
))}
|
|
439
|
+
</div>
|
|
440
|
+
|
|
332
441
|
{/* Timeline toggle */}
|
|
333
442
|
{onToggleTimeline !== undefined && (
|
|
334
443
|
<button
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
type ClipManifestClip,
|
|
8
8
|
mergeTimelineElementsPreservingDowngrades,
|
|
9
9
|
resolveStandaloneRootCompositionSrc,
|
|
10
|
+
shouldIgnorePlaybackShortcutEvent,
|
|
11
|
+
shouldIgnorePlaybackShortcutTarget,
|
|
10
12
|
} from "./useTimelinePlayer";
|
|
11
13
|
|
|
12
14
|
function createDocument(markup: string): Document {
|
|
@@ -32,6 +34,26 @@ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
38
|
+
return {
|
|
39
|
+
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
40
|
+
} as unknown as EventTarget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockKeyboardEvent(
|
|
44
|
+
code: string,
|
|
45
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
|
|
46
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
|
|
47
|
+
return {
|
|
48
|
+
altKey: false,
|
|
49
|
+
ctrlKey: false,
|
|
50
|
+
metaKey: false,
|
|
51
|
+
code,
|
|
52
|
+
target: mockTargetMatching("[data-missing]"),
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
36
58
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
37
59
|
expect(
|
|
@@ -153,3 +175,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
153
175
|
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
154
176
|
});
|
|
155
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("shouldIgnorePlaybackShortcutTarget", () => {
|
|
180
|
+
it("ignores focused toolbar buttons so Space can activate the button itself", () => {
|
|
181
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
|
|
185
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("allows non-interactive preview targets to use playback shortcuts", () => {
|
|
189
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("shouldIgnorePlaybackShortcutEvent", () => {
|
|
194
|
+
it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
|
|
195
|
+
expect(
|
|
196
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
|
|
197
|
+
).toBe(true);
|
|
198
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
|
|
199
|
+
true,
|
|
200
|
+
);
|
|
201
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
|
|
207
|
+
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
|
|
208
|
+
|
|
209
|
+
expect(
|
|
210
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
|
|
211
|
+
).toBe(true);
|
|
212
|
+
expect(
|
|
213
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
|
|
216
|
+
false,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("allows Arrow frame shortcuts when captions are not selected", () => {
|
|
221
|
+
expect(
|
|
222
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
223
|
+
isCaptionEditMode: true,
|
|
224
|
+
selectedCaptionSegmentCount: 0,
|
|
225
|
+
}),
|
|
226
|
+
).toBe(false);
|
|
227
|
+
expect(
|
|
228
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
229
|
+
isCaptionEditMode: false,
|
|
230
|
+
selectedCaptionSegmentCount: 1,
|
|
231
|
+
}),
|
|
232
|
+
).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|