@odori/cli 0.0.4 → 0.0.6
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/bin/odori.mjs +33 -11
- package/dist/{chunk-NYXWEZU2.js → chunk-6CE7U5KB.js} +124 -39
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-MSH2EA36.js → registry-snapshot-ADKSTGDR.js} +15 -14
- package/package.json +3 -3
- package/src/cli.ts +26 -4
- package/src/commands/add.ts +12 -1
- package/src/commands/dev.ts +4 -1
- package/src/commands/exportVideo.ts +6 -0
- package/src/commands/init.ts +34 -1
- package/src/commands/test.ts +24 -2
- package/src/commands/update.ts +58 -7
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +15 -15
- package/src/render.ts +8 -1
- package/studio/index.html +26 -0
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/CanvasStage.tsx +40 -3
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Inspector.tsx +36 -30
- package/studio/src/components/Navigator.tsx +64 -2
- package/studio/src/components/Settings.tsx +109 -0
- package/studio/src/components/Transport.tsx +136 -58
- package/studio/src/components/ui.tsx +20 -1
- package/studio/src/settings.ts +87 -0
- package/studio/src/studio.css +269 -20
- package/studio/src/views/VideosView.tsx +12 -5
package/src/render.ts
CHANGED
|
@@ -469,6 +469,13 @@ export type RenderOptions = {
|
|
|
469
469
|
/** Container and codec. Defaults to H.264 in MP4. */
|
|
470
470
|
format?: VideoFormat;
|
|
471
471
|
skipUnchangedFrames?: boolean;
|
|
472
|
+
/**
|
|
473
|
+
* Mix the composition's cues into the file. Defaults to true, because the
|
|
474
|
+
* score is part of the video. Set false for a silent cut: a loop for a
|
|
475
|
+
* landing page, a clip going into an editor that has its own audio, or a
|
|
476
|
+
* reviewer who just wants the picture.
|
|
477
|
+
*/
|
|
478
|
+
audio?: boolean;
|
|
472
479
|
/** Reuse encoded chunks whose frames still look identical. */
|
|
473
480
|
cache?: boolean;
|
|
474
481
|
signal?: AbortSignal;
|
|
@@ -571,7 +578,7 @@ export const renderMovie = async (
|
|
|
571
578
|
const captureMs = performance.now() - captureStart;
|
|
572
579
|
onProgress?.(1, "encoding");
|
|
573
580
|
|
|
574
|
-
const mixInputs: MixInput[] = (target.audio ?? [])
|
|
581
|
+
const mixInputs: MixInput[] = (options.audio === false ? [] : (target.audio ?? []))
|
|
575
582
|
.map((cue) => {
|
|
576
583
|
const file = resolveCueFile(config, cue.src);
|
|
577
584
|
if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
|
package/studio/index.html
CHANGED
|
@@ -32,6 +32,32 @@
|
|
|
32
32
|
root.style.colorScheme = theme;
|
|
33
33
|
root.style.background = theme === "dark" ? "#000000" : "#ffffff";
|
|
34
34
|
})();
|
|
35
|
+
|
|
36
|
+
// The panes, for the same reason and one step further: the grid columns
|
|
37
|
+
// are animated, so a pane whose state only arrives with React does not
|
|
38
|
+
// merely flash, it plays its opening or closing on every reload. The
|
|
39
|
+
// list defaults closed, the inspector open, matching the components; a
|
|
40
|
+
// stored width is restored here too, or the pane would slide from the
|
|
41
|
+
// default to it.
|
|
42
|
+
(function () {
|
|
43
|
+
var read = function (key) {
|
|
44
|
+
try {
|
|
45
|
+
return localStorage.getItem(key);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var root = document.documentElement;
|
|
51
|
+
root.dataset.navigator = read("odori-navigator-collapsed") === "false" ? "open" : "collapsed";
|
|
52
|
+
root.dataset.inspector = read("odori-inspector-collapsed") === "true" ? "collapsed" : "open";
|
|
53
|
+
|
|
54
|
+
var width = function (key, property) {
|
|
55
|
+
var stored = parseInt(read(key), 10);
|
|
56
|
+
if (stored > 0) root.style.setProperty(property, stored + "px");
|
|
57
|
+
};
|
|
58
|
+
width("odori-navigator-width", "--navigator-column");
|
|
59
|
+
width("odori-inspector-width", "--inspector-width");
|
|
60
|
+
})();
|
|
35
61
|
</script>
|
|
36
62
|
</head>
|
|
37
63
|
<body>
|
package/studio/src/Studio.tsx
CHANGED
|
@@ -8,15 +8,11 @@ import {HomeView} from "./views/HomeView";
|
|
|
8
8
|
import {CommandPalette} from "./components/CommandPalette";
|
|
9
9
|
import {Wordmark} from "./components/Wordmark";
|
|
10
10
|
import {Button, Icon, Kbd} from "./components/ui";
|
|
11
|
-
import {
|
|
11
|
+
import {Settings} from "./components/Settings";
|
|
12
|
+
import {useTheme} from "./theme";
|
|
12
13
|
|
|
13
14
|
const VIEWS = ["videos", "components", "brands", "assets"] as const;
|
|
14
15
|
|
|
15
|
-
const THEMES: Array<{id: Theme; label: string; icon: "display" | "sun" | "moon"}> = [
|
|
16
|
-
{id: "system", label: "System", icon: "display"},
|
|
17
|
-
{id: "light", label: "Light", icon: "sun"},
|
|
18
|
-
{id: "dark", label: "Dark", icon: "moon"},
|
|
19
|
-
];
|
|
20
16
|
export type StudioView = (typeof VIEWS)[number] | "home";
|
|
21
17
|
|
|
22
18
|
const ROUTES: readonly string[] = ["home", ...VIEWS];
|
|
@@ -48,7 +44,9 @@ export const Studio = () => {
|
|
|
48
44
|
const routeRef = useRef(route);
|
|
49
45
|
routeRef.current = route;
|
|
50
46
|
const search = useRef<HTMLInputElement>(null);
|
|
51
|
-
|
|
47
|
+
// Mounted here as well as in the menu so the stored theme is applied on
|
|
48
|
+
// load, whether or not anybody opens settings.
|
|
49
|
+
useTheme();
|
|
52
50
|
|
|
53
51
|
const navigate = (view: StudioView, selection?: string | null) => {
|
|
54
52
|
const next: Route = {view, selection: selection ?? null};
|
|
@@ -132,21 +130,7 @@ export const Studio = () => {
|
|
|
132
130
|
/>
|
|
133
131
|
<Kbd>⌘K</Kbd>
|
|
134
132
|
</div>
|
|
135
|
-
<
|
|
136
|
-
{THEMES.map((option) => (
|
|
137
|
-
<Button
|
|
138
|
-
key={option.id}
|
|
139
|
-
icon
|
|
140
|
-
active={theme === option.id}
|
|
141
|
-
aria-pressed={theme === option.id}
|
|
142
|
-
aria-label={option.label}
|
|
143
|
-
title={`${option.label} theme`}
|
|
144
|
-
onClick={() => setTheme(option.id)}
|
|
145
|
-
>
|
|
146
|
-
<Icon name={option.icon} />
|
|
147
|
-
</Button>
|
|
148
|
-
))}
|
|
149
|
-
</div>
|
|
133
|
+
<Settings />
|
|
150
134
|
</div>
|
|
151
135
|
</header>
|
|
152
136
|
|
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
import {useEffect, useRef, useState, type ReactNode, type RefObject} from "react";
|
|
1
|
+
import {useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject} from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Before the browser paints, and inert on the server.
|
|
5
|
+
*
|
|
6
|
+
* The fit has to be known for the first frame or it is not a fit, it is a
|
|
7
|
+
* correction the viewer watches happen.
|
|
8
|
+
*/
|
|
9
|
+
const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
2
10
|
|
|
3
11
|
/** Fit a fixed composition size into whatever space the container has. */
|
|
4
12
|
export const useFitScale = (width: number, height: number): [RefObject<HTMLDivElement | null>, number] => {
|
|
5
13
|
const container = useRef<HTMLDivElement>(null);
|
|
6
|
-
|
|
14
|
+
/*
|
|
15
|
+
* Zero until measured, not a guess.
|
|
16
|
+
*
|
|
17
|
+
* This started at 0.25, so every load drew the composition at a quarter of
|
|
18
|
+
* its size and then snapped it to the real fit once an effect had run. That
|
|
19
|
+
* is the flash: a frame arriving small, and everything around it moving as
|
|
20
|
+
* it grew. Zero paints nothing, and the measurement below lands before the
|
|
21
|
+
* browser paints at all, so the first frame the viewer sees is the right
|
|
22
|
+
* one.
|
|
23
|
+
*/
|
|
24
|
+
const [scale, setScale] = useState(0);
|
|
7
25
|
|
|
8
|
-
|
|
26
|
+
useBeforePaint(() => {
|
|
9
27
|
const element = container.current;
|
|
10
28
|
if (!element) return;
|
|
11
29
|
const fit = () => {
|
|
@@ -41,6 +59,22 @@ export const CanvasStage = ({
|
|
|
41
59
|
children: ReactNode;
|
|
42
60
|
}) => {
|
|
43
61
|
const [container, scale] = useFitScale(width, height);
|
|
62
|
+
/*
|
|
63
|
+
* Brand faces arrive after the composition mounts, and text set in a face
|
|
64
|
+
* that lands late repaints at a different width. The gallery cards already
|
|
65
|
+
* hold their pixels until the faces are in; the stage is the one place it
|
|
66
|
+
* matters most, because it is the biggest thing on screen.
|
|
67
|
+
*/
|
|
68
|
+
const [fontsReady, setFontsReady] = useState(false);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let live = true;
|
|
71
|
+
void document.fonts.ready.then(() => {
|
|
72
|
+
if (live) setFontsReady(true);
|
|
73
|
+
});
|
|
74
|
+
return () => {
|
|
75
|
+
live = false;
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
44
78
|
|
|
45
79
|
return (
|
|
46
80
|
<div className="viewport">
|
|
@@ -54,6 +88,9 @@ export const CanvasStage = ({
|
|
|
54
88
|
{
|
|
55
89
|
height: Math.round(height * scale),
|
|
56
90
|
width: Math.round(width * scale),
|
|
91
|
+
// The box is laid out; only its contents wait for the faces, so
|
|
92
|
+
// nothing around it moves when they arrive.
|
|
93
|
+
visibility: scale > 0 && fontsReady ? undefined : "hidden",
|
|
57
94
|
"--safe-x": `${(safeArea?.x ?? 0) * scale}px`,
|
|
58
95
|
"--safe-y": `${(safeArea?.y ?? 0) * scale}px`,
|
|
59
96
|
} as React.CSSProperties
|
|
@@ -65,24 +65,82 @@ export const ExportPanel = ({
|
|
|
65
65
|
}) => {
|
|
66
66
|
const [job, setJob] = useState<JobState | null>(null);
|
|
67
67
|
const [busy, setBusy] = useState(false);
|
|
68
|
+
/** Set when a job stops reporting progress, so a wedge reads as a wedge. */
|
|
69
|
+
const [stalled, setStalled] = useState(false);
|
|
70
|
+
/* Sound is part of the video, so it ships unless it is turned off. GIF has
|
|
71
|
+
no audio track at all and a still is one frame, so the choice only exists
|
|
72
|
+
where it means something. */
|
|
73
|
+
const [sound, setSound] = useState(true);
|
|
68
74
|
const [format, setFormat] = useState<Format>("mp4");
|
|
69
75
|
const [quality, setQuality] = useState<Quality>("studio");
|
|
70
76
|
const [scale, setScale] = useState(1);
|
|
71
77
|
const [notice, setNotice] = useState<string | null>(null);
|
|
72
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Watch a job until it finishes, and say so if it does not.
|
|
81
|
+
*
|
|
82
|
+
* The old loop treated every unreadable response as "try again" and, after
|
|
83
|
+
* fifteen minutes of that, returned without a word. A dev server restarted
|
|
84
|
+
* mid-export takes its job records with it, so what you saw was a bar frozen
|
|
85
|
+
* at whatever percentage it had reached, no error, and no end: the export
|
|
86
|
+
* looked like it was still going hours later. A watcher that cannot see the
|
|
87
|
+
* job has to say the job is unwatchable.
|
|
88
|
+
*/
|
|
73
89
|
const poll = async (id: string) => {
|
|
74
|
-
|
|
90
|
+
let unreachable = 0;
|
|
91
|
+
let last = -1;
|
|
92
|
+
let stalledFor = 0;
|
|
93
|
+
|
|
94
|
+
for (let attempt = 0; attempt < 1800; attempt += 1) {
|
|
75
95
|
await new Promise((wait) => setTimeout(wait, 1000));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
let payload: JobState | null = null;
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`/__odori/jobs/${id}`);
|
|
99
|
+
if (response.ok) payload = (await response.json()) as JobState;
|
|
100
|
+
} catch {
|
|
101
|
+
// Network error and 404 are the same thing here: no news about the job.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!payload) {
|
|
105
|
+
unreachable += 1;
|
|
106
|
+
// Ten seconds of silence is a server that went away, not a slow reply.
|
|
107
|
+
if (unreachable >= 10) {
|
|
108
|
+
setJob((current) => ({
|
|
109
|
+
...(current ?? {id, progress: 0}),
|
|
110
|
+
status: "failed",
|
|
111
|
+
error:
|
|
112
|
+
"Lost contact with the dev server, so this export can no longer be tracked. It may still have finished; check the out directory, or run odori jobs.",
|
|
113
|
+
}));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
unreachable = 0;
|
|
79
120
|
setJob(payload);
|
|
80
121
|
if (payload.status === "ready" || payload.status === "failed") return;
|
|
122
|
+
|
|
123
|
+
/* A long render is normal; a silent one is the thing worth naming. The
|
|
124
|
+
encoder reports every five percent, so two minutes without a number
|
|
125
|
+
means something is wedged rather than slow. */
|
|
126
|
+
if (payload.progress === last) stalledFor += 1;
|
|
127
|
+
else {
|
|
128
|
+
stalledFor = 0;
|
|
129
|
+
last = payload.progress;
|
|
130
|
+
}
|
|
131
|
+
setStalled(stalledFor >= 120);
|
|
81
132
|
}
|
|
133
|
+
|
|
134
|
+
setJob((current) => ({
|
|
135
|
+
...(current ?? {id, progress: 0}),
|
|
136
|
+
status: "failed",
|
|
137
|
+
error: "Stopped watching after thirty minutes. Run odori jobs to see whether it is still going.",
|
|
138
|
+
}));
|
|
82
139
|
};
|
|
83
140
|
|
|
84
141
|
const post = async (path: string, body?: unknown) => {
|
|
85
142
|
setBusy(true);
|
|
143
|
+
setStalled(false);
|
|
86
144
|
setJob({id: "pending", status: "queued", progress: 0});
|
|
87
145
|
try {
|
|
88
146
|
const response = await fetch(path, {
|
|
@@ -135,7 +193,7 @@ export const ExportPanel = ({
|
|
|
135
193
|
const run = async () => {
|
|
136
194
|
setNotice(null);
|
|
137
195
|
if (format === "frame") return post("/__odori/still", {videoId, input, frame});
|
|
138
|
-
return post("/__odori/exports", {videoId, input, format, quality, scale});
|
|
196
|
+
return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false});
|
|
139
197
|
};
|
|
140
198
|
|
|
141
199
|
const file = outputName(videoId);
|
|
@@ -143,6 +201,8 @@ export const ExportPanel = ({
|
|
|
143
201
|
// video's layout and every frame number in the project depends on it.
|
|
144
202
|
const still = format === "frame";
|
|
145
203
|
const hasQuality = format === "mp4" || format === "webm";
|
|
204
|
+
/** GIF carries no audio track and a still is one frame. */
|
|
205
|
+
const hasSound = format !== "gif" && format !== "frame";
|
|
146
206
|
const inFlight = job && job.status !== "ready" && job.status !== "failed";
|
|
147
207
|
// The button names the file it will write - extension implied by the type
|
|
148
208
|
// above it - and reports its own progress while writing it.
|
|
@@ -220,6 +280,22 @@ export const ExportPanel = ({
|
|
|
220
280
|
</div>
|
|
221
281
|
) : null}
|
|
222
282
|
|
|
283
|
+
{hasSound ? (
|
|
284
|
+
<div className="export-row">
|
|
285
|
+
<span className="export-label">Sound</span>
|
|
286
|
+
<div className="export-options">
|
|
287
|
+
<Button variant="outline" active={sound} disabled={busy} onClick={() => setSound(true)}
|
|
288
|
+
title="Mix the brand's cues into the file">
|
|
289
|
+
On
|
|
290
|
+
</Button>
|
|
291
|
+
<Button variant="outline" active={!sound} disabled={busy} onClick={() => setSound(false)}
|
|
292
|
+
title="Write the picture with no audio track">
|
|
293
|
+
Off
|
|
294
|
+
</Button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
223
299
|
<div className="export-actions">
|
|
224
300
|
<Button
|
|
225
301
|
variant="primary"
|
|
@@ -256,10 +332,18 @@ export const ExportPanel = ({
|
|
|
256
332
|
</span>
|
|
257
333
|
</div>
|
|
258
334
|
{job.status === "rendering" || job.status === "encoding" ? (
|
|
259
|
-
<div className="progress">
|
|
335
|
+
<div className="progress" data-stalled={stalled ? "true" : undefined}>
|
|
260
336
|
<span style={{width: `${Math.round((job.progress ?? 0) * 100)}%`}} />
|
|
261
337
|
</div>
|
|
262
338
|
) : null}
|
|
339
|
+
{/* A bar that has not moved in two minutes looks exactly like one
|
|
340
|
+
that is about to. Saying which is the whole point. */}
|
|
341
|
+
{stalled ? (
|
|
342
|
+
<p className="hint" style={{marginTop: 6}}>
|
|
343
|
+
No progress for two minutes. It may be a slow frame, or the render may be wedged. Run{" "}
|
|
344
|
+
<code>odori jobs</code> to see the log, and the dev server terminal for the error.
|
|
345
|
+
</p>
|
|
346
|
+
) : null}
|
|
263
347
|
</>
|
|
264
348
|
) : null}
|
|
265
349
|
|
|
@@ -95,12 +95,18 @@ export const Inspector = ({
|
|
|
95
95
|
event.preventDefault();
|
|
96
96
|
document.body.style.userSelect = "none";
|
|
97
97
|
document.body.style.cursor = "col-resize";
|
|
98
|
+
/* The column eases when a pane is put away, and a drag writes that
|
|
99
|
+
same column many times a second. Easing it then means the track
|
|
100
|
+
is always behind the pointer while the pane's own width is not,
|
|
101
|
+
and the difference between them is a strip of empty page. */
|
|
102
|
+
document.documentElement.dataset.resizing = "";
|
|
98
103
|
const onMove = (move: PointerEvent) => applyWidth(window.innerWidth - move.clientX);
|
|
99
104
|
const onUp = () => {
|
|
100
105
|
window.removeEventListener("pointermove", onMove);
|
|
101
106
|
window.removeEventListener("pointerup", onUp);
|
|
102
107
|
document.body.style.userSelect = "";
|
|
103
108
|
document.body.style.cursor = "";
|
|
109
|
+
delete document.documentElement.dataset.resizing;
|
|
104
110
|
const width = getComputedStyle(document.documentElement).getPropertyValue(WIDTH_VAR).trim();
|
|
105
111
|
if (width) window.localStorage.setItem(STORAGE_KEY, String(Number.parseInt(width, 10)));
|
|
106
112
|
};
|
|
@@ -113,37 +119,37 @@ export const Inspector = ({
|
|
|
113
119
|
window.localStorage.removeItem(STORAGE_KEY);
|
|
114
120
|
}}
|
|
115
121
|
/>
|
|
122
|
+
<div className="inspector-head">
|
|
123
|
+
<span className="inspector-title" title={hint ?? title}>
|
|
124
|
+
{title}
|
|
125
|
+
</span>
|
|
126
|
+
{files.length > 0 ? (
|
|
127
|
+
<div className="inspector-views" role="tablist">
|
|
128
|
+
{(["inspect", "code"] as const).map((view) => (
|
|
129
|
+
<button
|
|
130
|
+
key={view}
|
|
131
|
+
type="button"
|
|
132
|
+
role="tab"
|
|
133
|
+
aria-selected={showing === view}
|
|
134
|
+
data-active={showing === view ? "true" : undefined}
|
|
135
|
+
onClick={() => setShowing(view)}
|
|
136
|
+
>
|
|
137
|
+
{view === "inspect" ? "Inspect" : "Code"}
|
|
138
|
+
</button>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className="inspector-toggle"
|
|
145
|
+
aria-label="Hide inspector"
|
|
146
|
+
title="Hide inspector"
|
|
147
|
+
onClick={() => setCollapsed(true)}
|
|
148
|
+
>
|
|
149
|
+
<Icon name="sidebar" />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
116
152
|
<div className="inspector-scroll">
|
|
117
|
-
<div className="inspector-head">
|
|
118
|
-
<span className="inspector-title" title={hint ?? title}>
|
|
119
|
-
{title}
|
|
120
|
-
</span>
|
|
121
|
-
{files.length > 0 ? (
|
|
122
|
-
<div className="inspector-views" role="tablist">
|
|
123
|
-
{(["inspect", "code"] as const).map((view) => (
|
|
124
|
-
<button
|
|
125
|
-
key={view}
|
|
126
|
-
type="button"
|
|
127
|
-
role="tab"
|
|
128
|
-
aria-selected={showing === view}
|
|
129
|
-
data-active={showing === view ? "true" : undefined}
|
|
130
|
-
onClick={() => setShowing(view)}
|
|
131
|
-
>
|
|
132
|
-
{view === "inspect" ? "Inspect" : "Code"}
|
|
133
|
-
</button>
|
|
134
|
-
))}
|
|
135
|
-
</div>
|
|
136
|
-
) : null}
|
|
137
|
-
<button
|
|
138
|
-
type="button"
|
|
139
|
-
className="inspector-toggle"
|
|
140
|
-
aria-label="Hide inspector"
|
|
141
|
-
title="Hide inspector"
|
|
142
|
-
onClick={() => setCollapsed(true)}
|
|
143
|
-
>
|
|
144
|
-
<Icon name="sidebar" />
|
|
145
|
-
</button>
|
|
146
|
-
</div>
|
|
147
153
|
{files.length > 0 && showing === "code" ? (
|
|
148
154
|
<>
|
|
149
155
|
{/* One file is the file; several want naming, so the pane says
|
|
@@ -15,6 +15,19 @@ import {Icon} from "./ui";
|
|
|
15
15
|
* is the part a gallery cannot do at all.
|
|
16
16
|
*/
|
|
17
17
|
const COLLAPSED_KEY = "odori-navigator-collapsed";
|
|
18
|
+
const WIDTH_VAR = "--navigator-column";
|
|
19
|
+
const WIDTH_KEY = "odori-navigator-width";
|
|
20
|
+
/* Narrow enough for a filter box and a title, wide enough for a nested
|
|
21
|
+
category path without truncating every row. */
|
|
22
|
+
const MIN = 180;
|
|
23
|
+
const MAX = 420;
|
|
24
|
+
|
|
25
|
+
const clampWidth = (value: number): number =>
|
|
26
|
+
Math.round(Math.min(Math.max(value, MIN), Math.min(MAX, window.innerWidth * 0.4)));
|
|
27
|
+
|
|
28
|
+
const applyWidth = (value: number) => {
|
|
29
|
+
document.documentElement.style.setProperty(WIDTH_VAR, `${clampWidth(value)}px`);
|
|
30
|
+
};
|
|
18
31
|
|
|
19
32
|
export type NavigatorItem = {
|
|
20
33
|
id: string;
|
|
@@ -37,7 +50,15 @@ export const Navigator = ({
|
|
|
37
50
|
selected: string | null;
|
|
38
51
|
onSelect: (id: string) => void;
|
|
39
52
|
}) => {
|
|
40
|
-
|
|
53
|
+
/* Closed until asked for. Studio is most often docked beside something else,
|
|
54
|
+
where the stage wants every pixel, and the list is one click and a
|
|
55
|
+
remembered choice away. Anyone who opens it keeps it open. */
|
|
56
|
+
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) !== "false");
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const stored = Number(window.localStorage.getItem(WIDTH_KEY));
|
|
60
|
+
if (Number.isFinite(stored) && stored > 0) applyWidth(stored);
|
|
61
|
+
}, []);
|
|
41
62
|
const [query, setQuery] = useState("");
|
|
42
63
|
const field = useRef<HTMLInputElement>(null);
|
|
43
64
|
|
|
@@ -86,6 +107,43 @@ export const Navigator = ({
|
|
|
86
107
|
|
|
87
108
|
return (
|
|
88
109
|
<aside className="navigator" aria-label={label}>
|
|
110
|
+
{/* The mirror of the inspector's handle, on the inside edge for the same
|
|
111
|
+
reason: the pane clips its overflow, so a divider hung across the
|
|
112
|
+
border loses the clipped half of its hit area. */}
|
|
113
|
+
<div
|
|
114
|
+
className="navigator-resize"
|
|
115
|
+
role="separator"
|
|
116
|
+
aria-orientation="vertical"
|
|
117
|
+
aria-label="Resize list"
|
|
118
|
+
onPointerDown={(event) => {
|
|
119
|
+
// Eight pixels wide, and a fast drag leaves it between events, so
|
|
120
|
+
// the window owns the move for the duration rather than the handle.
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
document.body.style.userSelect = "none";
|
|
123
|
+
document.body.style.cursor = "col-resize";
|
|
124
|
+
/* The column eases when a pane is put away, and a drag writes that
|
|
125
|
+
same column many times a second. Easing it then means the track
|
|
126
|
+
is always behind the pointer while the pane's own width is not,
|
|
127
|
+
and the difference between them is a strip of empty page. */
|
|
128
|
+
document.documentElement.dataset.resizing = "";
|
|
129
|
+
const onMove = (move: PointerEvent) => applyWidth(move.clientX);
|
|
130
|
+
const onUp = () => {
|
|
131
|
+
window.removeEventListener("pointermove", onMove);
|
|
132
|
+
window.removeEventListener("pointerup", onUp);
|
|
133
|
+
document.body.style.userSelect = "";
|
|
134
|
+
document.body.style.cursor = "";
|
|
135
|
+
delete document.documentElement.dataset.resizing;
|
|
136
|
+
const width = getComputedStyle(document.documentElement).getPropertyValue(WIDTH_VAR).trim();
|
|
137
|
+
if (width) window.localStorage.setItem(WIDTH_KEY, String(Number.parseInt(width, 10)));
|
|
138
|
+
};
|
|
139
|
+
window.addEventListener("pointermove", onMove);
|
|
140
|
+
window.addEventListener("pointerup", onUp);
|
|
141
|
+
}}
|
|
142
|
+
onDoubleClick={() => {
|
|
143
|
+
document.documentElement.style.removeProperty(WIDTH_VAR);
|
|
144
|
+
window.localStorage.removeItem(WIDTH_KEY);
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
89
147
|
<div className="navigator-head">
|
|
90
148
|
<button
|
|
91
149
|
type="button"
|
|
@@ -119,7 +177,11 @@ export const Navigator = ({
|
|
|
119
177
|
<p className="hint navigator-empty">Nothing matches {query}.</p>
|
|
120
178
|
) : (
|
|
121
179
|
runs.map((run) => (
|
|
122
|
-
|
|
180
|
+
/* Keyed by the first item, not by the group. A run without a group
|
|
181
|
+
used to key on a constant, so a list that goes ungrouped, then
|
|
182
|
+
grouped, then ungrouped again handed React two siblings with the
|
|
183
|
+
same key - which it is free to drop or duplicate. */
|
|
184
|
+
<div key={run.items[0].id}>
|
|
123
185
|
{run.group ? <h3 className="navigator-group">{run.group}</h3> : null}
|
|
124
186
|
<ul className="list">
|
|
125
187
|
{run.items.map((item) => (
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {useEffect, useRef, useState} from "react";
|
|
2
|
+
import {Button, Icon} from "./ui";
|
|
3
|
+
import {useStartSound} from "../settings";
|
|
4
|
+
import {useTheme, type Theme} from "../theme";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace preferences, behind one control in the header.
|
|
8
|
+
*
|
|
9
|
+
* This replaced a three-button theme switcher. Theme was the only preference
|
|
10
|
+
* Studio had, so spending a permanent row of chrome on it was fine right up
|
|
11
|
+
* until there was a second one; three more buttons for sound would have made
|
|
12
|
+
* the header a settings panel that never closes. A gear says "there are
|
|
13
|
+
* choices here" in the space one of the old buttons used.
|
|
14
|
+
*/
|
|
15
|
+
const THEMES: Array<{id: Theme; label: string; hint: string}> = [
|
|
16
|
+
{id: "system", label: "System", hint: "Follow the operating system"},
|
|
17
|
+
{id: "light", label: "Light", hint: "Always light"},
|
|
18
|
+
{id: "dark", label: "Dark", hint: "Always dark"},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SOUNDS = [
|
|
22
|
+
{id: "on" as const, label: "On", hint: "Play the score with the video"},
|
|
23
|
+
{id: "off" as const, label: "Off", hint: "Open every video muted"},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const Settings = () => {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const {theme, setTheme} = useTheme();
|
|
29
|
+
const [sound, setSound] = useStartSound();
|
|
30
|
+
const wrapper = useRef<HTMLDivElement>(null);
|
|
31
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return undefined;
|
|
35
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
36
|
+
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
|
|
37
|
+
};
|
|
38
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
39
|
+
if (event.key !== "Escape") return;
|
|
40
|
+
setOpen(false);
|
|
41
|
+
// Closing with the keyboard has to put the focus somewhere, and the
|
|
42
|
+
// control that opened the menu is the only place that is not a surprise.
|
|
43
|
+
trigger.current?.focus();
|
|
44
|
+
};
|
|
45
|
+
// Capture, so a click that also does something else still closes the menu.
|
|
46
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
47
|
+
document.addEventListener("keydown", onKeyDown);
|
|
48
|
+
return () => {
|
|
49
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
50
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
51
|
+
};
|
|
52
|
+
}, [open]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="settings" ref={wrapper}>
|
|
56
|
+
<Button
|
|
57
|
+
ref={trigger}
|
|
58
|
+
icon
|
|
59
|
+
active={open}
|
|
60
|
+
aria-label="Settings"
|
|
61
|
+
aria-expanded={open}
|
|
62
|
+
aria-haspopup="menu"
|
|
63
|
+
title="Settings"
|
|
64
|
+
onClick={() => setOpen((value) => !value)}
|
|
65
|
+
>
|
|
66
|
+
<Icon name="settings" />
|
|
67
|
+
</Button>
|
|
68
|
+
|
|
69
|
+
{open ? (
|
|
70
|
+
<div className="menu settings-menu" role="menu" aria-label="Settings">
|
|
71
|
+
<p className="menu-heading">Theme</p>
|
|
72
|
+
{THEMES.map((option) => (
|
|
73
|
+
<button
|
|
74
|
+
key={option.id}
|
|
75
|
+
type="button"
|
|
76
|
+
role="menuitemradio"
|
|
77
|
+
aria-checked={theme === option.id}
|
|
78
|
+
className="menu-item"
|
|
79
|
+
data-selected={theme === option.id ? "true" : undefined}
|
|
80
|
+
onClick={() => setTheme(option.id)}
|
|
81
|
+
>
|
|
82
|
+
<span>{option.label}</span>
|
|
83
|
+
<span className="menu-hint">{option.hint}</span>
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
|
|
87
|
+
<p className="menu-heading">Sound</p>
|
|
88
|
+
{SOUNDS.map((option) => (
|
|
89
|
+
<button
|
|
90
|
+
key={option.id}
|
|
91
|
+
type="button"
|
|
92
|
+
role="menuitemradio"
|
|
93
|
+
aria-checked={sound === option.id}
|
|
94
|
+
className="menu-item"
|
|
95
|
+
data-selected={sound === option.id ? "true" : undefined}
|
|
96
|
+
onClick={() => setSound(option.id)}
|
|
97
|
+
>
|
|
98
|
+
<span>{option.label}</span>
|
|
99
|
+
<span className="menu-hint">{option.hint}</span>
|
|
100
|
+
</button>
|
|
101
|
+
))}
|
|
102
|
+
{/* Changing it now would mute or unmute the video being watched,
|
|
103
|
+
which is not what a default is. */}
|
|
104
|
+
<p className="menu-note">Applies to the next video you open.</p>
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|