@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.
@@ -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
- setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null): void;
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;
@@ -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();
@@ -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). */