@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 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) + 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`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `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')
@@ -11,17 +11,18 @@
11
11
  */
12
12
  export { createAmbience, createAmbienceLocal } from './shared';
13
13
  export { TIMING } from './engine';
14
- export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager } from './shared';
14
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager, type SharedTrackDownload } from './shared';
15
15
  export { createDefaultAmbience, DEFAULT_SCAPES, type DefaultAmbienceOptions } from './default';
16
16
  export { hibiscusScape } from './scapes/hibiscus';
17
17
  export { focusScape } from './scapes/focus';
18
18
  export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
19
19
  export { rain, waves } from './scapes/nature';
20
- export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
20
+ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
21
21
  export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './scapes/playlist';
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';
@@ -13,7 +13,7 @@
13
13
  // the plain engine until a host passes { shared: true }.
14
14
  export { createAmbience, createAmbienceLocal } from './shared';
15
15
  export { TIMING } from './engine';
16
- export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING } from './shared';
16
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload } from './shared';
17
17
  export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
18
18
  // The shipped scapes — hosts that want a hand-picked registry import them
19
19
  // individually instead of using createDefaultAmbience().
@@ -25,9 +25,14 @@ export { rain, waves } from './scapes/nature';
25
25
  // recordings. NOTE: never auto-registered in DEFAULT_SCAPES — a playlist is
26
26
  // per-host (its manifest URL and licensing claims are the host's); build one
27
27
  // with createPlaylistScape() and engine.register() it yourself.
28
- export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
28
+ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
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;
@@ -73,6 +89,11 @@ export interface PlaylistTrack {
73
89
  license: string;
74
90
  /** Page where the recording lives — the attribution link target. */
75
91
  sourceUrl?: string;
92
+ /** Explicit download override (jatui-0vxcu): `true` offers the file even
93
+ * when the licence string carries NC/ND terms; `false` hides the download
94
+ * button even for a redistributable licence. Absent: the licence gate
95
+ * (`isTrackDownloadable`) decides from the `license` string. */
96
+ downloadable?: boolean;
76
97
  }
77
98
  export interface PlaylistManifest {
78
99
  title?: string;
@@ -94,6 +115,11 @@ export interface PlaylistState {
94
115
  /** Total seconds of the current track (0 until metadata reports one —
95
116
  * a stream without a duration stays 0). */
96
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;
97
123
  /** Present on error. */
98
124
  error?: string;
99
125
  }
@@ -111,9 +137,16 @@ export interface PlaylistScapeOptions {
111
137
  tracks?: PlaylistTrack[];
112
138
  /** Track crossfade (s), 0–30. Default 3. */
113
139
  crossfade?: number;
114
- /** Randomized play order (Fisher–Yates over the engine's per-scape rand).
115
- * 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). */
116
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;
117
150
  /** Initial gain of the scape mix into the master chain, 0..1. Default 1. */
118
151
  gain?: number;
119
152
  /** Injectable for tests (default `fetch`). */
@@ -159,3 +192,29 @@ export declare function isPlaylistScape(scape: unknown): scape is PlaylistScape;
159
192
  * (performer only when present and different from the composer).
160
193
  */
161
194
  export declare function trackAttribution(track: PlaylistTrack | null | undefined): string;
195
+ /**
196
+ * Does this LICENCE STRING permit redistributing the file? The download
197
+ * allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
198
+ * and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
199
+ * derivatives) terms is refused — the host's only way past that is an
200
+ * explicit `downloadable: true` on the track (`isTrackDownloadable`).
201
+ * Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
202
+ * "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
203
+ * (default-deny) do not.
204
+ */
205
+ export declare function trackLicenseAllowsRedistribution(license: string | null | undefined): boolean;
206
+ /**
207
+ * May this track be downloaded? The licence gate behind the toggle's
208
+ * download button (jatui-0vxcu): an explicit boolean `downloadable` on the
209
+ * track overrides either way; otherwise the `license` string must permit
210
+ * redistribution (`trackLicenseAllowsRedistribution`). A track without a
211
+ * usable url is never downloadable.
212
+ */
213
+ export declare function isTrackDownloadable(track: PlaylistTrack | null | undefined): boolean;
214
+ /**
215
+ * The download filename — attribution travels with the file:
216
+ * "<Composer> - <Title>.<ext>". The extension comes from the track url
217
+ * (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
218
+ * sanitized for real filesystems and the result stays deterministic.
219
+ */
220
+ export declare function trackDownloadFilename(track: PlaylistTrack | null | undefined): string;
@@ -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
  },
@@ -657,7 +812,8 @@ function isValidTrack(t) {
657
812
  o.license.trim() !== '' &&
658
813
  (o.performer === undefined || typeof o.performer === 'string') &&
659
814
  (o.sourceUrl === undefined ||
660
- (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')));
815
+ (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')) &&
816
+ (o.downloadable === undefined || typeof o.downloadable === 'boolean'));
661
817
  }
662
818
  /** Narrow a registered scape to a playlist (to reach its state/subscribe). */
663
819
  export function isPlaylistScape(scape) {
@@ -679,3 +835,83 @@ export function trackAttribution(track) {
679
835
  parts.push(track.license);
680
836
  return parts.filter(Boolean).join(' · ');
681
837
  }
838
+ // ---- download gate + filename (jatui-0vxcu) ---------------------------------
839
+ /**
840
+ * Does this LICENCE STRING permit redistributing the file? The download
841
+ * allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
842
+ * and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
843
+ * derivatives) terms is refused — the host's only way past that is an
844
+ * explicit `downloadable: true` on the track (`isTrackDownloadable`).
845
+ * Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
846
+ * "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
847
+ * (default-deny) do not.
848
+ */
849
+ export function trackLicenseAllowsRedistribution(license) {
850
+ if (typeof license !== 'string' || license.trim() === '')
851
+ return false;
852
+ const l = license.toLowerCase().replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
853
+ // NC / ND terms — as their own tokens ("by nc", "by-nc" → "by nc") or as
854
+ // the spelled-out words.
855
+ if (/(^|\s)(nc|nd|noncommercial|non commercial|noderivs|no derivs|no derivatives)(\s|$)/.test(l))
856
+ return false;
857
+ return /(^|\s)(cc0|cc by|cc by sa|public domain|pd|pdm)(\s|$)/.test(l);
858
+ }
859
+ /**
860
+ * May this track be downloaded? The licence gate behind the toggle's
861
+ * download button (jatui-0vxcu): an explicit boolean `downloadable` on the
862
+ * track overrides either way; otherwise the `license` string must permit
863
+ * redistribution (`trackLicenseAllowsRedistribution`). A track without a
864
+ * usable url is never downloadable.
865
+ */
866
+ export function isTrackDownloadable(track) {
867
+ if (!track || typeof track.url !== 'string' || track.url.trim() === '')
868
+ return false;
869
+ if (typeof track.downloadable === 'boolean')
870
+ return track.downloadable;
871
+ return trackLicenseAllowsRedistribution(track.license);
872
+ }
873
+ /** Cap on one filename part — a "Composer - Title.ext" name stays well under
874
+ * the 255-byte filename floor even with long attribution strings. */
875
+ const FILENAME_PART_MAX = 96;
876
+ /** Extension for a url with no readable one — the Musopen-style manifests
877
+ * this feature targets serve mp3. */
878
+ const DEFAULT_AUDIO_EXT = 'mp3';
879
+ /** One filename part: filesystem-hostile characters and control chars →
880
+ * spaces, runs collapsed, no leading/trailing dots or spaces (Windows
881
+ * rejects both), clamped to {@link FILENAME_PART_MAX}. */
882
+ function sanitizeFilenamePart(part) {
883
+ return part
884
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
885
+ .replace(/[\\/:*?"<>|]/g, ' ')
886
+ .replace(/\s+/g, ' ')
887
+ .trim()
888
+ .replace(/^[. ]+|[. ]+$/g, '')
889
+ .slice(0, FILENAME_PART_MAX)
890
+ .replace(/[. ]+$/g, ''); // the clamp can re-expose a trailing dot
891
+ }
892
+ /** The audio extension of `url`'s last path segment ('' when there is none
893
+ * or it is not 1–5 alphanumeric chars — query strings excluded). */
894
+ function urlAudioExt(url) {
895
+ if (typeof url !== 'string')
896
+ return '';
897
+ const path = url.split(/[?#]/)[0];
898
+ const file = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
899
+ const dot = file.lastIndexOf('.');
900
+ if (dot <= 0)
901
+ return '';
902
+ const ext = file.slice(dot + 1).toLowerCase();
903
+ return /^[a-z0-9]{1,5}$/.test(ext) ? ext : '';
904
+ }
905
+ /**
906
+ * The download filename — attribution travels with the file:
907
+ * "<Composer> - <Title>.<ext>". The extension comes from the track url
908
+ * (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
909
+ * sanitized for real filesystems and the result stays deterministic.
910
+ */
911
+ export function trackDownloadFilename(track) {
912
+ if (!track)
913
+ return '';
914
+ const composer = sanitizeFilenamePart(track.composer) || 'Unknown composer';
915
+ const title = sanitizeFilenamePart(track.title) || 'Untitled';
916
+ return `${composer} - ${title}.${urlAudioExt(track.url) || DEFAULT_AUDIO_EXT}`;
917
+ }