@odori/cli 0.0.4 → 0.0.5

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.
@@ -0,0 +1,87 @@
1
+ import {useCallback, useEffect, useState} from "react";
2
+
3
+ /**
4
+ * Workspace preferences: the choices that belong to the person using Studio
5
+ * rather than to the project.
6
+ *
7
+ * They are deliberately not in `odori.config.ts`. A config file is checked in
8
+ * and shared, and "start muted" is a fact about someone's desk and how loud
9
+ * the room is, not about the video. Everything here is per browser, best
10
+ * effort, and safe to lose.
11
+ */
12
+ export const STORAGE_PREFIX = "odori-studio-";
13
+
14
+ /**
15
+ * Whether the transport starts with sound.
16
+ *
17
+ * Sound belongs on by default: a video with a score is not the same video
18
+ * without one, and a preview that silently drops half the work teaches you
19
+ * the wrong thing about it. But somebody working in an open office, or with
20
+ * Studio parked beside an agent all day, wants the opposite, and that is a
21
+ * setting rather than an argument.
22
+ */
23
+ export type StartSound = "on" | "off";
24
+
25
+ export const SETTINGS = {
26
+ sound: {key: "sound", values: ["on", "off"] as const, fallback: "on" as StartSound},
27
+ } as const;
28
+
29
+ const read = <T extends string>(key: string, values: readonly T[], fallback: T): T => {
30
+ try {
31
+ const stored = window.localStorage.getItem(`${STORAGE_PREFIX}${key}`);
32
+ return values.includes(stored as T) ? (stored as T) : fallback;
33
+ } catch {
34
+ // Private browsing and hardened profiles refuse storage. A preference is
35
+ // not worth failing a render over.
36
+ return fallback;
37
+ }
38
+ };
39
+
40
+ /** A single preference, persisted per browser. */
41
+ export const usePreference = <T extends string>(
42
+ key: string,
43
+ values: readonly T[],
44
+ fallback: T,
45
+ ): [T, (next: T) => void] => {
46
+ const [value, setValue] = useState<T>(() =>
47
+ typeof window === "undefined" ? fallback : read(key, values, fallback),
48
+ );
49
+
50
+ const update = useCallback(
51
+ (next: T) => {
52
+ setValue(next);
53
+ try {
54
+ window.localStorage.setItem(`${STORAGE_PREFIX}${key}`, next);
55
+ } catch {
56
+ // Applied for this session either way.
57
+ }
58
+ },
59
+ [key],
60
+ );
61
+
62
+ return [value, update];
63
+ };
64
+
65
+ /** Read once, outside React, for state that has to be right on first render. */
66
+ export const readStartSound = (): StartSound =>
67
+ typeof window === "undefined"
68
+ ? SETTINGS.sound.fallback
69
+ : read(SETTINGS.sound.key, SETTINGS.sound.values, SETTINGS.sound.fallback);
70
+
71
+ export const useStartSound = () =>
72
+ usePreference<StartSound>(SETTINGS.sound.key, SETTINGS.sound.values, SETTINGS.sound.fallback);
73
+
74
+ /**
75
+ * Other tabs of the same Studio should not disagree about a preference. The
76
+ * storage event fires only in the tabs that did not make the change, which is
77
+ * exactly the set that needs telling.
78
+ */
79
+ export const useSettingsSync = (onChange: () => void) => {
80
+ useEffect(() => {
81
+ const listener = (event: StorageEvent) => {
82
+ if (event.key?.startsWith(STORAGE_PREFIX)) onChange();
83
+ };
84
+ window.addEventListener("storage", listener);
85
+ return () => window.removeEventListener("storage", listener);
86
+ }, [onChange]);
87
+ };
@@ -306,6 +306,66 @@ a.button {
306
306
  font-size: 11px;
307
307
  }
308
308
 
309
+ /* ---------- settings ---------- */
310
+
311
+ .settings {
312
+ position: relative;
313
+ }
314
+ /* Right-aligned: the trigger is the last thing in the header, so a menu
315
+ growing rightward from its left edge would leave the window. */
316
+ .settings-menu {
317
+ left: auto;
318
+ right: 0;
319
+ }
320
+ .menu-heading {
321
+ color: var(--subtle);
322
+ font-size: 11px;
323
+ letter-spacing: 0.04em;
324
+ margin: 6px 0 2px;
325
+ padding: 0 9px;
326
+ text-transform: uppercase;
327
+ }
328
+ .menu-heading:first-child {
329
+ margin-top: 2px;
330
+ }
331
+ .menu-hint {
332
+ color: var(--subtle);
333
+ font-size: 11px;
334
+ }
335
+ /* A tick in a reserved column, so choosing an option never reflows the row
336
+ or nudges the one under the cursor. */
337
+ .menu-item {
338
+ display: grid;
339
+ grid-template-columns: 1fr 14px;
340
+ }
341
+ .menu-item > span:first-child {
342
+ grid-column: 1;
343
+ }
344
+ .menu-hint {
345
+ grid-column: 1;
346
+ }
347
+ .menu-item[data-selected="true"]::after {
348
+ align-self: center;
349
+ color: var(--foreground);
350
+ content: "✓";
351
+ font-size: 11px;
352
+ grid-column: 2;
353
+ grid-row: 1 / span 2;
354
+ justify-self: end;
355
+ }
356
+ /* A stalled bar stops looking like a working one. */
357
+ .progress[data-stalled="true"] > span {
358
+ opacity: 0.5;
359
+ }
360
+
361
+ .menu-note {
362
+ border-top: 1px solid var(--border);
363
+ color: var(--subtle);
364
+ font-size: 11px;
365
+ margin: 4px 0 0;
366
+ padding: 7px 9px 3px;
367
+ }
368
+
309
369
  /* Matches the transport's icon buttons rather than the host select. */
310
370
  .rate {
311
371
  appearance: none;
@@ -951,6 +1011,13 @@ select.input {
951
1011
 
952
1012
  .stage {
953
1013
  display: grid;
1014
+ /* The column has to be named. An implicit grid column is auto, which means
1015
+ max-content, so the transport's row of controls set the width and the
1016
+ timeline was laid out at its natural size and then clipped: in a pane this
1017
+ narrow the right half of every video sat under the inspector, unclickable,
1018
+ and the playhead walked off the edge on its way there. min-width on .stage
1019
+ does not help, because the overflow is the track inside it. */
1020
+ grid-template-columns: minmax(0, 1fr);
954
1021
  grid-template-rows: minmax(0, 1fr) auto;
955
1022
  min-width: 0;
956
1023
  overflow: hidden;
@@ -993,13 +1060,19 @@ select.input {
993
1060
  border-top: 1px solid var(--border);
994
1061
  display: grid;
995
1062
  gap: 10px;
1063
+ min-width: 0;
996
1064
  padding: 10px 14px 14px;
997
1065
  }
998
1066
 
999
1067
  .transport-row {
1000
1068
  align-items: center;
1001
1069
  display: flex;
1070
+ /* Wrapping is what lets the transport be narrower than its controls. Without
1071
+ it the row is a single min-content block and it is the row, not the
1072
+ timeline, that decides how wide the stage has to be. */
1073
+ flex-wrap: wrap;
1002
1074
  gap: 8px;
1075
+ min-width: 0;
1003
1076
  }
1004
1077
 
1005
1078
  .timecode {
@@ -1014,8 +1087,13 @@ select.input {
1014
1087
  }
1015
1088
 
1016
1089
  .timeline {
1017
- cursor: pointer;
1090
+ /* Only the track is clickable, so only the track shows a pointer. */
1018
1091
  display: grid;
1092
+ /* Named for the same reason .stage is: an implicit auto column would take
1093
+ its width from the ruler and the scene labels rather than from the space
1094
+ available, and push the track out from under the cursor. */
1095
+ grid-template-columns: minmax(0, 1fr);
1096
+ min-width: 0;
1019
1097
  gap: 4px;
1020
1098
  position: relative;
1021
1099
  user-select: none;
@@ -1029,6 +1107,8 @@ select.input {
1029
1107
  justify-content: space-between;
1030
1108
  }
1031
1109
  .track {
1110
+ /* The track is a click target, not something to drag. */
1111
+ cursor: pointer;
1032
1112
  background: var(--field);
1033
1113
  border: 1px solid var(--border);
1034
1114
  border-radius: var(--radius-sm);
@@ -1140,6 +1220,21 @@ select.input {
1140
1220
  width: 7.5px;
1141
1221
  }
1142
1222
 
1223
+ /* The frame under the cursor, which is a suggestion until it is clicked. It
1224
+ reads as the same instrument as the playhead, turned down. */
1225
+ .playhead[data-preview="true"],
1226
+ .playhead[data-preview="true"]::before {
1227
+ background: var(--subtle);
1228
+ }
1229
+ .playhead[data-preview="true"] {
1230
+ opacity: 0.75;
1231
+ }
1232
+ /* Muted rather than moved: the readout must not change width when it starts
1233
+ reporting the hovered frame, or the whole row shifts under the cursor. */
1234
+ .timecode[data-preview="true"] {
1235
+ color: var(--subtle);
1236
+ }
1237
+
1143
1238
  /* ---------- inspector ---------- */
1144
1239
 
1145
1240
  .facts {
@@ -23,6 +23,7 @@ import {Inspector} from "../components/Inspector";
23
23
  import {Navigator} from "../components/Navigator";
24
24
  import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
25
25
  import {measureTrack} from "../lib/mix-loudness";
26
+ import {readStartSound} from "../settings";
26
27
  import {useShortcuts} from "../shortcuts";
27
28
 
28
29
  /**
@@ -51,9 +52,15 @@ export const VideosView = ({
51
52
  const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
52
53
  const [track, setTrack] = useState<AudioTrack | null>(null);
53
54
  const [loop, setLoop] = useState(true);
54
- const [muted, setMuted] = useState(false);
55
+ /* Read once per mount rather than watched: flipping the default while a
56
+ video is open would mute the thing being listened to, and a default is a
57
+ statement about what happens next. */
58
+ const [muted, setMuted] = useState(() => readStartSound() === "off");
55
59
  const [soloCue, setSoloCue] = useState<string | null>(null);
56
- const [scrubbing, setScrubbing] = useState(false);
60
+ /* The frame the cursor is over, which the stage shows instead of the
61
+ playhead frame. Preview never moves the playhead and never sounds: it
62
+ answers "what is here" and nothing else. */
63
+ const [preview, setPreview] = useState<number | null>(null);
57
64
  const [rate, setRate] = useState(1);
58
65
  const [loudness, setLoudness] = useState<number | null>(null);
59
66
  const [audioBlocked, setAudioBlocked] = useState(false);
@@ -108,7 +115,7 @@ export const VideosView = ({
108
115
  playing: playback.playing,
109
116
  muted,
110
117
  soloCue,
111
- scrubbing,
118
+ scrubbing: preview !== null,
112
119
  rate,
113
120
  onBlocked: setAudioBlocked,
114
121
  onFailed: setAudioFailed,
@@ -238,7 +245,7 @@ export const VideosView = ({
238
245
  <PreviewBoundary resetKey={selected.metadata.id}>
239
246
  <OdoriRuntime
240
247
  entry={selected}
241
- frame={playback.frame}
248
+ frame={preview ?? playback.frame}
242
249
  input={input}
243
250
  assets={project.assets}
244
251
  onTimeline={handleTimeline}
@@ -258,7 +265,7 @@ export const VideosView = ({
258
265
  audioBlocked={audioBlocked}
259
266
  onEnableAudio={() => setMuted(false)}
260
267
  onSoloCue={setSoloCue}
261
- onScrubbing={setScrubbing}
268
+ onPreview={setPreview}
262
269
  rate={rate}
263
270
  onRate={setRate}
264
271
  onToggleLoop={() => setLoop((value) => !value)}