@hyperframes/studio 0.6.0-alpha.1 → 0.6.0-alpha.11
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-DjsVzYFP.js +418 -0
- package/dist/assets/index-FWg79aJz.css +1 -0
- package/dist/assets/index-xyVaWqe2.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +422 -71
- package/src/components/editor/PropertyPanel.test.ts +49 -0
- package/src/components/editor/PropertyPanel.tsx +277 -337
- package/src/components/editor/domEditing.test.ts +248 -0
- package/src/components/editor/domEditing.ts +126 -2
- package/src/components/editor/manualEditingAvailability.test.ts +15 -4
- package/src/components/editor/manualEditingAvailability.ts +4 -2
- package/src/components/editor/manualEdits.ts +15 -3
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +63 -24
- package/src/components/nle/NLEPreview.tsx +6 -0
- package/src/components/renders/RenderQueue.tsx +56 -4
- package/src/components/renders/useRenderQueue.ts +30 -6
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +71 -4
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.tsx +45 -20
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-Cd8vYWxP.js +0 -198
- package/dist/assets/index-D04_ZoMm.js +0 -107
- package/dist/assets/index-UWFaHilT.css +0 -1
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
memo,
|
|
3
|
+
useState,
|
|
4
|
+
useCallback,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
forwardRef,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
3
9
|
import { CompositionsTab } from "./CompositionsTab";
|
|
4
10
|
import { AssetsTab } from "./AssetsTab";
|
|
5
11
|
import { FileTree } from "../editor/FileTree";
|
|
6
12
|
|
|
7
|
-
type SidebarTab = "compositions" | "assets" | "code";
|
|
13
|
+
export type SidebarTab = "compositions" | "assets" | "code";
|
|
14
|
+
|
|
15
|
+
export interface LeftSidebarHandle {
|
|
16
|
+
selectTab: (tab: SidebarTab) => void;
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
10
20
|
|
|
@@ -39,201 +49,191 @@ interface LeftSidebarProps {
|
|
|
39
49
|
takeoverContent?: ReactNode;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
export const LeftSidebar = memo(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
52
|
+
export const LeftSidebar = memo(
|
|
53
|
+
forwardRef<LeftSidebarHandle, LeftSidebarProps>(function LeftSidebar(
|
|
54
|
+
{
|
|
55
|
+
width = 240,
|
|
56
|
+
projectId,
|
|
57
|
+
compositions,
|
|
58
|
+
assets,
|
|
59
|
+
activeComposition,
|
|
60
|
+
onSelectComposition,
|
|
61
|
+
onImportFiles,
|
|
62
|
+
fileTree: fileProp,
|
|
63
|
+
editingFile,
|
|
64
|
+
onSelectFile,
|
|
65
|
+
onCreateFile,
|
|
66
|
+
onCreateFolder,
|
|
67
|
+
onDeleteFile,
|
|
68
|
+
onRenameFile,
|
|
69
|
+
onDuplicateFile,
|
|
70
|
+
onMoveFile,
|
|
71
|
+
codeChildren,
|
|
72
|
+
onLint,
|
|
73
|
+
linting,
|
|
74
|
+
onToggleCollapse,
|
|
75
|
+
takeoverContent,
|
|
76
|
+
},
|
|
77
|
+
ref,
|
|
78
|
+
) {
|
|
79
|
+
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
66
80
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
const selectTab = useCallback((t: SidebarTab) => {
|
|
82
|
+
setTab(t);
|
|
83
|
+
localStorage.setItem(STORAGE_KEY, t);
|
|
84
|
+
}, []);
|
|
71
85
|
|
|
72
|
-
|
|
73
|
-
useMountEffect(() => {
|
|
74
|
-
const handler = (e: KeyboardEvent) => {
|
|
75
|
-
if (!e.metaKey && !e.ctrlKey) return;
|
|
76
|
-
if (e.key === "1") {
|
|
77
|
-
e.preventDefault();
|
|
78
|
-
selectTab("compositions");
|
|
79
|
-
}
|
|
80
|
-
if (e.key === "2") {
|
|
81
|
-
e.preventDefault();
|
|
82
|
-
selectTab("assets");
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
window.addEventListener("keydown", handler);
|
|
86
|
-
return () => window.removeEventListener("keydown", handler);
|
|
87
|
-
});
|
|
86
|
+
useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
>
|
|
105
|
-
<button
|
|
106
|
-
type="button"
|
|
107
|
-
onClick={() => selectTab("code")}
|
|
108
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
109
|
-
tab === "code"
|
|
110
|
-
? "bg-neutral-800 text-white"
|
|
111
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
112
|
-
}`}
|
|
113
|
-
>
|
|
114
|
-
Code
|
|
115
|
-
</button>
|
|
116
|
-
<button
|
|
117
|
-
type="button"
|
|
118
|
-
onClick={() => selectTab("compositions")}
|
|
119
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
120
|
-
tab === "compositions"
|
|
121
|
-
? "bg-neutral-800 text-white"
|
|
122
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
123
|
-
}`}
|
|
124
|
-
>
|
|
125
|
-
Compositions
|
|
126
|
-
</button>
|
|
127
|
-
<button
|
|
128
|
-
type="button"
|
|
129
|
-
onClick={() => selectTab("assets")}
|
|
130
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
131
|
-
tab === "assets"
|
|
132
|
-
? "bg-neutral-800 text-white"
|
|
133
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
134
|
-
}`}
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
|
|
91
|
+
style={{ width }}
|
|
92
|
+
>
|
|
93
|
+
{takeoverContent ? (
|
|
94
|
+
<div className="flex min-h-0 flex-1">{takeoverContent}</div>
|
|
95
|
+
) : (
|
|
96
|
+
<>
|
|
97
|
+
{/* Tabs — Code first */}
|
|
98
|
+
<div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<div
|
|
101
|
+
className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
102
|
+
style={{ gridTemplateColumns: "1fr 1fr 1fr" }}
|
|
135
103
|
>
|
|
136
|
-
|
|
137
|
-
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => selectTab("code")}
|
|
107
|
+
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
108
|
+
tab === "code"
|
|
109
|
+
? "bg-neutral-800 text-white"
|
|
110
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
111
|
+
}`}
|
|
112
|
+
>
|
|
113
|
+
Code
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={() => selectTab("compositions")}
|
|
118
|
+
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
119
|
+
tab === "compositions"
|
|
120
|
+
? "bg-neutral-800 text-white"
|
|
121
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
Comps
|
|
125
|
+
</button>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => selectTab("assets")}
|
|
129
|
+
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
|
130
|
+
tab === "assets"
|
|
131
|
+
? "bg-neutral-800 text-white"
|
|
132
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
133
|
+
}`}
|
|
134
|
+
>
|
|
135
|
+
Assets
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
{onToggleCollapse && (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onToggleCollapse}
|
|
142
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
143
|
+
title="Hide sidebar"
|
|
144
|
+
aria-label="Hide sidebar"
|
|
145
|
+
>
|
|
146
|
+
<svg
|
|
147
|
+
width="14"
|
|
148
|
+
height="14"
|
|
149
|
+
viewBox="0 0 24 24"
|
|
150
|
+
fill="none"
|
|
151
|
+
stroke="currentColor"
|
|
152
|
+
strokeWidth="1.5"
|
|
153
|
+
strokeLinecap="round"
|
|
154
|
+
strokeLinejoin="round"
|
|
155
|
+
aria-hidden="true"
|
|
156
|
+
>
|
|
157
|
+
<path d="m14 7-5 5 5 5" />
|
|
158
|
+
<path d="M19 4v16" />
|
|
159
|
+
</svg>
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
138
162
|
</div>
|
|
139
|
-
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Tab content */}
|
|
166
|
+
{tab === "compositions" && (
|
|
167
|
+
<CompositionsTab
|
|
168
|
+
projectId={projectId}
|
|
169
|
+
compositions={compositions}
|
|
170
|
+
activeComposition={activeComposition}
|
|
171
|
+
onSelect={onSelectComposition}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
{tab === "assets" && (
|
|
175
|
+
<AssetsTab
|
|
176
|
+
projectId={projectId}
|
|
177
|
+
assets={assets}
|
|
178
|
+
onImport={onImportFiles}
|
|
179
|
+
onDelete={onDeleteFile}
|
|
180
|
+
onRename={onRenameFile}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
{tab === "code" && (
|
|
184
|
+
<div className="flex flex-1 min-h-0">
|
|
185
|
+
{(fileProp?.length ?? 0) > 0 && (
|
|
186
|
+
<div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
|
187
|
+
<FileTree
|
|
188
|
+
files={fileProp ?? []}
|
|
189
|
+
activeFile={editingFile?.path ?? null}
|
|
190
|
+
onSelectFile={onSelectFile ?? (() => {})}
|
|
191
|
+
onCreateFile={onCreateFile}
|
|
192
|
+
onCreateFolder={onCreateFolder}
|
|
193
|
+
onDeleteFile={onDeleteFile}
|
|
194
|
+
onRenameFile={onRenameFile}
|
|
195
|
+
onDuplicateFile={onDuplicateFile}
|
|
196
|
+
onMoveFile={onMoveFile}
|
|
197
|
+
onImportFiles={onImportFiles}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
<div className="flex-1 overflow-hidden min-w-0">
|
|
202
|
+
{codeChildren ?? (
|
|
203
|
+
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
|
|
204
|
+
Select a file to edit
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{/* Lint button pinned at the bottom */}
|
|
212
|
+
{onLint && (
|
|
213
|
+
<div className="border-t border-neutral-800 p-2 flex-shrink-0">
|
|
140
214
|
<button
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
className="
|
|
144
|
-
title="Hide sidebar"
|
|
145
|
-
aria-label="Hide sidebar"
|
|
215
|
+
onClick={onLint}
|
|
216
|
+
disabled={linting}
|
|
217
|
+
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
|
|
146
218
|
>
|
|
147
219
|
<svg
|
|
148
|
-
width="
|
|
149
|
-
height="
|
|
220
|
+
width="12"
|
|
221
|
+
height="12"
|
|
150
222
|
viewBox="0 0 24 24"
|
|
151
223
|
fill="none"
|
|
152
224
|
stroke="currentColor"
|
|
153
|
-
strokeWidth="
|
|
154
|
-
strokeLinecap="round"
|
|
155
|
-
strokeLinejoin="round"
|
|
156
|
-
aria-hidden="true"
|
|
225
|
+
strokeWidth="2"
|
|
157
226
|
>
|
|
158
|
-
<path d="
|
|
159
|
-
<path d="
|
|
227
|
+
<path d="M9 11l3 3L22 4" />
|
|
228
|
+
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
160
229
|
</svg>
|
|
230
|
+
{linting ? "Linting…" : "Lint"}
|
|
161
231
|
</button>
|
|
162
|
-
)}
|
|
163
|
-
</div>
|
|
164
|
-
</div>
|
|
165
|
-
|
|
166
|
-
{/* Tab content */}
|
|
167
|
-
{tab === "compositions" && (
|
|
168
|
-
<CompositionsTab
|
|
169
|
-
projectId={projectId}
|
|
170
|
-
compositions={compositions}
|
|
171
|
-
activeComposition={activeComposition}
|
|
172
|
-
onSelect={onSelectComposition}
|
|
173
|
-
/>
|
|
174
|
-
)}
|
|
175
|
-
{tab === "assets" && (
|
|
176
|
-
<AssetsTab
|
|
177
|
-
projectId={projectId}
|
|
178
|
-
assets={assets}
|
|
179
|
-
onImport={onImportFiles}
|
|
180
|
-
onDelete={onDeleteFile}
|
|
181
|
-
onRename={onRenameFile}
|
|
182
|
-
/>
|
|
183
|
-
)}
|
|
184
|
-
{tab === "code" && (
|
|
185
|
-
<div className="flex flex-1 min-h-0">
|
|
186
|
-
{(fileProp?.length ?? 0) > 0 && (
|
|
187
|
-
<div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
|
188
|
-
<FileTree
|
|
189
|
-
files={fileProp ?? []}
|
|
190
|
-
activeFile={editingFile?.path ?? null}
|
|
191
|
-
onSelectFile={onSelectFile ?? (() => {})}
|
|
192
|
-
onCreateFile={onCreateFile}
|
|
193
|
-
onCreateFolder={onCreateFolder}
|
|
194
|
-
onDeleteFile={onDeleteFile}
|
|
195
|
-
onRenameFile={onRenameFile}
|
|
196
|
-
onDuplicateFile={onDuplicateFile}
|
|
197
|
-
onMoveFile={onMoveFile}
|
|
198
|
-
onImportFiles={onImportFiles}
|
|
199
|
-
/>
|
|
200
|
-
</div>
|
|
201
|
-
)}
|
|
202
|
-
<div className="flex-1 overflow-hidden min-w-0">
|
|
203
|
-
{codeChildren ?? (
|
|
204
|
-
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
|
|
205
|
-
Select a file to edit
|
|
206
|
-
</div>
|
|
207
|
-
)}
|
|
208
232
|
</div>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
onClick={onLint}
|
|
217
|
-
disabled={linting}
|
|
218
|
-
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
|
|
219
|
-
>
|
|
220
|
-
<svg
|
|
221
|
-
width="12"
|
|
222
|
-
height="12"
|
|
223
|
-
viewBox="0 0 24 24"
|
|
224
|
-
fill="none"
|
|
225
|
-
stroke="currentColor"
|
|
226
|
-
strokeWidth="2"
|
|
227
|
-
>
|
|
228
|
-
<path d="M9 11l3 3L22 4" />
|
|
229
|
-
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
230
|
-
</svg>
|
|
231
|
-
{linting ? "Linting…" : "Lint"}
|
|
232
|
-
</button>
|
|
233
|
-
</div>
|
|
234
|
-
)}
|
|
235
|
-
</>
|
|
236
|
-
)}
|
|
237
|
-
</div>
|
|
238
|
-
);
|
|
239
|
-
});
|
|
233
|
+
)}
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
|
|
5
|
+
|
|
6
|
+
describe("composition loading overlay", () => {
|
|
7
|
+
it("shows while the composition is loading", () => {
|
|
8
|
+
expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("hides after the composition is ready", () => {
|
|
12
|
+
expect(shouldShowCompositionLoadingOverlay(false)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps the asset overlay up while media is still buffering", () => {
|
|
16
|
+
const iframe = document.createElement("iframe");
|
|
17
|
+
document.body.appendChild(iframe);
|
|
18
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
19
|
+
expect(audio).toBeDefined();
|
|
20
|
+
Object.defineProperty(audio, "readyState", {
|
|
21
|
+
value: 0,
|
|
22
|
+
configurable: true,
|
|
23
|
+
});
|
|
24
|
+
Object.defineProperty(audio, "networkState", {
|
|
25
|
+
value: 2,
|
|
26
|
+
configurable: true,
|
|
27
|
+
});
|
|
28
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
29
|
+
|
|
30
|
+
expect(hasUnloadedAssets(iframe, false)).toBe(true);
|
|
31
|
+
|
|
32
|
+
iframe.remove();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not keep the asset overlay stuck on failed media sources", () => {
|
|
36
|
+
const iframe = document.createElement("iframe");
|
|
37
|
+
document.body.appendChild(iframe);
|
|
38
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
39
|
+
expect(audio).toBeDefined();
|
|
40
|
+
Object.defineProperty(audio, "error", {
|
|
41
|
+
value: { code: 4, message: "format error" },
|
|
42
|
+
configurable: true,
|
|
43
|
+
});
|
|
44
|
+
Object.defineProperty(audio, "readyState", {
|
|
45
|
+
value: 0,
|
|
46
|
+
configurable: true,
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(audio, "networkState", {
|
|
49
|
+
value: 3,
|
|
50
|
+
configurable: true,
|
|
51
|
+
});
|
|
52
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
53
|
+
|
|
54
|
+
expect(hasUnloadedAssets(iframe, false)).toBe(false);
|
|
55
|
+
|
|
56
|
+
iframe.remove();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -10,14 +10,19 @@ interface PlayerProps {
|
|
|
10
10
|
projectId?: string;
|
|
11
11
|
directUrl?: string;
|
|
12
12
|
onLoad: () => void;
|
|
13
|
+
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
13
14
|
portrait?: boolean;
|
|
14
15
|
style?: React.CSSProperties;
|
|
16
|
+
suppressLoadingOverlay?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
interface HyperframesPlayerElement extends HTMLElement {
|
|
18
20
|
iframeElement: HTMLIFrameElement;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
const MEDIA_HAVE_FUTURE_DATA = 3;
|
|
24
|
+
const MEDIA_NETWORK_NO_SOURCE = 3;
|
|
25
|
+
|
|
21
26
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
27
|
return typeof value === "object" && value !== null;
|
|
23
28
|
}
|
|
@@ -31,6 +36,10 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
31
36
|
return state.loading === true && state.ready !== true;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
40
|
+
return compositionLoading;
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
35
44
|
const root = player.shadowRoot;
|
|
36
45
|
if (!root) return;
|
|
@@ -42,6 +51,11 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
|
42
51
|
iframe?.style.setProperty("pointer-events", "auto");
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
function isPreviewMediaElement(el: Element): el is HTMLMediaElement {
|
|
55
|
+
const tagName = el.tagName.toLowerCase();
|
|
56
|
+
return tagName === "video" || tagName === "audio";
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
|
46
60
|
// to play through without buffering, and every registered Lottie animation has
|
|
47
61
|
// finished loading.
|
|
@@ -50,14 +64,19 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
|
50
64
|
// races so a brief access failure (e.g. an iframe that just swapped src)
|
|
51
65
|
// doesn't flicker the overlay state — we keep showing whatever was most
|
|
52
66
|
// recently true.
|
|
53
|
-
function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
|
|
67
|
+
export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
|
|
54
68
|
try {
|
|
55
69
|
const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
|
|
56
70
|
const doc = iframe.contentDocument;
|
|
57
71
|
if (!win || !doc) return lastResult;
|
|
58
72
|
|
|
59
73
|
for (const el of doc.querySelectorAll("video, audio")) {
|
|
60
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
isPreviewMediaElement(el) &&
|
|
76
|
+
!el.error &&
|
|
77
|
+
el.networkState !== MEDIA_NETWORK_NO_SOURCE &&
|
|
78
|
+
el.readyState < MEDIA_HAVE_FUTURE_DATA
|
|
79
|
+
) {
|
|
61
80
|
return true;
|
|
62
81
|
}
|
|
63
82
|
}
|
|
@@ -84,7 +103,18 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
|
|
84
103
|
* timeline probing, and DOM inspection.
|
|
85
104
|
*/
|
|
86
105
|
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
87
|
-
(
|
|
106
|
+
(
|
|
107
|
+
{
|
|
108
|
+
projectId,
|
|
109
|
+
directUrl,
|
|
110
|
+
onLoad,
|
|
111
|
+
onCompositionLoadingChange,
|
|
112
|
+
portrait,
|
|
113
|
+
style,
|
|
114
|
+
suppressLoadingOverlay,
|
|
115
|
+
},
|
|
116
|
+
ref,
|
|
117
|
+
) => {
|
|
88
118
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
89
119
|
const loadCountRef = useRef(0);
|
|
90
120
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -93,6 +123,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
93
123
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
94
124
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
95
125
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
126
|
+
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
96
127
|
|
|
97
128
|
useMountEffect(() => {
|
|
98
129
|
const container = containerRef.current;
|
|
@@ -138,10 +169,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
138
169
|
};
|
|
139
170
|
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
140
171
|
|
|
172
|
+
const handleReady = () => {
|
|
173
|
+
setCompositionLoading(false);
|
|
174
|
+
};
|
|
175
|
+
const handleError = () => {
|
|
176
|
+
setCompositionLoading(false);
|
|
177
|
+
};
|
|
178
|
+
player.addEventListener("ready", handleReady);
|
|
179
|
+
player.addEventListener("error", handleError);
|
|
180
|
+
|
|
141
181
|
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
142
182
|
const handleLoad = () => {
|
|
143
183
|
loadCountRef.current++;
|
|
144
184
|
setShaderTransitionLoading(false);
|
|
185
|
+
setCompositionLoading(true);
|
|
145
186
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
146
187
|
if (loadCountRef.current > 1) {
|
|
147
188
|
container.classList.remove("preview-revealing");
|
|
@@ -192,6 +233,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
192
233
|
iframe.removeEventListener("load", handleLoad);
|
|
193
234
|
player.removeEventListener("click", preventToggle, { capture: true });
|
|
194
235
|
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
236
|
+
player.removeEventListener("ready", handleReady);
|
|
237
|
+
player.removeEventListener("error", handleError);
|
|
195
238
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
196
239
|
assetPollRef.current = null;
|
|
197
240
|
container.removeChild(player);
|
|
@@ -237,7 +280,14 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
237
280
|
};
|
|
238
281
|
}, [assetsLoading]);
|
|
239
282
|
|
|
240
|
-
const
|
|
283
|
+
const showCompositionOverlay =
|
|
284
|
+
!suppressLoadingOverlay && shouldShowCompositionLoadingOverlay(compositionLoading);
|
|
285
|
+
const showAssetOverlay =
|
|
286
|
+
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
|
|
287
|
+
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay);
|
|
290
|
+
}, [onCompositionLoadingChange, showCompositionOverlay, showAssetOverlay]);
|
|
241
291
|
|
|
242
292
|
return (
|
|
243
293
|
<div
|
|
@@ -245,6 +295,23 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
245
295
|
style={style}
|
|
246
296
|
>
|
|
247
297
|
<div ref={containerRef} className="w-full h-full" />
|
|
298
|
+
{showCompositionOverlay && (
|
|
299
|
+
<div
|
|
300
|
+
className="absolute inset-0 bg-black flex items-center justify-center z-30 select-none"
|
|
301
|
+
data-hyperframes-ignore=""
|
|
302
|
+
data-testid="composition-loading-overlay"
|
|
303
|
+
draggable={false}
|
|
304
|
+
onDragStart={(event) => event.preventDefault()}
|
|
305
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
306
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
307
|
+
>
|
|
308
|
+
<HyperframesLoader
|
|
309
|
+
title="Loading composition"
|
|
310
|
+
detail="Preparing the Studio preview."
|
|
311
|
+
size={56}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
248
315
|
{showAssetOverlay && (
|
|
249
316
|
<div
|
|
250
317
|
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
|