entangled-worlds-orbiters-shared 2.7.0 → 2.9.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.
@@ -14,6 +14,16 @@ export interface SyncSettingsLike {
14
14
  sourceTimeSec?: unknown;
15
15
  } | null | undefined> | null;
16
16
  gridStartTimeSec?: unknown;
17
+ /** Whether this track time-stretches onto the grid. A BOOLEAN, so `false` is an answer rather than
18
+ * an absence — see `resolveWarpEnabledFromTrackData`. */
19
+ warpEnabled?: unknown;
20
+ /** Whether this track plays looped. Same boolean rule as `warpEnabled`, and the same three states:
21
+ * on, off, and nobody-said — see `resolveLoopEnabledFromTrackData`. */
22
+ loopEnabled?: unknown;
23
+ /** The loop region in SECONDS (storage's time unit; the audio engine works in ms and converts at
24
+ * its edge). Only ever meaningful as a pair — see `resolveLoopRangeSecFromTrackData`. */
25
+ loopStartSec?: unknown;
26
+ loopEndSec?: unknown;
17
27
  }
18
28
  export interface TrackMetadataLike {
19
29
  trackBpm?: unknown;
@@ -71,3 +81,33 @@ export declare function resolveTrackBpmFromTrackData(trackData?: TrackDataLike |
71
81
  * Returns null when nothing resolves (consumers default to 0).
72
82
  */
73
83
  export declare function resolvePrimaryGridMarkerTimeSecFromTrackData(trackData?: TrackDataLike | null): number | null;
84
+ /**
85
+ * Resolve whether this track should warp (time-stretch onto the grid) for the viewer.
86
+ *
87
+ * Returns null when nothing anywhere expressed a preference. Null means "no opinion", NOT "off" —
88
+ * the caller supplies its own default, because only the caller knows its runtime constraints (in
89
+ * orbiters, a deck with no tempo cannot warp at all, whatever was saved).
90
+ */
91
+ export declare function resolveWarpEnabledFromTrackData(trackData?: TrackDataLike | null): boolean | null;
92
+ /**
93
+ * Resolve whether this track should play looped for the viewer.
94
+ *
95
+ * Same three states as warp — on, off, nobody-said — but no smart default anywhere behind it: the
96
+ * server stops at the owner's setting, so null here is genuinely common and simply means the caller
97
+ * keeps whatever looping default it already had.
98
+ */
99
+ export declare function resolveLoopEnabledFromTrackData(trackData?: TrackDataLike | null): boolean | null;
100
+ /**
101
+ * Resolve the saved loop region, in seconds, or null when no layer holds a complete one.
102
+ *
103
+ * Resolved as a PAIR, never edge by edge: a start borrowed from one layer and an end from another
104
+ * describes a loop nobody set, and can invert. `start` of 0 is a real value, so the guard is a
105
+ * finiteness test rather than a truthiness one.
106
+ *
107
+ * The caller must still clamp this against the audio's real duration before using it — a region
108
+ * saved against an earlier version of the track can outlive the audio it was drawn on.
109
+ */
110
+ export declare function resolveLoopRangeSecFromTrackData(trackData?: TrackDataLike | null): {
111
+ startSec: number;
112
+ endSec: number;
113
+ } | null;
@@ -20,6 +20,22 @@ function firstResolvedValue(candidates, parser) {
20
20
  }
21
21
  return null;
22
22
  }
23
+ /**
24
+ * The first candidate that is an actual boolean, or null when none of them is.
25
+ *
26
+ * Deliberately separate from `firstResolvedValue`: that one folds "absent" and "invalid" together
27
+ * and returns a number, which is exactly the wrong shape here. A warp preference has THREE states —
28
+ * on, off, and nobody-said — and `false` is the middle one, not the last. Any `||`, any truthiness
29
+ * test, any `?? ` against a default collapses off into nobody-said and loses the user's choice.
30
+ */
31
+ function firstBoolean(candidates) {
32
+ for (const candidate of candidates) {
33
+ if (typeof candidate === 'boolean') {
34
+ return candidate;
35
+ }
36
+ }
37
+ return null;
38
+ }
23
39
  /**
24
40
  * Resolve the effective track BPM. Precedence (first finite, > 0 wins):
25
41
  * owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
@@ -64,3 +80,71 @@ export function resolvePrimaryGridMarkerTimeSecFromTrackData(trackData = null) {
64
80
  trackData?.metadata?.wrap?.gridStartTimeSec,
65
81
  ], toNonNegativeNumber);
66
82
  }
83
+ /**
84
+ * The shared walk behind the boolean preferences below.
85
+ *
86
+ * The server already applied the real precedence — the viewer's own saved setting, then the owner's,
87
+ * then (for warp) a smart default — and publishes the answer at `effectiveSettings.sync`. So that is
88
+ * read FIRST: it is the resolved value, not a competing opinion. The per-user path below it covers
89
+ * the shapes where the server has not resolved anything (a raw `trackUserSettings` fetch that has not
90
+ * been merged into a track payload yet).
91
+ *
92
+ * One walk, not one per field: warp and loop differ in what they MEAN, never in where they are found,
93
+ * and two hand-maintained copies of this list would drift the moment a payload shape changed.
94
+ */
95
+ function resolveSyncFlagFromTrackData(trackData, field) {
96
+ return firstBoolean([
97
+ trackData?.track?.metadata?.effectiveSettings?.sync?.[field],
98
+ trackData?.release?.metadata?.effectiveSettings?.sync?.[field],
99
+ trackData?.metadata?.effectiveSettings?.sync?.[field],
100
+ trackData?.effectiveSettings?.sync?.[field],
101
+ trackData?.trackUserSettings?.sync?.[field],
102
+ ]);
103
+ }
104
+ /**
105
+ * Resolve whether this track should warp (time-stretch onto the grid) for the viewer.
106
+ *
107
+ * Returns null when nothing anywhere expressed a preference. Null means "no opinion", NOT "off" —
108
+ * the caller supplies its own default, because only the caller knows its runtime constraints (in
109
+ * orbiters, a deck with no tempo cannot warp at all, whatever was saved).
110
+ */
111
+ export function resolveWarpEnabledFromTrackData(trackData = null) {
112
+ return resolveSyncFlagFromTrackData(trackData, 'warpEnabled');
113
+ }
114
+ /**
115
+ * Resolve whether this track should play looped for the viewer.
116
+ *
117
+ * Same three states as warp — on, off, nobody-said — but no smart default anywhere behind it: the
118
+ * server stops at the owner's setting, so null here is genuinely common and simply means the caller
119
+ * keeps whatever looping default it already had.
120
+ */
121
+ export function resolveLoopEnabledFromTrackData(trackData = null) {
122
+ return resolveSyncFlagFromTrackData(trackData, 'loopEnabled');
123
+ }
124
+ /**
125
+ * Resolve the saved loop region, in seconds, or null when no layer holds a complete one.
126
+ *
127
+ * Resolved as a PAIR, never edge by edge: a start borrowed from one layer and an end from another
128
+ * describes a loop nobody set, and can invert. `start` of 0 is a real value, so the guard is a
129
+ * finiteness test rather than a truthiness one.
130
+ *
131
+ * The caller must still clamp this against the audio's real duration before using it — a region
132
+ * saved against an earlier version of the track can outlive the audio it was drawn on.
133
+ */
134
+ export function resolveLoopRangeSecFromTrackData(trackData = null) {
135
+ const candidates = [
136
+ trackData?.track?.metadata?.effectiveSettings?.sync,
137
+ trackData?.release?.metadata?.effectiveSettings?.sync,
138
+ trackData?.metadata?.effectiveSettings?.sync,
139
+ trackData?.effectiveSettings?.sync,
140
+ trackData?.trackUserSettings?.sync,
141
+ ];
142
+ for (const candidate of candidates) {
143
+ const startSec = toNonNegativeNumber(candidate?.loopStartSec);
144
+ const endSec = toNonNegativeNumber(candidate?.loopEndSec);
145
+ if (startSec !== null && endSec !== null && endSec > startSec) {
146
+ return { startSec, endSec };
147
+ }
148
+ }
149
+ return null;
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,6 +11,7 @@
11
11
  {
12
12
  "id": "water-drops",
13
13
  "filename": "18-water-drops.ktx2",
14
+ "still": "thumbs/18-water-drops.jpg",
14
15
  "label": "Water Drops",
15
16
  "description": "Atmospheric water droplets",
16
17
  "tags": ["atmospheric", "water", "natural"]
@@ -18,6 +19,7 @@
18
19
  {
19
20
  "id": "scream-1",
20
21
  "filename": "17-scream.ktx2",
22
+ "still": "thumbs/17-scream.jpg",
21
23
  "label": "Scream",
22
24
  "description": "Abstract screaming patterns",
23
25
  "tags": ["abstract", "artistic"]
@@ -25,6 +27,7 @@
25
27
  {
26
28
  "id": "scream-2",
27
29
  "filename": "16-scream.ktx2",
30
+ "still": "thumbs/16-scream.jpg",
28
31
  "label": "Scream Alt",
29
32
  "description": "Alternative screaming patterns",
30
33
  "tags": ["abstract", "artistic"]
@@ -32,6 +35,7 @@
32
35
  {
33
36
  "id": "neon-lights-1",
34
37
  "filename": "15-neon-lights.ktx2",
38
+ "still": "thumbs/15-neon-lights.jpg",
35
39
  "label": "Neon Lights",
36
40
  "description": "Electric neon lighting effects",
37
41
  "tags": ["electric", "urban", "artificial"]
@@ -39,6 +43,7 @@
39
43
  {
40
44
  "id": "neon-lights-2",
41
45
  "filename": "14-neon-lights.ktx2",
46
+ "still": "thumbs/14-neon-lights.jpg",
42
47
  "label": "Neon Lights Alt",
43
48
  "description": "Alternative neon lighting",
44
49
  "tags": ["electric", "urban", "artificial"]
@@ -46,6 +51,7 @@
46
51
  {
47
52
  "id": "neon-lights-3",
48
53
  "filename": "13-neon-lights.ktx2",
54
+ "still": "thumbs/13-neon-lights.jpg",
49
55
  "label": "Neon Lights Pro",
50
56
  "description": "Professional neon effects",
51
57
  "tags": ["electric", "urban", "artificial"]
@@ -53,6 +59,7 @@
53
59
  {
54
60
  "id": "marble-1",
55
61
  "filename": "12-marble.ktx2",
62
+ "still": "thumbs/12-marble.jpg",
56
63
  "label": "Marble",
57
64
  "description": "Natural marble patterns",
58
65
  "tags": ["stone", "natural", "elegant"]
@@ -60,6 +67,7 @@
60
67
  {
61
68
  "id": "marble-2",
62
69
  "filename": "11-marble.ktx2",
70
+ "still": "thumbs/11-marble.jpg",
63
71
  "label": "Marble Alt",
64
72
  "description": "Alternative marble texture",
65
73
  "tags": ["stone", "natural", "elegant"]
@@ -67,6 +75,7 @@
67
75
  {
68
76
  "id": "isolines-1",
69
77
  "filename": "10-isolines.ktx2",
78
+ "still": "thumbs/10-isolines.jpg",
70
79
  "label": "Isolines",
71
80
  "description": "Topographic isolines",
72
81
  "tags": ["geometric", "lines", "technical"]
@@ -74,6 +83,7 @@
74
83
  {
75
84
  "id": "isolines-2",
76
85
  "filename": "09-isolines.ktx2",
86
+ "still": "thumbs/09-isolines.jpg",
77
87
  "label": "Isolines Alt",
78
88
  "description": "Alternative isolines pattern",
79
89
  "tags": ["geometric", "lines", "technical"]
@@ -81,6 +91,7 @@
81
91
  {
82
92
  "id": "fordite-1",
83
93
  "filename": "08-fordite.ktx2",
94
+ "still": "thumbs/08-fordite.jpg",
84
95
  "label": "Fordite",
85
96
  "description": "Industrial fordite patterns",
86
97
  "tags": ["industrial", "layered", "colorful"]
@@ -88,6 +99,7 @@
88
99
  {
89
100
  "id": "fordite-2",
90
101
  "filename": "07-fordite.ktx2",
102
+ "still": "thumbs/07-fordite.jpg",
91
103
  "label": "Fordite Alt",
92
104
  "description": "Alternative fordite texture",
93
105
  "tags": ["industrial", "layered", "colorful"]
@@ -95,6 +107,7 @@
95
107
  {
96
108
  "id": "entangled-1",
97
109
  "filename": "06-entangled.ktx2",
110
+ "still": "thumbs/06-entangled.jpg",
98
111
  "label": "Entangled",
99
112
  "description": "Quantum entangled patterns",
100
113
  "tags": ["quantum", "abstract", "complex"]
@@ -102,6 +115,7 @@
102
115
  {
103
116
  "id": "entangled-2",
104
117
  "filename": "05-entangled.ktx2",
118
+ "still": "thumbs/05-entangled.jpg",
105
119
  "label": "Entangled Alt",
106
120
  "description": "Alternative entangled patterns",
107
121
  "tags": ["quantum", "abstract", "complex"]
@@ -109,6 +123,7 @@
109
123
  {
110
124
  "id": "clouds-1",
111
125
  "filename": "04-clouds.ktx2",
126
+ "still": "thumbs/04-clouds.jpg",
112
127
  "label": "Clouds",
113
128
  "description": "Natural cloud formations",
114
129
  "tags": ["natural", "atmospheric", "default"]
@@ -116,6 +131,7 @@
116
131
  {
117
132
  "id": "clouds-2",
118
133
  "filename": "03-clouds.ktx2",
134
+ "still": "thumbs/03-clouds.jpg",
119
135
  "label": "Clouds Alt",
120
136
  "description": "Alternative cloud patterns",
121
137
  "tags": ["natural", "atmospheric"]
@@ -123,6 +139,7 @@
123
139
  {
124
140
  "id": "cave-art-1",
125
141
  "filename": "02-cave-art.ktx2",
142
+ "still": "thumbs/02-cave-art.jpg",
126
143
  "label": "Cave Art",
127
144
  "description": "Ancient cave paintings",
128
145
  "tags": ["ancient", "artistic", "historic"]
@@ -130,6 +147,7 @@
130
147
  {
131
148
  "id": "cave-art-2",
132
149
  "filename": "01-cave-art.ktx2",
150
+ "still": "thumbs/01-cave-art.jpg",
133
151
  "label": "Cave Art Alt",
134
152
  "description": "Alternative cave art patterns",
135
153
  "tags": ["ancient", "artistic", "historic"]
@@ -144,14 +162,16 @@
144
162
  {
145
163
  "id": "electric-moon",
146
164
  "filename": "moon-equirectangular.ktx2",
165
+ "still": "thumbs/moon-equirectangular.jpg",
147
166
  "label": "Electric Moon",
148
167
  "description": "Standard lunar surface texture",
149
168
  "tags": ["lunar", "realistic", "default"],
150
- "fallback": "moon-equirectangular.jpg"
169
+ "fallback": "moon-equirectangular.png"
151
170
  },
152
171
  {
153
172
  "id": "earth-moon",
154
173
  "filename": "2k_moon.ktx2",
174
+ "still": "thumbs/2k_moon.jpg",
155
175
  "label": "Earth Moon",
156
176
  "description": "Realistic Earth satellite texture",
157
177
  "tags": ["lunar", "earth", "satellite"],
@@ -160,6 +180,7 @@
160
180
  {
161
181
  "id": "crater-lines",
162
182
  "filename": "crater-lines.ktx2",
183
+ "still": "thumbs/crater-lines.jpg",
163
184
  "label": "Crater Lines",
164
185
  "description": "Linear crater formations across the lunar surface",
165
186
  "tags": ["craters", "lunar", "pattern"],
@@ -168,6 +189,7 @@
168
189
  {
169
190
  "id": "moon-dust",
170
191
  "filename": "moon-dust.ktx2",
192
+ "still": "thumbs/moon-dust.jpg",
171
193
  "label": "Moon Dust",
172
194
  "description": "Fine dust texture inspired by lunar regolith",
173
195
  "tags": ["dust", "surface", "regolith"],
@@ -176,6 +198,7 @@
176
198
  {
177
199
  "id": "lunar-scepter",
178
200
  "filename": "lunar-scepter.ktx2",
201
+ "still": "thumbs/lunar-scepter.jpg",
179
202
  "label": "Lunar Scepter",
180
203
  "description": "Sharp, vertical ridge texture reminiscent of lunar highlands",
181
204
  "tags": ["ridges", "highlands", "lunar"],
@@ -184,6 +207,7 @@
184
207
  {
185
208
  "id": "satin-moon",
186
209
  "filename": "satin-moon.ktx2",
210
+ "still": "thumbs/satin-moon.jpg",
187
211
  "label": "Satin Moon",
188
212
  "description": "Smooth satin-like lunar surface",
189
213
  "tags": ["smooth", "lunar", "texture"],
@@ -192,6 +216,7 @@
192
216
  {
193
217
  "id": "impact-craters",
194
218
  "filename": "impact-craters.ktx2",
219
+ "still": "thumbs/impact-craters.jpg",
195
220
  "label": "Impact Craters",
196
221
  "description": "Dense field of lunar craters",
197
222
  "tags": ["craters", "impact", "texture"],
@@ -200,6 +225,7 @@
200
225
  {
201
226
  "id": "eclipse-dots",
202
227
  "filename": "eclipse-dots.ktx2",
228
+ "still": "thumbs/eclipse-dots.jpg",
203
229
  "label": "Eclipse Dots",
204
230
  "description": "Pattern of scattered dots inspired by lunar eclipses",
205
231
  "tags": ["dots", "eclipse", "pattern"],
@@ -208,6 +234,7 @@
208
234
  {
209
235
  "id": "shadow-dots",
210
236
  "filename": "shadow-dots.ktx2",
237
+ "still": "thumbs/shadow-dots.jpg",
211
238
  "label": "Shadow Dots",
212
239
  "description": "Dot textures evoking shadows over the lunar surface",
213
240
  "tags": ["dots", "shadows", "surface"],
@@ -216,6 +243,7 @@
216
243
  {
217
244
  "id": "moonrise",
218
245
  "filename": "moonrise.ktx2",
246
+ "still": "thumbs/moonrise.jpg",
219
247
  "label": "Moonrise",
220
248
  "description": "Atmospheric gradient evoking moonrise",
221
249
  "tags": ["moonrise", "gradient", "lunar"],
@@ -224,6 +252,7 @@
224
252
  {
225
253
  "id": "midnight-moon",
226
254
  "filename": "midnight-moon.ktx2",
255
+ "still": "thumbs/midnight-moon.jpg",
227
256
  "label": "Midnight Moon",
228
257
  "description": "Dark-toned lunar surface texture",
229
258
  "tags": ["midnight", "dark", "lunar"],
@@ -232,6 +261,7 @@
232
261
  {
233
262
  "id": "pale-orbit",
234
263
  "filename": "pale-orbit.ktx2",
264
+ "still": "thumbs/pale-orbit.jpg",
235
265
  "label": "Pale Orbit",
236
266
  "description": "Light lunar surface with subtle orbital tones",
237
267
  "tags": ["orbit", "pale", "surface"],
@@ -240,6 +270,7 @@
240
270
  {
241
271
  "id": "tidal-glow",
242
272
  "filename": "tidal-glow.ktx2",
273
+ "still": "thumbs/tidal-glow.jpg",
243
274
  "label": "Tidal Glow",
244
275
  "description": "Soft glowing lunar texture reflecting tidal pull",
245
276
  "tags": ["glow", "tidal", "lunar"],
@@ -248,6 +279,7 @@
248
279
  {
249
280
  "id": "silver-glow",
250
281
  "filename": "silver-glow.ktx2",
282
+ "still": "thumbs/silver-glow.jpg",
251
283
  "label": "Silver Glow",
252
284
  "description": "Bright silver surface with reflective highlights",
253
285
  "tags": ["silver", "glow", "lunar"],
@@ -18,6 +18,16 @@ export interface SyncSettingsLike {
18
18
  primaryGridMarkerTimeSec?: unknown;
19
19
  gridMarkers?: ReadonlyArray<{ sourceTimeSec?: unknown } | null | undefined> | null;
20
20
  gridStartTimeSec?: unknown;
21
+ /** Whether this track time-stretches onto the grid. A BOOLEAN, so `false` is an answer rather than
22
+ * an absence — see `resolveWarpEnabledFromTrackData`. */
23
+ warpEnabled?: unknown;
24
+ /** Whether this track plays looped. Same boolean rule as `warpEnabled`, and the same three states:
25
+ * on, off, and nobody-said — see `resolveLoopEnabledFromTrackData`. */
26
+ loopEnabled?: unknown;
27
+ /** The loop region in SECONDS (storage's time unit; the audio engine works in ms and converts at
28
+ * its edge). Only ever meaningful as a pair — see `resolveLoopRangeSecFromTrackData`. */
29
+ loopStartSec?: unknown;
30
+ loopEndSec?: unknown;
21
31
  }
22
32
 
23
33
  export interface TrackMetadataLike {
@@ -72,6 +82,23 @@ function firstResolvedValue(
72
82
  return null;
73
83
  }
74
84
 
85
+ /**
86
+ * The first candidate that is an actual boolean, or null when none of them is.
87
+ *
88
+ * Deliberately separate from `firstResolvedValue`: that one folds "absent" and "invalid" together
89
+ * and returns a number, which is exactly the wrong shape here. A warp preference has THREE states —
90
+ * on, off, and nobody-said — and `false` is the middle one, not the last. Any `||`, any truthiness
91
+ * test, any `?? ` against a default collapses off into nobody-said and loses the user's choice.
92
+ */
93
+ function firstBoolean(candidates: ReadonlyArray<unknown>): boolean | null {
94
+ for (const candidate of candidates) {
95
+ if (typeof candidate === 'boolean') {
96
+ return candidate;
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
75
102
  /**
76
103
  * Resolve the effective track BPM. Precedence (first finite, > 0 wins):
77
104
  * owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
@@ -125,3 +152,85 @@ export function resolvePrimaryGridMarkerTimeSecFromTrackData(
125
152
  toNonNegativeNumber,
126
153
  );
127
154
  }
155
+
156
+ /**
157
+ * The shared walk behind the boolean preferences below.
158
+ *
159
+ * The server already applied the real precedence — the viewer's own saved setting, then the owner's,
160
+ * then (for warp) a smart default — and publishes the answer at `effectiveSettings.sync`. So that is
161
+ * read FIRST: it is the resolved value, not a competing opinion. The per-user path below it covers
162
+ * the shapes where the server has not resolved anything (a raw `trackUserSettings` fetch that has not
163
+ * been merged into a track payload yet).
164
+ *
165
+ * One walk, not one per field: warp and loop differ in what they MEAN, never in where they are found,
166
+ * and two hand-maintained copies of this list would drift the moment a payload shape changed.
167
+ */
168
+ function resolveSyncFlagFromTrackData(
169
+ trackData: TrackDataLike | null,
170
+ field: 'warpEnabled' | 'loopEnabled',
171
+ ): boolean | null {
172
+ return firstBoolean([
173
+ trackData?.track?.metadata?.effectiveSettings?.sync?.[field],
174
+ trackData?.release?.metadata?.effectiveSettings?.sync?.[field],
175
+ trackData?.metadata?.effectiveSettings?.sync?.[field],
176
+ trackData?.effectiveSettings?.sync?.[field],
177
+ trackData?.trackUserSettings?.sync?.[field],
178
+ ]);
179
+ }
180
+
181
+ /**
182
+ * Resolve whether this track should warp (time-stretch onto the grid) for the viewer.
183
+ *
184
+ * Returns null when nothing anywhere expressed a preference. Null means "no opinion", NOT "off" —
185
+ * the caller supplies its own default, because only the caller knows its runtime constraints (in
186
+ * orbiters, a deck with no tempo cannot warp at all, whatever was saved).
187
+ */
188
+ export function resolveWarpEnabledFromTrackData(
189
+ trackData: TrackDataLike | null = null,
190
+ ): boolean | null {
191
+ return resolveSyncFlagFromTrackData(trackData, 'warpEnabled');
192
+ }
193
+
194
+ /**
195
+ * Resolve whether this track should play looped for the viewer.
196
+ *
197
+ * Same three states as warp — on, off, nobody-said — but no smart default anywhere behind it: the
198
+ * server stops at the owner's setting, so null here is genuinely common and simply means the caller
199
+ * keeps whatever looping default it already had.
200
+ */
201
+ export function resolveLoopEnabledFromTrackData(
202
+ trackData: TrackDataLike | null = null,
203
+ ): boolean | null {
204
+ return resolveSyncFlagFromTrackData(trackData, 'loopEnabled');
205
+ }
206
+
207
+ /**
208
+ * Resolve the saved loop region, in seconds, or null when no layer holds a complete one.
209
+ *
210
+ * Resolved as a PAIR, never edge by edge: a start borrowed from one layer and an end from another
211
+ * describes a loop nobody set, and can invert. `start` of 0 is a real value, so the guard is a
212
+ * finiteness test rather than a truthiness one.
213
+ *
214
+ * The caller must still clamp this against the audio's real duration before using it — a region
215
+ * saved against an earlier version of the track can outlive the audio it was drawn on.
216
+ */
217
+ export function resolveLoopRangeSecFromTrackData(
218
+ trackData: TrackDataLike | null = null,
219
+ ): { startSec: number; endSec: number } | null {
220
+ const candidates: Array<SyncSettingsLike | null | undefined> = [
221
+ trackData?.track?.metadata?.effectiveSettings?.sync,
222
+ trackData?.release?.metadata?.effectiveSettings?.sync,
223
+ trackData?.metadata?.effectiveSettings?.sync,
224
+ trackData?.effectiveSettings?.sync,
225
+ trackData?.trackUserSettings?.sync,
226
+ ];
227
+
228
+ for (const candidate of candidates) {
229
+ const startSec = toNonNegativeNumber(candidate?.loopStartSec);
230
+ const endSec = toNonNegativeNumber(candidate?.loopEndSec);
231
+ if (startSec !== null && endSec !== null && endSec > startSec) {
232
+ return { startSec, endSec };
233
+ }
234
+ }
235
+ return null;
236
+ }
@@ -1,8 +1,9 @@
1
1
  // Configuration exports
2
2
  export {
3
- loadTextureConfig,
4
- getTextureOptions,
5
- getDefaultTexture,
3
+ loadTextureConfig,
4
+ getTextureOptions,
5
+ getTextureChoices,
6
+ getDefaultTexture,
6
7
  getSkyboxOptions,
7
8
  getDefaultSkybox,
8
9
  getTextureFallbackUrl
@@ -91,33 +91,59 @@ export async function loadTextureConfig(configUrl = null, useCache = true) {
91
91
  }
92
92
 
93
93
  /**
94
- * Get texture options for a specific category (clouds, moons, etc.)
95
- * @param {string} category - Texture category (clouds, moons, skyboxes)
94
+ * @typedef {Object} TextureChoice
95
+ * @property {string} id Catalogue id.
96
+ * @property {string} label Human-readable name.
97
+ * @property {string} url Resolved texture URL (the .ktx2 the world renders).
98
+ * @property {string|null} still Resolved URL of the item's picture — a small thumbnail suitable
99
+ * for showing the texture in a picker. `null` when the catalogue names none.
100
+ *
101
+ * Every catalogue item IS a picture, so a chooser should show it rather than its name. `still` is
102
+ * read straight from the catalogue and never derived from `filename`: the stills do not share one
103
+ * extension, and a wrong guess fails silently (a broken `<img>` just shows nothing).
104
+ */
105
+
106
+ /**
107
+ * Get the full choice records for a category (clouds, moons, etc.) — label, texture URL, and the
108
+ * picture to show for it. The single resolution point for a category's items; `getTextureOptions`
109
+ * is the flat `{ label: url }` projection of this.
110
+ * @param {string} category - Texture category (clouds, moons)
96
111
  * @param {Object} config - Optional pre-loaded config object
97
- * @returns {Promise<Object>} Options object suitable for dropdown option lists
112
+ * @returns {Promise<TextureChoice[]>} Choice records in catalogue order
98
113
  */
99
- export async function getTextureOptions(category, config = null) {
114
+ export async function getTextureChoices(category, config = null) {
100
115
  const textureConfig = config || await loadTextureConfig();
101
116
  const categoryConfig = textureConfig.textures[category];
102
-
117
+
103
118
  if (!categoryConfig) {
104
119
  console.warn(`[TextureConfig] Category '${category}' not found`);
105
- return {};
120
+ return [];
106
121
  }
107
122
 
108
- const options = {};
109
123
  const baseUrl = textureConfig.baseUrl || '/default';
110
124
  const categoryPath = categoryConfig.basePath || `/${category}`;
111
125
 
112
- categoryConfig.items.forEach(item => {
113
- const key = item.label || item.id;
114
- const resolved = buildTextureUrl(baseUrl, categoryPath, item.filename || item.id);
115
- if (resolved) {
116
- options[key] = resolved;
117
- }
126
+ return (categoryConfig.items || []).flatMap(item => {
127
+ const url = buildTextureUrl(baseUrl, categoryPath, item.filename || item.id);
128
+ if (!url) return [];
129
+ return [{
130
+ id: item.id,
131
+ label: item.label || item.id,
132
+ url,
133
+ still: item.still ? buildTextureUrl(baseUrl, categoryPath, item.still) : null,
134
+ }];
118
135
  });
136
+ }
119
137
 
120
- return options;
138
+ /**
139
+ * Get texture options for a specific category (clouds, moons, etc.)
140
+ * @param {string} category - Texture category (clouds, moons, skyboxes)
141
+ * @param {Object} config - Optional pre-loaded config object
142
+ * @returns {Promise<Object>} Options object suitable for dropdown option lists
143
+ */
144
+ export async function getTextureOptions(category, config = null) {
145
+ const choices = await getTextureChoices(category, config);
146
+ return Object.fromEntries(choices.map(c => [c.label, c.url]));
121
147
  }
122
148
 
123
149
  /**