@joewinke/jatui 0.46.0 → 0.48.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.
Files changed (39) hide show
  1. package/README.md +46 -0
  2. package/dist/ambience/README.md +131 -0
  3. package/dist/ambience/breath.d.ts +22 -0
  4. package/dist/ambience/breath.js +43 -0
  5. package/dist/ambience/default.d.ts +24 -0
  6. package/dist/ambience/default.js +34 -0
  7. package/dist/ambience/engine.d.ts +21 -0
  8. package/dist/ambience/engine.js +313 -0
  9. package/dist/ambience/index.d.ts +23 -0
  10. package/dist/ambience/index.js +27 -0
  11. package/dist/ambience/master.d.ts +49 -0
  12. package/dist/ambience/master.js +83 -0
  13. package/dist/ambience/persist.d.ts +11 -0
  14. package/dist/ambience/persist.js +24 -0
  15. package/dist/ambience/registry.d.ts +11 -0
  16. package/dist/ambience/registry.js +16 -0
  17. package/dist/ambience/scapes/focus.d.ts +55 -0
  18. package/dist/ambience/scapes/focus.js +245 -0
  19. package/dist/ambience/scapes/hibiscus.d.ts +103 -0
  20. package/dist/ambience/scapes/hibiscus.js +345 -0
  21. package/dist/ambience/scapes/nature.d.ts +36 -0
  22. package/dist/ambience/scapes/nature.js +325 -0
  23. package/dist/ambience/scapes/noise.d.ts +77 -0
  24. package/dist/ambience/scapes/noise.js +226 -0
  25. package/dist/ambience/scapes/playlist.d.ts +129 -0
  26. package/dist/ambience/scapes/playlist.js +569 -0
  27. package/dist/ambience/types.d.ts +80 -0
  28. package/dist/ambience/types.js +9 -0
  29. package/dist/components/AmbienceToggle.svelte +599 -0
  30. package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
  31. package/dist/components/VideoPlayer.svelte +73 -6
  32. package/dist/components/VideoPlayer.svelte.d.ts +9 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +9 -0
  35. package/dist/video/keys.d.ts +4 -3
  36. package/dist/video/keys.js +5 -4
  37. package/dist/video/settings/remembered.d.ts +9 -0
  38. package/dist/video/settings/remembered.js +9 -0
  39. package/package.json +6 -1
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Ambience scape — playlist (phase 2): a host-supplied manifest of
3
+ * public-domain recordings, streamed through the shared master chain.
4
+ *
5
+ * The HOST owns the manifest and its licensing claims: pass `tracks` inline or
6
+ * a `manifestUrl` that is fetched once, lazily, on the first play — nothing
7
+ * loads at import time, and no audio ever ships in jatui (see
8
+ * src/lib/ambience/README.md for a Musopen-shaped example manifest).
9
+ *
10
+ * Audio streams through
11
+ *
12
+ * HTMLAudioElement → MediaElementAudioSourceNode → per-track gain → mix → ctx.input
13
+ *
14
+ * so the engine's warm tilt + soft-clip limiter apply, and every crossfade is
15
+ * a native gain-node ramp — no per-sample JS, no decoded buffers held.
16
+ *
17
+ * Two ping-pong "slots" (element + MediaElementAudioSourceNode + gain each)
18
+ * are built ONCE per play and reused for every track: while the current track
19
+ * nears its end (stateless per-tick check in the lookahead) the idle slot —
20
+ * already preloaded with the next track — starts and the two gains ramp
21
+ * against each other. A stream that never reports a duration advances on the
22
+ * element's own `ended` event instead, with a short ramp. The per-tick check
23
+ * fires at most ONE transition per tick, always at that tick's `now`, so a
24
+ * returning background tab (setInterval throttled to ~1/min while the audio
25
+ * clock runs on) cannot burst missed transitions.
26
+ *
27
+ * Teardown is WALL-CLOCK, never a lookahead tick: the engine retires a stopped
28
+ * instance's lookahead callbacks fade*1000 + 250 ms after stop (engine.ts
29
+ * retireCbs) — a tick-based disconnect could be dropped with nodes still
30
+ * wired into the shared input. stop() ramps the gains to zero on the audio
31
+ * clock and disconnects via setTimeout(fade*1000 + 50).
32
+ *
33
+ * The returned object is a plain Scape for the engine plus a UI surface
34
+ * (state/subscribe/skip/setShuffle) so a toggle can show the current track's
35
+ * attribution — composer, performer, license and a link back to the source.
36
+ * Nothing is registered globally; the integration task wires the default
37
+ * registry (jatui-poq30).
38
+ */
39
+ import type { Scape } from '../types';
40
+ export declare const PLAYLIST: Readonly<{
41
+ /** Crossfade between consecutive tracks (s). */
42
+ CROSSFADE: 3;
43
+ /** Fade-in for the playlist's very first track (s). */
44
+ START_RAMP: 0.5;
45
+ /** Ramp when a track ends/fails with no crossfade window (s). */
46
+ END_RAMP: 0.25;
47
+ /** Start the next track when at most this much of the current track is
48
+ * left ON TOP of the crossfade (s). */
49
+ END_MARGIN: 0.25;
50
+ /** setParam('gain') ramp (s). */
51
+ GAIN_RAMP: 0.15;
52
+ /** Disconnect + pause this many ms after a stop fade ends — before the
53
+ * engine's lookahead retirement (fade*1000 + 250) can drop anything. */
54
+ TEARDOWN_DELAY_MS: 50;
55
+ /** Pause an outgoing (crossfaded-out) element this many ms after its fade. */
56
+ PAUSE_MARGIN_MS: 100;
57
+ /** Silent-out ramp when the playlist gives up (s). */
58
+ FAIL_FADE: 0.3;
59
+ }>;
60
+ export interface PlaylistTrack {
61
+ /** Track title. */
62
+ title: string;
63
+ /** Composer — the attribution line the toggle shows. */
64
+ composer: string;
65
+ /** Performer / recording artist (optional second attribution line). */
66
+ performer?: string;
67
+ /** Streamable audio URL. The host must serve it with CORS headers. */
68
+ url: string;
69
+ /** License string, e.g. "Public domain" or "CC0 1.0" — shown in the UI. */
70
+ license: string;
71
+ /** Page where the recording lives — the attribution link target. */
72
+ sourceUrl?: string;
73
+ }
74
+ export interface PlaylistManifest {
75
+ title?: string;
76
+ tracks: PlaylistTrack[];
77
+ }
78
+ export type PlaylistStatus = 'idle' | 'loading' | 'playing' | 'error';
79
+ export interface PlaylistState {
80
+ status: PlaylistStatus;
81
+ /** The track sounding right now (null while idle/loading or on error). */
82
+ track: PlaylistTrack | null;
83
+ /** Position of the current track in the play order (0-based). */
84
+ index: number;
85
+ /** Number of tracks in the resolved playlist (0 until resolved). */
86
+ count: number;
87
+ /** Present on error. */
88
+ error?: string;
89
+ }
90
+ export interface PlaylistScapeOptions {
91
+ id: string;
92
+ label: string;
93
+ description?: string;
94
+ /** Manifest URL — fetched lazily, ONCE, on the first play. A failed fetch
95
+ * is retried on the next play. */
96
+ manifestUrl?: string;
97
+ /** Tracks inline — skips the fetch entirely. */
98
+ tracks?: PlaylistTrack[];
99
+ /** Track crossfade (s), 0–30. Default 3. */
100
+ crossfade?: number;
101
+ /** Randomized play order (Fisher–Yates over the engine's per-scape rand).
102
+ * Default false. Takes effect for the running order immediately. */
103
+ shuffle?: boolean;
104
+ /** Initial gain of the scape mix into the master chain, 0..1. Default 1. */
105
+ gain?: number;
106
+ /** Injectable for tests (default `fetch`). */
107
+ fetchFn?: (url: string) => Promise<Response>;
108
+ /** Injectable for tests (default `new Audio(url)`). */
109
+ createMediaElement?: (url: string) => HTMLAudioElement;
110
+ }
111
+ /** A Scape plus the playlist UI surface. Cast a registered Scape to this in
112
+ * the toggle to reach state()/subscribe()/skip()/setShuffle(). */
113
+ export interface PlaylistScape extends Scape {
114
+ group: 'music';
115
+ /** Current snapshot (also the initial value subscribe() fires with). */
116
+ state(): PlaylistState;
117
+ /** Subscribe to playlist state changes; fires immediately. */
118
+ subscribe(fn: (s: PlaylistState) => void): () => void;
119
+ /** Crossfade to the next track now. No-op while loading/released. */
120
+ skip(): void;
121
+ /** Toggle shuffle; the current track stays, the rest reorder at once. */
122
+ setShuffle(on: boolean): void;
123
+ /** The resolved track list, or null until the manifest resolved. */
124
+ tracks(): PlaylistTrack[] | null;
125
+ }
126
+ export declare function createPlaylistScape(options: PlaylistScapeOptions): PlaylistScape;
127
+ /** Fisher–Yates over an injected rand (the engine's per-scape PRNG), so the
128
+ * play order is deterministic for tests. */
129
+ export declare function shuffledOrder(n: number, rand: () => number): number[];
@@ -0,0 +1,569 @@
1
+ export const PLAYLIST = Object.freeze({
2
+ /** Crossfade between consecutive tracks (s). */
3
+ CROSSFADE: 3,
4
+ /** Fade-in for the playlist's very first track (s). */
5
+ START_RAMP: 0.5,
6
+ /** Ramp when a track ends/fails with no crossfade window (s). */
7
+ END_RAMP: 0.25,
8
+ /** Start the next track when at most this much of the current track is
9
+ * left ON TOP of the crossfade (s). */
10
+ END_MARGIN: 0.25,
11
+ /** setParam('gain') ramp (s). */
12
+ GAIN_RAMP: 0.15,
13
+ /** Disconnect + pause this many ms after a stop fade ends — before the
14
+ * engine's lookahead retirement (fade*1000 + 250) can drop anything. */
15
+ TEARDOWN_DELAY_MS: 50,
16
+ /** Pause an outgoing (crossfaded-out) element this many ms after its fade. */
17
+ PAUSE_MARGIN_MS: 100,
18
+ /** Silent-out ramp when the playlist gives up (s). */
19
+ FAIL_FADE: 0.3
20
+ });
21
+ export function createPlaylistScape(options) {
22
+ if (!options?.id)
23
+ throw new Error('createPlaylistScape: id is required');
24
+ if (!options.label)
25
+ throw new Error('createPlaylistScape: label is required');
26
+ const crossfade = Math.min(30, Math.max(0, options.crossfade ?? PLAYLIST.CROSSFADE));
27
+ const mixGain0 = Math.min(1, Math.max(0, options.gain ?? 1));
28
+ const fetchFn = options.fetchFn ?? ((url) => fetch(url));
29
+ let shuffle = options.shuffle ?? false;
30
+ /** Resolved track list — cached across plays (the manifest is fetched once,
31
+ * on the first play). null = unresolved; a FAILED fetch stays null so the
32
+ * next play retries. Inline tracks resolve eagerly (a pure local filter —
33
+ * no I/O), so a manifest-less playlist starts synchronously on play. */
34
+ let resolved = null;
35
+ let resolveError = null;
36
+ /** In-flight resolve; nulled on failure so a later play can retry. */
37
+ let pending = null;
38
+ if (options.tracks) {
39
+ const tracks = options.tracks.filter(isValidTrack);
40
+ if (tracks.length > 0) {
41
+ resolved = tracks;
42
+ pending = Promise.resolve(null);
43
+ }
44
+ }
45
+ /** Shared UI snapshot + subscribers. Only the latest build writes it. */
46
+ let snap = { status: 'idle', track: null, index: 0, count: 0 };
47
+ const subs = new Set();
48
+ /** Handle to the most recent build — skip()/setShuffle() route here. */
49
+ let latest = null;
50
+ let instanceSeq = 0;
51
+ function setSnap(patch) {
52
+ snap = { ...snap, ...patch };
53
+ for (const fn of subs)
54
+ fn(snap);
55
+ }
56
+ /** Fetch + validate the manifest (or pick up inline tracks). Resolves with
57
+ * an error message instead of rejecting so every awaiting instance can
58
+ * fail the same way. */
59
+ function ensureTracks() {
60
+ if (resolved)
61
+ return Promise.resolve(null);
62
+ pending ??= (async () => {
63
+ try {
64
+ if (options.tracks) {
65
+ const tracks = options.tracks.filter(isValidTrack);
66
+ if (tracks.length === 0)
67
+ throw new Error('The supplied track list has no valid entries');
68
+ resolved = tracks;
69
+ }
70
+ else if (options.manifestUrl) {
71
+ const res = await fetchFn(options.manifestUrl);
72
+ if (!res.ok)
73
+ throw new Error(`Manifest fetch failed (HTTP ${res.status})`);
74
+ const tracks = extractTracks(await res.json()).filter(isValidTrack);
75
+ if (tracks.length === 0)
76
+ throw new Error('Manifest contains no valid tracks');
77
+ resolved = tracks;
78
+ }
79
+ else {
80
+ throw new Error('createPlaylistScape needs manifestUrl or tracks');
81
+ }
82
+ resolveError = null;
83
+ return null;
84
+ }
85
+ catch (err) {
86
+ pending = null; // failed fetches are not cached — next play retries
87
+ resolveError = err instanceof Error ? err.message : String(err);
88
+ return resolveError;
89
+ }
90
+ })();
91
+ return pending;
92
+ }
93
+ /** One media element, CORS-anonymous so a cross-origin source actually
94
+ * delivers audio into the graph (a tainted element would play silence). */
95
+ function makeElement(url) {
96
+ const el = options.createMediaElement ? options.createMediaElement(url) : new Audio(url);
97
+ try {
98
+ el.preload = 'auto';
99
+ el.crossOrigin = 'anonymous';
100
+ }
101
+ catch {
102
+ /* exotic environments */
103
+ }
104
+ return el;
105
+ }
106
+ function buildInstance(scapeCtx) {
107
+ const ctx = scapeCtx.audio;
108
+ const instId = ++instanceSeq;
109
+ let released = false;
110
+ let started = false;
111
+ const timers = new Set();
112
+ let unsub = null;
113
+ // The scape graph — built once per play, reused for every track:
114
+ // el → MediaElementSource → track gain → mix → ctx.input (master chain)
115
+ const mix = ctx.createGain();
116
+ mix.gain.value = mixGain0;
117
+ mix.connect(scapeCtx.input);
118
+ const slots = [0, 1].map(() => {
119
+ const el = makeElement('');
120
+ const source = ctx.createMediaElementSource(el);
121
+ const gain = ctx.createGain();
122
+ gain.gain.value = 0;
123
+ source.connect(gain);
124
+ gain.connect(mix);
125
+ return { el, gain, source, trackIndex: -1 };
126
+ });
127
+ /** Position in `order` of the track sounding now; order[pos] = track idx. */
128
+ let active = -1; // active slot index
129
+ let order = [];
130
+ let pos = 0;
131
+ let failures = 0;
132
+ const isLive = () => instId === instanceSeq && !released;
133
+ function atTimer(ms, fn) {
134
+ if (released)
135
+ return;
136
+ const t = setTimeout(() => {
137
+ timers.delete(t);
138
+ if (!released)
139
+ fn();
140
+ }, Math.max(0, ms));
141
+ timers.add(t);
142
+ }
143
+ function wireSlotEvents(slot) {
144
+ const el = slot.el;
145
+ // A stream without metadata never triggers the end-detection tick —
146
+ // the element's own `ended` is the fallback advance.
147
+ el.addEventListener('ended', () => {
148
+ if (released || !isLive() || active < 0 || slots[active] !== slot)
149
+ return;
150
+ const ni = nextIdx();
151
+ if (ni !== -1 && ni !== slot.trackIndex)
152
+ startTrack(ni, 'ended');
153
+ });
154
+ el.addEventListener('error', () => {
155
+ if (released || !isLive())
156
+ return;
157
+ if (active < 0 || slots[active] !== slot) {
158
+ // A PRELOAD failed — drop it; the crossfade path re-srcs.
159
+ slot.trackIndex = -1;
160
+ return;
161
+ }
162
+ onTrackFailed();
163
+ });
164
+ // Audible playback started — the failure streak is over.
165
+ el.addEventListener('playing', () => {
166
+ if (released || active < 0 || slots[active] !== slot)
167
+ return;
168
+ failures = 0;
169
+ });
170
+ }
171
+ for (const slot of slots)
172
+ wireSlotEvents(slot);
173
+ function wireLookahead() {
174
+ unsub = scapeCtx.lookahead((_now) => {
175
+ if (released || active < 0)
176
+ return;
177
+ const slot = slots[active];
178
+ if (slot.trackIndex < 0)
179
+ return;
180
+ const el = slot.el;
181
+ const d = el.duration;
182
+ // Stateless per-tick check (no while-loop to re-anchor): at most ONE
183
+ // transition per tick, always anchored at THIS tick's now, so a
184
+ // returning throttled tab cannot burst missed transitions.
185
+ if (Number.isFinite(d) &&
186
+ d > 0 &&
187
+ el.currentTime > 0 &&
188
+ d - el.currentTime <= crossfade + PLAYLIST.END_MARGIN) {
189
+ const ni = nextIdx();
190
+ if (ni !== -1 && ni !== slot.trackIndex)
191
+ startTrack(ni, 'cross');
192
+ }
193
+ });
194
+ }
195
+ function start(_at) {
196
+ if (started || released)
197
+ return;
198
+ started = true;
199
+ setSnap({ status: 'loading', track: null, index: 0, count: resolved?.length ?? 0 });
200
+ if (resolved) {
201
+ begin();
202
+ return;
203
+ }
204
+ void ensureTracks().then((err) => {
205
+ if (!isLive())
206
+ return; // replaced or torn down while fetching
207
+ if (err)
208
+ fail(err);
209
+ else
210
+ begin();
211
+ });
212
+ }
213
+ function begin() {
214
+ if (released)
215
+ return;
216
+ if (!resolved || resolved.length === 0) {
217
+ fail(resolveError ?? 'Playlist has no playable tracks');
218
+ return;
219
+ }
220
+ order = shuffle ? shuffledOrder(resolved.length, scapeCtx.rand) : plainOrder(resolved.length);
221
+ pos = 0;
222
+ startTrack(order[0], 'first');
223
+ }
224
+ /** Load a track into the idle slot, fade it in, fade the outgoing slot
225
+ * out against it, and update the attribution state. */
226
+ function startTrack(trackIdx, mode) {
227
+ if (released)
228
+ return;
229
+ const track = resolved?.[trackIdx];
230
+ if (!track)
231
+ return;
232
+ const slotIdx = active < 0 ? 0 : 1 - active;
233
+ const slot = slots[slotIdx];
234
+ const now = ctx.currentTime;
235
+ const fadeIn = mode === 'first' ? PLAYLIST.START_RAMP : mode === 'cross' ? crossfade : PLAYLIST.END_RAMP;
236
+ // Reuse the preloaded media when the slot already holds this track.
237
+ if (slot.trackIndex !== trackIdx) {
238
+ slot.el.src = track.url;
239
+ slot.el.loop = (resolved?.length ?? 0) === 1; // a one-track playlist loops
240
+ slot.trackIndex = trackIdx;
241
+ }
242
+ const g = slot.gain.gain;
243
+ g.cancelScheduledValues(now);
244
+ if (fadeIn > 0) {
245
+ g.setValueAtTime(0, now);
246
+ g.linearRampToValueAtTime(1, now + fadeIn);
247
+ }
248
+ else {
249
+ g.setValueAtTime(1, now);
250
+ }
251
+ if (mode !== 'first') {
252
+ const old = slots[1 - slotIdx];
253
+ if (old.trackIndex !== -1) {
254
+ const og = old.gain.gain;
255
+ const out = Math.max(fadeIn, 0.01);
256
+ og.cancelScheduledValues(now);
257
+ og.setValueAtTime(og.value, now);
258
+ og.linearRampToValueAtTime(0, now + out);
259
+ // Silence lands on the audio clock (the gain ramp); the element
260
+ // pause is wall-clock hygiene — never a lookahead tick.
261
+ atTimer(out * 1000 + PLAYLIST.PAUSE_MARGIN_MS, () => {
262
+ if (released || active === slots.indexOf(old))
263
+ return;
264
+ quietSlot(old);
265
+ });
266
+ }
267
+ }
268
+ active = slotIdx;
269
+ try {
270
+ const p = slot.el.play();
271
+ if (p)
272
+ p.catch(() => onTrackFailed());
273
+ }
274
+ catch {
275
+ onTrackFailed();
276
+ }
277
+ setSnap({ status: 'playing', track, index: pos, count: resolved.length, error: undefined });
278
+ preloadNext();
279
+ }
280
+ function quietSlot(slot) {
281
+ try {
282
+ slot.el.pause();
283
+ slot.el.currentTime = 0;
284
+ }
285
+ catch {
286
+ /* element already unusable */
287
+ }
288
+ }
289
+ function onTrackFailed() {
290
+ if (released || !isLive())
291
+ return;
292
+ failures++;
293
+ const n = resolved?.length ?? 0;
294
+ if (failures >= n) {
295
+ fail(`All ${n} playlist tracks failed to load`);
296
+ return;
297
+ }
298
+ const ni = nextIdx();
299
+ if (ni !== -1)
300
+ startTrack(ni, 'ended'); // the failed slot is already silent
301
+ }
302
+ /** Advance the play order (wrapping, reshuffling a shuffled cycle);
303
+ * returns the next track index or -1. */
304
+ function nextIdx() {
305
+ if (order.length === 0)
306
+ return -1;
307
+ const lastPlayed = order[pos];
308
+ pos += 1;
309
+ if (pos >= order.length) {
310
+ order = wrapOrder(lastPlayed);
311
+ pos = 0;
312
+ }
313
+ return order[pos];
314
+ }
315
+ /** The track that would play next, WITHOUT advancing (for preload). */
316
+ function peekNextIdx() {
317
+ if (order.length === 0)
318
+ return -1;
319
+ return pos + 1 < order.length ? order[pos + 1] : order[0];
320
+ }
321
+ /** Order for the next cycle; never opens with the track that just
322
+ * ended when a swap can avoid it. */
323
+ function wrapOrder(lastPlayed) {
324
+ const idx = plainOrder(resolved?.length ?? 0);
325
+ if (shuffle) {
326
+ shuffleInPlace(idx, scapeCtx.rand);
327
+ if (idx.length > 1 && idx[0] === lastPlayed) {
328
+ const t = idx[0];
329
+ idx[0] = idx[1];
330
+ idx[1] = t;
331
+ }
332
+ }
333
+ return idx;
334
+ }
335
+ /** Preload the next track into the idle slot so the crossfade starts
336
+ * without a buffering gap. */
337
+ function preloadNext() {
338
+ const idx = peekNextIdx();
339
+ const track = resolved?.[idx];
340
+ if (!track || active < 0 || idx === slots[active].trackIndex)
341
+ return;
342
+ const idle = slots[1 - active];
343
+ if (idle.trackIndex !== idx) {
344
+ idle.el.src = track.url;
345
+ idle.el.loop = (resolved?.length ?? 0) === 1;
346
+ idle.trackIndex = idx;
347
+ }
348
+ }
349
+ function stop(at, fade) {
350
+ if (released)
351
+ return;
352
+ // The playlist is going away (user stop or the engine crossfading to
353
+ // another scape) — the UI drops the attribution now; the silence
354
+ // follows on the audio clock.
355
+ setSnap({
356
+ status: 'idle',
357
+ track: null,
358
+ index: 0,
359
+ count: resolved?.length ?? 0,
360
+ error: undefined
361
+ });
362
+ const f = Math.max(0, fade);
363
+ for (const slot of slots) {
364
+ if (slot.trackIndex === -1)
365
+ continue;
366
+ const g = slot.gain.gain;
367
+ g.cancelScheduledValues(at);
368
+ g.setValueAtTime(g.value, at);
369
+ g.linearRampToValueAtTime(0, at + f);
370
+ }
371
+ // Stop scheduling sound immediately, then tear down WALL-CLOCK —
372
+ // never on a lookahead tick: the engine retires stopped instances'
373
+ // lookahead callbacks at fade*1000 + 250 ms, so a tick-based teardown
374
+ // can be dropped with nodes still connected to the shared input.
375
+ unsub?.();
376
+ unsub = null;
377
+ if (f <= 0) {
378
+ release();
379
+ }
380
+ else {
381
+ atTimer(f * 1000 + PLAYLIST.TEARDOWN_DELAY_MS, release);
382
+ }
383
+ }
384
+ /** Everything down: elements paused + de-src'd, every node disconnected.
385
+ * Idempotent — a second stop()/kill() is a no-op. */
386
+ function release() {
387
+ if (released)
388
+ return;
389
+ released = true;
390
+ for (const t of timers)
391
+ clearTimeout(t);
392
+ timers.clear();
393
+ unsub?.();
394
+ unsub = null;
395
+ for (const slot of slots) {
396
+ try {
397
+ slot.el.pause();
398
+ slot.el.currentTime = 0;
399
+ }
400
+ catch {
401
+ /* element already unusable */
402
+ }
403
+ try {
404
+ slot.el.removeAttribute('src');
405
+ slot.el.load();
406
+ }
407
+ catch {
408
+ /* older engines */
409
+ }
410
+ try {
411
+ slot.source.disconnect();
412
+ slot.gain.disconnect();
413
+ }
414
+ catch {
415
+ /* already torn down */
416
+ }
417
+ }
418
+ try {
419
+ mix.disconnect();
420
+ }
421
+ catch {
422
+ /* already torn down */
423
+ }
424
+ }
425
+ /** Manifest missing/failed, no valid tracks, or every track failed to
426
+ * load: report, go silent on the audio clock, tear down wall-clock. */
427
+ function fail(message) {
428
+ if (released)
429
+ return;
430
+ setSnap({
431
+ status: 'error',
432
+ error: message,
433
+ track: null,
434
+ index: 0,
435
+ count: resolved?.length ?? 0
436
+ });
437
+ const now = ctx.currentTime;
438
+ const g = mix.gain;
439
+ g.cancelScheduledValues(now);
440
+ g.setValueAtTime(g.value, now);
441
+ g.linearRampToValueAtTime(0, now + PLAYLIST.FAIL_FADE);
442
+ unsub?.();
443
+ unsub = null;
444
+ atTimer(PLAYLIST.FAIL_FADE * 1000 + PLAYLIST.TEARDOWN_DELAY_MS, release);
445
+ }
446
+ /** setParam — scape-defined keys. 'gain': the scape mix level (0..1). */
447
+ function setParam(key, value) {
448
+ if (key !== 'gain' || released)
449
+ return;
450
+ const v = Math.min(1, Math.max(0, value));
451
+ const now = ctx.currentTime;
452
+ const g = mix.gain;
453
+ g.cancelScheduledValues(now);
454
+ g.setValueAtTime(g.value, now);
455
+ g.linearRampToValueAtTime(v, now + PLAYLIST.GAIN_RAMP);
456
+ }
457
+ function skipInternal() {
458
+ if (released || !started || order.length === 0)
459
+ return;
460
+ const ni = nextIdx();
461
+ if (ni !== -1)
462
+ startTrack(ni, 'cross');
463
+ }
464
+ /** setShuffle mid-play: with shuffle the rest reorder (current first);
465
+ * without, the natural order resumes from the current track. */
466
+ function reshuffle() {
467
+ if (released || !started || !resolved || resolved.length < 2)
468
+ return;
469
+ const current = order[pos] ?? 0;
470
+ if (shuffle) {
471
+ const rest = plainOrder(resolved.length).filter((i) => i !== current);
472
+ shuffleInPlace(rest, scapeCtx.rand);
473
+ order = [current, ...rest];
474
+ pos = 0;
475
+ }
476
+ else {
477
+ order = plainOrder(resolved.length);
478
+ pos = current; // pointer at the current track — next is its successor
479
+ }
480
+ }
481
+ wireLookahead();
482
+ return {
483
+ api: { start, stop, setParam },
484
+ skip: skipInternal,
485
+ reshuffle,
486
+ isReleased: () => released
487
+ };
488
+ }
489
+ const scape = {
490
+ id: options.id,
491
+ label: options.label,
492
+ group: 'music',
493
+ description: options.description,
494
+ build(scapeCtx) {
495
+ const inst = buildInstance(scapeCtx);
496
+ latest = inst;
497
+ return inst.api;
498
+ },
499
+ state: () => snap,
500
+ subscribe(fn) {
501
+ subs.add(fn);
502
+ fn(snap);
503
+ return () => {
504
+ subs.delete(fn);
505
+ };
506
+ },
507
+ skip() {
508
+ if (latest && !latest.isReleased())
509
+ latest.skip();
510
+ },
511
+ setShuffle(on) {
512
+ shuffle = on;
513
+ if (latest && !latest.isReleased())
514
+ latest.reshuffle();
515
+ },
516
+ tracks: () => (resolved ? [...resolved] : null)
517
+ };
518
+ return scape;
519
+ }
520
+ // ---- pure helpers ------------------------------------------------------------
521
+ /** Sequential order 0..n-1. */
522
+ function plainOrder(n) {
523
+ return Array.from({ length: n }, (_, i) => i);
524
+ }
525
+ /** Fisher–Yates over an injected rand (the engine's per-scape PRNG), so the
526
+ * play order is deterministic for tests. */
527
+ export function shuffledOrder(n, rand) {
528
+ const idx = plainOrder(n);
529
+ shuffleInPlace(idx, rand);
530
+ return idx;
531
+ }
532
+ function shuffleInPlace(idx, rand) {
533
+ for (let i = idx.length - 1; i > 0; i--) {
534
+ const j = Math.floor(rand() * (i + 1));
535
+ const t = idx[i];
536
+ idx[i] = idx[j];
537
+ idx[j] = t;
538
+ }
539
+ }
540
+ /** Accept `{ tracks: [...] }` or a bare [...] manifest. */
541
+ function extractTracks(json) {
542
+ if (Array.isArray(json))
543
+ return json;
544
+ if (typeof json === 'object' && json !== null) {
545
+ const tracks = json.tracks;
546
+ if (Array.isArray(tracks))
547
+ return tracks;
548
+ }
549
+ return [];
550
+ }
551
+ /** A track needs title, composer, url and license — the attribution the UI
552
+ * promises. performer/sourceUrl are optional strings. Anything else is
553
+ * filtered out rather than failing the whole playlist. */
554
+ function isValidTrack(t) {
555
+ if (typeof t !== 'object' || t === null)
556
+ return false;
557
+ const o = t;
558
+ return (typeof o.title === 'string' &&
559
+ o.title.trim() !== '' &&
560
+ typeof o.composer === 'string' &&
561
+ o.composer.trim() !== '' &&
562
+ typeof o.url === 'string' &&
563
+ o.url.trim() !== '' &&
564
+ typeof o.license === 'string' &&
565
+ o.license.trim() !== '' &&
566
+ (o.performer === undefined || typeof o.performer === 'string') &&
567
+ (o.sourceUrl === undefined ||
568
+ (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')));
569
+ }