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