@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.
- package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1436
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2462
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +9 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
- package/src/player/hooks/useTimelinePlayer.ts +487 -106
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +6 -1
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -442
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
|
+
import { useCaptionStore } from "../../captions/store";
|
|
4
6
|
|
|
5
7
|
interface PlaybackAdapter {
|
|
6
8
|
play: () => void;
|
|
@@ -20,7 +22,7 @@ interface TimelineLike {
|
|
|
20
22
|
isActive: () => boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
interface ClipManifestClip {
|
|
24
26
|
id: string | null;
|
|
25
27
|
label: string;
|
|
26
28
|
start: number;
|
|
@@ -62,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
65
|
-
|
|
67
|
+
const win = el.ownerDocument.defaultView ?? window;
|
|
68
|
+
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
69
|
+
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
|
|
70
|
+
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
|
|
66
71
|
const candidate = el.querySelector("video, audio, img");
|
|
67
|
-
return candidate instanceof
|
|
72
|
+
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
|
|
68
73
|
? candidate
|
|
69
74
|
: null;
|
|
70
75
|
}
|
|
@@ -90,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
90
95
|
const src = mediaEl.getAttribute("src");
|
|
91
96
|
if (src) entry.src = src;
|
|
92
97
|
|
|
93
|
-
|
|
98
|
+
const win = mediaEl.ownerDocument.defaultView ?? window;
|
|
99
|
+
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
100
|
+
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
|
|
94
101
|
|
|
95
102
|
const sourceDurationAttr =
|
|
96
103
|
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
|
@@ -105,11 +112,82 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
const SHUTTLE_SPEEDS = [1, 2, 4] as const;
|
|
116
|
+
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
|
117
|
+
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
118
|
+
"input",
|
|
119
|
+
"textarea",
|
|
120
|
+
"select",
|
|
121
|
+
"button",
|
|
122
|
+
"a[href]",
|
|
123
|
+
"[contenteditable='true']",
|
|
124
|
+
"[role='button']",
|
|
125
|
+
"[role='checkbox']",
|
|
126
|
+
"[role='combobox']",
|
|
127
|
+
"[role='menuitem']",
|
|
128
|
+
"[role='radio']",
|
|
129
|
+
"[role='slider']",
|
|
130
|
+
"[role='spinbutton']",
|
|
131
|
+
"[role='switch']",
|
|
132
|
+
"[role='textbox']",
|
|
133
|
+
].join(",");
|
|
134
|
+
|
|
135
|
+
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
|
136
|
+
if (!target || typeof target !== "object") return false;
|
|
137
|
+
const candidate = target as { closest?: unknown };
|
|
138
|
+
if (typeof candidate.closest !== "function") return false;
|
|
139
|
+
return (
|
|
140
|
+
(candidate.closest as (selector: string) => Element | null).call(
|
|
141
|
+
target,
|
|
142
|
+
PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
|
|
143
|
+
) !== null
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface PlaybackShortcutCaptionState {
|
|
148
|
+
isCaptionEditMode: boolean;
|
|
149
|
+
selectedCaptionSegmentCount: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type PlaybackShortcutEvent = Pick<
|
|
153
|
+
KeyboardEvent,
|
|
154
|
+
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
|
|
155
|
+
>;
|
|
156
|
+
|
|
157
|
+
export function shouldIgnorePlaybackShortcutEvent(
|
|
158
|
+
event: PlaybackShortcutEvent,
|
|
159
|
+
captionState: PlaybackShortcutCaptionState = {
|
|
160
|
+
isCaptionEditMode: false,
|
|
161
|
+
selectedCaptionSegmentCount: 0,
|
|
162
|
+
},
|
|
163
|
+
): boolean {
|
|
164
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
|
165
|
+
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
|
166
|
+
return (
|
|
167
|
+
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
|
168
|
+
captionState.isCaptionEditMode &&
|
|
169
|
+
captionState.selectedCaptionSegmentCount > 0
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getTimelineElementDisplayLabel(input: {
|
|
174
|
+
id?: string | null;
|
|
175
|
+
label?: string | null;
|
|
176
|
+
tag?: string | null;
|
|
177
|
+
}): string {
|
|
178
|
+
const label = input.label?.trim();
|
|
179
|
+
if (label) return label;
|
|
180
|
+
const id = input.id?.trim();
|
|
181
|
+
if (id) return id;
|
|
182
|
+
const tag = input.tag?.trim().toLowerCase();
|
|
183
|
+
return tag ? `${tag} clip` : "Timeline clip";
|
|
184
|
+
}
|
|
185
|
+
|
|
108
186
|
/**
|
|
109
187
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
110
188
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
111
189
|
*/
|
|
112
|
-
function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
190
|
+
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
113
191
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
114
192
|
const nodes = doc.querySelectorAll("[data-start]");
|
|
115
193
|
const els: TimelineElement[] = [];
|
|
@@ -140,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
140
218
|
const selector = getTimelineElementSelector(el);
|
|
141
219
|
const sourceFile = getTimelineElementSourceFile(el);
|
|
142
220
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
143
|
-
const
|
|
221
|
+
const label = getTimelineElementDisplayLabel({
|
|
222
|
+
id: el.id || compId || null,
|
|
223
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
224
|
+
tag: tagLower,
|
|
225
|
+
});
|
|
226
|
+
const identity = buildTimelineElementIdentity({
|
|
227
|
+
preferredId: el.id || compId || null,
|
|
228
|
+
label,
|
|
229
|
+
fallbackIndex: els.length,
|
|
230
|
+
domId: el.id || undefined,
|
|
231
|
+
selector,
|
|
232
|
+
selectorIndex,
|
|
233
|
+
sourceFile,
|
|
234
|
+
});
|
|
144
235
|
const entry: TimelineElement = {
|
|
145
|
-
id,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
fallbackIndex: els.length,
|
|
149
|
-
domId: el.id || undefined,
|
|
150
|
-
selector,
|
|
151
|
-
selectorIndex,
|
|
152
|
-
sourceFile,
|
|
153
|
-
}),
|
|
236
|
+
id: identity.id,
|
|
237
|
+
label,
|
|
238
|
+
key: identity.key,
|
|
154
239
|
tag: tagLower,
|
|
155
240
|
start,
|
|
156
241
|
duration: dur,
|
|
@@ -251,6 +336,40 @@ function buildTimelineElementKey(params: {
|
|
|
251
336
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
252
337
|
}
|
|
253
338
|
|
|
339
|
+
function buildTimelineElementIdentity(params: {
|
|
340
|
+
preferredId?: string | null;
|
|
341
|
+
label: string;
|
|
342
|
+
fallbackIndex: number;
|
|
343
|
+
domId?: string;
|
|
344
|
+
selector?: string;
|
|
345
|
+
selectorIndex?: number;
|
|
346
|
+
sourceFile?: string;
|
|
347
|
+
}): { id: string; key: string } {
|
|
348
|
+
const id =
|
|
349
|
+
params.preferredId?.trim() ||
|
|
350
|
+
buildTimelineElementKey({
|
|
351
|
+
id: params.label,
|
|
352
|
+
fallbackIndex: params.fallbackIndex,
|
|
353
|
+
domId: params.domId,
|
|
354
|
+
selector: params.selector,
|
|
355
|
+
selectorIndex: params.selectorIndex,
|
|
356
|
+
sourceFile: params.sourceFile,
|
|
357
|
+
});
|
|
358
|
+
const key = buildTimelineElementKey({
|
|
359
|
+
id,
|
|
360
|
+
fallbackIndex: params.fallbackIndex,
|
|
361
|
+
domId: params.domId,
|
|
362
|
+
selector: params.selector,
|
|
363
|
+
selectorIndex: params.selectorIndex,
|
|
364
|
+
sourceFile: params.sourceFile,
|
|
365
|
+
});
|
|
366
|
+
return { id, key };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getTimelineElementIdentity(element: TimelineElement): string {
|
|
370
|
+
return element.key ?? element.id;
|
|
371
|
+
}
|
|
372
|
+
|
|
254
373
|
function getTimelineDomNodes(doc: Document): Element[] {
|
|
255
374
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
256
375
|
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
|
|
@@ -292,6 +411,103 @@ export function findTimelineDomNodeForClip(
|
|
|
292
411
|
return candidates[fallbackIndex] ?? null;
|
|
293
412
|
}
|
|
294
413
|
|
|
414
|
+
export function createTimelineElementFromManifestClip(params: {
|
|
415
|
+
clip: ClipManifestClip;
|
|
416
|
+
fallbackIndex: number;
|
|
417
|
+
doc?: Document | null;
|
|
418
|
+
hostEl?: Element | null;
|
|
419
|
+
}): TimelineElement {
|
|
420
|
+
const { clip, fallbackIndex, doc } = params;
|
|
421
|
+
let hostEl = params.hostEl ?? null;
|
|
422
|
+
const label = getTimelineElementDisplayLabel({
|
|
423
|
+
id: clip.id,
|
|
424
|
+
label: clip.label,
|
|
425
|
+
tag: clip.tagName || clip.kind,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
let domId: string | undefined;
|
|
429
|
+
let selector: string | undefined;
|
|
430
|
+
let selectorIndex: number | undefined;
|
|
431
|
+
let sourceFile: string | undefined;
|
|
432
|
+
|
|
433
|
+
if (hostEl) {
|
|
434
|
+
domId = hostEl.id || undefined;
|
|
435
|
+
selector = getTimelineElementSelector(hostEl);
|
|
436
|
+
selectorIndex =
|
|
437
|
+
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
|
438
|
+
sourceFile = getTimelineElementSourceFile(hostEl);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const identity = buildTimelineElementIdentity({
|
|
442
|
+
preferredId: clip.id,
|
|
443
|
+
label,
|
|
444
|
+
fallbackIndex,
|
|
445
|
+
domId,
|
|
446
|
+
selector,
|
|
447
|
+
selectorIndex,
|
|
448
|
+
sourceFile,
|
|
449
|
+
});
|
|
450
|
+
const entry: TimelineElement = {
|
|
451
|
+
id: identity.id,
|
|
452
|
+
label,
|
|
453
|
+
key: identity.key,
|
|
454
|
+
tag: clip.tagName || clip.kind,
|
|
455
|
+
start: clip.start,
|
|
456
|
+
duration: clip.duration,
|
|
457
|
+
track: clip.track,
|
|
458
|
+
domId,
|
|
459
|
+
selector,
|
|
460
|
+
selectorIndex,
|
|
461
|
+
sourceFile,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (hostEl) {
|
|
465
|
+
applyMediaMetadataFromElement(entry, hostEl);
|
|
466
|
+
}
|
|
467
|
+
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
468
|
+
if (clip.kind === "composition" && clip.compositionId) {
|
|
469
|
+
let resolvedSrc = clip.compositionSrc;
|
|
470
|
+
if (!resolvedSrc) {
|
|
471
|
+
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
472
|
+
resolvedSrc =
|
|
473
|
+
hostEl?.getAttribute("data-composition-src") ??
|
|
474
|
+
hostEl?.getAttribute("data-composition-file") ??
|
|
475
|
+
null;
|
|
476
|
+
}
|
|
477
|
+
if (resolvedSrc) {
|
|
478
|
+
entry.compositionSrc = resolvedSrc;
|
|
479
|
+
} else if (hostEl) {
|
|
480
|
+
const innerVideo = hostEl.querySelector("video[src]");
|
|
481
|
+
if (innerVideo) {
|
|
482
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
483
|
+
entry.tag = "video";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (hostEl) {
|
|
487
|
+
entry.domId = hostEl.id || undefined;
|
|
488
|
+
entry.selector = getTimelineElementSelector(hostEl);
|
|
489
|
+
entry.selectorIndex =
|
|
490
|
+
doc && entry.selector
|
|
491
|
+
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
|
|
492
|
+
: undefined;
|
|
493
|
+
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
494
|
+
const nextIdentity = buildTimelineElementIdentity({
|
|
495
|
+
preferredId: clip.id,
|
|
496
|
+
label,
|
|
497
|
+
fallbackIndex,
|
|
498
|
+
domId: entry.domId,
|
|
499
|
+
selector: entry.selector,
|
|
500
|
+
selectorIndex: entry.selectorIndex,
|
|
501
|
+
sourceFile: entry.sourceFile,
|
|
502
|
+
});
|
|
503
|
+
entry.id = nextIdentity.id;
|
|
504
|
+
entry.key = nextIdentity.key;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return entry;
|
|
509
|
+
}
|
|
510
|
+
|
|
295
511
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
296
512
|
return (
|
|
297
513
|
doc.getElementById(id) ??
|
|
@@ -320,6 +536,10 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
320
536
|
|
|
321
537
|
return {
|
|
322
538
|
id: params.compositionId,
|
|
539
|
+
label: getTimelineElementDisplayLabel({
|
|
540
|
+
id: params.compositionId,
|
|
541
|
+
tag: params.tagName,
|
|
542
|
+
}),
|
|
323
543
|
key: buildTimelineElementKey({
|
|
324
544
|
id: params.compositionId,
|
|
325
545
|
fallbackIndex: 0,
|
|
@@ -337,6 +557,7 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
337
557
|
sourceFile: compositionSrc,
|
|
338
558
|
};
|
|
339
559
|
}
|
|
560
|
+
|
|
340
561
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
341
562
|
if (doc.documentElement) {
|
|
342
563
|
doc.documentElement.style.overflow = "hidden";
|
|
@@ -440,8 +661,10 @@ export function mergeTimelineElementsPreservingDowngrades(
|
|
|
440
661
|
return nextElements;
|
|
441
662
|
}
|
|
442
663
|
|
|
443
|
-
const
|
|
444
|
-
const preserved = currentElements.filter(
|
|
664
|
+
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
|
|
665
|
+
const preserved = currentElements.filter(
|
|
666
|
+
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
|
|
667
|
+
);
|
|
445
668
|
if (preserved.length === 0) return nextElements;
|
|
446
669
|
return [...nextElements, ...preserved];
|
|
447
670
|
}
|
|
@@ -452,6 +675,13 @@ export function useTimelinePlayer() {
|
|
|
452
675
|
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
453
676
|
const pendingSeekRef = useRef<number | null>(null);
|
|
454
677
|
const isRefreshingRef = useRef(false);
|
|
678
|
+
const reverseRafRef = useRef<number>(0);
|
|
679
|
+
const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
|
|
680
|
+
const shuttleSpeedIndexRef = useRef(0);
|
|
681
|
+
const pressedCodesRef = useRef(new Set<string>());
|
|
682
|
+
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
|
|
683
|
+
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
684
|
+
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
455
685
|
|
|
456
686
|
// ZERO store subscriptions — this hook never causes re-renders.
|
|
457
687
|
// All reads use getState() (point-in-time), all writes use the stable setters.
|
|
@@ -510,6 +740,10 @@ export function useTimelinePlayer() {
|
|
|
510
740
|
}
|
|
511
741
|
}, []);
|
|
512
742
|
|
|
743
|
+
const stopReverseLoop = useCallback(() => {
|
|
744
|
+
cancelAnimationFrame(reverseRafRef.current);
|
|
745
|
+
}, []);
|
|
746
|
+
|
|
513
747
|
const startRAFLoop = useCallback(() => {
|
|
514
748
|
const tick = () => {
|
|
515
749
|
const adapter = getAdapter();
|
|
@@ -518,6 +752,14 @@ export function useTimelinePlayer() {
|
|
|
518
752
|
const dur = adapter.getDuration();
|
|
519
753
|
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
520
754
|
if (time >= dur && !adapter.isPlaying()) {
|
|
755
|
+
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
756
|
+
adapter.seek(0);
|
|
757
|
+
liveTime.notify(0);
|
|
758
|
+
adapter.play();
|
|
759
|
+
setIsPlaying(true);
|
|
760
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
521
763
|
setCurrentTime(time); // sync Zustand once at end
|
|
522
764
|
setIsPlaying(false);
|
|
523
765
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -560,6 +802,8 @@ export function useTimelinePlayer() {
|
|
|
560
802
|
}, []);
|
|
561
803
|
|
|
562
804
|
const play = useCallback(() => {
|
|
805
|
+
stopRAFLoop();
|
|
806
|
+
stopReverseLoop();
|
|
563
807
|
const adapter = getAdapter();
|
|
564
808
|
if (!adapter) return;
|
|
565
809
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
@@ -568,18 +812,68 @@ export function useTimelinePlayer() {
|
|
|
568
812
|
unmutePreviewMedia(iframeRef.current);
|
|
569
813
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
570
814
|
adapter.play();
|
|
815
|
+
shuttleDirectionRef.current = "forward";
|
|
571
816
|
setIsPlaying(true);
|
|
572
817
|
startRAFLoop();
|
|
573
|
-
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
|
|
818
|
+
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
|
|
819
|
+
|
|
820
|
+
const playBackward = useCallback(
|
|
821
|
+
(rate: number) => {
|
|
822
|
+
stopRAFLoop();
|
|
823
|
+
stopReverseLoop();
|
|
824
|
+
const adapter = getAdapter();
|
|
825
|
+
if (!adapter) return;
|
|
826
|
+
const duration = Math.max(0, adapter.getDuration());
|
|
827
|
+
const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
|
|
828
|
+
adapter.pause();
|
|
829
|
+
if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
|
|
830
|
+
unmutePreviewMedia(iframeRef.current);
|
|
831
|
+
const speed = Math.max(0.1, Math.min(4, rate));
|
|
832
|
+
let startTime = initialTime;
|
|
833
|
+
let startedAt = performance.now();
|
|
834
|
+
|
|
835
|
+
const tick = (now: number) => {
|
|
836
|
+
const elapsed = ((now - startedAt) / 1000) * speed;
|
|
837
|
+
let nextTime = startTime - elapsed;
|
|
838
|
+
if (nextTime <= 0) {
|
|
839
|
+
if (usePlayerStore.getState().loopEnabled && duration > 0) {
|
|
840
|
+
startTime = duration;
|
|
841
|
+
startedAt = now;
|
|
842
|
+
nextTime = duration;
|
|
843
|
+
} else {
|
|
844
|
+
adapter.seek(0);
|
|
845
|
+
liveTime.notify(0);
|
|
846
|
+
setCurrentTime(0);
|
|
847
|
+
setIsPlaying(false);
|
|
848
|
+
shuttleDirectionRef.current = null;
|
|
849
|
+
reverseRafRef.current = 0;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
adapter.seek(Math.max(0, nextTime));
|
|
854
|
+
liveTime.notify(Math.max(0, nextTime));
|
|
855
|
+
setIsPlaying(true);
|
|
856
|
+
reverseRafRef.current = requestAnimationFrame(tick);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
setIsPlaying(true);
|
|
860
|
+
shuttleDirectionRef.current = "backward";
|
|
861
|
+
reverseRafRef.current = requestAnimationFrame(tick);
|
|
862
|
+
},
|
|
863
|
+
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
864
|
+
);
|
|
574
865
|
|
|
575
866
|
const pause = useCallback(() => {
|
|
867
|
+
stopReverseLoop();
|
|
576
868
|
const adapter = getAdapter();
|
|
577
869
|
if (!adapter) return;
|
|
578
870
|
adapter.pause();
|
|
579
871
|
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
|
580
872
|
setIsPlaying(false);
|
|
873
|
+
shuttleDirectionRef.current = null;
|
|
874
|
+
shuttleSpeedIndexRef.current = 0;
|
|
581
875
|
stopRAFLoop();
|
|
582
|
-
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
|
|
876
|
+
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
583
877
|
|
|
584
878
|
const togglePlay = useCallback(() => {
|
|
585
879
|
if (usePlayerStore.getState().isPlaying) {
|
|
@@ -591,18 +885,136 @@ export function useTimelinePlayer() {
|
|
|
591
885
|
|
|
592
886
|
const seek = useCallback(
|
|
593
887
|
(time: number) => {
|
|
888
|
+
stopReverseLoop();
|
|
594
889
|
const adapter = getAdapter();
|
|
595
890
|
if (!adapter) return;
|
|
596
|
-
adapter.
|
|
597
|
-
|
|
598
|
-
|
|
891
|
+
const duration = Math.max(0, adapter.getDuration());
|
|
892
|
+
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
|
|
893
|
+
adapter.seek(nextTime);
|
|
894
|
+
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
895
|
+
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
599
896
|
stopRAFLoop();
|
|
600
897
|
// Only update store if state actually changes (avoids unnecessary re-renders)
|
|
601
898
|
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
899
|
+
shuttleDirectionRef.current = null;
|
|
900
|
+
shuttleSpeedIndexRef.current = 0;
|
|
901
|
+
},
|
|
902
|
+
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const stepFrames = useCallback(
|
|
906
|
+
(deltaFrames: number) => {
|
|
907
|
+
const adapter = getAdapter();
|
|
908
|
+
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
909
|
+
seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
|
|
910
|
+
},
|
|
911
|
+
[getAdapter, seek],
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
const shuttle = useCallback(
|
|
915
|
+
(direction: "forward" | "backward") => {
|
|
916
|
+
if (shuttleDirectionRef.current === direction) {
|
|
917
|
+
shuttleSpeedIndexRef.current = Math.min(
|
|
918
|
+
shuttleSpeedIndexRef.current + 1,
|
|
919
|
+
SHUTTLE_SPEEDS.length - 1,
|
|
920
|
+
);
|
|
921
|
+
} else {
|
|
922
|
+
shuttleSpeedIndexRef.current = 0;
|
|
923
|
+
}
|
|
924
|
+
const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
|
|
925
|
+
usePlayerStore.getState().setPlaybackRate(speed);
|
|
926
|
+
if (direction === "forward") {
|
|
927
|
+
play();
|
|
928
|
+
} else {
|
|
929
|
+
playBackward(speed);
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
[play, playBackward],
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
const handlePlaybackKeyDown = useCallback(
|
|
936
|
+
(e: KeyboardEvent) => {
|
|
937
|
+
if (e.defaultPrevented) return;
|
|
938
|
+
const captionState = useCaptionStore.getState();
|
|
939
|
+
if (
|
|
940
|
+
shouldIgnorePlaybackShortcutEvent(e, {
|
|
941
|
+
isCaptionEditMode: captionState.isEditMode,
|
|
942
|
+
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
|
943
|
+
})
|
|
944
|
+
) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
pressedCodesRef.current.add(e.code);
|
|
948
|
+
if (e.code === "Space") {
|
|
949
|
+
e.preventDefault();
|
|
950
|
+
togglePlay();
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (e.code === "ArrowLeft") {
|
|
954
|
+
e.preventDefault();
|
|
955
|
+
stepFrames(e.shiftKey ? -10 : -1);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (e.code === "ArrowRight") {
|
|
959
|
+
e.preventDefault();
|
|
960
|
+
stepFrames(e.shiftKey ? 10 : 1);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (e.repeat) return;
|
|
964
|
+
if (e.code === "KeyK") {
|
|
965
|
+
e.preventDefault();
|
|
966
|
+
pause();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (e.code === "KeyJ") {
|
|
970
|
+
e.preventDefault();
|
|
971
|
+
if (pressedCodesRef.current.has("KeyK")) {
|
|
972
|
+
stepFrames(-1);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
shuttle("backward");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (e.code === "KeyL") {
|
|
979
|
+
e.preventDefault();
|
|
980
|
+
if (pressedCodesRef.current.has("KeyK")) {
|
|
981
|
+
stepFrames(1);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
shuttle("forward");
|
|
985
|
+
}
|
|
602
986
|
},
|
|
603
|
-
[
|
|
987
|
+
[pause, shuttle, stepFrames, togglePlay],
|
|
604
988
|
);
|
|
605
989
|
|
|
990
|
+
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
|
991
|
+
pressedCodesRef.current.delete(e.code);
|
|
992
|
+
}, []);
|
|
993
|
+
playbackKeyDownRef.current = handlePlaybackKeyDown;
|
|
994
|
+
playbackKeyUpRef.current = handlePlaybackKeyUp;
|
|
995
|
+
|
|
996
|
+
const attachIframeShortcutListeners = useCallback(() => {
|
|
997
|
+
iframeShortcutCleanupRef.current?.();
|
|
998
|
+
iframeShortcutCleanupRef.current = null;
|
|
999
|
+
|
|
1000
|
+
const iframeWin = iframeRef.current?.contentWindow;
|
|
1001
|
+
const iframeDoc = iframeRef.current?.contentDocument;
|
|
1002
|
+
if (!iframeWin && !iframeDoc) return;
|
|
1003
|
+
|
|
1004
|
+
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
1005
|
+
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
1006
|
+
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
1007
|
+
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
1008
|
+
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
1009
|
+
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
1010
|
+
iframeShortcutCleanupRef.current = () => {
|
|
1011
|
+
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
1012
|
+
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
1013
|
+
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
1014
|
+
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
1015
|
+
};
|
|
1016
|
+
}, []);
|
|
1017
|
+
|
|
606
1018
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
607
1019
|
const processTimelineMessage = useCallback(
|
|
608
1020
|
(data: {
|
|
@@ -627,72 +1039,16 @@ export function useTimelinePlayer() {
|
|
|
627
1039
|
}
|
|
628
1040
|
const usedHostEls = new Set<Element>();
|
|
629
1041
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
630
|
-
|
|
1042
|
+
const hostEl = iframeDoc
|
|
631
1043
|
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
632
1044
|
: null;
|
|
633
1045
|
if (hostEl) usedHostEls.add(hostEl);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
id,
|
|
637
|
-
tag: clip.tagName || clip.kind,
|
|
638
|
-
start: clip.start,
|
|
639
|
-
duration: clip.duration,
|
|
640
|
-
track: clip.track,
|
|
641
|
-
};
|
|
642
|
-
if (hostEl) {
|
|
643
|
-
entry.domId = hostEl.id || undefined;
|
|
644
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
645
|
-
entry.selectorIndex =
|
|
646
|
-
iframeDoc && entry.selector
|
|
647
|
-
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
648
|
-
: undefined;
|
|
649
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
650
|
-
applyMediaMetadataFromElement(entry, hostEl);
|
|
651
|
-
}
|
|
652
|
-
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
653
|
-
if (clip.kind === "composition" && clip.compositionId) {
|
|
654
|
-
// The bundler renames data-composition-src to data-composition-file
|
|
655
|
-
// after inlining, so the clip manifest may not have compositionSrc.
|
|
656
|
-
// Fall back to reading data-composition-file from the DOM.
|
|
657
|
-
let resolvedSrc = clip.compositionSrc;
|
|
658
|
-
if (!resolvedSrc) {
|
|
659
|
-
hostEl =
|
|
660
|
-
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
661
|
-
resolvedSrc =
|
|
662
|
-
hostEl?.getAttribute("data-composition-src") ??
|
|
663
|
-
hostEl?.getAttribute("data-composition-file") ??
|
|
664
|
-
null;
|
|
665
|
-
}
|
|
666
|
-
if (resolvedSrc) {
|
|
667
|
-
entry.compositionSrc = resolvedSrc;
|
|
668
|
-
} else if (hostEl) {
|
|
669
|
-
// Inline composition (no external file) — expose inner video for thumbnails
|
|
670
|
-
const innerVideo = hostEl.querySelector("video[src]");
|
|
671
|
-
if (innerVideo) {
|
|
672
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
673
|
-
entry.tag = "video";
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (hostEl) {
|
|
677
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
678
|
-
entry.domId = hostEl.id || undefined;
|
|
679
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
680
|
-
entry.selectorIndex =
|
|
681
|
-
iframeDoc && entry.selector
|
|
682
|
-
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
683
|
-
: undefined;
|
|
684
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
entry.key = buildTimelineElementKey({
|
|
688
|
-
id,
|
|
1046
|
+
return createTimelineElementFromManifestClip({
|
|
1047
|
+
clip,
|
|
689
1048
|
fallbackIndex: index,
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
selectorIndex: entry.selectorIndex,
|
|
693
|
-
sourceFile: entry.sourceFile,
|
|
1049
|
+
doc: iframeDoc,
|
|
1050
|
+
hostEl,
|
|
694
1051
|
});
|
|
695
|
-
return entry;
|
|
696
1052
|
});
|
|
697
1053
|
const rawDuration = data.durationInFrames / 30;
|
|
698
1054
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
@@ -806,17 +1162,24 @@ export function useTimelinePlayer() {
|
|
|
806
1162
|
const selector = getTimelineElementSelector(el);
|
|
807
1163
|
const sourceFile = getTimelineElementSourceFile(el);
|
|
808
1164
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
809
|
-
const
|
|
1165
|
+
const label = getTimelineElementDisplayLabel({
|
|
1166
|
+
id: el.id || compId || null,
|
|
1167
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
1168
|
+
tag: el.tagName,
|
|
1169
|
+
});
|
|
1170
|
+
const identity = buildTimelineElementIdentity({
|
|
1171
|
+
preferredId: el.id || compId || null,
|
|
1172
|
+
label,
|
|
1173
|
+
fallbackIndex: missing.length,
|
|
1174
|
+
domId: el.id || undefined,
|
|
1175
|
+
selector,
|
|
1176
|
+
selectorIndex,
|
|
1177
|
+
sourceFile,
|
|
1178
|
+
});
|
|
810
1179
|
const entry: TimelineElement = {
|
|
811
|
-
id,
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
fallbackIndex: missing.length,
|
|
815
|
-
domId: el.id || undefined,
|
|
816
|
-
selector,
|
|
817
|
-
selectorIndex,
|
|
818
|
-
sourceFile,
|
|
819
|
-
}),
|
|
1180
|
+
id: identity.id,
|
|
1181
|
+
label,
|
|
1182
|
+
key: identity.key,
|
|
820
1183
|
tag: el.tagName.toLowerCase(),
|
|
821
1184
|
start,
|
|
822
1185
|
duration: dur,
|
|
@@ -906,6 +1269,7 @@ export function useTimelinePlayer() {
|
|
|
906
1269
|
if (doc && iframeWin) {
|
|
907
1270
|
normalizePreviewViewport(doc, iframeWin);
|
|
908
1271
|
autoHealMissingCompositionIds(doc);
|
|
1272
|
+
attachIframeShortcutListeners();
|
|
909
1273
|
}
|
|
910
1274
|
|
|
911
1275
|
// Try reading __clipManifest if already available (fast path)
|
|
@@ -972,6 +1336,7 @@ export function useTimelinePlayer() {
|
|
|
972
1336
|
processTimelineMessage,
|
|
973
1337
|
enrichMissingCompositions,
|
|
974
1338
|
syncTimelineElements,
|
|
1339
|
+
attachIframeShortcutListeners,
|
|
975
1340
|
]);
|
|
976
1341
|
|
|
977
1342
|
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
@@ -982,11 +1347,22 @@ export function useTimelinePlayer() {
|
|
|
982
1347
|
: (usePlayerStore.getState().currentTime ?? 0);
|
|
983
1348
|
isRefreshingRef.current = true;
|
|
984
1349
|
stopRAFLoop();
|
|
1350
|
+
stopReverseLoop();
|
|
985
1351
|
setIsPlaying(false);
|
|
986
|
-
}, [getAdapter, stopRAFLoop, setIsPlaying]);
|
|
1352
|
+
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
1353
|
+
|
|
1354
|
+
const refreshPlayer = useCallback(() => {
|
|
1355
|
+
const iframe = iframeRef.current;
|
|
1356
|
+
if (!iframe) return;
|
|
1357
|
+
|
|
1358
|
+
saveSeekPosition();
|
|
1359
|
+
|
|
1360
|
+
const src = iframe.src;
|
|
1361
|
+
const url = new URL(src, window.location.origin);
|
|
1362
|
+
url.searchParams.set("_t", String(Date.now()));
|
|
1363
|
+
iframe.src = url.toString();
|
|
1364
|
+
}, [saveSeekPosition]);
|
|
987
1365
|
|
|
988
|
-
const togglePlayRef = useRef(togglePlay);
|
|
989
|
-
togglePlayRef.current = togglePlay;
|
|
990
1366
|
const getAdapterRef = useRef(getAdapter);
|
|
991
1367
|
getAdapterRef.current = getAdapter;
|
|
992
1368
|
const processTimelineMessageRef = useRef(processTimelineMessage);
|
|
@@ -995,12 +1371,8 @@ export function useTimelinePlayer() {
|
|
|
995
1371
|
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
|
996
1372
|
|
|
997
1373
|
useMountEffect(() => {
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
e.preventDefault();
|
|
1001
|
-
togglePlayRef.current();
|
|
1002
|
-
}
|
|
1003
|
-
};
|
|
1374
|
+
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
1375
|
+
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
1004
1376
|
|
|
1005
1377
|
// Listen for timeline messages from the iframe runtime.
|
|
1006
1378
|
// The runtime sends this AFTER all external compositions load,
|
|
@@ -1073,24 +1445,32 @@ export function useTimelinePlayer() {
|
|
|
1073
1445
|
}
|
|
1074
1446
|
};
|
|
1075
1447
|
|
|
1076
|
-
window.addEventListener("keydown",
|
|
1448
|
+
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
1449
|
+
window.addEventListener("keyup", handleWindowKeyUp, true);
|
|
1077
1450
|
window.addEventListener("message", handleMessage);
|
|
1078
1451
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
1079
1452
|
return () => {
|
|
1080
|
-
window.removeEventListener("keydown",
|
|
1453
|
+
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
|
1454
|
+
window.removeEventListener("keyup", handleWindowKeyUp, true);
|
|
1455
|
+
iframeShortcutCleanupRef.current?.();
|
|
1456
|
+
iframeShortcutCleanupRef.current = null;
|
|
1081
1457
|
window.removeEventListener("message", handleMessage);
|
|
1082
1458
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1083
1459
|
stopRAFLoop();
|
|
1460
|
+
stopReverseLoop();
|
|
1084
1461
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1462
|
+
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
|
|
1463
|
+
// to prevent blink. New data will replace old when the iframe reloads.
|
|
1085
1464
|
};
|
|
1086
1465
|
});
|
|
1087
1466
|
|
|
1088
1467
|
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
|
|
1089
1468
|
const resetPlayer = useCallback(() => {
|
|
1090
1469
|
stopRAFLoop();
|
|
1470
|
+
stopReverseLoop();
|
|
1091
1471
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1092
1472
|
usePlayerStore.getState().reset();
|
|
1093
|
-
}, [stopRAFLoop]);
|
|
1473
|
+
}, [stopRAFLoop, stopReverseLoop]);
|
|
1094
1474
|
|
|
1095
1475
|
return {
|
|
1096
1476
|
iframeRef,
|
|
@@ -1099,6 +1479,7 @@ export function useTimelinePlayer() {
|
|
|
1099
1479
|
togglePlay,
|
|
1100
1480
|
seek,
|
|
1101
1481
|
onIframeLoad,
|
|
1482
|
+
refreshPlayer,
|
|
1102
1483
|
saveSeekPosition,
|
|
1103
1484
|
resetPlayer,
|
|
1104
1485
|
};
|