@joewinke/jatui 0.61.0 → 0.63.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/CLAUDE.md +1 -1
- package/dist/ambience/README.md +46 -2
- package/dist/ambience/engine.js +4 -1
- package/dist/ambience/groups.d.ts +4 -8
- package/dist/ambience/groups.js +4 -11
- package/dist/ambience/index.d.ts +4 -3
- package/dist/ambience/index.js +9 -4
- package/dist/ambience/scapes/playlist.d.ts +61 -2
- package/dist/ambience/scapes/playlist.js +252 -16
- package/dist/ambience/scapes/radio.d.ts +113 -0
- package/dist/ambience/scapes/radio.js +297 -0
- package/dist/ambience/shared.d.ts +41 -2
- package/dist/ambience/shared.js +56 -5
- package/dist/ambience/types.d.ts +7 -0
- package/dist/components/AmbienceToggle.svelte +362 -13
- package/dist/components/AmbienceToggle.svelte.d.ts +29 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambience scape — radio stream (jatui-plbzq): play a station's public
|
|
3
|
+
* stream DIRECTLY in the player.
|
|
4
|
+
*
|
|
5
|
+
* By design (jatui-mdq2p) a `radioStation()` is an outbound LINK: the
|
|
6
|
+
* station's own site opens in a new tab and no stream is ever relayed
|
|
7
|
+
* through the host's servers. `streamUrl` opts a station into a second
|
|
8
|
+
* behavior: the browser fetches the station's public stream CLIENT-side
|
|
9
|
+
* (the same constraint — nothing is relayed — just moved from "not played"
|
|
10
|
+
* to "not proxied") and it sounds through this scape.
|
|
11
|
+
*
|
|
12
|
+
* The element is a plain `<audio src>` — deliberately NOT routed through
|
|
13
|
+
* the Web Audio graph:
|
|
14
|
+
*
|
|
15
|
+
* - Routing a cross-origin element through a MediaElementAudioSourceNode
|
|
16
|
+
* without CORS headers yields SILENCE (the tainted-graph rule), and most
|
|
17
|
+
* station streams do not send `Access-Control-Allow-Origin`. Setting
|
|
18
|
+
* `crossOrigin = 'anonymous'` first (the playlist's contract — its host
|
|
19
|
+
* owns the manifest and can serve CORS) would refuse to load those
|
|
20
|
+
* streams outright.
|
|
21
|
+
* - A plain element plays any http(s) stream. Its loudness follows the
|
|
22
|
+
* engine by mirroring the engine's volume onto `el.volume` (the scape's
|
|
23
|
+
* own gain × the context's `volume()` — refreshed on the lookahead tick,
|
|
24
|
+
* so the volume slider and cross-tab mute apply to radio too).
|
|
25
|
+
*
|
|
26
|
+
* What radio gives up versus the playlist: no crossfades (a live stream
|
|
27
|
+
* has no tracks to fade between — a stop is a stepped element-volume fade),
|
|
28
|
+
* no progress bar (a stream reports no duration) and no per-track
|
|
29
|
+
* attribution. Attribution stays: the scape keeps the station's site url
|
|
30
|
+
* (`safeAmbienceHref`-validated) and the now-playing row shows
|
|
31
|
+
* "Open station site".
|
|
32
|
+
*
|
|
33
|
+
* The returned object is a plain Scape for the engine plus a small UI
|
|
34
|
+
* surface (`state()` / `subscribe()`, discriminated by `radio: true`) that
|
|
35
|
+
* the toggle narrows with `isRadioScape()`. Nothing is registered globally.
|
|
36
|
+
*/
|
|
37
|
+
import type { AmbienceLink, Scape, ScapeGroup } from '../types';
|
|
38
|
+
/** Ramp + teardown timing for the radio scape. */
|
|
39
|
+
export declare const RADIO: Readonly<{
|
|
40
|
+
/** Stepped element-volume fade on stop() — steps per second of fade. */
|
|
41
|
+
FADE_STEPS_PER_S: 8;
|
|
42
|
+
/** Pause + de-src this many ms after a stop fade ends — before the
|
|
43
|
+
* engine's lookahead retirement (fade*1000 + 250) can drop anything. */
|
|
44
|
+
TEARDOWN_DELAY_MS: 50;
|
|
45
|
+
}>;
|
|
46
|
+
export type RadioStreamStatus = 'idle' | 'loading' | 'playing' | 'error';
|
|
47
|
+
export interface RadioStreamState {
|
|
48
|
+
status: RadioStreamStatus;
|
|
49
|
+
/** The station's name — the now-playing row's title. */
|
|
50
|
+
station: string;
|
|
51
|
+
/** The validated station-site url (the "Open station site" attribution
|
|
52
|
+
* link), or null when the site href was not a safe absolute http(s)
|
|
53
|
+
* one — the stream still plays, the link just hides. */
|
|
54
|
+
href: string | null;
|
|
55
|
+
/** Present on error. */
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface RadioStreamScapeOptions {
|
|
59
|
+
id: string;
|
|
60
|
+
label: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
/** The menu section (default 'radio'). */
|
|
63
|
+
group?: ScapeGroup;
|
|
64
|
+
/** The station's own page — the attribution link target. Validated with
|
|
65
|
+
* `safeAmbienceHref`; an unsafe href keeps the stream playable and
|
|
66
|
+
* just hides the link. */
|
|
67
|
+
href?: string;
|
|
68
|
+
/** The station's public stream url. MUST be an absolute http(s) one —
|
|
69
|
+
* anything else is refused at creation. */
|
|
70
|
+
streamUrl: string;
|
|
71
|
+
/** Initial loudness multiplier, 0..1 (default 1) — the element's own
|
|
72
|
+
* volume is this × the engine's master volume. `setParam('gain', v)`
|
|
73
|
+
* changes it live. */
|
|
74
|
+
gain?: number;
|
|
75
|
+
/** Injectable for tests (default `new Audio(url)`). */
|
|
76
|
+
createMediaElement?: (url: string) => HTMLAudioElement;
|
|
77
|
+
}
|
|
78
|
+
/** A Scape plus the radio UI surface. Narrow a registered Scape with
|
|
79
|
+
* `isRadioScape()` in the toggle to reach state()/subscribe(). */
|
|
80
|
+
export interface RadioStreamScape extends Scape {
|
|
81
|
+
/** Discriminates a radio stream from other scapes. */
|
|
82
|
+
readonly radio: true;
|
|
83
|
+
/** The validated station-site url, or null (no attribution link). */
|
|
84
|
+
readonly href: string | null;
|
|
85
|
+
/** Current snapshot (also the initial value subscribe() fires with). */
|
|
86
|
+
state(): RadioStreamState;
|
|
87
|
+
/** Subscribe to radio state changes; fires immediately. */
|
|
88
|
+
subscribe(fn: (s: RadioStreamState) => void): () => void;
|
|
89
|
+
}
|
|
90
|
+
export declare function createRadioStreamScape(options: RadioStreamScapeOptions): RadioStreamScape;
|
|
91
|
+
/** Narrow a registered scape to a radio stream (to reach its state surface). */
|
|
92
|
+
export declare function isRadioScape(scape: unknown): scape is RadioStreamScape;
|
|
93
|
+
/**
|
|
94
|
+
* Build a radio-station item: WITHOUT `streamUrl` an outbound link (the
|
|
95
|
+
* station's site opens in a new tab — the original, always-available
|
|
96
|
+
* behavior); WITH `streamUrl` the station's public stream plays right in
|
|
97
|
+
* the player (client-side fetch from the station — never relayed) and the
|
|
98
|
+
* site link survives as the attribution target in the now-playing row.
|
|
99
|
+
*
|
|
100
|
+
* A `streamUrl` that fails `safeAmbienceHref` (anything but an absolute
|
|
101
|
+
* http(s) url) is REFUSED: the station falls back to the outbound link and
|
|
102
|
+
* the bad url is never stored or loaded.
|
|
103
|
+
*/
|
|
104
|
+
export declare function radioStation(options: {
|
|
105
|
+
id: string;
|
|
106
|
+
label: string;
|
|
107
|
+
href: string;
|
|
108
|
+
description?: string;
|
|
109
|
+
group?: ScapeGroup;
|
|
110
|
+
/** The station's public stream url (absolute http(s) only) — when
|
|
111
|
+
* present, the station plays in the player. */
|
|
112
|
+
streamUrl?: string;
|
|
113
|
+
}): AmbienceLink | RadioStreamScape;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { safeAmbienceHref } from '../groups';
|
|
2
|
+
/** Ramp + teardown timing for the radio scape. */
|
|
3
|
+
export const RADIO = Object.freeze({
|
|
4
|
+
/** Stepped element-volume fade on stop() — steps per second of fade. */
|
|
5
|
+
FADE_STEPS_PER_S: 8,
|
|
6
|
+
/** Pause + de-src this many ms after a stop fade ends — before the
|
|
7
|
+
* engine's lookahead retirement (fade*1000 + 250) can drop anything. */
|
|
8
|
+
TEARDOWN_DELAY_MS: 50
|
|
9
|
+
});
|
|
10
|
+
export function createRadioStreamScape(options) {
|
|
11
|
+
if (!options?.id)
|
|
12
|
+
throw new Error('createRadioStreamScape: id is required');
|
|
13
|
+
if (!options.label)
|
|
14
|
+
throw new Error('createRadioStreamScape: label is required');
|
|
15
|
+
if (!options.streamUrl || typeof options.streamUrl !== 'string')
|
|
16
|
+
throw new Error('createRadioStreamScape: streamUrl is required');
|
|
17
|
+
// Refuse at create time: a `javascript:`/`data:`/relative stream url
|
|
18
|
+
// would end up in an <audio> src — http(s) absolute only, the same gate
|
|
19
|
+
// as every other ambience href.
|
|
20
|
+
const validated = safeAmbienceHref(options.streamUrl);
|
|
21
|
+
if (!validated)
|
|
22
|
+
throw new Error('createRadioStreamScape: streamUrl must be an absolute http(s) url');
|
|
23
|
+
// Explicitly typed alias: buildInstance is a hoisted declaration, and TS
|
|
24
|
+
// resets truthiness narrowing at its capture — the guard above is the
|
|
25
|
+
// build-time refusal this module promises.
|
|
26
|
+
const streamUrl = validated;
|
|
27
|
+
const href = safeAmbienceHref(options.href);
|
|
28
|
+
let mixGain0 = Math.min(1, Math.max(0, options.gain ?? 1));
|
|
29
|
+
const makeElement = options.createMediaElement ?? ((url) => new Audio(url));
|
|
30
|
+
/** Shared UI snapshot + subscribers. Only the latest build writes it. */
|
|
31
|
+
let snap = {
|
|
32
|
+
status: 'idle',
|
|
33
|
+
station: options.label,
|
|
34
|
+
href: href ?? null
|
|
35
|
+
};
|
|
36
|
+
const subs = new Set();
|
|
37
|
+
let instanceSeq = 0;
|
|
38
|
+
function setSnap(patch) {
|
|
39
|
+
snap = { ...snap, ...patch };
|
|
40
|
+
for (const fn of subs)
|
|
41
|
+
fn(snap);
|
|
42
|
+
}
|
|
43
|
+
function buildInstance(scapeCtx) {
|
|
44
|
+
const instId = ++instanceSeq;
|
|
45
|
+
let released = false;
|
|
46
|
+
let started = false;
|
|
47
|
+
let fading = false;
|
|
48
|
+
const timers = new Set();
|
|
49
|
+
let unsub = null;
|
|
50
|
+
// A plain <audio> element — no crossOrigin (a station stream without
|
|
51
|
+
// CORS headers must still load), no source node (the tainted-graph
|
|
52
|
+
// rule would silence it). Loudness is el.volume, below.
|
|
53
|
+
const el = makeElement(streamUrl);
|
|
54
|
+
try {
|
|
55
|
+
// The src is set here, not only via the constructor: an injectable
|
|
56
|
+
// factory may build a bare element (the real `new Audio(url)` already
|
|
57
|
+
// carries it), and the tests read the assignment back.
|
|
58
|
+
el.src = streamUrl;
|
|
59
|
+
el.preload = 'auto';
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* exotic environments */
|
|
63
|
+
}
|
|
64
|
+
const isLive = () => instId === instanceSeq && !released;
|
|
65
|
+
function atTimer(ms, fn) {
|
|
66
|
+
if (released)
|
|
67
|
+
return;
|
|
68
|
+
const t = setTimeout(() => {
|
|
69
|
+
timers.delete(t);
|
|
70
|
+
if (!released)
|
|
71
|
+
fn();
|
|
72
|
+
}, Math.max(0, ms));
|
|
73
|
+
timers.add(t);
|
|
74
|
+
}
|
|
75
|
+
/** The element's loudness right now: the scape gain × the engine's
|
|
76
|
+
* master volume (a hand-made test context may omit the hook — it
|
|
77
|
+
* then reads full volume). */
|
|
78
|
+
function elementVolume() {
|
|
79
|
+
const master = scapeCtx.volume ? scapeCtx.volume() : 1;
|
|
80
|
+
return Math.min(1, Math.max(0, master)) * Math.min(1, Math.max(0, mixGain0));
|
|
81
|
+
}
|
|
82
|
+
function applyVolume() {
|
|
83
|
+
try {
|
|
84
|
+
el.volume = Math.min(1, Math.max(0, elementVolume()));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* element already unusable */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// The element's own events, so a stream that stalls or dies is
|
|
91
|
+
// visible in the state surface instead of playing silence.
|
|
92
|
+
el.addEventListener('playing', () => {
|
|
93
|
+
if (!isLive() || fading)
|
|
94
|
+
return;
|
|
95
|
+
setSnap({ status: 'playing', error: undefined });
|
|
96
|
+
});
|
|
97
|
+
el.addEventListener('ended', () => {
|
|
98
|
+
// A live stream should not end; when it does, say so rather than
|
|
99
|
+
// showing a playing row over silence.
|
|
100
|
+
if (!isLive() || fading)
|
|
101
|
+
return;
|
|
102
|
+
fail('The stream ended');
|
|
103
|
+
});
|
|
104
|
+
el.addEventListener('error', () => {
|
|
105
|
+
if (!isLive() || fading)
|
|
106
|
+
return;
|
|
107
|
+
fail('The stream could not be loaded');
|
|
108
|
+
});
|
|
109
|
+
// Mirror the engine's volume on every lookahead tick (~250 ms) so the
|
|
110
|
+
// volume slider and cross-tab mute reach an element outside the graph.
|
|
111
|
+
unsub = scapeCtx.lookahead(() => {
|
|
112
|
+
if (released || !started || fading)
|
|
113
|
+
return;
|
|
114
|
+
applyVolume();
|
|
115
|
+
});
|
|
116
|
+
function start(_at) {
|
|
117
|
+
// A superseded (stale) build is inert: only the newest instance may
|
|
118
|
+
// sound or report — the shared snapshot belongs to the live one.
|
|
119
|
+
if (started || released || !isLive())
|
|
120
|
+
return;
|
|
121
|
+
started = true;
|
|
122
|
+
setSnap({ status: 'loading', error: undefined });
|
|
123
|
+
applyVolume();
|
|
124
|
+
try {
|
|
125
|
+
const p = el.play();
|
|
126
|
+
if (p)
|
|
127
|
+
p.then(() => {
|
|
128
|
+
if (!isLive() || fading)
|
|
129
|
+
return;
|
|
130
|
+
setSnap({ status: 'playing', error: undefined });
|
|
131
|
+
}, () => fail('The stream could not be started'));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
fail('The stream could not be started');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function fail(message) {
|
|
138
|
+
if (released || !isLive())
|
|
139
|
+
return;
|
|
140
|
+
setSnap({ status: 'error', error: message });
|
|
141
|
+
release();
|
|
142
|
+
}
|
|
143
|
+
function release() {
|
|
144
|
+
if (released)
|
|
145
|
+
return;
|
|
146
|
+
released = true;
|
|
147
|
+
unsub?.();
|
|
148
|
+
unsub = null;
|
|
149
|
+
for (const t of timers)
|
|
150
|
+
clearTimeout(t);
|
|
151
|
+
timers.clear();
|
|
152
|
+
try {
|
|
153
|
+
el.pause();
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* element already unusable */
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
el.removeAttribute('src');
|
|
160
|
+
el.load();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* older engines */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function stop(_at, fade) {
|
|
167
|
+
if (released)
|
|
168
|
+
return;
|
|
169
|
+
setSnap({ status: 'idle', error: undefined });
|
|
170
|
+
const f = Math.max(0, fade);
|
|
171
|
+
if (f <= 0) {
|
|
172
|
+
release();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Stepped element-volume fade (there is no audio-clock gain here):
|
|
176
|
+
// FADE_STEPS_PER_S steps per fade second, then the wall-clock release.
|
|
177
|
+
fading = true;
|
|
178
|
+
const steps = Math.max(1, Math.round(f * RADIO.FADE_STEPS_PER_S));
|
|
179
|
+
const stepMs = (f * 1000) / steps;
|
|
180
|
+
for (let i = 1; i <= steps; i++) {
|
|
181
|
+
atTimer(stepMs * i, () => {
|
|
182
|
+
const k = 1 - i / steps;
|
|
183
|
+
try {
|
|
184
|
+
el.volume = Math.min(1, Math.max(0, elementVolume())) * k;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* element already unusable */
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
atTimer(f * 1000 + RADIO.TEARDOWN_DELAY_MS, release);
|
|
192
|
+
}
|
|
193
|
+
function setParam(key, value) {
|
|
194
|
+
if (key !== 'gain' || released)
|
|
195
|
+
return;
|
|
196
|
+
mixGain0 = Math.min(1, Math.max(0, value));
|
|
197
|
+
if (started && !fading)
|
|
198
|
+
applyVolume();
|
|
199
|
+
}
|
|
200
|
+
function pauseInternal() {
|
|
201
|
+
if (released || !started)
|
|
202
|
+
return;
|
|
203
|
+
try {
|
|
204
|
+
el.pause();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* element already unusable */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function resumeInternal() {
|
|
211
|
+
if (released || !started)
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
const p = el.play();
|
|
215
|
+
if (p)
|
|
216
|
+
p.catch(() => { }); // the error surface owns failures
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* element already unusable */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { start, stop, setParam, pause: pauseInternal, resume: resumeInternal };
|
|
223
|
+
}
|
|
224
|
+
const scape = {
|
|
225
|
+
id: options.id,
|
|
226
|
+
label: options.label,
|
|
227
|
+
group: options.group ?? 'radio',
|
|
228
|
+
description: options.description,
|
|
229
|
+
radio: true,
|
|
230
|
+
href: href ?? null,
|
|
231
|
+
build(scapeCtx) {
|
|
232
|
+
return buildInstance(scapeCtx);
|
|
233
|
+
},
|
|
234
|
+
state: () => snap,
|
|
235
|
+
subscribe(fn) {
|
|
236
|
+
subs.add(fn);
|
|
237
|
+
fn(snap);
|
|
238
|
+
return () => {
|
|
239
|
+
subs.delete(fn);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
return scape;
|
|
244
|
+
}
|
|
245
|
+
/** Narrow a registered scape to a radio stream (to reach its state surface). */
|
|
246
|
+
export function isRadioScape(scape) {
|
|
247
|
+
return (typeof scape === 'object' &&
|
|
248
|
+
scape !== null &&
|
|
249
|
+
scape.radio === true &&
|
|
250
|
+
typeof scape.subscribe === 'function');
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Build a radio-station item: WITHOUT `streamUrl` an outbound link (the
|
|
254
|
+
* station's site opens in a new tab — the original, always-available
|
|
255
|
+
* behavior); WITH `streamUrl` the station's public stream plays right in
|
|
256
|
+
* the player (client-side fetch from the station — never relayed) and the
|
|
257
|
+
* site link survives as the attribution target in the now-playing row.
|
|
258
|
+
*
|
|
259
|
+
* A `streamUrl` that fails `safeAmbienceHref` (anything but an absolute
|
|
260
|
+
* http(s) url) is REFUSED: the station falls back to the outbound link and
|
|
261
|
+
* the bad url is never stored or loaded.
|
|
262
|
+
*/
|
|
263
|
+
export function radioStation(options) {
|
|
264
|
+
if (options.streamUrl === undefined || options.streamUrl === null) {
|
|
265
|
+
return {
|
|
266
|
+
kind: 'link',
|
|
267
|
+
id: options.id,
|
|
268
|
+
label: options.label,
|
|
269
|
+
group: options.group ?? 'radio',
|
|
270
|
+
href: options.href,
|
|
271
|
+
description: options.description
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (!safeAmbienceHref(options.streamUrl)) {
|
|
275
|
+
// Refused: a typo'd scheme silently dropping the stream would be very
|
|
276
|
+
// hard to spot from the outside — say so once, then degrade to the
|
|
277
|
+
// always-available link behavior.
|
|
278
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function')
|
|
279
|
+
console.warn(`radioStation(${options.id}): streamUrl must be an absolute http(s) url — falling back to the outbound link`);
|
|
280
|
+
return {
|
|
281
|
+
kind: 'link',
|
|
282
|
+
id: options.id,
|
|
283
|
+
label: options.label,
|
|
284
|
+
group: options.group ?? 'radio',
|
|
285
|
+
href: options.href,
|
|
286
|
+
description: options.description
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return createRadioStreamScape({
|
|
290
|
+
id: options.id,
|
|
291
|
+
label: options.label,
|
|
292
|
+
description: options.description,
|
|
293
|
+
group: options.group,
|
|
294
|
+
href: options.href,
|
|
295
|
+
streamUrl: options.streamUrl
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -57,6 +57,31 @@ export declare const SHARED_TIMING: Readonly<{
|
|
|
57
57
|
/** A remote play command waits this long (ms) for the leader's echo. */
|
|
58
58
|
REMOTE_PLAY_TIMEOUT_MS: 2500;
|
|
59
59
|
}>;
|
|
60
|
+
/** The current track's download info (jatui-0vxcu), published by the leader
|
|
61
|
+
* alongside the now-playing parts — present ONLY when the licence permits
|
|
62
|
+
* redistribution (`isTrackDownloadable`), so a remote tab can offer the
|
|
63
|
+
* same download button without re-deriving the gate or the filename. */
|
|
64
|
+
export interface SharedTrackDownload {
|
|
65
|
+
/** The track's audio url (the leader's manifest entry). */
|
|
66
|
+
url: string;
|
|
67
|
+
/** Ready-made filename — "Composer - Title.ext", built leader-side by
|
|
68
|
+
* `trackDownloadFilename` so attribution travels with the file. */
|
|
69
|
+
filename: string;
|
|
70
|
+
/** The licence string, for the button's tooltip and describedby line. */
|
|
71
|
+
license: string;
|
|
72
|
+
/** Page the recording came from (attribution link target). */
|
|
73
|
+
sourceUrl?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A published download re-validated on the CONSUMING side (jatui-0vxcu):
|
|
77
|
+
* the leader is another tab, not a trusted source — its `url` must survive
|
|
78
|
+
* `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
|
|
79
|
+
* scheme-relative url from a compromised manifest or a hostile tab would
|
|
80
|
+
* otherwise reach this tab's `window.open` fallback) and the filename and
|
|
81
|
+
* licence must be usable strings. Anything else returns null — the remote
|
|
82
|
+
* tab's download button hides. The validated url replaces the original.
|
|
83
|
+
*/
|
|
84
|
+
export declare function safeSharedTrackDownload(download: SharedTrackDownload | null | undefined): SharedTrackDownload | null;
|
|
60
85
|
/** The live cross-tab state every tab renders. */
|
|
61
86
|
export interface SharedAmbienceState {
|
|
62
87
|
/** Currently playing (or preselected) scape id, or null. */
|
|
@@ -92,6 +117,14 @@ export interface SharedAmbienceState {
|
|
|
92
117
|
/** True while the leader is PAUSED (engine.pause() — the scape is frozen
|
|
93
118
|
* in place, not torn down; resume continues it). */
|
|
94
119
|
paused: boolean;
|
|
120
|
+
/** The published track's download info (url + filename + licence), or
|
|
121
|
+
* null when nothing is playing or the licence forbids redistribution
|
|
122
|
+
* (jatui-0vxcu). A remote tab's download button reads this. */
|
|
123
|
+
trackDownload: SharedTrackDownload | null;
|
|
124
|
+
/** The playing playlist's shuffle state (jatui-plbzq), published so a
|
|
125
|
+
* remote tab's Shuffle toggle shows the leader's truth. Null when
|
|
126
|
+
* unknown (nothing playing, or the scape is not a playlist). */
|
|
127
|
+
trackShuffle: boolean | null;
|
|
95
128
|
}
|
|
96
129
|
/** An Ambience whose playback is shared across tabs (see module doc). */
|
|
97
130
|
export interface SharedAmbience extends Ambience {
|
|
@@ -105,12 +138,18 @@ export interface SharedAmbience extends Ambience {
|
|
|
105
138
|
/** Leader-side hook: the host publishes the playing track's attribution
|
|
106
139
|
* (and playlist position + progress) so remote tabs show it too.
|
|
107
140
|
* `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
|
|
108
|
-
* now-playing row; nulls mean unknown.
|
|
109
|
-
|
|
141
|
+
* now-playing row; nulls mean unknown. `download` (jatui-0vxcu) carries
|
|
142
|
+
* the track's download info when its licence permits redistribution —
|
|
143
|
+
* null clears it. `shuffle` (jatui-plbzq) publishes the playing
|
|
144
|
+
* playlist's shuffle state for the remotes' Shuffle toggle. */
|
|
145
|
+
setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null, download?: SharedTrackDownload | null, shuffle?: boolean | null): void;
|
|
110
146
|
/** Now-playing-row transport (jatui-153tz): skip / go back one track on a
|
|
111
147
|
* playlist scape. A remote routes the command to the leader's instance. */
|
|
112
148
|
skipTrack(id: string): void;
|
|
113
149
|
prevTrack(id: string): void;
|
|
150
|
+
/** Shuffle transport (jatui-plbzq): set the playing playlist's shuffle
|
|
151
|
+
* state. A remote routes the command to the leader's instance. */
|
|
152
|
+
shuffleTrack(id: string, on: boolean): void;
|
|
114
153
|
/** Release the lock, stop the heartbeat and close the channel. The local
|
|
115
154
|
* engine is killed if this tab was the leader. */
|
|
116
155
|
destroy(): void;
|
package/dist/ambience/shared.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { createAmbience as createLocalEngine } from './engine';
|
|
44
44
|
import { isPlaylistScape } from './scapes/playlist';
|
|
45
|
+
import { safeAmbienceHref } from './groups';
|
|
45
46
|
import { loadPersisted, savePersisted } from './persist';
|
|
46
47
|
/** BroadcastChannel name — the ambience control channel for the origin. */
|
|
47
48
|
export const AMBIENCE_CHANNEL = 'jatui:ambience';
|
|
@@ -58,6 +59,27 @@ export const SHARED_TIMING = Object.freeze({
|
|
|
58
59
|
/** A remote play command waits this long (ms) for the leader's echo. */
|
|
59
60
|
REMOTE_PLAY_TIMEOUT_MS: 2500
|
|
60
61
|
});
|
|
62
|
+
/**
|
|
63
|
+
* A published download re-validated on the CONSUMING side (jatui-0vxcu):
|
|
64
|
+
* the leader is another tab, not a trusted source — its `url` must survive
|
|
65
|
+
* `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
|
|
66
|
+
* scheme-relative url from a compromised manifest or a hostile tab would
|
|
67
|
+
* otherwise reach this tab's `window.open` fallback) and the filename and
|
|
68
|
+
* licence must be usable strings. Anything else returns null — the remote
|
|
69
|
+
* tab's download button hides. The validated url replaces the original.
|
|
70
|
+
*/
|
|
71
|
+
export function safeSharedTrackDownload(download) {
|
|
72
|
+
if (!download || typeof download !== 'object')
|
|
73
|
+
return null;
|
|
74
|
+
const url = safeAmbienceHref(download.url);
|
|
75
|
+
if (!url)
|
|
76
|
+
return null;
|
|
77
|
+
if (typeof download.filename !== 'string' || download.filename.trim() === '')
|
|
78
|
+
return null;
|
|
79
|
+
if (typeof download.license !== 'string' || download.license.trim() === '')
|
|
80
|
+
return null;
|
|
81
|
+
return { ...download, url };
|
|
82
|
+
}
|
|
61
83
|
function randomTabId() {
|
|
62
84
|
try {
|
|
63
85
|
return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
@@ -120,6 +142,10 @@ export function createSharedAmbience(options = {}) {
|
|
|
120
142
|
let nowPlayingTitle = null;
|
|
121
143
|
let nowPlayingPosition = null;
|
|
122
144
|
let nowPlayingDuration = null;
|
|
145
|
+
/** The published track's download info (jatui-0vxcu). */
|
|
146
|
+
let nowPlayingDownload = null;
|
|
147
|
+
/** The published playlist's shuffle state (jatui-plbzq). */
|
|
148
|
+
let nowPlayingShuffle = null;
|
|
123
149
|
const ambienceSubs = new Set();
|
|
124
150
|
const sharedSubs = new Set();
|
|
125
151
|
function sharedState() {
|
|
@@ -137,7 +163,9 @@ export function createSharedAmbience(options = {}) {
|
|
|
137
163
|
trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null),
|
|
138
164
|
trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
|
|
139
165
|
trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
|
|
140
|
-
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null)
|
|
166
|
+
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null),
|
|
167
|
+
trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null),
|
|
168
|
+
trackShuffle: role === 'leader' ? nowPlayingShuffle : (remoteS?.trackShuffle ?? null)
|
|
141
169
|
};
|
|
142
170
|
}
|
|
143
171
|
function ambienceState() {
|
|
@@ -206,6 +234,8 @@ export function createSharedAmbience(options = {}) {
|
|
|
206
234
|
nowPlayingTitle = null;
|
|
207
235
|
nowPlayingPosition = null;
|
|
208
236
|
nowPlayingDuration = null;
|
|
237
|
+
nowPlayingDownload = null;
|
|
238
|
+
nowPlayingShuffle = null;
|
|
209
239
|
remoteS = null;
|
|
210
240
|
if (heartbeatTimer) {
|
|
211
241
|
clearInterval(heartbeatTimer);
|
|
@@ -404,7 +434,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
404
434
|
// The leader tab is closing: mark it gone at once — no staleness
|
|
405
435
|
// wait, and never a surprise autoplay in a background tab.
|
|
406
436
|
if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
|
|
407
|
-
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
437
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
|
|
408
438
|
leaderGone = true;
|
|
409
439
|
emitShared();
|
|
410
440
|
}
|
|
@@ -445,6 +475,14 @@ export function createSharedAmbience(options = {}) {
|
|
|
445
475
|
p?.prev();
|
|
446
476
|
break;
|
|
447
477
|
}
|
|
478
|
+
case 'shuffle': {
|
|
479
|
+
// The Shuffle toggle (jatui-plbzq): routed to the leader's own
|
|
480
|
+
// playlist scape instance (its preference persists there).
|
|
481
|
+
if (!msg.id)
|
|
482
|
+
return;
|
|
483
|
+
playlistFor(msg.id)?.setShuffle((msg.arg ?? 0) === 1);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
448
486
|
case 'setVolume':
|
|
449
487
|
applyVolume(msg.arg ?? userVolume);
|
|
450
488
|
break;
|
|
@@ -487,7 +525,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
487
525
|
if (remoteS?.playing &&
|
|
488
526
|
lastBeatAt > 0 &&
|
|
489
527
|
Date.now() - lastBeatAt > staleMs) {
|
|
490
|
-
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
528
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
|
|
491
529
|
leaderGone = true;
|
|
492
530
|
emitShared();
|
|
493
531
|
}
|
|
@@ -571,7 +609,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
571
609
|
post({ t: 'cmd', cmd: 'stop', from: tabId });
|
|
572
610
|
// Optimistic: the leader echoes the authoritative state right after.
|
|
573
611
|
if (remoteS)
|
|
574
|
-
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
612
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
|
|
575
613
|
emitShared();
|
|
576
614
|
}
|
|
577
615
|
/** Freeze in place (the scape is not torn down; a playlist keeps its track
|
|
@@ -618,6 +656,16 @@ export function createSharedAmbience(options = {}) {
|
|
|
618
656
|
}
|
|
619
657
|
post({ t: 'cmd', cmd: 'prev', id, from: tabId });
|
|
620
658
|
}
|
|
659
|
+
/** Shuffle transport (jatui-plbzq): set the playing playlist's shuffle
|
|
660
|
+
* state — routed to the leader's live instance when this tab is a
|
|
661
|
+
* remote (its preference persists there). */
|
|
662
|
+
function shuffleTrack(id, on) {
|
|
663
|
+
if (role === 'leader') {
|
|
664
|
+
playlistFor(id)?.setShuffle(on);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
post({ t: 'cmd', cmd: 'shuffle', id, arg: on ? 1 : 0, from: tabId });
|
|
668
|
+
}
|
|
621
669
|
function setVolume(v) {
|
|
622
670
|
applyVolume(v);
|
|
623
671
|
savePersisted(storageKey, {
|
|
@@ -687,6 +735,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
687
735
|
resume,
|
|
688
736
|
skipTrack,
|
|
689
737
|
prevTrack,
|
|
738
|
+
shuffleTrack,
|
|
690
739
|
kill: () => {
|
|
691
740
|
// A remote has nothing local to kill — tell the leader instead.
|
|
692
741
|
if (role === 'leader')
|
|
@@ -697,12 +746,14 @@ export function createSharedAmbience(options = {}) {
|
|
|
697
746
|
setVolume,
|
|
698
747
|
setMuted,
|
|
699
748
|
toggleMute,
|
|
700
|
-
setNowPlaying(line, index, position, duration, title) {
|
|
749
|
+
setNowPlaying(line, index, position, duration, title, download, shuffle) {
|
|
701
750
|
nowPlaying = line;
|
|
702
751
|
nowPlayingIndex = line ? (index ?? null) : null;
|
|
703
752
|
nowPlayingPosition = line ? (position ?? null) : null;
|
|
704
753
|
nowPlayingDuration = line ? (duration ?? null) : null;
|
|
705
754
|
nowPlayingTitle = line ? (title ?? null) : null;
|
|
755
|
+
nowPlayingDownload = line ? (download ?? null) : null;
|
|
756
|
+
nowPlayingShuffle = line ? (shuffle ?? null) : null;
|
|
706
757
|
if (role === 'leader') {
|
|
707
758
|
publish();
|
|
708
759
|
emitShared();
|
package/dist/ambience/types.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export interface ScapeContext {
|
|
|
19
19
|
lookahead: (cb: (now: number) => void) => () => void;
|
|
20
20
|
/** Deterministic-ish random source scoped to the scape instance. */
|
|
21
21
|
rand: () => number;
|
|
22
|
+
/** The engine's CURRENT master volume, 0..1 (jatui-plbzq). Optional —
|
|
23
|
+
* only scapes that play OUTSIDE the audio graph need it: a radio
|
|
24
|
+
* stream's plain `<audio>` element mirrors `volume() × `its own gain
|
|
25
|
+
* onto `el.volume`, refreshed on the lookahead tick, so the volume
|
|
26
|
+
* slider and cross-tab mute reach it too. Hand-made test contexts may
|
|
27
|
+
* omit it (the scape then reads full volume). */
|
|
28
|
+
volume?: () => number;
|
|
22
29
|
}
|
|
23
30
|
export interface ScapeInstance {
|
|
24
31
|
/** Begin sounding at audio time `at` (usually ctx.currentTime). */
|