@hyperframes/studio 0.6.29 → 0.6.31
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/index-BWBj8I6Q.css +1 -0
- package/dist/assets/index-DSLrl2tB.js +531 -0
- package/dist/assets/index-Do0kAMcy.js +115 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +13 -0
- package/src/components/StudioErrorBoundary.tsx +69 -0
- package/src/components/StudioHeader.tsx +15 -3
- package/src/components/editor/PropertyPanel.tsx +4 -1
- package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
- package/src/components/nle/NLELayout.tsx +41 -6
- package/src/components/renders/RenderQueue.tsx +2 -0
- package/src/components/renders/useRenderQueue.ts +9 -0
- package/src/components/sidebar/LeftSidebar.tsx +2 -0
- package/src/contexts/FileManagerContext.tsx +3 -3
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useDomEditCommits.ts +52 -24
- package/src/hooks/useFileManager.ts +15 -13
- package/src/hooks/usePanelLayout.ts +11 -1
- package/src/hooks/useRenderClipContent.test.ts +50 -0
- package/src/hooks/useRenderClipContent.ts +23 -4
- package/src/hooks/useServerConnection.ts +11 -1
- package/src/main.tsx +36 -1
- package/src/player/components/CompositionThumbnail.tsx +10 -44
- package/src/player/components/PlayerControls.tsx +75 -3
- package/src/player/components/TimelineCanvas.tsx +9 -23
- package/src/player/components/TimelineClip.tsx +63 -67
- package/src/player/components/timelineTheme.ts +18 -48
- package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
- package/src/player/lib/mediaProbe.ts +20 -5
- package/src/styles/studio.css +9 -0
- package/src/telemetry/client.test.ts +100 -0
- package/src/telemetry/client.ts +145 -0
- package/src/telemetry/config.ts +78 -0
- package/src/telemetry/events.test.ts +57 -0
- package/src/telemetry/events.ts +27 -0
- package/src/telemetry/system.ts +48 -0
- package/src/utils/studioTelemetry.ts +128 -0
- package/dist/assets/index-C-kAqQVb.js +0 -362
- package/dist/assets/index-DVpLGNHi.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
3
3
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
5
|
|
|
5
6
|
export interface InitialPanelLayoutState {
|
|
6
7
|
rightCollapsed?: boolean | null;
|
|
@@ -26,6 +27,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
26
27
|
const toggleLeftSidebar = useCallback(() => {
|
|
27
28
|
setLeftCollapsed((collapsed) => {
|
|
28
29
|
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
|
30
|
+
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
|
|
29
31
|
return !collapsed;
|
|
30
32
|
});
|
|
31
33
|
}, []);
|
|
@@ -63,6 +65,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
63
65
|
panelDragRef.current = null;
|
|
64
66
|
}, []);
|
|
65
67
|
|
|
68
|
+
const trackedSetRightPanelTab = useCallback(
|
|
69
|
+
(tab: RightPanelTab) => {
|
|
70
|
+
setRightPanelTab(tab);
|
|
71
|
+
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
72
|
+
},
|
|
73
|
+
[setRightPanelTab],
|
|
74
|
+
);
|
|
75
|
+
|
|
66
76
|
return {
|
|
67
77
|
leftWidth,
|
|
68
78
|
setLeftWidth,
|
|
@@ -72,7 +82,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
72
82
|
rightCollapsed,
|
|
73
83
|
setRightCollapsed,
|
|
74
84
|
rightPanelTab,
|
|
75
|
-
setRightPanelTab,
|
|
85
|
+
setRightPanelTab: trackedSetRightPanelTab,
|
|
76
86
|
toggleLeftSidebar,
|
|
77
87
|
handlePanelResizeStart,
|
|
78
88
|
handlePanelResizeMove,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
|
3
|
+
|
|
4
|
+
describe("normalizeCompositionSrc", () => {
|
|
5
|
+
const origin = "http://localhost:5190";
|
|
6
|
+
const pid = "my-project";
|
|
7
|
+
|
|
8
|
+
it("strips absolute preview URL to relative path", () => {
|
|
9
|
+
const result = normalizeCompositionSrc(
|
|
10
|
+
"http://localhost:5190/api/projects/my-project/preview/compositions/intro.html",
|
|
11
|
+
pid,
|
|
12
|
+
origin,
|
|
13
|
+
);
|
|
14
|
+
expect(result).toBe("compositions/intro.html");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("preserves already-relative paths", () => {
|
|
18
|
+
const result = normalizeCompositionSrc("compositions/intro.html", pid, origin);
|
|
19
|
+
expect(result).toBe("compositions/intro.html");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("preserves absolute URLs from different origins", () => {
|
|
23
|
+
const result = normalizeCompositionSrc(
|
|
24
|
+
"https://cdn.example.com/compositions/intro.html",
|
|
25
|
+
pid,
|
|
26
|
+
origin,
|
|
27
|
+
);
|
|
28
|
+
expect(result).toBe("https://cdn.example.com/compositions/intro.html");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("preserves absolute URLs for different projects", () => {
|
|
32
|
+
const result = normalizeCompositionSrc(
|
|
33
|
+
"http://localhost:5190/api/projects/other-project/preview/compositions/intro.html",
|
|
34
|
+
pid,
|
|
35
|
+
origin,
|
|
36
|
+
);
|
|
37
|
+
expect(result).toBe(
|
|
38
|
+
"http://localhost:5190/api/projects/other-project/preview/compositions/intro.html",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles nested composition paths", () => {
|
|
43
|
+
const result = normalizeCompositionSrc(
|
|
44
|
+
"http://localhost:5190/api/projects/my-project/preview/compositions/scenes/hero.html",
|
|
45
|
+
pid,
|
|
46
|
+
origin,
|
|
47
|
+
);
|
|
48
|
+
expect(result).toBe("compositions/scenes/hero.html");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -5,6 +5,23 @@ import type { TimelineElement } from "../player";
|
|
|
5
5
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
6
6
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
7
7
|
|
|
8
|
+
export function normalizeCompositionSrc(
|
|
9
|
+
compSrc: string,
|
|
10
|
+
projectId: string,
|
|
11
|
+
origin: string,
|
|
12
|
+
): string {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(compSrc, origin);
|
|
15
|
+
const previewPrefix = `/api/projects/${projectId}/preview/`;
|
|
16
|
+
if (parsed.pathname.startsWith(previewPrefix)) {
|
|
17
|
+
return parsed.pathname.slice(previewPrefix.length);
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// already relative
|
|
21
|
+
}
|
|
22
|
+
return compSrc;
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
interface UseRenderClipContentOptions {
|
|
9
26
|
projectIdRef: { current: string | null };
|
|
10
27
|
compIdToSrc: Map<string, string>;
|
|
@@ -23,8 +40,10 @@ export function useRenderClipContent({
|
|
|
23
40
|
const pid = projectIdRef.current;
|
|
24
41
|
if (!pid) return null;
|
|
25
42
|
|
|
26
|
-
// Resolve composition source path using the compIdToSrc map
|
|
27
43
|
let compSrc = el.compositionSrc;
|
|
44
|
+
if (compSrc) {
|
|
45
|
+
compSrc = normalizeCompositionSrc(compSrc, pid, window.location.origin);
|
|
46
|
+
}
|
|
28
47
|
if (compSrc && compIdToSrc.size > 0) {
|
|
29
48
|
const resolved =
|
|
30
49
|
compIdToSrc.get(el.id) ||
|
|
@@ -40,7 +59,7 @@ export function useRenderClipContent({
|
|
|
40
59
|
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
|
|
41
60
|
label: getTimelineElementLabel(el),
|
|
42
61
|
labelColor: style.label,
|
|
43
|
-
|
|
62
|
+
|
|
44
63
|
seekTime: 0,
|
|
45
64
|
duration: el.duration,
|
|
46
65
|
});
|
|
@@ -53,7 +72,7 @@ export function useRenderClipContent({
|
|
|
53
72
|
previewUrl: activePreviewUrl,
|
|
54
73
|
label: getTimelineElementLabel(el),
|
|
55
74
|
labelColor: style.label,
|
|
56
|
-
|
|
75
|
+
|
|
57
76
|
selector: el.selector,
|
|
58
77
|
selectorIndex: el.selectorIndex,
|
|
59
78
|
seekTime: el.start,
|
|
@@ -109,7 +128,7 @@ export function useRenderClipContent({
|
|
|
109
128
|
previewUrl: `/api/projects/${pid}/preview`,
|
|
110
129
|
label: getTimelineElementLabel(el),
|
|
111
130
|
labelColor: style.label,
|
|
112
|
-
|
|
131
|
+
|
|
113
132
|
selector: el.selector,
|
|
114
133
|
selectorIndex: el.selectorIndex,
|
|
115
134
|
seekTime: el.start,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
|
|
3
3
|
import { useMountEffect } from "./useMountEffect";
|
|
4
4
|
|
|
@@ -67,5 +67,15 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
67
67
|
};
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const onHashChange = () => {
|
|
73
|
+
const next = parseProjectIdFromHash(window.location.hash);
|
|
74
|
+
if (next && next !== projectId) setProjectId(next);
|
|
75
|
+
};
|
|
76
|
+
window.addEventListener("hashchange", onHashChange);
|
|
77
|
+
return () => window.removeEventListener("hashchange", onHashChange);
|
|
78
|
+
}, [projectId]);
|
|
79
|
+
|
|
70
80
|
return { projectId, resolving, waitingForServer };
|
|
71
81
|
}
|
package/src/main.tsx
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import { StrictMode } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
import { StudioApp } from "./App";
|
|
4
|
+
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
5
|
+
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
4
6
|
import "./styles/studio.css";
|
|
5
7
|
|
|
8
|
+
trackStudioEvent("session_start");
|
|
9
|
+
|
|
10
|
+
function errorProps(value: unknown): {
|
|
11
|
+
error_message: string;
|
|
12
|
+
error_name: string | null;
|
|
13
|
+
stack_trace: string | null;
|
|
14
|
+
} {
|
|
15
|
+
if (value instanceof Error) {
|
|
16
|
+
return {
|
|
17
|
+
error_message: value.message,
|
|
18
|
+
error_name: value.name,
|
|
19
|
+
stack_trace: value.stack?.slice(0, 4000) ?? null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { error_message: String(value), error_name: null, stack_trace: null };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
window.addEventListener("error", (event) => {
|
|
26
|
+
trackStudioEvent("unhandled_error", {
|
|
27
|
+
...errorProps(event.error),
|
|
28
|
+
error_message: event.message,
|
|
29
|
+
filename: event.filename,
|
|
30
|
+
lineno: event.lineno,
|
|
31
|
+
colno: event.colno,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
36
|
+
trackStudioEvent("unhandled_promise_rejection", errorProps(event.reason));
|
|
37
|
+
});
|
|
38
|
+
|
|
6
39
|
createRoot(document.getElementById("root")!).render(
|
|
7
40
|
<StrictMode>
|
|
8
|
-
<
|
|
41
|
+
<StudioErrorBoundary>
|
|
42
|
+
<StudioApp />
|
|
43
|
+
</StudioErrorBoundary>
|
|
9
44
|
</StrictMode>,
|
|
10
45
|
);
|
|
@@ -5,7 +5,6 @@ interface CompositionThumbnailProps {
|
|
|
5
5
|
previewUrl: string;
|
|
6
6
|
label: string;
|
|
7
7
|
labelColor: string;
|
|
8
|
-
accentColor?: string;
|
|
9
8
|
selector?: string;
|
|
10
9
|
selectorIndex?: number;
|
|
11
10
|
seekTime?: number;
|
|
@@ -16,7 +15,6 @@ interface CompositionThumbnailProps {
|
|
|
16
15
|
|
|
17
16
|
const CLIP_HEIGHT = 66;
|
|
18
17
|
const THUMBNAIL_URL_VERSION = "v3";
|
|
19
|
-
const COMPOSITION_THUMBNAIL_LABEL_Z_INDEX = 10;
|
|
20
18
|
|
|
21
19
|
export function buildCompositionThumbnailUrl({
|
|
22
20
|
previewUrl,
|
|
@@ -53,7 +51,6 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
53
51
|
previewUrl,
|
|
54
52
|
label,
|
|
55
53
|
labelColor,
|
|
56
|
-
accentColor = "#6B7280",
|
|
57
54
|
selector,
|
|
58
55
|
selectorIndex,
|
|
59
56
|
seekTime = 2,
|
|
@@ -110,8 +107,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
110
107
|
className="hidden"
|
|
111
108
|
/>
|
|
112
109
|
|
|
113
|
-
{loaded
|
|
114
|
-
<div
|
|
110
|
+
{loaded && (
|
|
111
|
+
<div
|
|
112
|
+
className="absolute inset-0 flex"
|
|
113
|
+
style={{ animation: "hf-thumb-fade 200ms ease-out", mixBlendMode: "lighten" }}
|
|
114
|
+
>
|
|
115
115
|
{Array.from({ length: frameCount }).map((_, i) => (
|
|
116
116
|
<div
|
|
117
117
|
key={i}
|
|
@@ -122,59 +122,25 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
122
122
|
src={url}
|
|
123
123
|
alt=""
|
|
124
124
|
draggable={false}
|
|
125
|
-
className="absolute inset-0 h-full w-full object-cover
|
|
125
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
126
|
+
style={{ opacity: 0.7 }}
|
|
126
127
|
/>
|
|
127
128
|
</div>
|
|
128
129
|
))}
|
|
129
130
|
</div>
|
|
130
|
-
) : (
|
|
131
|
-
<div
|
|
132
|
-
className="absolute inset-0 animate-pulse"
|
|
133
|
-
style={{
|
|
134
|
-
background:
|
|
135
|
-
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
136
|
-
}}
|
|
137
|
-
/>
|
|
138
131
|
)}
|
|
139
132
|
|
|
140
|
-
<div
|
|
141
|
-
className="absolute inset-0"
|
|
142
|
-
style={{
|
|
143
|
-
background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
|
|
144
|
-
}}
|
|
145
|
-
/>
|
|
146
|
-
|
|
147
|
-
<div
|
|
148
|
-
className="absolute left-2 top-2"
|
|
149
|
-
style={{ zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX }}
|
|
150
|
-
>
|
|
133
|
+
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
|
|
151
134
|
<span
|
|
152
|
-
className="block max-w-full truncate
|
|
135
|
+
className="block max-w-full truncate text-[10px] font-semibold leading-none"
|
|
153
136
|
style={{
|
|
154
137
|
color: labelColor,
|
|
155
|
-
|
|
156
|
-
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
|
|
138
|
+
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
|
|
157
139
|
}}
|
|
158
140
|
>
|
|
159
141
|
{label}
|
|
160
142
|
</span>
|
|
161
143
|
</div>
|
|
162
|
-
|
|
163
|
-
<div
|
|
164
|
-
className="absolute bottom-0 left-0 right-0 px-1.5 pb-0.5 pt-3"
|
|
165
|
-
style={{
|
|
166
|
-
zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX,
|
|
167
|
-
background:
|
|
168
|
-
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
169
|
-
}}
|
|
170
|
-
>
|
|
171
|
-
<span
|
|
172
|
-
className="block truncate text-[9px] font-semibold leading-tight"
|
|
173
|
-
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
174
|
-
>
|
|
175
|
-
{label}
|
|
176
|
-
</span>
|
|
177
|
-
</div>
|
|
178
144
|
</div>
|
|
179
145
|
);
|
|
180
146
|
});
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
|
|
4
4
|
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
|
5
5
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
6
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
6
7
|
|
|
7
8
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
8
9
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
@@ -15,8 +16,11 @@ const SHORTCUT_SECTIONS = [
|
|
|
15
16
|
{ key: "J", label: "Play backward" },
|
|
16
17
|
{ key: "K", label: "Stop" },
|
|
17
18
|
{ key: "L", label: "Play forward" },
|
|
19
|
+
{ key: "M", label: "Toggle mute" },
|
|
20
|
+
{ key: "⇧L", label: "Toggle loop" },
|
|
18
21
|
{ key: "←/→", label: "Step 1 frame" },
|
|
19
22
|
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
|
23
|
+
{ key: "F", label: "Toggle fullscreen" },
|
|
20
24
|
],
|
|
21
25
|
},
|
|
22
26
|
{
|
|
@@ -46,12 +50,16 @@ interface PlayerControlsProps {
|
|
|
46
50
|
onTogglePlay: () => void;
|
|
47
51
|
onSeek: (time: number) => void;
|
|
48
52
|
disabled?: boolean;
|
|
53
|
+
isFullscreen?: boolean;
|
|
54
|
+
onToggleFullscreen?: () => void;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export const PlayerControls = memo(function PlayerControls({
|
|
52
58
|
onTogglePlay,
|
|
53
59
|
onSeek,
|
|
54
60
|
disabled = false,
|
|
61
|
+
isFullscreen = false,
|
|
62
|
+
onToggleFullscreen,
|
|
55
63
|
}: PlayerControlsProps) {
|
|
56
64
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
57
65
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
@@ -335,7 +343,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
335
343
|
<button
|
|
336
344
|
type="button"
|
|
337
345
|
aria-label={isPlaying ? "Pause" : "Play"}
|
|
338
|
-
onClick={
|
|
346
|
+
onClick={() => {
|
|
347
|
+
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
|
348
|
+
onTogglePlay();
|
|
349
|
+
}}
|
|
339
350
|
disabled={controlsDisabled}
|
|
340
351
|
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
|
341
352
|
style={{ background: "rgba(255,255,255,0.06)" }}
|
|
@@ -461,7 +472,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
461
472
|
<button
|
|
462
473
|
type="button"
|
|
463
474
|
onClick={() => {
|
|
464
|
-
if (!audioAutoMuted)
|
|
475
|
+
if (!audioAutoMuted) {
|
|
476
|
+
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
|
|
477
|
+
setAudioMuted(!audioMuted);
|
|
478
|
+
}
|
|
465
479
|
}}
|
|
466
480
|
disabled={controlsDisabled || audioAutoMuted}
|
|
467
481
|
title={muteButtonLabel}
|
|
@@ -528,6 +542,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
528
542
|
<button
|
|
529
543
|
key={rate}
|
|
530
544
|
onClick={() => {
|
|
545
|
+
trackStudioEvent("playback", { action: "speed_change", rate });
|
|
531
546
|
setPlaybackRate(rate);
|
|
532
547
|
setShowSpeedMenu(false);
|
|
533
548
|
}}
|
|
@@ -553,7 +568,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
553
568
|
|
|
554
569
|
<button
|
|
555
570
|
type="button"
|
|
556
|
-
onClick={() =>
|
|
571
|
+
onClick={() => {
|
|
572
|
+
trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
|
|
573
|
+
setLoopEnabled(!loopEnabled);
|
|
574
|
+
}}
|
|
557
575
|
disabled={disabled}
|
|
558
576
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
559
577
|
loopEnabled
|
|
@@ -582,6 +600,60 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
582
600
|
</svg>
|
|
583
601
|
</button>
|
|
584
602
|
|
|
603
|
+
{/* Fullscreen toggle */}
|
|
604
|
+
{onToggleFullscreen && (
|
|
605
|
+
<button
|
|
606
|
+
type="button"
|
|
607
|
+
onClick={() => {
|
|
608
|
+
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
|
|
609
|
+
onToggleFullscreen();
|
|
610
|
+
}}
|
|
611
|
+
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
|
|
612
|
+
isFullscreen
|
|
613
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
614
|
+
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
615
|
+
}`}
|
|
616
|
+
title={isFullscreen ? "Exit fullscreen (F)" : "Enter fullscreen (F)"}
|
|
617
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
618
|
+
>
|
|
619
|
+
{isFullscreen ? (
|
|
620
|
+
<svg
|
|
621
|
+
width="13"
|
|
622
|
+
height="13"
|
|
623
|
+
viewBox="0 0 24 24"
|
|
624
|
+
fill="none"
|
|
625
|
+
stroke="currentColor"
|
|
626
|
+
strokeWidth="2"
|
|
627
|
+
strokeLinecap="round"
|
|
628
|
+
strokeLinejoin="round"
|
|
629
|
+
aria-hidden="true"
|
|
630
|
+
>
|
|
631
|
+
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
|
632
|
+
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
|
633
|
+
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
|
634
|
+
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
|
635
|
+
</svg>
|
|
636
|
+
) : (
|
|
637
|
+
<svg
|
|
638
|
+
width="13"
|
|
639
|
+
height="13"
|
|
640
|
+
viewBox="0 0 24 24"
|
|
641
|
+
fill="none"
|
|
642
|
+
stroke="currentColor"
|
|
643
|
+
strokeWidth="2"
|
|
644
|
+
strokeLinecap="round"
|
|
645
|
+
strokeLinejoin="round"
|
|
646
|
+
aria-hidden="true"
|
|
647
|
+
>
|
|
648
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
|
649
|
+
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
|
650
|
+
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
|
651
|
+
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
652
|
+
</svg>
|
|
653
|
+
)}
|
|
654
|
+
</button>
|
|
655
|
+
)}
|
|
656
|
+
|
|
585
657
|
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
|
|
586
658
|
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
|
587
659
|
<button
|
|
@@ -10,7 +10,6 @@ import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"
|
|
|
10
10
|
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
12
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
13
|
-
import { formatTime } from "../lib/time";
|
|
14
13
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
15
14
|
|
|
16
15
|
interface TimelineCanvasProps {
|
|
@@ -134,28 +133,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
134
133
|
className={
|
|
135
134
|
renderClipContent
|
|
136
135
|
? "absolute inset-0 overflow-hidden"
|
|
137
|
-
: "flex
|
|
136
|
+
: "flex items-center overflow-hidden flex-1 min-w-0 px-3 gap-2"
|
|
138
137
|
}
|
|
139
138
|
>
|
|
140
139
|
{renderClipContent?.(element, clipStyle) ?? (
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
|
|
148
|
-
}}
|
|
149
|
-
>
|
|
150
|
-
{element.tag}
|
|
151
|
-
</span>
|
|
152
|
-
<span
|
|
153
|
-
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
|
|
154
|
-
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.04)" }}
|
|
155
|
-
>
|
|
156
|
-
{formatTime(element.start)} {"→"} {formatTime(element.start + element.duration)}
|
|
157
|
-
</span>
|
|
158
|
-
</div>
|
|
140
|
+
<span
|
|
141
|
+
className="truncate text-[10px] font-medium leading-none"
|
|
142
|
+
style={{ color: clipStyle.label }}
|
|
143
|
+
>
|
|
144
|
+
{element.label || element.id || element.tag}
|
|
145
|
+
</span>
|
|
159
146
|
)}
|
|
160
147
|
</div>
|
|
161
148
|
</>
|
|
@@ -221,10 +208,9 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
221
208
|
paddingLeft: 16,
|
|
222
209
|
color: ts.label,
|
|
223
210
|
fontSize: 11,
|
|
224
|
-
letterSpacing: "0.
|
|
211
|
+
letterSpacing: "0.06em",
|
|
225
212
|
textTransform: "uppercase",
|
|
226
|
-
|
|
227
|
-
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
|
|
213
|
+
opacity: 0.5,
|
|
228
214
|
}}
|
|
229
215
|
>
|
|
230
216
|
New track
|