@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.
package/CLAUDE.md CHANGED
@@ -336,7 +336,7 @@ const rec = createAudioRecorder({
336
336
 
337
337
  ### Ambience (subpath: `jatui/ambience`, components on the root)
338
338
 
339
- `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only; plus a Download IconButton for tracks whose licence permits redistribution — allowlist CC0/public-domain/CC-BY/CC-BY-SA via `isTrackDownloadable` (NC/ND refused unless the manifest flags `downloadable: true`); the url must survive `safeAmbienceHref` (absolute http/https — before the fetch AND the new-tab CORS-failure fallback; remotes re-validate the leader's publication via `safeSharedTrackDownload`); fetch→Blob→`a[download="Composer - Title.ext"]` (`trackDownloadFilename`); licence + source in the tooltip and the `aria-describedby` line; busy spinner never blocks playback) + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration/trackDownload`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `isTrackDownloadable`, `trackLicenseAllowsRedistribution`, `trackDownloadFilename`, `safeSharedTrackDownload`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
339
+ `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, and a shuffle / prev / play-pause / next transport — the Shuffle toggle is an `aria-pressed` IconButton, persisted per station and defaulting to the scape's registered `shuffle` option; a session's order is FRESHLY shuffled — never the engine's scape-id-seeded rand, which made every session identical — with recently-heard tracks pushed to the end of the new order (no-repeat across sessions; per-station recents in localStorage `jatui:ambience:recents:<id>`, guarded, capped `RECENTS_CAP` 200 by track url; the window resets once the whole station has been heard) — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only; plus a Download IconButton for tracks whose licence permits redistribution — allowlist CC0/public-domain/CC-BY/CC-BY-SA via `isTrackDownloadable` (NC/ND refused unless the manifest flags `downloadable: true`); the url must survive `safeAmbienceHref` (absolute http/https — before the fetch AND the new-tab CORS-failure fallback; remotes re-validate the leader's publication via `safeSharedTrackDownload`); fetch→Blob→`a[download="Composer - Title.ext"]` (`trackDownloadFilename`); licence + source in the tooltip and the `aria-describedby` line; busy spinner never blocks playback) + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href, streamUrl?})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. With a public `streamUrl` (absolute http(s) via `safeAmbienceHref`; a bad scheme is refused and degrades to the link) the station becomes a PLAYABLE scape (`scapes/radio.ts`, `isRadioScape`): the browser fetches the stream client-side through a plain `<audio>` element — deliberately outside the Web Audio graph (a MediaElementSourceNode without CORS headers is silenced by the tainted-graph rule, and station streams rarely send CORS) — with loudness mirroring the engine master via the optional `ScapeContext.volume` hook (`el.volume = gain × master`, refreshed each lookahead tick, so volume + cross-tab mute still apply); the now-playing row shows the station name, the `Open station site` attribution link and an error line when the stream stalls; play/pause only. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration/shuffle` + `skip()`/`prev()`/`setShuffle()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration/trackDownload/trackShuffle`) and route pause/resume/skip/prev/shuffle via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `isRadioScape`, `trackAttribution`, `isTrackDownloadable`, `trackLicenseAllowsRedistribution`, `trackDownloadFilename`, `safeSharedTrackDownload`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
340
340
 
341
341
  ### Sound (first-class sibling of motion)
342
342
 
@@ -49,7 +49,8 @@ inline `tracks` or a `manifestUrl`:
49
49
  | `manifestUrl` | Fetched **once, lazily, on the first play** — never at import. A failed fetch is not cached; the next play retries it. |
50
50
  | `tracks` | Inline track list — no fetch at all (resolved eagerly, a pure local filter). |
51
51
  | `crossfade` | Track-to-track crossfade in seconds, 0–30 (default 3). |
52
- | `shuffle` | Fisher–Yates play order over the engine's per-scape PRNG (default off). `setShuffle()` toggles live: the current track stays, the rest reorder. |
52
+ | `shuffle` | Fisher–Yates play order (default off). `setShuffle()` toggles live: the current track stays, the rest reorder. The order is FRESH per session — a crypto-seeded source (`freshShuffleRand()`), never the engine's scape-id-seeded rand, which produced the same "shuffled" order every session (jatui-plbzq). Recently-heard tracks close the new order (no-repeat across sessions, see below). The toggle state persists per station (`jatui:ambience:shuffle:<id>`) and the scape's registered `shuffle` stays the default until the listener toggles. |
53
+ | `shuffleRand` | Injectable shuffle source for tests (default: a fresh `freshShuffleRand()` per play). |
53
54
  | `gain` | Initial gain of the scape mix into the master chain, 0–1 (default 1). `setParam('gain', v)` ramps it live. |
54
55
  | `fetchFn`, `createMediaElement` | Injectable for tests (default `fetch` / `new Audio(url)`). |
55
56
 
@@ -91,6 +92,17 @@ serving the audio must send CORS headers** (`Access-Control-Allow-Origin`);
91
92
  a non-CORS source then fails loudly per track (the scape skips to the next
92
93
  track) instead of playing silence into a tainted graph.
93
94
 
95
+ ### No-repeat across sessions (jatui-plbzq)
96
+
97
+ Every track that starts sounding is remembered per station: localStorage
98
+ `jatui:ambience:recents:<scapeId>` (guarded try/catch, capped at
99
+ `RECENTS_CAP` = 200, ids are the tracks' urls — so the memory is shared
100
+ across tabs of the same origin). A shuffled order leads with unheard
101
+ tracks and closes with the recently-heard ones, so a returning listener
102
+ does not hear the same first tracks; when the WHOLE station has been
103
+ heard the window resets and a plain fresh shuffle plays. The rule applies
104
+ at play start, on cycle wrap and on a mid-play `setShuffle(true)`.
105
+
94
106
  ### Behavior notes
95
107
 
96
108
  - Two ping-pong slots (element + source node + gain each) are **built once per
@@ -125,10 +137,42 @@ scape.subscribe((s) => {
125
137
  });
126
138
  scape.skip(); // crossfade to the next track now
127
139
  scape.prev(); // restart the track when >3 s are in, else the previous track
128
- scape.setShuffle(true);
140
+ scape.setShuffle(true); // persisted per station; state().shuffle reports it
129
141
  scape.tracks(); // resolved list, or null until the manifest resolved
130
142
  ```
131
143
 
144
+ ## Radio stream scape — a station's public stream in the player (jatui-plbzq)
145
+
146
+ `src/lib/ambience/scapes/radio.ts` — `radioStation({ id, label, href,
147
+ streamUrl })` builds an outbound link as before when `streamUrl` is
148
+ absent; with one, it builds a PLAYABLE scape: the station's public
149
+ stream, fetched by the BROWSER from the station (never relayed, never
150
+ proxied through the host).
151
+
152
+ - The stream url must be an absolute http(s) one (`safeAmbienceHref`) —
153
+ a bad scheme is refused with a console warning and the station degrades
154
+ to its link behavior. The station-site `href` stays the attribution
155
+ target (validated again when the row renders).
156
+ - The element is a plain `<audio>`, deliberately OUTSIDE the Web Audio
157
+ graph: a `MediaElementAudioSourceNode` without CORS headers is silenced
158
+ by the tainted-graph rule, and station streams rarely send
159
+ `Access-Control-Allow-Origin` (a `crossOrigin='anonymous'` load would
160
+ fail outright). Loudness mirrors the engine through the optional
161
+ `ScapeContext.volume` hook: `el.volume = scape gain × master volume`,
162
+ refreshed on the lookahead tick — so the volume slider and cross-tab
163
+ mute apply. A stop fades the element volume in steps, wall-clock.
164
+ - The scape's `state()`/`subscribe()` surface (`radio: true`) reports
165
+ `status`, `station`, `href` and an `error` line for a stream that
166
+ stalls or dies. The toggle renders the station name, an
167
+ `Open station site` link and play/pause only.
168
+ - `createRadioStreamScape()` is the low-level factory (throws on a bad
169
+ `streamUrl`); `radioStation()` is the public shape that picks link vs
170
+ scape.
171
+
172
+ **The host is responsible for a station's terms**: only add a
173
+ `streamUrl` for stations that publish their stream and allow third-party
174
+ players — jatui never relays audio and never claims the right to play.
175
+
132
176
  Cast a scape from `ambience.list()` to `PlaylistScape` in the toggle to reach
133
177
  it. The toggle renders the attribution line — `composer · performer · license`
134
178
  — linked to `sourceUrl` while a playlist scape is playing, and (jatui-153tz) a
@@ -182,7 +182,10 @@ export function createAmbience(options = {}) {
182
182
  lookahead: scheduleLookaheadFor(instCbs),
183
183
  // Fresh PRNG per build: a shared stream would interleave between the
184
184
  // outgoing and incoming scape during a crossfade.
185
- rand: makeRand(id)
185
+ rand: makeRand(id),
186
+ // The live master volume — a scape that plays OUTSIDE the graph (a
187
+ // radio stream's plain element) mirrors it onto el.volume (jatui-plbzq).
188
+ volume: () => volume
186
189
  };
187
190
  let inst;
188
191
  try {
@@ -29,17 +29,13 @@ export declare function ambienceGroupLabel(group: ScapeGroup, defs?: readonly Am
29
29
  * groups appended in first-seen order.
30
30
  */
31
31
  export declare function groupAmbienceItems<T extends AmbienceItem>(items: readonly T[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceSection<T>[];
32
- /** Build a radio-station link item (group 'radio' unless the host says otherwise). */
33
- export declare function radioStation(options: {
34
- id: string;
35
- label: string;
36
- href: string;
37
- description?: string;
38
- group?: ScapeGroup;
39
- }): AmbienceLink;
40
32
  /**
41
33
  * The href the toggle may open: absolute http(s) only. A `javascript:` or
42
34
  * `data:` href from a host's station list would run in the page, so it is
43
35
  * refused (null) and the item renders disabled.
36
+ *
37
+ * `radioStation()` (the station-item factory, optionally WITH a playable
38
+ * `streamUrl`) lives in `./scapes/radio` — it needs the stream scape, and
39
+ * this module must stay import-free of the scapes.
44
40
  */
45
41
  export declare function safeAmbienceHref(href: string | null | undefined): string | null;
@@ -69,21 +69,14 @@ export function groupAmbienceItems(items, groups) {
69
69
  }
70
70
  return out;
71
71
  }
72
- /** Build a radio-station link item (group 'radio' unless the host says otherwise). */
73
- export function radioStation(options) {
74
- return {
75
- kind: 'link',
76
- id: options.id,
77
- label: options.label,
78
- group: options.group ?? 'radio',
79
- href: options.href,
80
- description: options.description
81
- };
82
- }
83
72
  /**
84
73
  * The href the toggle may open: absolute http(s) only. A `javascript:` or
85
74
  * `data:` href from a host's station list would run in the page, so it is
86
75
  * refused (null) and the item renders disabled.
76
+ *
77
+ * `radioStation()` (the station-item factory, optionally WITH a playable
78
+ * `streamUrl`) lives in `./scapes/radio` — it needs the stream scape, and
79
+ * this module must stay import-free of the scapes.
87
80
  */
88
81
  export function safeAmbienceHref(href) {
89
82
  if (typeof href !== 'string')
@@ -22,6 +22,7 @@ export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistSta
22
22
  export { BreathClock, BREATHS_PER_MINUTE } from './breath';
23
23
  export { MASTER } from './master';
24
24
  export { ScapeRegistry } from './registry';
25
- export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './groups';
25
+ export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, safeAmbienceHref } from './groups';
26
26
  export type { AmbienceSection } from './groups';
27
+ export { radioStation, createRadioStreamScape, isRadioScape, RADIO, type RadioStreamScape, type RadioStreamScapeOptions, type RadioStreamState, type RadioStreamStatus } from './scapes/radio';
27
28
  export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './types';
@@ -29,5 +29,10 @@ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAll
29
29
  export { BreathClock, BREATHS_PER_MINUTE } from './breath';
30
30
  export { MASTER } from './master';
31
31
  export { ScapeRegistry } from './registry';
32
- // Menu sections + outbound radio links (jatui-mdq2p).
33
- export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './groups';
32
+ // Menu sections + the outbound-radio-link helpers (jatui-mdq2p).
33
+ export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, safeAmbienceHref } from './groups';
34
+ // Radio stream scape (jatui-plbzq) — a radioStation() with a public
35
+ // streamUrl plays the station's stream right in the player (client-side
36
+ // fetch from the station, never relayed); without one it is the outbound
37
+ // link it always was.
38
+ export { radioStation, createRadioStreamScape, isRadioScape, RADIO } from './scapes/radio';
@@ -37,6 +37,22 @@
37
37
  * registry (jatui-poq30).
38
38
  */
39
39
  import type { Scape, ScapeGroup } from '../types';
40
+ /** Cap on the recently-played list per station — at ~200 entries a station
41
+ * remembers the last 200 heard tracks; a longer station simply never hits
42
+ * the cap (the no-repeat window is then always "the last 200"). */
43
+ export declare const RECENTS_CAP = 200;
44
+ /**
45
+ * A FRESH random source for one session's shuffle order (jatui-plbzq):
46
+ * seeded once from crypto.getRandomValues, then streamed as mulberry32 —
47
+ * the same PRNG quality the engine uses, but never the same twice.
48
+ *
49
+ * The engine's per-scape rand is seeded by the SCAPE ID, so shuffling with
50
+ * it produced the identical order every session — the bug that started
51
+ * this. The seeded rand stays available for tests through
52
+ * `PlaylistScapeOptions.shuffleRand` (and the pure helpers below still
53
+ * take any `rand` injection); the default here is the fresh source.
54
+ */
55
+ export declare function freshShuffleRand(): () => number;
40
56
  export declare const PLAYLIST: Readonly<{
41
57
  /** Crossfade between consecutive tracks (s). */
42
58
  CROSSFADE: 3;
@@ -99,6 +115,11 @@ export interface PlaylistState {
99
115
  /** Total seconds of the current track (0 until metadata reports one —
100
116
  * a stream without a duration stays 0). */
101
117
  duration: number;
118
+ /** Whether shuffled play is on right now (jatui-plbzq) — the transport's
119
+ * Shuffle toggle reads it and `setShuffle()` writes it. Persisted per
120
+ * station; the initial value is the stored preference, else the
121
+ * scape's `shuffle` option (stations register with `shuffle: true`). */
122
+ shuffle: boolean;
102
123
  /** Present on error. */
103
124
  error?: string;
104
125
  }
@@ -116,9 +137,16 @@ export interface PlaylistScapeOptions {
116
137
  tracks?: PlaylistTrack[];
117
138
  /** Track crossfade (s), 0–30. Default 3. */
118
139
  crossfade?: number;
119
- /** Randomized play order (Fisher–Yates over the engine's per-scape rand).
120
- * Default false. Takes effect for the running order immediately. */
140
+ /** Randomized play order (Fisher–Yates). Default false. Takes effect
141
+ * for the running order immediately. Since jatui-plbzq the order is
142
+ * FRESH per session — never the engine's seeded rand, which produced
143
+ * the same "shuffled" order every session — and recently-heard tracks
144
+ * are pushed to the end of the new order (no-repeat). */
121
145
  shuffle?: boolean;
146
+ /** Injectable shuffle source for tests (default: a fresh unseeded
147
+ * `freshShuffleRand()` per play). Passed the same way the old seeded
148
+ * engine rand was, so order assertions stay deterministic. */
149
+ shuffleRand?: () => number;
122
150
  /** Initial gain of the scape mix into the master chain, 0..1. Default 1. */
123
151
  gain?: number;
124
152
  /** Injectable for tests (default `fetch`). */
@@ -1,3 +1,114 @@
1
+ // ---- shuffle randomness + no-repeat memory (jatui-plbzq) --------------------
2
+ /** Cap on the recently-played list per station — at ~200 entries a station
3
+ * remembers the last 200 heard tracks; a longer station simply never hits
4
+ * the cap (the no-repeat window is then always "the last 200"). */
5
+ export const RECENTS_CAP = 200;
6
+ function recentsKey(scapeId) {
7
+ return `jatui:ambience:recents:${scapeId}`;
8
+ }
9
+ function shufflePrefKey(scapeId) {
10
+ return `jatui:ambience:shuffle:${scapeId}`;
11
+ }
12
+ /** The recently-played track ids for a station, most recent first. Guarded
13
+ * try/catch (private mode / disabled storage / SSR → empty — the shuffle
14
+ * still works, it just repeats across sessions). */
15
+ function loadRecents(scapeId) {
16
+ try {
17
+ if (typeof localStorage === 'undefined')
18
+ return new Set();
19
+ const raw = localStorage.getItem(recentsKey(scapeId));
20
+ if (!raw)
21
+ return new Set();
22
+ const parsed = JSON.parse(raw);
23
+ if (!Array.isArray(parsed))
24
+ return new Set();
25
+ return new Set(parsed.filter((s) => typeof s === 'string'));
26
+ }
27
+ catch {
28
+ return new Set();
29
+ }
30
+ }
31
+ function saveRecents(scapeId, ids) {
32
+ try {
33
+ if (typeof localStorage === 'undefined')
34
+ return;
35
+ localStorage.setItem(recentsKey(scapeId), JSON.stringify(ids.slice(0, RECENTS_CAP)));
36
+ }
37
+ catch {
38
+ /* private mode / quota — persistence is best-effort */
39
+ }
40
+ }
41
+ /** A track just started sounding: move its id to the front of the station's
42
+ * recents (dedup, capped). The station ids the track by its url — stable
43
+ * across sessions and, since recents live in localStorage, across tabs. */
44
+ function recordPlayed(scapeId, trackId) {
45
+ const list = [...loadRecents(scapeId)];
46
+ const at = list.indexOf(trackId);
47
+ if (at !== -1)
48
+ list.splice(at, 1);
49
+ list.unshift(trackId);
50
+ saveRecents(scapeId, list);
51
+ }
52
+ /** The persisted shuffle preference for a station (null = none saved). */
53
+ function loadShufflePref(scapeId) {
54
+ try {
55
+ if (typeof localStorage === 'undefined')
56
+ return null;
57
+ const raw = localStorage.getItem(shufflePrefKey(scapeId));
58
+ return raw === '1' ? true : raw === '0' ? false : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function saveShufflePref(scapeId, on) {
65
+ try {
66
+ if (typeof localStorage === 'undefined')
67
+ return;
68
+ localStorage.setItem(shufflePrefKey(scapeId), on ? '1' : '0');
69
+ }
70
+ catch {
71
+ /* private mode / quota — persistence is best-effort */
72
+ }
73
+ }
74
+ /**
75
+ * A FRESH random source for one session's shuffle order (jatui-plbzq):
76
+ * seeded once from crypto.getRandomValues, then streamed as mulberry32 —
77
+ * the same PRNG quality the engine uses, but never the same twice.
78
+ *
79
+ * The engine's per-scape rand is seeded by the SCAPE ID, so shuffling with
80
+ * it produced the identical order every session — the bug that started
81
+ * this. The seeded rand stays available for tests through
82
+ * `PlaylistScapeOptions.shuffleRand` (and the pure helpers below still
83
+ * take any `rand` injection); the default here is the fresh source.
84
+ */
85
+ export function freshShuffleRand() {
86
+ let s;
87
+ try {
88
+ const seed = new Uint32Array(1);
89
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
90
+ crypto.getRandomValues(seed);
91
+ s = seed[0];
92
+ }
93
+ else {
94
+ s = (Math.random() * 0x100000000) >>> 0;
95
+ }
96
+ }
97
+ catch {
98
+ s = (Math.random() * 0x100000000) >>> 0;
99
+ }
100
+ return mulberry32(s || 1);
101
+ }
102
+ function mulberry32(seed) {
103
+ let s = seed >>> 0;
104
+ return () => {
105
+ s = (s + 0x6d2b79f5) >>> 0;
106
+ let t = s;
107
+ t = Math.imul(t ^ (t >>> 15), t | 1);
108
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
109
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
110
+ };
111
+ }
1
112
  export const PLAYLIST = Object.freeze({
2
113
  /** Crossfade between consecutive tracks (s). */
3
114
  CROSSFADE: 3,
@@ -29,7 +140,10 @@ export function createPlaylistScape(options) {
29
140
  const crossfade = Math.min(30, Math.max(0, options.crossfade ?? PLAYLIST.CROSSFADE));
30
141
  const mixGain0 = Math.min(1, Math.max(0, options.gain ?? 1));
31
142
  const fetchFn = options.fetchFn ?? ((url) => fetch(url));
32
- let shuffle = options.shuffle ?? false;
143
+ // Shuffle preference: the stored per-station choice wins, then the
144
+ // scape's registered default (stations pass shuffle: true — the task's
145
+ // "default ON for stations" is the host's registration, jatui-plbzq).
146
+ let shuffle = loadShufflePref(options.id) ?? (options.shuffle ?? false);
33
147
  /** Resolved track list — cached across plays (the manifest is fetched once,
34
148
  * on the first play). null = unresolved; a FAILED fetch stays null so the
35
149
  * next play retries. Inline tracks resolve eagerly (a pure local filter —
@@ -46,7 +160,15 @@ export function createPlaylistScape(options) {
46
160
  }
47
161
  }
48
162
  /** Shared UI snapshot + subscribers. Only the latest build writes it. */
49
- let snap = { status: 'idle', track: null, index: 0, count: 0, position: 0, duration: 0 };
163
+ let snap = {
164
+ status: 'idle',
165
+ track: null,
166
+ index: 0,
167
+ count: 0,
168
+ position: 0,
169
+ duration: 0,
170
+ shuffle
171
+ };
50
172
  const subs = new Set();
51
173
  /** Handle to the most recent build — skip()/setShuffle() route here. */
52
174
  let latest = null;
@@ -132,6 +254,33 @@ export function createPlaylistScape(options) {
132
254
  let order = [];
133
255
  let pos = 0;
134
256
  let failures = 0;
257
+ // This play's shuffle source: the injected one (tests) or a FRESH
258
+ // unseeded draw — never the engine's scape id-seeded rand, which made
259
+ // every session's "shuffled" order identical (jatui-plbzq root cause).
260
+ const shuffleRand = options.shuffleRand ?? freshShuffleRand();
261
+ /** Fresh shuffle order with the no-repeat rule (jatui-plbzq): tracks
262
+ * the listener has heard recently close the order, unheard ones lead;
263
+ * when the WHOLE station has been heard the recents reset and the
264
+ * order is a plain fresh shuffle. */
265
+ function recentsAwareOrder() {
266
+ const n = resolved?.length ?? 0;
267
+ const fresh = shuffledOrder(n, shuffleRand);
268
+ const heard = loadRecents(options.id);
269
+ if (heard.size === 0)
270
+ return fresh;
271
+ const tracks = resolved;
272
+ const unheard = [];
273
+ const recent = [];
274
+ for (const i of fresh)
275
+ (heard.has(tracks[i].url) ? recent : unheard).push(i);
276
+ if (unheard.length === 0) {
277
+ // The whole station has been heard — the no-repeat window starts
278
+ // over: clear it and play a plain fresh shuffle.
279
+ saveRecents(options.id, []);
280
+ return fresh;
281
+ }
282
+ return [...unheard, ...recent];
283
+ }
135
284
  const isLive = () => instId === instanceSeq && !released;
136
285
  function atTimer(ms, fn) {
137
286
  if (released)
@@ -226,7 +375,7 @@ export function createPlaylistScape(options) {
226
375
  fail(resolveError ?? 'Playlist has no playable tracks');
227
376
  return;
228
377
  }
229
- order = shuffle ? shuffledOrder(resolved.length, scapeCtx.rand) : plainOrder(resolved.length);
378
+ order = shuffle ? recentsAwareOrder() : plainOrder(resolved.length);
230
379
  pos = 0;
231
380
  startTrack(order[0], 'first');
232
381
  }
@@ -292,6 +441,9 @@ export function createPlaylistScape(options) {
292
441
  duration: 0,
293
442
  error: undefined
294
443
  });
444
+ // This track is now sounding: it goes on the station's no-repeat
445
+ // list (most recent first, capped — jatui-plbzq).
446
+ recordPlayed(options.id, track.url);
295
447
  preloadNext();
296
448
  }
297
449
  function quietSlot(slot) {
@@ -335,17 +487,16 @@ export function createPlaylistScape(options) {
335
487
  return -1;
336
488
  return pos + 1 < order.length ? order[pos + 1] : order[0];
337
489
  }
338
- /** Order for the next cycle; never opens with the track that just
339
- * ended when a swap can avoid it. */
490
+ /** Order for the next cycle: no-repeat-aware when shuffled (never
491
+ * opens with the track that just ended when a swap can avoid it). */
340
492
  function wrapOrder(lastPlayed) {
341
- const idx = plainOrder(resolved?.length ?? 0);
342
- if (shuffle) {
343
- shuffleInPlace(idx, scapeCtx.rand);
344
- if (idx.length > 1 && idx[0] === lastPlayed) {
345
- const t = idx[0];
346
- idx[0] = idx[1];
347
- idx[1] = t;
348
- }
493
+ if (!shuffle)
494
+ return plainOrder(resolved?.length ?? 0);
495
+ const idx = recentsAwareOrder();
496
+ if (idx.length > 1 && idx[0] === lastPlayed) {
497
+ const t = idx[0];
498
+ idx[0] = idx[1];
499
+ idx[1] = t;
349
500
  }
350
501
  return idx;
351
502
  }
@@ -549,8 +700,8 @@ export function createPlaylistScape(options) {
549
700
  return;
550
701
  const current = order[pos] ?? 0;
551
702
  if (shuffle) {
552
- const rest = plainOrder(resolved.length).filter((i) => i !== current);
553
- shuffleInPlace(rest, scapeCtx.rand);
703
+ // No-repeat-aware rest: recently-heard tracks close the order.
704
+ const rest = recentsAwareOrder().filter((i) => i !== current);
554
705
  order = [current, ...rest];
555
706
  pos = 0;
556
707
  }
@@ -598,6 +749,10 @@ export function createPlaylistScape(options) {
598
749
  },
599
750
  setShuffle(on) {
600
751
  shuffle = on;
752
+ // The per-station preference (jatui-plbzq): survives sessions and is
753
+ // read back as the initial value on the next create.
754
+ saveShufflePref(options.id, on);
755
+ setSnap({ shuffle: on });
601
756
  if (latest && !latest.isReleased())
602
757
  latest.reshuffle();
603
758
  },
@@ -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;