Skip to content

Commit 37329b0

Browse files
Merge pull request #1000 from heygen-com/feat/studio-preserve-playhead
fix(studio): preserve playhead position on composition refresh
2 parents b58a811 + e2de547 commit 37329b0

7 files changed

Lines changed: 50 additions & 62 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,15 @@ export function StudioApp() {
295295
handleCut,
296296
});
297297

298+
const selectSidebarTabStable = useCallback(
299+
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
300+
[],
301+
);
302+
const getSidebarTabStable = useCallback(
303+
() => leftSidebarRef.current?.getTab() ?? "compositions",
304+
[],
305+
);
306+
298307
const domEditSession = useDomEditSession({
299308
projectId,
300309
activeCompPath,
@@ -327,7 +336,8 @@ export function StudioApp() {
327336
reloadPreview,
328337
setRefreshKey,
329338
openSourceForSelection: fileManager.openSourceForSelection,
330-
selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
339+
selectSidebarTab: selectSidebarTabStable,
340+
getSidebarTab: getSidebarTabStable,
331341
});
332342

333343
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;

packages/studio/src/components/nle/NLELayout.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ export const NLELayout = memo(function NLELayout({
133133

134134
// Lightweight reload: change iframe src instead of destroying the Player.
135135
// refreshPlayer() saves the seek position and appends a cache-busting _t
136-
// param, avoiding the full web-component teardown + crossfade that the
137-
// key-based path uses.
136+
// param the Player instance stays alive so the adapter is available for
137+
// saveSeekPosition() to read the current time before the reload.
138138
const prevRefreshKeyRef = useRef(refreshKey);
139139
useEffect(() => {
140140
if (refreshKey === prevRefreshKeyRef.current) return;
@@ -352,7 +352,6 @@ export const NLELayout = memo(function NLELayout({
352352
onCompositionLoadingChange={setCompositionLoading}
353353
portrait={portrait}
354354
directUrl={directUrl}
355-
refreshKey={refreshKey}
356355
suppressLoadingOverlay={hasLoadedOnceRef.current}
357356
/>
358357
{!isFullscreen && previewOverlay}

packages/studio/src/components/nle/NLEPreview.test.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,9 @@ function renderPreview() {
112112
}
113113

114114
describe("getPreviewPlayerKey", () => {
115-
it("keeps the same player identity when only refreshKey changes", () => {
116-
expect(
117-
getPreviewPlayerKey({
118-
projectId: "timeline-edit-playground",
119-
refreshKey: 1,
120-
}),
121-
).toBe(
122-
getPreviewPlayerKey({
123-
projectId: "timeline-edit-playground",
124-
refreshKey: 2,
125-
}),
115+
it("uses projectId as key when no directUrl", () => {
116+
expect(getPreviewPlayerKey({ projectId: "timeline-edit-playground" })).toBe(
117+
"timeline-edit-playground",
126118
);
127119
});
128120

packages/studio/src/components/nle/NLEPreview.tsx

Lines changed: 5 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ interface NLEPreviewProps {
2020
onCompositionLoadingChange?: (loading: boolean) => void;
2121
portrait?: boolean;
2222
directUrl?: string;
23-
refreshKey?: number;
2423
suppressLoadingOverlay?: boolean;
2524
}
2625

@@ -30,7 +29,6 @@ export function getPreviewPlayerKey({
3029
}: {
3130
projectId: string;
3231
directUrl?: string;
33-
refreshKey?: number;
3432
}): string {
3533
return directUrl ?? projectId;
3634
}
@@ -91,16 +89,12 @@ export const NLEPreview = memo(function NLEPreview({
9189
onCompositionLoadingChange,
9290
portrait,
9391
directUrl,
94-
refreshKey,
9592
suppressLoadingOverlay,
9693
}: NLEPreviewProps) {
97-
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
98-
const prevRefreshKeyRef = useRef(refreshKey);
94+
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
9995
const viewportRef = useRef<HTMLDivElement>(null);
10096
const stageRef = useRef<HTMLDivElement>(null);
101-
const [retiringKey, setRetiringKey] = useState<string | null>(null);
10297
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
103-
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
10498

10599
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
106100
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
@@ -120,7 +114,6 @@ export const NLEPreview = memo(function NLEPreview({
120114
return () => {
121115
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
122116
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
123-
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
124117
};
125118
}, []);
126119

@@ -205,31 +198,13 @@ export const NLEPreview = memo(function NLEPreview({
205198
[applyTransform],
206199
);
207200

208-
if (refreshKey !== prevRefreshKeyRef.current) {
209-
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
210-
prevRefreshKeyRef.current = refreshKey;
211-
setRetiringKey(oldKey);
212-
}
213-
214-
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
215-
216201
const applyInitialZoom = useCallback(() => {
217202
const z = zoomRef.current;
218203
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
219204
writeTransform(z);
220205
}
221206
}, [writeTransform]);
222207

223-
const handleNewPlayerLoad = () => {
224-
onIframeLoad();
225-
applyInitialZoom();
226-
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
227-
retiringTimerRef.current = setTimeout(() => {
228-
setRetiringKey(null);
229-
retiringTimerRef.current = null;
230-
}, 160);
231-
};
232-
233208
useEffect(() => {
234209
const viewport = viewportRef.current;
235210
if (!viewport) return;
@@ -412,32 +387,17 @@ export const NLEPreview = memo(function NLEPreview({
412387
}}
413388
data-testid="preview-zoom-stage"
414389
>
415-
{retiringKey && (
416-
<Player
417-
key={retiringKey}
418-
projectId={directUrl ? undefined : projectId}
419-
directUrl={directUrl}
420-
onLoad={() => {}}
421-
portrait={portrait}
422-
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
423-
/>
424-
)}
425390
<Player
426391
key={activeKey}
427392
ref={iframeRef}
428393
projectId={directUrl ? undefined : projectId}
429394
directUrl={directUrl}
430-
onLoad={
431-
retiringKey
432-
? handleNewPlayerLoad
433-
: () => {
434-
onIframeLoad();
435-
applyInitialZoom();
436-
}
437-
}
395+
onLoad={() => {
396+
onIframeLoad();
397+
applyInitialZoom();
398+
}}
438399
onCompositionLoadingChange={onCompositionLoadingChange}
439400
portrait={portrait}
440-
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
441401
suppressLoadingOverlay={suppressLoadingOverlay}
442402
/>
443403
</div>

packages/studio/src/components/sidebar/LeftSidebar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
useState,
44
useCallback,
55
useImperativeHandle,
6+
useRef,
67
forwardRef,
78
type ReactNode,
89
} from "react";
@@ -17,6 +18,7 @@ export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
1718

1819
export interface LeftSidebarHandle {
1920
selectTab: (tab: SidebarTab) => void;
21+
getTab: () => SidebarTab;
2022
}
2123

2224
const STORAGE_KEY = "hf-studio-sidebar-tab";
@@ -87,14 +89,18 @@ export const LeftSidebar = memo(
8789
ref,
8890
) {
8991
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
92+
const tabRef = useRef(tab);
93+
tabRef.current = tab;
9094

9195
const selectTab = useCallback((t: SidebarTab) => {
9296
setTab(t);
9397
localStorage.setItem(STORAGE_KEY, t);
9498
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
9599
}, []);
96100

97-
useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
101+
const getTab = useCallback(() => tabRef.current, []);
102+
103+
useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
98104

99105
return (
100106
<div

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect } from "react";
1+
import { useCallback, useEffect, useRef } from "react";
22
import type { TimelineElement } from "../player";
33
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
44
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
@@ -56,6 +56,7 @@ export interface UseDomEditSessionParams {
5656
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
5757
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
5858
selectSidebarTab?: (tab: SidebarTab) => void;
59+
getSidebarTab?: () => SidebarTab;
5960
}
6061

6162
// ── Hook ──
@@ -93,6 +94,7 @@ export function useDomEditSession({
9394
setRefreshKey: _setRefreshKey,
9495
openSourceForSelection,
9596
selectSidebarTab,
97+
getSidebarTab,
9698
}: UseDomEditSessionParams) {
9799
void _setRefreshKey;
98100

@@ -281,6 +283,22 @@ export function useDomEditSession({
281283
applyStudioManualEditsToPreviewRef,
282284
]);
283285

286+
// Auto-reveal source when an element is selected while the Code tab is active.
287+
// Use a ref for the callback so the effect only fires on selection changes,
288+
// not when openSourceForSelection is recreated due to editingFile content updates.
289+
const openSourceRef = useRef(openSourceForSelection);
290+
openSourceRef.current = openSourceForSelection;
291+
useEffect(() => {
292+
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
293+
if (!domEditSelection.sourceFile) return;
294+
if (getSidebarTab() !== "code") return;
295+
openSourceRef.current(domEditSelection.sourceFile, {
296+
id: domEditSelection.id,
297+
selector: domEditSelection.selector,
298+
selectorIndex: domEditSelection.selectorIndex,
299+
});
300+
}, [domEditSelection, getSidebarTab]);
301+
284302
return {
285303
// State
286304
domEditSelection,

packages/studio/src/hooks/useFileManager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ export function useFileManager({
122122
const handleFileSelect = useCallback((path: string) => {
123123
const pid = projectIdRef.current;
124124
if (!pid) return;
125+
revealAbortRef.current?.abort();
126+
revealAbortRef.current = null;
127+
revealRequestIdRef.current++;
125128
// Skip fetching binary content for media files — just set the path for preview
126129
if (isMediaFile(path)) {
127130
setEditingFile({ path, content: null });

0 commit comments

Comments
 (0)