@hyperframes/studio 0.5.0-alpha.8 → 0.5.0

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.
Files changed (69) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1436
  7. package/src/captions/components/CaptionOverlay.tsx +2 -1
  8. package/src/captions/generator.test.ts +19 -0
  9. package/src/captions/generator.ts +9 -2
  10. package/src/captions/hooks/useCaptionSync.ts +6 -1
  11. package/src/captions/keyboard.test.ts +38 -0
  12. package/src/captions/keyboard.ts +8 -0
  13. package/src/captions/parser.test.ts +14 -0
  14. package/src/captions/parser.ts +1 -0
  15. package/src/components/LintModal.tsx +4 -3
  16. package/src/components/editor/PropertyPanel.tsx +206 -2462
  17. package/src/components/nle/NLELayout.tsx +47 -17
  18. package/src/components/nle/NLEPreview.tsx +9 -50
  19. package/src/components/sidebar/AssetsTab.tsx +4 -3
  20. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  21. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  22. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  23. package/src/components/ui/HyperframesLoader.tsx +104 -0
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/icons/SystemIcons.tsx +2 -0
  26. package/src/player/components/CompositionThumbnail.tsx +10 -42
  27. package/src/player/components/EditModal.tsx +20 -5
  28. package/src/player/components/Player.tsx +129 -28
  29. package/src/player/components/PlayerControls.tsx +117 -49
  30. package/src/player/components/Timeline.test.ts +0 -12
  31. package/src/player/components/Timeline.tsx +25 -52
  32. package/src/player/components/TimelineClip.tsx +9 -21
  33. package/src/player/components/timelineEditing.test.ts +4 -2
  34. package/src/player/components/timelineEditing.ts +3 -1
  35. package/src/player/components/timelineTheme.test.ts +19 -0
  36. package/src/player/components/timelineTheme.ts +8 -4
  37. package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
  38. package/src/player/hooks/useTimelinePlayer.ts +487 -106
  39. package/src/player/lib/time.test.ts +29 -1
  40. package/src/player/lib/time.ts +26 -0
  41. package/src/player/store/playerStore.test.ts +11 -1
  42. package/src/player/store/playerStore.ts +6 -1
  43. package/src/styles/studio.css +112 -0
  44. package/src/utils/frameCapture.test.ts +26 -0
  45. package/src/utils/frameCapture.ts +40 -0
  46. package/src/utils/mediaTypes.ts +1 -1
  47. package/src/utils/projectRouting.test.ts +87 -0
  48. package/src/utils/projectRouting.ts +27 -0
  49. package/src/utils/sourcePatcher.test.ts +1 -128
  50. package/src/utils/sourcePatcher.ts +18 -130
  51. package/src/utils/timelineAssetDrop.test.ts +11 -31
  52. package/src/utils/timelineAssetDrop.ts +2 -22
  53. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  54. package/dist/assets/index-0Zt0t13W.css +0 -1
  55. package/dist/assets/index-C9f5eif8.js +0 -105
  56. package/src/components/editor/DomEditOverlay.tsx +0 -442
  57. package/src/components/editor/colorValue.test.ts +0 -82
  58. package/src/components/editor/colorValue.ts +0 -175
  59. package/src/components/editor/domEditing.test.ts +0 -537
  60. package/src/components/editor/domEditing.ts +0 -762
  61. package/src/components/editor/floatingPanel.test.ts +0 -34
  62. package/src/components/editor/floatingPanel.ts +0 -54
  63. package/src/components/editor/fontAssets.ts +0 -32
  64. package/src/components/editor/fontCatalog.ts +0 -126
  65. package/src/components/editor/gradientValue.test.ts +0 -89
  66. package/src/components/editor/gradientValue.ts +0 -445
  67. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  68. package/src/utils/clipboard.test.ts +0 -88
  69. package/src/utils/clipboard.ts +0 -57
@@ -1,2 +1,4 @@
1
1
  // Minimal UI primitives for studio canvas components
2
2
  export { Button, IconButton } from "./Button";
3
+ export { HyperframesLoader, StatusFrame } from "./HyperframesLoader";
4
+ export type { HyperframesLoaderProps } from "./HyperframesLoader";
@@ -53,6 +53,7 @@ import {
53
53
  CaretRight,
54
54
  ClipboardText,
55
55
  ArrowCounterClockwise,
56
+ Camera as PhCamera,
56
57
  Gear,
57
58
  } from "@phosphor-icons/react";
58
59
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -127,4 +128,5 @@ export const ChevronDown = makeIcon(CaretDown);
127
128
  export const ChevronRight = makeIcon(CaretRight);
128
129
  export const ClipboardList = makeIcon(ClipboardText);
129
130
  export const RotateCcw = makeIcon(ArrowCounterClockwise);
131
+ export const Camera = makeIcon(PhCamera);
130
132
  export const Settings = makeIcon(Gear);
@@ -7,7 +7,6 @@ interface CompositionThumbnailProps {
7
7
  labelColor: string;
8
8
  accentColor?: string;
9
9
  selector?: string;
10
- selectorIndex?: number;
11
10
  seekTime?: number;
12
11
  duration?: number;
13
12
  width?: number;
@@ -17,44 +16,12 @@ interface CompositionThumbnailProps {
17
16
  const CLIP_HEIGHT = 66;
18
17
  const THUMBNAIL_URL_VERSION = "v2";
19
18
 
20
- export function buildCompositionThumbnailUrl({
21
- previewUrl,
22
- seekTime = 2,
23
- duration = 5,
24
- selector,
25
- selectorIndex,
26
- origin,
27
- }: {
28
- previewUrl: string;
29
- seekTime?: number;
30
- duration?: number;
31
- selector?: string;
32
- selectorIndex?: number;
33
- origin: string;
34
- }): string {
35
- const thumbnailBase = previewUrl
36
- .replace("/preview/comp/", "/thumbnail/")
37
- .replace(/\/preview$/, "/thumbnail/index.html");
38
- const midTime = seekTime + duration / 2;
39
- const thumbnailUrl = new URL(thumbnailBase, origin);
40
- thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
41
- thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
42
- if (selector) {
43
- thumbnailUrl.searchParams.set("selector", selector);
44
- if (selectorIndex != null && selectorIndex > 0) {
45
- thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
46
- }
47
- }
48
- return thumbnailUrl.toString();
49
- }
50
-
51
19
  export const CompositionThumbnail = memo(function CompositionThumbnail({
52
20
  previewUrl,
53
21
  label,
54
22
  labelColor,
55
23
  accentColor = "#6B7280",
56
24
  selector,
57
- selectorIndex,
58
25
  seekTime = 2,
59
26
  duration = 5,
60
27
  }: CompositionThumbnailProps) {
@@ -81,14 +48,15 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
81
48
  roRef.current?.disconnect();
82
49
  });
83
50
 
84
- const url = buildCompositionThumbnailUrl({
85
- previewUrl,
86
- seekTime,
87
- duration,
88
- selector,
89
- selectorIndex,
90
- origin: window.location.origin,
91
- });
51
+ const thumbnailBase = previewUrl
52
+ .replace("/preview/comp/", "/thumbnail/")
53
+ .replace(/\/preview$/, "/thumbnail/index.html");
54
+ const midTime = seekTime + duration / 2;
55
+ const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
56
+ thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
57
+ thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
58
+ if (selector) thumbnailUrl.searchParams.set("selector", selector);
59
+ const url = thumbnailUrl.toString();
92
60
  const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
93
61
  const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
94
62
 
@@ -98,7 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
98
66
  src={url}
99
67
  alt=""
100
68
  draggable={false}
101
- loading="eager"
69
+ loading="lazy"
102
70
  onLoad={(e) => {
103
71
  const img = e.currentTarget;
104
72
  if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -3,7 +3,6 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { usePlayerStore } from "../store/playerStore";
4
4
  import { formatTime } from "../lib/time";
5
5
  import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
6
- import { copyTextToClipboard } from "../../utils/clipboard";
7
6
 
8
7
  interface EditPopoverProps {
9
8
  rangeStart: number;
@@ -63,8 +62,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
63
62
  }, [start, end, elementsInRange, prompt]);
64
63
 
65
64
  const handleCopy = useCallback(async () => {
66
- const copied = await copyTextToClipboard(buildClipboardText());
67
- if (!copied) return;
65
+ try {
66
+ await navigator.clipboard.writeText(buildClipboardText());
67
+ } catch {
68
+ const ta = document.createElement("textarea");
69
+ ta.value = buildClipboardText();
70
+ document.body.appendChild(ta);
71
+ ta.select();
72
+ document.execCommand("copy");
73
+ document.body.removeChild(ta);
74
+ }
68
75
  setCopiedAgentPrompt(true);
69
76
  setTimeout(() => {
70
77
  setCopiedAgentPrompt(false);
@@ -75,8 +82,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
75
82
  const handleCopyPrompt = useCallback(async () => {
76
83
  const promptText = buildPromptCopyText(prompt);
77
84
  if (!promptText) return;
78
- const copied = await copyTextToClipboard(promptText);
79
- if (!copied) return;
85
+ try {
86
+ await navigator.clipboard.writeText(promptText);
87
+ } catch {
88
+ const ta = document.createElement("textarea");
89
+ ta.value = promptText;
90
+ document.body.appendChild(ta);
91
+ ta.select();
92
+ document.execCommand("copy");
93
+ document.body.removeChild(ta);
94
+ }
80
95
  setCopiedPromptOnly(true);
81
96
  setTimeout(() => {
82
97
  setCopiedPromptOnly(false);
@@ -1,37 +1,43 @@
1
- import { forwardRef, useRef, useState } from "react";
1
+ import { forwardRef, useEffect, useRef, useState } from "react";
2
+ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
2
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { HyperframesLoader } from "../../components/ui";
5
+ // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
6
+ // at module load, which throws under SSR. Defer the import to the mount effect
7
+ // so it only runs in the browser.
3
8
 
4
9
  interface PlayerProps {
5
10
  projectId?: string;
6
11
  directUrl?: string;
7
12
  onLoad: () => void;
8
13
  portrait?: boolean;
9
- style?: React.CSSProperties;
10
14
  }
11
15
 
12
16
  interface HyperframesPlayerElement extends HTMLElement {
13
17
  iframeElement: HTMLIFrameElement;
14
18
  }
15
19
 
16
- function enableInteractiveIframe(player: HyperframesPlayerElement): void {
17
- const root = player.shadowRoot;
18
- if (!root) return;
19
-
20
- const container = root.querySelector<HTMLElement>(".hfp-container");
21
- const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
22
-
23
- container?.style.setProperty("pointer-events", "auto");
24
- iframe?.style.setProperty("pointer-events", "auto");
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null;
25
22
  }
26
23
 
27
- function isLottieAnimationReady(anim: unknown): boolean {
28
- if (typeof anim !== "object" || anim === null) return true;
29
- const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
30
- if (maybe.isLoaded === true) return true;
31
- if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true;
32
- return false;
24
+ function getShaderTransitionLoading(event: Event): boolean | null {
25
+ if (!(event instanceof CustomEvent)) return null;
26
+ const detail: unknown = event.detail;
27
+ if (!isRecord(detail)) return null;
28
+ const state = detail.state;
29
+ if (!isRecord(state)) return null;
30
+ return state.loading === true && state.ready !== true;
33
31
  }
34
32
 
33
+ // Assets are considered ready when every `<video>`/`<audio>` has enough data
34
+ // to play through without buffering, and every registered Lottie animation has
35
+ // finished loading.
36
+ //
37
+ // Returns whichever value was returned last on cross-origin / transient DOM
38
+ // races so a brief access failure (e.g. an iframe that just swapped src)
39
+ // doesn't flicker the overlay state — we keep showing whatever was most
40
+ // recently true.
35
41
  function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
36
42
  try {
37
43
  const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
@@ -47,7 +53,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
47
53
  const lotties = win.__hfLottie;
48
54
  if (lotties?.length) {
49
55
  for (const anim of lotties) {
50
- if (!isLottieAnimationReady(anim)) return true;
56
+ if (!isLottieAnimationLoaded(anim)) return true;
51
57
  }
52
58
  }
53
59
 
@@ -57,11 +63,24 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
57
63
  }
58
64
  }
59
65
 
66
+ /**
67
+ * Renders a composition preview using the <hyperframes-player> web component.
68
+ *
69
+ * The web component handles iframe scaling, dimension detection, and
70
+ * ResizeObserver internally. This wrapper bridges its inner iframe to the
71
+ * forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
72
+ * timeline probing, and DOM inspection.
73
+ */
60
74
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
61
- ({ projectId, directUrl, onLoad, portrait, style }, ref) => {
75
+ ({ projectId, directUrl, onLoad, portrait }, ref) => {
62
76
  const containerRef = useRef<HTMLDivElement>(null);
77
+ const loadCountRef = useRef(0);
63
78
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
79
+ const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64
80
  const [assetsLoading, setAssetsLoading] = useState(false);
81
+ const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
82
+ const [assetOverlayFading, setAssetOverlayFading] = useState(false);
83
+ const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
65
84
 
66
85
  useMountEffect(() => {
67
86
  const container = containerRef.current;
@@ -70,11 +89,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
70
89
  let canceled = false;
71
90
  let cleanup: (() => void) | undefined;
72
91
 
92
+ // Dynamic import registers the custom element in the browser only.
73
93
  import("@hyperframes/player").then(() => {
74
94
  if (canceled) return;
75
95
 
96
+ // Create the web component imperatively to avoid JSX custom-element typing.
76
97
  const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
77
98
  const src = directUrl || `/api/projects/${projectId}/preview`;
99
+ player.setAttribute("shader-capture-scale", "1");
100
+ player.setAttribute("shader-loading", "player");
78
101
  player.setAttribute("src", src);
79
102
  player.setAttribute("width", String(portrait ? 1080 : 1920));
80
103
  player.setAttribute("height", String(portrait ? 1920 : 1080));
@@ -82,8 +105,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
82
105
  player.style.height = "100%";
83
106
  player.style.display = "block";
84
107
  container.appendChild(player);
85
- enableInteractiveIframe(player);
86
108
 
109
+ // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
87
110
  const iframe = player.iframeElement;
88
111
  if (typeof ref === "function") {
89
112
  ref(iframe);
@@ -91,12 +114,42 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
91
114
  (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
92
115
  }
93
116
 
117
+ // Prevent the web component's built-in click-to-toggle behavior.
118
+ // The studio manages playback exclusively via useTimelinePlayer.
94
119
  const preventToggle = (e: Event) => e.stopImmediatePropagation();
95
120
  player.addEventListener("click", preventToggle, { capture: true });
96
121
 
122
+ const handleShaderTransitionState = (event: Event) => {
123
+ const loading = getShaderTransitionLoading(event);
124
+ if (loading !== null) setShaderTransitionLoading(loading);
125
+ };
126
+ player.addEventListener("shadertransitionstate", handleShaderTransitionState);
127
+
128
+ // Forward the iframe's native load event to the studio's onIframeLoad.
97
129
  const handleLoad = () => {
130
+ loadCountRef.current++;
131
+ setShaderTransitionLoading(false);
132
+ // Reveal animation on reload (hot-reload, composition switch)
133
+ if (loadCountRef.current > 1) {
134
+ container.classList.remove("preview-revealing");
135
+ void container.offsetWidth;
136
+ container.classList.add("preview-revealing");
137
+ const onEnd = () => container.classList.remove("preview-revealing");
138
+ container.addEventListener("animationend", onEnd, { once: true });
139
+ }
98
140
  onLoad();
99
141
 
142
+ // Show a loading overlay until every `<video>`/`<audio>` and Lottie
143
+ // asset is ready. Without this users can click play before audio has
144
+ // buffered — the runtime is resilient (queued play() resolves once
145
+ // data arrives), but the overlay communicates why the first frame
146
+ // or first audio beat may lag.
147
+ //
148
+ // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
149
+ // trips we hide the overlay so the UI doesn't appear stuck forever,
150
+ // but we log a debug warning so the case is diagnosable — a long
151
+ // cold video or a broken asset can legitimately exceed 10 s on a
152
+ // slow network.
100
153
  if (assetPollRef.current) clearInterval(assetPollRef.current);
101
154
  let lastUnloaded = hasUnloadedAssets(iframe, false);
102
155
  if (lastUnloaded) {
@@ -109,6 +162,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
109
162
  if (assetPollRef.current) clearInterval(assetPollRef.current);
110
163
  assetPollRef.current = null;
111
164
  setAssetsLoading(false);
165
+ if (lastUnloaded) {
166
+ console.debug(
167
+ "[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
168
+ );
169
+ }
112
170
  }
113
171
  }, 100);
114
172
  } else {
@@ -120,9 +178,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
120
178
  cleanup = () => {
121
179
  iframe.removeEventListener("load", handleLoad);
122
180
  player.removeEventListener("click", preventToggle, { capture: true });
181
+ player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
123
182
  if (assetPollRef.current) clearInterval(assetPollRef.current);
124
183
  assetPollRef.current = null;
125
184
  container.removeChild(player);
185
+ // Clear the forwarded ref
126
186
  if (typeof ref === "function") {
127
187
  ref(null);
128
188
  } else if (ref) {
@@ -137,16 +197,57 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
137
197
  };
138
198
  });
139
199
 
200
+ useEffect(() => {
201
+ if (assetFadeRef.current) {
202
+ clearTimeout(assetFadeRef.current);
203
+ assetFadeRef.current = null;
204
+ }
205
+
206
+ if (assetsLoading) {
207
+ setAssetOverlayVisible(true);
208
+ setAssetOverlayFading(false);
209
+ return;
210
+ }
211
+
212
+ setAssetOverlayFading(true);
213
+ assetFadeRef.current = setTimeout(() => {
214
+ setAssetOverlayVisible(false);
215
+ setAssetOverlayFading(false);
216
+ assetFadeRef.current = null;
217
+ }, 240);
218
+
219
+ return () => {
220
+ if (assetFadeRef.current) {
221
+ clearTimeout(assetFadeRef.current);
222
+ assetFadeRef.current = null;
223
+ }
224
+ };
225
+ }, [assetsLoading]);
226
+
227
+ const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
228
+
140
229
  return (
141
- <div
142
- className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
143
- style={style}
144
- >
230
+ <div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
145
231
  <div ref={containerRef} className="w-full h-full" />
146
- {assetsLoading && (
147
- <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
148
- <div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
149
- <span className="text-white/60 text-xs mt-3">Loading assets…</span>
232
+ {showAssetOverlay && (
233
+ <div
234
+ className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
235
+ data-hyperframes-ignore=""
236
+ draggable={false}
237
+ style={{
238
+ opacity: assetOverlayFading ? 0 : 1,
239
+ pointerEvents: assetOverlayFading ? "none" : "auto",
240
+ transition: "opacity 240ms ease-out",
241
+ }}
242
+ onDragStart={(event) => event.preventDefault()}
243
+ onMouseDown={(event) => event.preventDefault()}
244
+ onPointerDown={(event) => event.preventDefault()}
245
+ >
246
+ <HyperframesLoader
247
+ title="Preparing preview assets"
248
+ detail="Waiting for media and motion assets before playback starts."
249
+ size={56}
250
+ />
150
251
  </div>
151
252
  )}
152
253
  </div>
@@ -1,14 +1,17 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import {
4
- TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
- getTimelineToggleTitle,
6
- } from "../../utils/timelineDiscovery";
7
- import { formatTime } from "../lib/time";
3
+ import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
8
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
9
5
 
10
6
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
11
7
  const SEEK_EDGE_SNAP_PX = 8;
8
+ type TimeDisplayMode = "time" | "frame";
9
+ const SHORTCUT_HINTS = [
10
+ { key: "J", label: "Play backward" },
11
+ { key: "K", label: "Stop playback" },
12
+ { key: "L", label: "Play forward" },
13
+ { key: "←/→", label: "Step one frame backward or forward" },
14
+ ] as const;
12
15
 
13
16
  export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
14
17
  if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
@@ -23,23 +26,23 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
23
26
  interface PlayerControlsProps {
24
27
  onTogglePlay: () => void;
25
28
  onSeek: (time: number) => void;
26
- timelineVisible?: boolean;
27
- onToggleTimeline?: () => void;
28
29
  }
29
30
 
30
31
  export const PlayerControls = memo(function PlayerControls({
31
32
  onTogglePlay,
32
33
  onSeek,
33
- timelineVisible,
34
- onToggleTimeline,
35
34
  }: PlayerControlsProps) {
36
35
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
37
36
  const isPlaying = usePlayerStore((s) => s.isPlaying);
38
37
  const duration = usePlayerStore((s) => s.duration);
39
38
  const timelineReady = usePlayerStore((s) => s.timelineReady);
40
39
  const playbackRate = usePlayerStore((s) => s.playbackRate);
40
+ const loopEnabled = usePlayerStore((s) => s.loopEnabled);
41
41
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
42
+ const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
42
43
  const [showSpeedMenu, setShowSpeedMenu] = useState(false);
44
+ const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
45
+ const [jumpFrame, setJumpFrame] = useState("");
43
46
 
44
47
  const progressFillRef = useRef<HTMLDivElement>(null);
45
48
  const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -49,6 +52,8 @@ export const PlayerControls = memo(function PlayerControls({
49
52
  const speedMenuContainerRef = useRef<HTMLDivElement>(null);
50
53
  const isDraggingRef = useRef(false);
51
54
  const currentTimeRef = useRef(0);
55
+ const timeDisplayModeRef = useRef(timeDisplayMode);
56
+ timeDisplayModeRef.current = timeDisplayMode;
52
57
 
53
58
  const durationRef = useRef(duration);
54
59
  durationRef.current = duration;
@@ -59,7 +64,10 @@ export const PlayerControls = memo(function PlayerControls({
59
64
  const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
60
65
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
61
66
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
62
- if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
67
+ if (timeDisplayRef.current) {
68
+ timeDisplayRef.current.textContent =
69
+ timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
70
+ }
63
71
  if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
64
72
  };
65
73
  const unsub = liveTime.subscribe(updateProgress);
@@ -82,6 +90,13 @@ export const PlayerControls = memo(function PlayerControls({
82
90
  };
83
91
  });
84
92
 
93
+ useEffect(() => {
94
+ if (!timeDisplayRef.current) return;
95
+ const t = currentTimeRef.current;
96
+ timeDisplayRef.current.textContent =
97
+ timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
98
+ }, [duration, timeDisplayMode]);
99
+
85
100
  useEffect(() => {
86
101
  if (!showSpeedMenu) return;
87
102
  const handleMouseDown = (e: MouseEvent) => {
@@ -190,21 +205,44 @@ export const PlayerControls = memo(function PlayerControls({
190
205
  const handleKeyDown = useCallback(
191
206
  (e: React.KeyboardEvent) => {
192
207
  if (!timelineReady || duration <= 0) return;
193
- const step = e.shiftKey ? 5 : 1;
208
+ const step = e.shiftKey ? 10 : 1;
194
209
  if (e.key === "ArrowLeft") {
195
210
  e.preventDefault();
196
- onSeek(Math.max(0, currentTimeRef.current - step));
211
+ onSeek(stepFrameTime(currentTimeRef.current, -step));
197
212
  } else if (e.key === "ArrowRight") {
198
213
  e.preventDefault();
199
- onSeek(Math.min(duration, currentTimeRef.current + step));
214
+ onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
200
215
  }
201
216
  },
202
217
  [timelineReady, duration, onSeek],
203
218
  );
204
219
 
220
+ const commitJumpFrame = useCallback(() => {
221
+ const frame = Number.parseInt(jumpFrame, 10);
222
+ if (!Number.isFinite(frame) || duration <= 0) return;
223
+ onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
224
+ }, [duration, jumpFrame, onSeek]);
225
+
226
+ const handleJumpSubmit = useCallback(
227
+ (e: React.FormEvent) => {
228
+ e.preventDefault();
229
+ commitJumpFrame();
230
+ },
231
+ [commitJumpFrame],
232
+ );
233
+
234
+ const handleJumpKeyDown = useCallback(
235
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
236
+ if (e.key !== "Enter") return;
237
+ e.preventDefault();
238
+ commitJumpFrame();
239
+ },
240
+ [commitJumpFrame],
241
+ );
242
+
205
243
  return (
206
244
  <div
207
- className="px-4 py-2 flex items-center gap-3"
245
+ className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
208
246
  style={{
209
247
  borderTop: "1px solid rgba(255,255,255,0.04)",
210
248
  // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -236,12 +274,16 @@ export const PlayerControls = memo(function PlayerControls({
236
274
 
237
275
  {/* Time display */}
238
276
  <span
239
- className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
277
+ className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
240
278
  style={{ color: "#A1A1AA" }}
241
279
  >
242
280
  <span ref={timeDisplayRef}>{formatTime(0)}</span>
243
- <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
244
- <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
281
+ {timeDisplayMode === "time" ? (
282
+ <>
283
+ <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
284
+ <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
285
+ </>
286
+ ) : null}
245
287
  </span>
246
288
 
247
289
  {/* Seek bar — teal progress fill */}
@@ -256,7 +298,7 @@ export const PlayerControls = memo(function PlayerControls({
256
298
  aria-valuemin={0}
257
299
  aria-valuemax={Math.round(duration)}
258
300
  aria-valuenow={0}
259
- className="flex-1 h-6 flex items-center cursor-pointer group"
301
+ className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
260
302
  // `touch-action: none` tells the browser we're handling every
261
303
  // pointer gesture on this element ourselves. Without it, iOS
262
304
  // Safari consumes horizontal swipes for its own swipe-back-to-
@@ -292,7 +334,7 @@ export const PlayerControls = memo(function PlayerControls({
292
334
  <button
293
335
  type="button"
294
336
  onClick={() => setShowSpeedMenu((v) => !v)}
295
- className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
337
+ className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
296
338
  style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
297
339
  >
298
340
  {playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -329,38 +371,64 @@ export const PlayerControls = memo(function PlayerControls({
329
371
  )}
330
372
  </div>
331
373
 
332
- {/* Timeline toggle */}
333
- {onToggleTimeline !== undefined && (
334
- <button
335
- type="button"
336
- onClick={onToggleTimeline}
337
- className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
338
- timelineVisible
339
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
340
- : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
341
- }`}
342
- title={getTimelineToggleTitle(Boolean(timelineVisible))}
343
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
344
- >
345
- <svg
346
- width="13"
347
- height="13"
348
- viewBox="0 0 24 24"
349
- fill="none"
350
- stroke="currentColor"
351
- strokeWidth="2"
352
- strokeLinecap="round"
374
+ <button
375
+ type="button"
376
+ onClick={() => setLoopEnabled(!loopEnabled)}
377
+ className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
378
+ loopEnabled
379
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
380
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
381
+ }`}
382
+ title="Loop playback"
383
+ aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
384
+ aria-pressed={loopEnabled}
385
+ >
386
+ Loop
387
+ </button>
388
+
389
+ <button
390
+ type="button"
391
+ onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
392
+ 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"
393
+ title="Toggle time/frame display"
394
+ aria-label="Toggle time and frame display"
395
+ >
396
+ {timeDisplayMode === "time" ? "m:ss" : "frames"}
397
+ </button>
398
+
399
+ <form
400
+ onSubmit={handleJumpSubmit}
401
+ className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
402
+ >
403
+ <input
404
+ value={jumpFrame}
405
+ onChange={(e) => setJumpFrame(e.target.value)}
406
+ inputMode="numeric"
407
+ pattern="[0-9]*"
408
+ aria-label="Jump to frame"
409
+ placeholder="frame"
410
+ 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"
411
+ onKeyDown={handleJumpKeyDown}
412
+ onBlur={commitJumpFrame}
413
+ />
414
+ </form>
415
+
416
+ <div
417
+ className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
418
+ aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
419
+ >
420
+ {SHORTCUT_HINTS.map((shortcut) => (
421
+ <span
422
+ key={shortcut.key}
423
+ className="group relative rounded border border-neutral-800 px-1 py-0.5"
353
424
  >
354
- <rect x="3" y="13" width="18" height="8" rx="1" />
355
- <line x1="3" y1="9" x2="21" y2="9" />
356
- <line x1="3" y1="5" x2="21" y2="5" />
357
- </svg>
358
- <span>Timeline</span>
359
- <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
360
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
425
+ {shortcut.key}
426
+ <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">
427
+ {shortcut.label}
428
+ </span>
361
429
  </span>
362
- </button>
363
- )}
430
+ ))}
431
+ </div>
364
432
  </div>
365
433
  );
366
434
  });