@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.
- package/README.md +46 -0
- package/dist/ambience/README.md +131 -0
- package/dist/ambience/breath.d.ts +22 -0
- package/dist/ambience/breath.js +43 -0
- package/dist/ambience/default.d.ts +24 -0
- package/dist/ambience/default.js +34 -0
- package/dist/ambience/engine.d.ts +21 -0
- package/dist/ambience/engine.js +313 -0
- package/dist/ambience/index.d.ts +23 -0
- package/dist/ambience/index.js +27 -0
- package/dist/ambience/master.d.ts +49 -0
- package/dist/ambience/master.js +83 -0
- package/dist/ambience/persist.d.ts +11 -0
- package/dist/ambience/persist.js +24 -0
- package/dist/ambience/registry.d.ts +11 -0
- package/dist/ambience/registry.js +16 -0
- package/dist/ambience/scapes/focus.d.ts +55 -0
- package/dist/ambience/scapes/focus.js +245 -0
- package/dist/ambience/scapes/hibiscus.d.ts +103 -0
- package/dist/ambience/scapes/hibiscus.js +345 -0
- package/dist/ambience/scapes/nature.d.ts +36 -0
- package/dist/ambience/scapes/nature.js +325 -0
- package/dist/ambience/scapes/noise.d.ts +77 -0
- package/dist/ambience/scapes/noise.js +226 -0
- package/dist/ambience/scapes/playlist.d.ts +129 -0
- package/dist/ambience/scapes/playlist.js +569 -0
- package/dist/ambience/types.d.ts +80 -0
- package/dist/ambience/types.js +9 -0
- package/dist/components/AmbienceToggle.svelte +599 -0
- package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
- package/dist/components/VideoPlayer.svelte +73 -6
- package/dist/components/VideoPlayer.svelte.d.ts +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/video/keys.d.ts +4 -3
- package/dist/video/keys.js +5 -4
- package/dist/video/settings/remembered.d.ts +9 -0
- package/dist/video/settings/remembered.js +9 -0
- 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
|
+
}
|