@hyperframes/studio 0.6.52 → 0.6.53

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/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-Bvy50smZ.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-SKRp8mGz.css">
8
+ <script type="module" crossorigin src="/assets/index-CZNoIjSE.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B2mn12z0.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.52",
3
+ "version": "0.6.53",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.52",
35
- "@hyperframes/player": "0.6.52"
34
+ "@hyperframes/core": "0.6.53",
35
+ "@hyperframes/player": "0.6.53"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.52"
49
+ "@hyperframes/producer": "0.6.53"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
@@ -0,0 +1,208 @@
1
+ import { memo, useState, useCallback, useRef, useEffect } from "react";
2
+ import { trackStudioFeedback } from "../telemetry/events";
3
+
4
+ const DEFAULT_FEEDBACK_INTERVAL = 10;
5
+ const AUTO_DISMISS_MS = 20_000;
6
+
7
+ function isFeedbackDisabled(): boolean {
8
+ try {
9
+ return import.meta.env.VITE_HYPERFRAMES_NO_FEEDBACK === "1";
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ // fallow-ignore-next-line complexity
16
+ function getFeedbackInterval(): number {
17
+ try {
18
+ const v = import.meta.env.VITE_HYPERFRAMES_FEEDBACK_INTERVAL as string | undefined;
19
+ if (v) {
20
+ const n = parseInt(v, 10);
21
+ if (Number.isFinite(n) && n > 0) return n;
22
+ }
23
+ } catch {
24
+ // import.meta.env unavailable
25
+ }
26
+ return DEFAULT_FEEDBACK_INTERVAL;
27
+ }
28
+
29
+ const STORAGE_KEYS = {
30
+ sessionCount: "hyperframes-studio:feedbackSessionCount",
31
+ lastPromptedAt: "hyperframes-studio:feedbackLastPromptedAt",
32
+ } as const;
33
+
34
+ // fallow-ignore-next-line complexity
35
+ function shouldShowFeedback(): boolean {
36
+ if (isFeedbackDisabled()) return false;
37
+ try {
38
+ const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
39
+ const lastAt = parseInt(localStorage.getItem(STORAGE_KEYS.lastPromptedAt) || "0", 10) || 0;
40
+ return count - lastAt >= getFeedbackInterval();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ const SESSION_COUNTED_KEY = "hyperframes-studio:feedbackSessionCounted";
47
+
48
+ // fallow-ignore-next-line complexity
49
+ function incrementSessionCount(): void {
50
+ try {
51
+ if (sessionStorage.getItem(SESSION_COUNTED_KEY)) return;
52
+ sessionStorage.setItem(SESSION_COUNTED_KEY, "1");
53
+ const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
54
+ localStorage.setItem(STORAGE_KEYS.sessionCount, String(count + 1));
55
+ } catch {
56
+ // storage unavailable
57
+ }
58
+ }
59
+
60
+ function markPrompted(): void {
61
+ try {
62
+ const count = localStorage.getItem(STORAGE_KEYS.sessionCount) || "0";
63
+ localStorage.setItem(STORAGE_KEYS.lastPromptedAt, count);
64
+ } catch {
65
+ // localStorage unavailable
66
+ }
67
+ }
68
+
69
+ // fallow-ignore-next-line complexity
70
+ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
71
+ const [visible, setVisible] = useState(false);
72
+ const [rating, setRating] = useState<number | null>(null);
73
+ const [comment, setComment] = useState("");
74
+ const [submitted, setSubmitted] = useState(false);
75
+ const [exiting, setExiting] = useState(false);
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+ const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
+
79
+ // On mount: increment session count, check if we should show
80
+ useEffect(() => {
81
+ incrementSessionCount();
82
+ // Small delay so the bar doesn't flash on page load
83
+ const showTimer = setTimeout(() => {
84
+ if (shouldShowFeedback()) {
85
+ setVisible(true);
86
+ }
87
+ }, 3000);
88
+ return () => clearTimeout(showTimer);
89
+ }, []);
90
+
91
+ // Auto-dismiss timer — reset when user interacts (sets rating)
92
+ useEffect(() => {
93
+ if (!visible || rating !== null || submitted) return;
94
+ dismissTimerRef.current = setTimeout(() => {
95
+ handleDismiss();
96
+ }, AUTO_DISMISS_MS);
97
+ return () => {
98
+ if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
99
+ };
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [visible, rating, submitted]);
102
+
103
+ // Focus text input when rating is selected
104
+ useEffect(() => {
105
+ if (rating !== null && inputRef.current) {
106
+ inputRef.current.focus();
107
+ }
108
+ }, [rating]);
109
+
110
+ const handleDismiss = useCallback(() => {
111
+ setExiting(true);
112
+ markPrompted();
113
+ setTimeout(() => setVisible(false), 300);
114
+ }, []);
115
+
116
+ const handleSubmit = useCallback(() => {
117
+ if (rating === null) return;
118
+ trackStudioFeedback({
119
+ rating,
120
+ comment: comment.trim() || undefined,
121
+ });
122
+ setSubmitted(true);
123
+ markPrompted();
124
+ setTimeout(() => {
125
+ setExiting(true);
126
+ setTimeout(() => setVisible(false), 300);
127
+ }, 1500);
128
+ }, [rating, comment]);
129
+
130
+ const handleRating = useCallback((n: number) => {
131
+ setRating(n);
132
+ // Cancel auto-dismiss — user is engaged
133
+ if (dismissTimerRef.current) {
134
+ clearTimeout(dismissTimerRef.current);
135
+ dismissTimerRef.current = null;
136
+ }
137
+ }, []);
138
+
139
+ if (!visible) return null;
140
+
141
+ return (
142
+ <div
143
+ className={[
144
+ "flex items-center gap-3 px-4 h-8 border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300",
145
+ exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
146
+ ].join(" ")}
147
+ >
148
+ {submitted ? (
149
+ <span className="text-neutral-500">Thanks for the feedback!</span>
150
+ ) : rating !== null ? (
151
+ <>
152
+ <input
153
+ ref={inputRef}
154
+ type="text"
155
+ value={comment}
156
+ onChange={(e) => setComment(e.target.value)}
157
+ onKeyDown={(e) => {
158
+ if (e.key === "Enter") handleSubmit();
159
+ if (e.key === "Escape") handleDismiss();
160
+ }}
161
+ placeholder="Any details? (enter to send, esc to close)"
162
+ className="flex-1 bg-transparent border-none text-[11px] text-neutral-300 placeholder-neutral-600 outline-none"
163
+ maxLength={500}
164
+ />
165
+ <button
166
+ onClick={handleSubmit}
167
+ className="text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors flex-shrink-0"
168
+ >
169
+ send
170
+ </button>
171
+ </>
172
+ ) : (
173
+ <>
174
+ <span className="text-neutral-500 flex-shrink-0">How's the Studio experience?</span>
175
+ <div className="flex items-center gap-0.5">
176
+ {[1, 2, 3, 4, 5].map((n) => (
177
+ <button
178
+ key={n}
179
+ onClick={() => handleRating(n)}
180
+ className="w-6 h-6 rounded text-[11px] text-neutral-600 hover:text-neutral-200 hover:bg-neutral-700/50 transition-colors"
181
+ >
182
+ {n}
183
+ </button>
184
+ ))}
185
+ </div>
186
+ <div className="flex-1" />
187
+ <button
188
+ onClick={handleDismiss}
189
+ className="text-neutral-700 hover:text-neutral-400 transition-colors flex-shrink-0"
190
+ aria-label="Dismiss"
191
+ >
192
+ <svg
193
+ width="12"
194
+ height="12"
195
+ viewBox="0 0 24 24"
196
+ fill="none"
197
+ stroke="currentColor"
198
+ strokeWidth="2"
199
+ strokeLinecap="round"
200
+ >
201
+ <path d="M18 6L6 18M6 6l12 12" />
202
+ </svg>
203
+ </button>
204
+ </>
205
+ )}
206
+ </div>
207
+ );
208
+ });
@@ -3,6 +3,7 @@ import { NLELayout } from "./nle/NLELayout";
3
3
  import { CaptionOverlay } from "../captions/components/CaptionOverlay";
4
4
  import { CaptionTimeline } from "../captions/components/CaptionTimeline";
5
5
  import { DomEditOverlay } from "./editor/DomEditOverlay";
6
+ import { StudioFeedbackBar } from "./StudioFeedbackBar";
6
7
  import type { TimelineElement } from "../player";
7
8
  import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
8
9
  import {
@@ -53,6 +54,7 @@ export interface StudioPreviewAreaProps {
53
54
  blockPreview?: BlockPreviewInfo | null;
54
55
  }
55
56
 
57
+ // fallow-ignore-next-line complexity
56
58
  export function StudioPreviewArea({
57
59
  timelineToolbar,
58
60
  renderClipContent,
@@ -102,100 +104,103 @@ export function StudioPreviewArea({
102
104
  } = useDomEditContext();
103
105
 
104
106
  return (
105
- <div className="flex-1 relative min-w-0">
106
- <NLELayout
107
- projectId={projectId}
108
- refreshKey={refreshKey}
109
- activeCompositionPath={activeCompPath}
110
- timelineToolbar={timelineToolbar}
111
- renderClipContent={renderClipContent}
112
- onDeleteElement={handleTimelineElementDelete}
113
- onAssetDrop={handleTimelineAssetDrop}
114
- onBlockDrop={handleTimelineBlockDrop}
115
- onPreviewBlockDrop={handlePreviewBlockDrop}
116
- onFileDrop={handleTimelineFileDrop}
117
- onMoveElement={handleTimelineElementMove}
118
- onResizeElement={handleTimelineElementResize}
119
- onBlockedEditAttempt={handleBlockedTimelineEdit}
120
- onSelectTimelineElement={handleTimelineElementSelect}
121
- onCompIdToSrcChange={setCompIdToSrc}
122
- onCompositionLoadingChange={setCompositionLoading}
123
- onCompositionChange={(compPath) => {
124
- // Sync activeCompPath when user drills down via timeline double-click
125
- // or navigates back via breadcrumb keeps sidebar + thumbnails in sync.
126
- // Guard against no-op updates to prevent circular refresh cascades
127
- // between activeCompPath compositionStack onCompositionChange.
128
- if (compPath !== activeCompPath) {
129
- setActiveCompPath(compPath);
130
- refreshPreviewDocumentVersion();
107
+ <div className="flex-1 flex flex-col relative min-w-0">
108
+ <div className="flex-1 min-h-0 relative">
109
+ <NLELayout
110
+ projectId={projectId}
111
+ refreshKey={refreshKey}
112
+ activeCompositionPath={activeCompPath}
113
+ timelineToolbar={timelineToolbar}
114
+ renderClipContent={renderClipContent}
115
+ onDeleteElement={handleTimelineElementDelete}
116
+ onAssetDrop={handleTimelineAssetDrop}
117
+ onBlockDrop={handleTimelineBlockDrop}
118
+ onPreviewBlockDrop={handlePreviewBlockDrop}
119
+ onFileDrop={handleTimelineFileDrop}
120
+ onMoveElement={handleTimelineElementMove}
121
+ onResizeElement={handleTimelineElementResize}
122
+ onBlockedEditAttempt={handleBlockedTimelineEdit}
123
+ onSelectTimelineElement={handleTimelineElementSelect}
124
+ onCompIdToSrcChange={setCompIdToSrc}
125
+ onCompositionLoadingChange={setCompositionLoading}
126
+ onCompositionChange={(compPath) => {
127
+ // Sync activeCompPath when user drills down via timeline double-click
128
+ // or navigates back via breadcrumb keeps sidebar + thumbnails in sync.
129
+ // Guard against no-op updates to prevent circular refresh cascades
130
+ // between activeCompPath → compositionStack → onCompositionChange.
131
+ if (compPath !== activeCompPath) {
132
+ setActiveCompPath(compPath);
133
+ refreshPreviewDocumentVersion();
134
+ }
135
+ }}
136
+ onIframeRef={handlePreviewIframeRef}
137
+ previewOverlay={
138
+ blockPreview ? (
139
+ <div className="absolute inset-0 z-30 bg-black pointer-events-none">
140
+ {blockPreview.videoUrl ? (
141
+ <video
142
+ src={blockPreview.videoUrl}
143
+ autoPlay
144
+ muted
145
+ loop
146
+ playsInline
147
+ className="w-full h-full object-contain"
148
+ />
149
+ ) : blockPreview.posterUrl ? (
150
+ <img
151
+ src={blockPreview.posterUrl}
152
+ alt={blockPreview.title}
153
+ className="w-full h-full object-contain"
154
+ />
155
+ ) : null}
156
+ </div>
157
+ ) : captionEditMode ? (
158
+ <CaptionOverlay iframeRef={previewIframeRef} />
159
+ ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
160
+ <DomEditOverlay
161
+ iframeRef={previewIframeRef}
162
+ activeCompositionPath={activeCompPath}
163
+ hoverSelection={
164
+ STUDIO_PREVIEW_SELECTION_ENABLED &&
165
+ !captionEditMode &&
166
+ !compositionLoading &&
167
+ !isPlaying
168
+ ? domEditHoverSelection
169
+ : null
170
+ }
171
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
172
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
173
+ allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
174
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
175
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
176
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
177
+ onSelectionChange={applyDomSelection}
178
+ onBlockedMove={handleBlockedDomMove}
179
+ onManualDragStart={handleDomManualDragStart}
180
+ onPathOffsetCommit={handleDomPathOffsetCommit}
181
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
182
+ onBoxSizeCommit={handleDomBoxSizeCommit}
183
+ onRotationCommit={handleDomRotationCommit}
184
+ />
185
+ ) : null
131
186
  }
132
- }}
133
- onIframeRef={handlePreviewIframeRef}
134
- previewOverlay={
135
- blockPreview ? (
136
- <div className="absolute inset-0 z-30 bg-black pointer-events-none">
137
- {blockPreview.videoUrl ? (
138
- <video
139
- src={blockPreview.videoUrl}
140
- autoPlay
141
- muted
142
- loop
143
- playsInline
144
- className="w-full h-full object-contain"
145
- />
146
- ) : blockPreview.posterUrl ? (
147
- <img
148
- src={blockPreview.posterUrl}
149
- alt={blockPreview.title}
150
- className="w-full h-full object-contain"
151
- />
152
- ) : null}
153
- </div>
154
- ) : captionEditMode ? (
155
- <CaptionOverlay iframeRef={previewIframeRef} />
156
- ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
157
- <DomEditOverlay
158
- iframeRef={previewIframeRef}
159
- activeCompositionPath={activeCompPath}
160
- hoverSelection={
161
- STUDIO_PREVIEW_SELECTION_ENABLED &&
162
- !captionEditMode &&
163
- !compositionLoading &&
164
- !isPlaying
165
- ? domEditHoverSelection
166
- : null
167
- }
168
- selection={shouldShowSelectedDomBounds ? domEditSelection : null}
169
- groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
170
- allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
171
- onCanvasMouseDown={handlePreviewCanvasMouseDown}
172
- onCanvasPointerMove={handlePreviewCanvasPointerMove}
173
- onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
174
- onSelectionChange={applyDomSelection}
175
- onBlockedMove={handleBlockedDomMove}
176
- onManualDragStart={handleDomManualDragStart}
177
- onPathOffsetCommit={handleDomPathOffsetCommit}
178
- onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
179
- onBoxSizeCommit={handleDomBoxSizeCommit}
180
- onRotationCommit={handleDomRotationCommit}
181
- />
182
- ) : null
183
- }
184
- timelineFooter={
185
- captionEditMode ? (
186
- <div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
187
- <div className="flex items-center gap-1.5 px-2 py-0.5">
188
- <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
189
- Captions
190
- </span>
187
+ timelineFooter={
188
+ captionEditMode ? (
189
+ <div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
190
+ <div className="flex items-center gap-1.5 px-2 py-0.5">
191
+ <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
192
+ Captions
193
+ </span>
194
+ </div>
195
+ <CaptionTimeline pixelsPerSecond={100} />
191
196
  </div>
192
- <CaptionTimeline pixelsPerSecond={100} />
193
- </div>
194
- ) : undefined
195
- }
196
- timelineVisible={timelineVisible}
197
- onToggleTimeline={toggleTimelineVisibility}
198
- />
197
+ ) : undefined
198
+ }
199
+ timelineVisible={timelineVisible}
200
+ onToggleTimeline={toggleTimelineVisibility}
201
+ />
202
+ </div>
203
+ <StudioFeedbackBar />
199
204
  </div>
200
205
  );
201
206
  }
@@ -279,3 +279,145 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
279
279
  expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
280
280
  });
281
281
  });
282
+
283
+ describe("useTimelinePlayer RAF loop wrap-around", () => {
284
+ type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
285
+
286
+ function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
287
+ const iframe = document.createElement("iframe");
288
+ let currentTime = 0;
289
+ let playing = false;
290
+ const seekCalls: SeekCall[] = [];
291
+ const adapter = {
292
+ play: vi.fn(() => {
293
+ playing = true;
294
+ }),
295
+ pause: vi.fn(() => {
296
+ playing = false;
297
+ }),
298
+ seek: vi.fn((time: number, options?: { keepPlaying?: boolean }) => {
299
+ currentTime = time;
300
+ seekCalls.push({ time, options });
301
+ }),
302
+ getTime: () => currentTime,
303
+ getDuration: () => duration,
304
+ isPlaying: () => playing,
305
+ setTime: (t: number) => {
306
+ currentTime = t;
307
+ },
308
+ };
309
+ Object.defineProperty(iframe, "contentWindow", {
310
+ value: {
311
+ __player: adapter,
312
+ postMessage: () => {},
313
+ scrollTo: () => {},
314
+ addEventListener: () => {},
315
+ removeEventListener: () => {},
316
+ },
317
+ configurable: true,
318
+ });
319
+ Object.defineProperty(iframe, "contentDocument", {
320
+ value: document.implementation.createHTMLDocument("preview"),
321
+ configurable: true,
322
+ });
323
+ act(() => {
324
+ api.iframeRef.current = iframe;
325
+ api.onIframeLoad();
326
+ });
327
+ return { adapter, seekCalls };
328
+ }
329
+
330
+ function installRafCapture(): {
331
+ flushOne: () => boolean;
332
+ restore: () => void;
333
+ } {
334
+ const callbacks: FrameRequestCallback[] = [];
335
+ const originalRAF = globalThis.requestAnimationFrame;
336
+ const originalCancel = globalThis.cancelAnimationFrame;
337
+ globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
338
+ callbacks.push(cb);
339
+ return callbacks.length;
340
+ }) as typeof requestAnimationFrame;
341
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame;
342
+ return {
343
+ flushOne: () => {
344
+ const next = callbacks.shift();
345
+ if (!next) return false;
346
+ next(performance.now());
347
+ return true;
348
+ },
349
+ restore: () => {
350
+ globalThis.requestAnimationFrame = originalRAF;
351
+ globalThis.cancelAnimationFrame = originalCancel;
352
+ },
353
+ };
354
+ }
355
+
356
+ it("passes { keepPlaying: true } when forward playback wraps around loopEnd", () => {
357
+ const raf = installRafCapture();
358
+ try {
359
+ const { api, root } = renderTimelinePlayerHarness();
360
+ const { adapter, seekCalls } = attachInstrumentedAdapter(api);
361
+
362
+ act(() => {
363
+ usePlayerStore.getState().setInPoint(2);
364
+ usePlayerStore.getState().setOutPoint(5);
365
+ });
366
+ expect(usePlayerStore.getState().loopEnabled).toBe(true);
367
+
368
+ act(() => {
369
+ api.play();
370
+ });
371
+ adapter.seek.mockClear();
372
+ seekCalls.length = 0;
373
+
374
+ adapter.setTime(6); // past outPoint=5
375
+ act(() => {
376
+ raf.flushOne();
377
+ });
378
+
379
+ const wrapSeek = seekCalls.find((call) => call.time === 2);
380
+ expect(wrapSeek).toBeDefined();
381
+ expect(wrapSeek?.options).toEqual({ keepPlaying: true });
382
+ expect(adapter.play).toHaveBeenCalled();
383
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
384
+
385
+ unmountWithAct(root);
386
+ } finally {
387
+ raf.restore();
388
+ }
389
+ });
390
+
391
+ it("does not seek and pauses cleanly when forward playback reaches the end without loop", () => {
392
+ const raf = installRafCapture();
393
+ try {
394
+ const { api, root } = renderTimelinePlayerHarness();
395
+ const { adapter, seekCalls } = attachInstrumentedAdapter(api);
396
+
397
+ act(() => {
398
+ usePlayerStore.getState().setLoopEnabled(false);
399
+ });
400
+
401
+ act(() => {
402
+ api.play();
403
+ });
404
+ adapter.seek.mockClear();
405
+ seekCalls.length = 0;
406
+ adapter.play.mockClear();
407
+ adapter.pause.mockClear();
408
+
409
+ adapter.setTime(adapter.getDuration() + 1); // past end
410
+ act(() => {
411
+ raf.flushOne();
412
+ });
413
+
414
+ expect(seekCalls).toHaveLength(0);
415
+ expect(adapter.pause).toHaveBeenCalled();
416
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
417
+
418
+ unmountWithAct(root);
419
+ } finally {
420
+ raf.restore();
421
+ }
422
+ });
423
+ });
@@ -229,7 +229,8 @@ export function useTimelinePlayer() {
229
229
  const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
230
230
  if (time >= loopEnd) {
231
231
  if (usePlayerStore.getState().loopEnabled && dur > 0) {
232
- adapter.seek(loopStart);
232
+ // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
233
+ adapter.seek(loopStart, { keepPlaying: true });
233
234
  liveTime.notify(loopStart);
234
235
  adapter.play();
235
236
  setIsPlaying(true);
@@ -25,3 +25,35 @@ export function trackStudioRenderStart(props: {
25
25
  composition: props.composition,
26
26
  });
27
27
  }
28
+
29
+ function getBrowserDoctorSummary(): string {
30
+ try {
31
+ const nav = navigator as Navigator & {
32
+ deviceMemory?: number;
33
+ connection?: { effectiveType?: string };
34
+ userAgentData?: { platform?: string };
35
+ };
36
+ const platform = nav.userAgentData?.platform ?? navigator.platform ?? "unknown";
37
+ const parts = [
38
+ `ua=${platform}`,
39
+ `screen=${screen.width}x${screen.height}@${devicePixelRatio}x`,
40
+ `lang=${navigator.language}`,
41
+ ];
42
+ if (nav.deviceMemory) parts.push(`mem=${nav.deviceMemory}GB`);
43
+ if (nav.connection?.effectiveType) parts.push(`net=${nav.connection.effectiveType}`);
44
+ if (navigator.hardwareConcurrency) parts.push(`cpu=${navigator.hardwareConcurrency}cores`);
45
+ return parts.join(" ");
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
52
+ trackEvent("survey sent", {
53
+ $survey_id: "studio_experience",
54
+ $survey_response: props.rating,
55
+ ...(props.comment ? { $survey_response_2: props.comment } : {}),
56
+ doctor_summary: getBrowserDoctorSummary(),
57
+ source: "studio",
58
+ });
59
+ }