@joewinke/jatui 0.62.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,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
+ }
@@ -121,6 +121,10 @@ export interface SharedAmbienceState {
121
121
  * null when nothing is playing or the licence forbids redistribution
122
122
  * (jatui-0vxcu). A remote tab's download button reads this. */
123
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;
124
128
  }
125
129
  /** An Ambience whose playback is shared across tabs (see module doc). */
126
130
  export interface SharedAmbience extends Ambience {
@@ -136,12 +140,16 @@ export interface SharedAmbience extends Ambience {
136
140
  * `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
137
141
  * now-playing row; nulls mean unknown. `download` (jatui-0vxcu) carries
138
142
  * the track's download info when its licence permits redistribution —
139
- * null clears it. */
140
- setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null, download?: SharedTrackDownload | null): void;
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;
141
146
  /** Now-playing-row transport (jatui-153tz): skip / go back one track on a
142
147
  * playlist scape. A remote routes the command to the leader's instance. */
143
148
  skipTrack(id: string): void;
144
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;
145
153
  /** Release the lock, stop the heartbeat and close the channel. The local
146
154
  * engine is killed if this tab was the leader. */
147
155
  destroy(): void;
@@ -144,6 +144,8 @@ export function createSharedAmbience(options = {}) {
144
144
  let nowPlayingDuration = null;
145
145
  /** The published track's download info (jatui-0vxcu). */
146
146
  let nowPlayingDownload = null;
147
+ /** The published playlist's shuffle state (jatui-plbzq). */
148
+ let nowPlayingShuffle = null;
147
149
  const ambienceSubs = new Set();
148
150
  const sharedSubs = new Set();
149
151
  function sharedState() {
@@ -162,7 +164,8 @@ export function createSharedAmbience(options = {}) {
162
164
  trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
163
165
  trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
164
166
  trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null),
165
- trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null)
167
+ trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null),
168
+ trackShuffle: role === 'leader' ? nowPlayingShuffle : (remoteS?.trackShuffle ?? null)
166
169
  };
167
170
  }
168
171
  function ambienceState() {
@@ -232,6 +235,7 @@ export function createSharedAmbience(options = {}) {
232
235
  nowPlayingPosition = null;
233
236
  nowPlayingDuration = null;
234
237
  nowPlayingDownload = null;
238
+ nowPlayingShuffle = null;
235
239
  remoteS = null;
236
240
  if (heartbeatTimer) {
237
241
  clearInterval(heartbeatTimer);
@@ -471,6 +475,14 @@ export function createSharedAmbience(options = {}) {
471
475
  p?.prev();
472
476
  break;
473
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
+ }
474
486
  case 'setVolume':
475
487
  applyVolume(msg.arg ?? userVolume);
476
488
  break;
@@ -644,6 +656,16 @@ export function createSharedAmbience(options = {}) {
644
656
  }
645
657
  post({ t: 'cmd', cmd: 'prev', id, from: tabId });
646
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
+ }
647
669
  function setVolume(v) {
648
670
  applyVolume(v);
649
671
  savePersisted(storageKey, {
@@ -713,6 +735,7 @@ export function createSharedAmbience(options = {}) {
713
735
  resume,
714
736
  skipTrack,
715
737
  prevTrack,
738
+ shuffleTrack,
716
739
  kill: () => {
717
740
  // A remote has nothing local to kill — tell the leader instead.
718
741
  if (role === 'leader')
@@ -723,13 +746,14 @@ export function createSharedAmbience(options = {}) {
723
746
  setVolume,
724
747
  setMuted,
725
748
  toggleMute,
726
- setNowPlaying(line, index, position, duration, title, download) {
749
+ setNowPlaying(line, index, position, duration, title, download, shuffle) {
727
750
  nowPlaying = line;
728
751
  nowPlayingIndex = line ? (index ?? null) : null;
729
752
  nowPlayingPosition = line ? (position ?? null) : null;
730
753
  nowPlayingDuration = line ? (duration ?? null) : null;
731
754
  nowPlayingTitle = line ? (title ?? null) : null;
732
755
  nowPlayingDownload = line ? (download ?? null) : null;
756
+ nowPlayingShuffle = line ? (shuffle ?? null) : null;
733
757
  if (role === 'leader') {
734
758
  publish();
735
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). */
@@ -26,12 +26,18 @@
26
26
  * - A playing (or paused) scape shows a now-playing block above the
27
27
  * volume row (jatui-153tz): for a playlist — the track title, its
28
28
  * composer · license attribution, an elapsed/total progress bar and a
29
- * prev / play-pause / next transport (prev restarts the track when more
30
- * than 3 s are in, else goes back one; play-pause freezes the station
31
- * without closing the menu; next skips). Generative scapes (noise /
32
- * focus) get the play/pause control only. The transport joins the
33
- * menu's roving order; a paused row stays visible (its play button is
34
- * the resume) and a stopped one hides.
29
+ * shuffle / prev / play-pause / next transport (shuffle is an
30
+ * aria-pressed IconButton, persisted per station and fresh-shuffled
31
+ * per session with no-repeat — jatui-plbzq; prev restarts the track
32
+ * when more than 3 s are in, else goes back one; play-pause freezes
33
+ * the station without closing the menu; next skips). Generative
34
+ * scapes (noise / focus) get the play/pause control only. A radio
35
+ * station registered with a public `streamUrl` (jatui-plbzq) gets the
36
+ * station name, a `Open station site` attribution link and
37
+ * play/pause — the stream is fetched by the browser from the station
38
+ * itself, never relayed. The transport joins the menu's roving order;
39
+ * a paused row stays visible (its play button is the resume) and a
40
+ * stopped one hides.
35
41
  * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
36
42
  * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
37
43
  * marks the track `downloadable: true`) also get a Download button
@@ -98,6 +104,14 @@
98
104
  return `${track.title} — ${trackAttribution(track)}`;
99
105
  }
100
106
 
107
+ /** The radio row's attribution href (jatui-plbzq): the scape's site url
108
+ * re-validated with `safeAmbienceHref` — the registry could be hostile
109
+ * on a remote tab too, and the leader is another tab, not a source of
110
+ * trust. Null hides the 'Open station site' link. */
111
+ export function radioSite(scape: RadioStreamScape | null | undefined): string | null {
112
+ return scape ? safeAmbienceHref(scape.href) : null;
113
+ }
114
+
101
115
  /** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
102
116
  export function formatTrackTime(seconds: number | null | undefined): string {
103
117
  if (
@@ -243,6 +257,7 @@
243
257
  import { getMotionContext } from '../motion/level';
244
258
  import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
245
259
  import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
260
+ import { isRadioScape, type RadioStreamScape, type RadioStreamState } from '../ambience/scapes/radio';
246
261
  import {
247
262
  isTrackDownloadable,
248
263
  trackDownloadFilename
@@ -321,6 +336,10 @@
321
336
  * now-playing row's progress bar. Null on plain/remote tabs without a
322
337
  * local playing instance. */
323
338
  let playlist = $state<PlaylistState | null>(null);
339
+ /** The current radio scape's live snapshot (jatui-plbzq) — its error
340
+ * line (a station that stalls or dies) and status. Null on remote tabs
341
+ * without a local playing instance. */
342
+ let radioState = $state<RadioStreamState | null>(null);
324
343
  /** The shared player's now-playing line (jatui-lm1mf): published by the
325
344
  * leader tab, so a remote tab shows the same track attribution without
326
345
  * owning the playlist instance. Null on a plain engine. */
@@ -335,6 +354,9 @@
335
354
  * download button reads this; null hides the button (not published, or
336
355
  * the licence forbids redistribution). */
337
356
  let sharedDownload = $state<SharedTrackDownload | null>(null);
357
+ /** The leader's published playlist shuffle state (jatui-plbzq) — a
358
+ * remote tab's Shuffle toggle reads this; null when unknown. */
359
+ let sharedShuffle = $state<boolean | null>(null);
338
360
  /** True while a download fetch is in flight — the button shows its busy
339
361
  * spinner and refuses re-entry. The fetch runs beside playback; nothing
340
362
  * here touches the engine. */
@@ -367,13 +389,31 @@
367
389
  * published state and routes transport commands to it. */
368
390
  const isRemote = $derived(sharedRole === 'remote');
369
391
  const currentIsPlaylist = $derived(isPlaylistScape(currentItem));
392
+ /** The current scape is a radio stream (jatui-plbzq): its now-playing
393
+ * row shows the station name + the 'Open station site' attribution
394
+ * link, and play/pause only — no tracks to skip, no download. */
395
+ const currentIsRadio = $derived(isRadioScape(currentItem));
396
+ /** The current radio scape, narrowed — carries the validated site url
397
+ * even on a remote tab (the registry is the same everywhere). */
398
+ const currentRadio = $derived(currentIsRadio ? (currentItem as unknown as RadioStreamScape) : null);
399
+ const radioSiteHref = $derived(radioSite(currentRadio));
400
+ /** The radio row's error line — leader/local only (a remote has no
401
+ * local instance to report through). */
402
+ const radioError = $derived(radioState?.status === 'error' ? (radioState.error ?? '') : '');
403
+ /** The Shuffle toggle's pressed state (jatui-plbzq): the playing
404
+ * playlist's own truth locally; the leader's published one remotely. */
405
+ const shufflePressed = $derived(
406
+ currentIsPlaylist ? (isRemote ? (sharedShuffle ?? playlist?.shuffle ?? false) : (playlist?.shuffle ?? false)) : false
407
+ );
370
408
  /** The row exists while something is sounding or paused — a paused row
371
409
  * must stay (its play button is how you resume); a stopped one hides. */
372
410
  const showNowPlaying = $derived((playing || paused) && !!currentItem);
373
411
  const npTitle = $derived(
374
- isRemote
375
- ? (sharedTitle ?? '')
376
- : (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
412
+ currentIsRadio
413
+ ? (currentRadio?.label ?? '')
414
+ : isRemote
415
+ ? (sharedTitle ?? '')
416
+ : (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
377
417
  );
378
418
  const npAttrib = $derived(
379
419
  isRemote
@@ -470,6 +510,7 @@
470
510
  sharedPosition = s.trackPosition;
471
511
  sharedDuration = s.trackDuration;
472
512
  sharedDownload = s.trackDownload ?? null;
513
+ sharedShuffle = s.trackShuffle;
473
514
  });
474
515
  });
475
516
 
@@ -505,11 +546,29 @@
505
546
  license: t.license,
506
547
  ...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
507
548
  }
508
- : null
549
+ : null,
550
+ // The Shuffle toggle's truth (jatui-plbzq): published so a remote
551
+ // tab renders the leader's shuffle state.
552
+ s.shuffle
509
553
  );
510
554
  });
511
555
  });
512
556
 
557
+ // The current scape is a radio stream (jatui-plbzq) → follow its state
558
+ // for the error line (a stream that stalls or dies). The row's station
559
+ // name and attribution link come from the registered item itself, so a
560
+ // remote tab needs no publication to render them.
561
+ $effect(() => {
562
+ const item = currentItem;
563
+ if (!isRadioScape(item)) {
564
+ radioState = null;
565
+ return;
566
+ }
567
+ return item.subscribe((s) => {
568
+ radioState = s;
569
+ });
570
+ });
571
+
513
572
  // Breathing glyph: follow the engine's shared breath clock while playing,
514
573
  // and only where ambient motion is allowed (getMotionContext().ambient is
515
574
  // false under prefers-reduced-motion AND under data-motion="calm"). The
@@ -766,6 +825,21 @@
766
825
  if (isPlaylistScape(currentItem)) currentItem.skip();
767
826
  }
768
827
 
828
+ /** The Shuffle toggle (jatui-plbzq): flip the playing playlist's
829
+ * shuffle state — the per-station preference persists, and a session's
830
+ * order is freshly shuffled with recently-heard tracks last. A remote
831
+ * routes the command to the leader (its instance owns the running
832
+ * order and the persisted preference). */
833
+ function onShuffleClick() {
834
+ const next = !shufflePressed;
835
+ const e = engine as SharedAmbience;
836
+ if (isRemote && typeof e.shuffleTrack === 'function' && currentId) {
837
+ e.shuffleTrack(currentId, next);
838
+ return;
839
+ }
840
+ if (isPlaylistScape(currentItem)) currentItem.setShuffle(next);
841
+ }
842
+
769
843
  /**
770
844
  * The download button (jatui-0vxcu). A cross-origin `<a download>` is
771
845
  * ignored by browsers, so the file is fetched to a Blob and saved through
@@ -1115,7 +1189,45 @@
1115
1189
  {#if npTitle}
1116
1190
  <p class="block truncate text-sm font-medium" title={npTitle}>{npTitle}</p>
1117
1191
  {/if}
1118
- {#if npAttrib}
1192
+ {#if currentIsRadio}
1193
+ <!-- Radio (jatui-plbzq): the station's own site is the
1194
+ attribution — a real link, kept even though the stream
1195
+ now plays right here (it is fetched by the browser from
1196
+ the station, never relayed). A stream that stalls or
1197
+ dies says so instead of playing silence. -->
1198
+ <div class="text-2xs mt-0.5 flex items-center gap-2">
1199
+ {#if radioSiteHref}
1200
+ <a
1201
+ href={radioSiteHref}
1202
+ target="_blank"
1203
+ rel="noopener noreferrer"
1204
+ tabindex="-1"
1205
+ class="inline-flex min-w-0 items-center gap-1 text-primary
1206
+ hover:underline focus-visible:underline"
1207
+ >
1208
+ <span class="truncate">Open station site</span>
1209
+ <svg
1210
+ xmlns="http://www.w3.org/2000/svg"
1211
+ viewBox="0 0 24 24"
1212
+ fill="none"
1213
+ stroke="currentColor"
1214
+ stroke-width="2"
1215
+ stroke-linecap="round"
1216
+ stroke-linejoin="round"
1217
+ class="size-3 shrink-0"
1218
+ aria-hidden="true"
1219
+ >
1220
+ <path d="M15 3h6v6" />
1221
+ <path d="M10 14 21 3" />
1222
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
1223
+ </svg>
1224
+ </a>
1225
+ {/if}
1226
+ {#if radioError}
1227
+ <span class="truncate text-error" title={radioError}>{radioError}</span>
1228
+ {/if}
1229
+ </div>
1230
+ {:else if npAttrib}
1119
1231
  <p class="text-2xs text-base-content/55 block truncate" title={npAttrib}>{npAttrib}</p>
1120
1232
  {/if}
1121
1233
  {#if npDuration > 0}
@@ -1136,6 +1248,43 @@
1136
1248
  </div>
1137
1249
  {/if}
1138
1250
  <div class="mt-1.5 flex items-center justify-center gap-1">
1251
+ {#if currentIsPlaylist}
1252
+ <!-- Shuffle (jatui-plbzq): an aria-pressed toggle over the
1253
+ playlist's fresh-per-session order (recently-heard tracks
1254
+ last). Persisted per station; a remote routes the toggle
1255
+ to the leader. -->
1256
+ <IconButton
1257
+ label="Shuffle"
1258
+ variant="ghost"
1259
+ size="sm"
1260
+ shape="circle"
1261
+ tooltip="Shuffle"
1262
+ tooltipPlacement="top"
1263
+ pressed={shufflePressed}
1264
+ tabindex={-1}
1265
+ data-ambience-nav
1266
+ onclick={onShuffleClick}
1267
+ >
1268
+ <svg
1269
+ xmlns="http://www.w3.org/2000/svg"
1270
+ viewBox="0 0 24 24"
1271
+ fill="none"
1272
+ stroke="currentColor"
1273
+ stroke-width="2"
1274
+ stroke-linecap="round"
1275
+ stroke-linejoin="round"
1276
+ class="size-4 {shufflePressed ? 'text-primary' : ''}"
1277
+ aria-hidden="true"
1278
+ >
1279
+ <!-- shuffle, lucide geometry -->
1280
+ <path d="m18 14 4 4-4 4" />
1281
+ <path d="m18 2 4 4-4 4" />
1282
+ <path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
1283
+ <path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
1284
+ <path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
1285
+ </svg>
1286
+ </IconButton>
1287
+ {/if}
1139
1288
  {#if currentIsPlaylist}
1140
1289
  <button
1141
1290
  type="button"