@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 +1 -1
- package/dist/ambience/README.md +46 -2
- package/dist/ambience/engine.js +4 -1
- package/dist/ambience/groups.d.ts +4 -8
- package/dist/ambience/groups.js +4 -11
- package/dist/ambience/index.d.ts +2 -1
- package/dist/ambience/index.js +7 -2
- package/dist/ambience/scapes/playlist.d.ts +30 -2
- package/dist/ambience/scapes/playlist.js +170 -15
- package/dist/ambience/scapes/radio.d.ts +113 -0
- package/dist/ambience/scapes/radio.js +297 -0
- package/dist/ambience/shared.d.ts +10 -2
- package/dist/ambience/shared.js +26 -2
- package/dist/ambience/types.d.ts +7 -0
- package/dist/components/AmbienceToggle.svelte +160 -11
- package/dist/components/AmbienceToggle.svelte.d.ts +18 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
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
|
|
package/dist/ambience/README.md
CHANGED
|
@@ -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
|
|
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
|
package/dist/ambience/engine.js
CHANGED
|
@@ -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;
|
package/dist/ambience/groups.js
CHANGED
|
@@ -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')
|
package/dist/ambience/index.d.ts
CHANGED
|
@@ -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,
|
|
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';
|
package/dist/ambience/index.js
CHANGED
|
@@ -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
|
|
33
|
-
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups,
|
|
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
|
|
120
|
-
*
|
|
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
|
-
|
|
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 = {
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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;
|