@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
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { safeAmbienceHref } from '../groups';
|
|
2
|
+
/** Ramp + teardown timing for the radio scape. */
|
|
3
|
+
export const RADIO = Object.freeze({
|
|
4
|
+
/** Stepped element-volume fade on stop() — steps per second of fade. */
|
|
5
|
+
FADE_STEPS_PER_S: 8,
|
|
6
|
+
/** Pause + de-src this many ms after a stop fade ends — before the
|
|
7
|
+
* engine's lookahead retirement (fade*1000 + 250) can drop anything. */
|
|
8
|
+
TEARDOWN_DELAY_MS: 50
|
|
9
|
+
});
|
|
10
|
+
export function createRadioStreamScape(options) {
|
|
11
|
+
if (!options?.id)
|
|
12
|
+
throw new Error('createRadioStreamScape: id is required');
|
|
13
|
+
if (!options.label)
|
|
14
|
+
throw new Error('createRadioStreamScape: label is required');
|
|
15
|
+
if (!options.streamUrl || typeof options.streamUrl !== 'string')
|
|
16
|
+
throw new Error('createRadioStreamScape: streamUrl is required');
|
|
17
|
+
// Refuse at create time: a `javascript:`/`data:`/relative stream url
|
|
18
|
+
// would end up in an <audio> src — http(s) absolute only, the same gate
|
|
19
|
+
// as every other ambience href.
|
|
20
|
+
const validated = safeAmbienceHref(options.streamUrl);
|
|
21
|
+
if (!validated)
|
|
22
|
+
throw new Error('createRadioStreamScape: streamUrl must be an absolute http(s) url');
|
|
23
|
+
// Explicitly typed alias: buildInstance is a hoisted declaration, and TS
|
|
24
|
+
// resets truthiness narrowing at its capture — the guard above is the
|
|
25
|
+
// build-time refusal this module promises.
|
|
26
|
+
const streamUrl = validated;
|
|
27
|
+
const href = safeAmbienceHref(options.href);
|
|
28
|
+
let mixGain0 = Math.min(1, Math.max(0, options.gain ?? 1));
|
|
29
|
+
const makeElement = options.createMediaElement ?? ((url) => new Audio(url));
|
|
30
|
+
/** Shared UI snapshot + subscribers. Only the latest build writes it. */
|
|
31
|
+
let snap = {
|
|
32
|
+
status: 'idle',
|
|
33
|
+
station: options.label,
|
|
34
|
+
href: href ?? null
|
|
35
|
+
};
|
|
36
|
+
const subs = new Set();
|
|
37
|
+
let instanceSeq = 0;
|
|
38
|
+
function setSnap(patch) {
|
|
39
|
+
snap = { ...snap, ...patch };
|
|
40
|
+
for (const fn of subs)
|
|
41
|
+
fn(snap);
|
|
42
|
+
}
|
|
43
|
+
function buildInstance(scapeCtx) {
|
|
44
|
+
const instId = ++instanceSeq;
|
|
45
|
+
let released = false;
|
|
46
|
+
let started = false;
|
|
47
|
+
let fading = false;
|
|
48
|
+
const timers = new Set();
|
|
49
|
+
let unsub = null;
|
|
50
|
+
// A plain <audio> element — no crossOrigin (a station stream without
|
|
51
|
+
// CORS headers must still load), no source node (the tainted-graph
|
|
52
|
+
// rule would silence it). Loudness is el.volume, below.
|
|
53
|
+
const el = makeElement(streamUrl);
|
|
54
|
+
try {
|
|
55
|
+
// The src is set here, not only via the constructor: an injectable
|
|
56
|
+
// factory may build a bare element (the real `new Audio(url)` already
|
|
57
|
+
// carries it), and the tests read the assignment back.
|
|
58
|
+
el.src = streamUrl;
|
|
59
|
+
el.preload = 'auto';
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* exotic environments */
|
|
63
|
+
}
|
|
64
|
+
const isLive = () => instId === instanceSeq && !released;
|
|
65
|
+
function atTimer(ms, fn) {
|
|
66
|
+
if (released)
|
|
67
|
+
return;
|
|
68
|
+
const t = setTimeout(() => {
|
|
69
|
+
timers.delete(t);
|
|
70
|
+
if (!released)
|
|
71
|
+
fn();
|
|
72
|
+
}, Math.max(0, ms));
|
|
73
|
+
timers.add(t);
|
|
74
|
+
}
|
|
75
|
+
/** The element's loudness right now: the scape gain × the engine's
|
|
76
|
+
* master volume (a hand-made test context may omit the hook — it
|
|
77
|
+
* then reads full volume). */
|
|
78
|
+
function elementVolume() {
|
|
79
|
+
const master = scapeCtx.volume ? scapeCtx.volume() : 1;
|
|
80
|
+
return Math.min(1, Math.max(0, master)) * Math.min(1, Math.max(0, mixGain0));
|
|
81
|
+
}
|
|
82
|
+
function applyVolume() {
|
|
83
|
+
try {
|
|
84
|
+
el.volume = Math.min(1, Math.max(0, elementVolume()));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* element already unusable */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// The element's own events, so a stream that stalls or dies is
|
|
91
|
+
// visible in the state surface instead of playing silence.
|
|
92
|
+
el.addEventListener('playing', () => {
|
|
93
|
+
if (!isLive() || fading)
|
|
94
|
+
return;
|
|
95
|
+
setSnap({ status: 'playing', error: undefined });
|
|
96
|
+
});
|
|
97
|
+
el.addEventListener('ended', () => {
|
|
98
|
+
// A live stream should not end; when it does, say so rather than
|
|
99
|
+
// showing a playing row over silence.
|
|
100
|
+
if (!isLive() || fading)
|
|
101
|
+
return;
|
|
102
|
+
fail('The stream ended');
|
|
103
|
+
});
|
|
104
|
+
el.addEventListener('error', () => {
|
|
105
|
+
if (!isLive() || fading)
|
|
106
|
+
return;
|
|
107
|
+
fail('The stream could not be loaded');
|
|
108
|
+
});
|
|
109
|
+
// Mirror the engine's volume on every lookahead tick (~250 ms) so the
|
|
110
|
+
// volume slider and cross-tab mute reach an element outside the graph.
|
|
111
|
+
unsub = scapeCtx.lookahead(() => {
|
|
112
|
+
if (released || !started || fading)
|
|
113
|
+
return;
|
|
114
|
+
applyVolume();
|
|
115
|
+
});
|
|
116
|
+
function start(_at) {
|
|
117
|
+
// A superseded (stale) build is inert: only the newest instance may
|
|
118
|
+
// sound or report — the shared snapshot belongs to the live one.
|
|
119
|
+
if (started || released || !isLive())
|
|
120
|
+
return;
|
|
121
|
+
started = true;
|
|
122
|
+
setSnap({ status: 'loading', error: undefined });
|
|
123
|
+
applyVolume();
|
|
124
|
+
try {
|
|
125
|
+
const p = el.play();
|
|
126
|
+
if (p)
|
|
127
|
+
p.then(() => {
|
|
128
|
+
if (!isLive() || fading)
|
|
129
|
+
return;
|
|
130
|
+
setSnap({ status: 'playing', error: undefined });
|
|
131
|
+
}, () => fail('The stream could not be started'));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
fail('The stream could not be started');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function fail(message) {
|
|
138
|
+
if (released || !isLive())
|
|
139
|
+
return;
|
|
140
|
+
setSnap({ status: 'error', error: message });
|
|
141
|
+
release();
|
|
142
|
+
}
|
|
143
|
+
function release() {
|
|
144
|
+
if (released)
|
|
145
|
+
return;
|
|
146
|
+
released = true;
|
|
147
|
+
unsub?.();
|
|
148
|
+
unsub = null;
|
|
149
|
+
for (const t of timers)
|
|
150
|
+
clearTimeout(t);
|
|
151
|
+
timers.clear();
|
|
152
|
+
try {
|
|
153
|
+
el.pause();
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* element already unusable */
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
el.removeAttribute('src');
|
|
160
|
+
el.load();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* older engines */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function stop(_at, fade) {
|
|
167
|
+
if (released)
|
|
168
|
+
return;
|
|
169
|
+
setSnap({ status: 'idle', error: undefined });
|
|
170
|
+
const f = Math.max(0, fade);
|
|
171
|
+
if (f <= 0) {
|
|
172
|
+
release();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Stepped element-volume fade (there is no audio-clock gain here):
|
|
176
|
+
// FADE_STEPS_PER_S steps per fade second, then the wall-clock release.
|
|
177
|
+
fading = true;
|
|
178
|
+
const steps = Math.max(1, Math.round(f * RADIO.FADE_STEPS_PER_S));
|
|
179
|
+
const stepMs = (f * 1000) / steps;
|
|
180
|
+
for (let i = 1; i <= steps; i++) {
|
|
181
|
+
atTimer(stepMs * i, () => {
|
|
182
|
+
const k = 1 - i / steps;
|
|
183
|
+
try {
|
|
184
|
+
el.volume = Math.min(1, Math.max(0, elementVolume())) * k;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* element already unusable */
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
atTimer(f * 1000 + RADIO.TEARDOWN_DELAY_MS, release);
|
|
192
|
+
}
|
|
193
|
+
function setParam(key, value) {
|
|
194
|
+
if (key !== 'gain' || released)
|
|
195
|
+
return;
|
|
196
|
+
mixGain0 = Math.min(1, Math.max(0, value));
|
|
197
|
+
if (started && !fading)
|
|
198
|
+
applyVolume();
|
|
199
|
+
}
|
|
200
|
+
function pauseInternal() {
|
|
201
|
+
if (released || !started)
|
|
202
|
+
return;
|
|
203
|
+
try {
|
|
204
|
+
el.pause();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* element already unusable */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function resumeInternal() {
|
|
211
|
+
if (released || !started)
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
const p = el.play();
|
|
215
|
+
if (p)
|
|
216
|
+
p.catch(() => { }); // the error surface owns failures
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* element already unusable */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { start, stop, setParam, pause: pauseInternal, resume: resumeInternal };
|
|
223
|
+
}
|
|
224
|
+
const scape = {
|
|
225
|
+
id: options.id,
|
|
226
|
+
label: options.label,
|
|
227
|
+
group: options.group ?? 'radio',
|
|
228
|
+
description: options.description,
|
|
229
|
+
radio: true,
|
|
230
|
+
href: href ?? null,
|
|
231
|
+
build(scapeCtx) {
|
|
232
|
+
return buildInstance(scapeCtx);
|
|
233
|
+
},
|
|
234
|
+
state: () => snap,
|
|
235
|
+
subscribe(fn) {
|
|
236
|
+
subs.add(fn);
|
|
237
|
+
fn(snap);
|
|
238
|
+
return () => {
|
|
239
|
+
subs.delete(fn);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
return scape;
|
|
244
|
+
}
|
|
245
|
+
/** Narrow a registered scape to a radio stream (to reach its state surface). */
|
|
246
|
+
export function isRadioScape(scape) {
|
|
247
|
+
return (typeof scape === 'object' &&
|
|
248
|
+
scape !== null &&
|
|
249
|
+
scape.radio === true &&
|
|
250
|
+
typeof scape.subscribe === 'function');
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Build a radio-station item: WITHOUT `streamUrl` an outbound link (the
|
|
254
|
+
* station's site opens in a new tab — the original, always-available
|
|
255
|
+
* behavior); WITH `streamUrl` the station's public stream plays right in
|
|
256
|
+
* the player (client-side fetch from the station — never relayed) and the
|
|
257
|
+
* site link survives as the attribution target in the now-playing row.
|
|
258
|
+
*
|
|
259
|
+
* A `streamUrl` that fails `safeAmbienceHref` (anything but an absolute
|
|
260
|
+
* http(s) url) is REFUSED: the station falls back to the outbound link and
|
|
261
|
+
* the bad url is never stored or loaded.
|
|
262
|
+
*/
|
|
263
|
+
export function radioStation(options) {
|
|
264
|
+
if (options.streamUrl === undefined || options.streamUrl === null) {
|
|
265
|
+
return {
|
|
266
|
+
kind: 'link',
|
|
267
|
+
id: options.id,
|
|
268
|
+
label: options.label,
|
|
269
|
+
group: options.group ?? 'radio',
|
|
270
|
+
href: options.href,
|
|
271
|
+
description: options.description
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (!safeAmbienceHref(options.streamUrl)) {
|
|
275
|
+
// Refused: a typo'd scheme silently dropping the stream would be very
|
|
276
|
+
// hard to spot from the outside — say so once, then degrade to the
|
|
277
|
+
// always-available link behavior.
|
|
278
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function')
|
|
279
|
+
console.warn(`radioStation(${options.id}): streamUrl must be an absolute http(s) url — falling back to the outbound link`);
|
|
280
|
+
return {
|
|
281
|
+
kind: 'link',
|
|
282
|
+
id: options.id,
|
|
283
|
+
label: options.label,
|
|
284
|
+
group: options.group ?? 'radio',
|
|
285
|
+
href: options.href,
|
|
286
|
+
description: options.description
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return createRadioStreamScape({
|
|
290
|
+
id: options.id,
|
|
291
|
+
label: options.label,
|
|
292
|
+
description: options.description,
|
|
293
|
+
group: options.group,
|
|
294
|
+
href: options.href,
|
|
295
|
+
streamUrl: options.streamUrl
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -121,6 +121,10 @@ export interface SharedAmbienceState {
|
|
|
121
121
|
* null when nothing is playing or the licence forbids redistribution
|
|
122
122
|
* (jatui-0vxcu). A remote tab's download button reads this. */
|
|
123
123
|
trackDownload: SharedTrackDownload | null;
|
|
124
|
+
/** The playing playlist's shuffle state (jatui-plbzq), published so a
|
|
125
|
+
* remote tab's Shuffle toggle shows the leader's truth. Null when
|
|
126
|
+
* unknown (nothing playing, or the scape is not a playlist). */
|
|
127
|
+
trackShuffle: boolean | null;
|
|
124
128
|
}
|
|
125
129
|
/** An Ambience whose playback is shared across tabs (see module doc). */
|
|
126
130
|
export interface SharedAmbience extends Ambience {
|
|
@@ -136,12 +140,16 @@ export interface SharedAmbience extends Ambience {
|
|
|
136
140
|
* `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
|
|
137
141
|
* now-playing row; nulls mean unknown. `download` (jatui-0vxcu) carries
|
|
138
142
|
* the track's download info when its licence permits redistribution —
|
|
139
|
-
* null clears it.
|
|
140
|
-
|
|
143
|
+
* null clears it. `shuffle` (jatui-plbzq) publishes the playing
|
|
144
|
+
* playlist's shuffle state for the remotes' Shuffle toggle. */
|
|
145
|
+
setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null, download?: SharedTrackDownload | null, shuffle?: boolean | null): void;
|
|
141
146
|
/** Now-playing-row transport (jatui-153tz): skip / go back one track on a
|
|
142
147
|
* playlist scape. A remote routes the command to the leader's instance. */
|
|
143
148
|
skipTrack(id: string): void;
|
|
144
149
|
prevTrack(id: string): void;
|
|
150
|
+
/** Shuffle transport (jatui-plbzq): set the playing playlist's shuffle
|
|
151
|
+
* state. A remote routes the command to the leader's instance. */
|
|
152
|
+
shuffleTrack(id: string, on: boolean): void;
|
|
145
153
|
/** Release the lock, stop the heartbeat and close the channel. The local
|
|
146
154
|
* engine is killed if this tab was the leader. */
|
|
147
155
|
destroy(): void;
|
package/dist/ambience/shared.js
CHANGED
|
@@ -144,6 +144,8 @@ export function createSharedAmbience(options = {}) {
|
|
|
144
144
|
let nowPlayingDuration = null;
|
|
145
145
|
/** The published track's download info (jatui-0vxcu). */
|
|
146
146
|
let nowPlayingDownload = null;
|
|
147
|
+
/** The published playlist's shuffle state (jatui-plbzq). */
|
|
148
|
+
let nowPlayingShuffle = null;
|
|
147
149
|
const ambienceSubs = new Set();
|
|
148
150
|
const sharedSubs = new Set();
|
|
149
151
|
function sharedState() {
|
|
@@ -162,7 +164,8 @@ export function createSharedAmbience(options = {}) {
|
|
|
162
164
|
trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
|
|
163
165
|
trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
|
|
164
166
|
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null),
|
|
165
|
-
trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null)
|
|
167
|
+
trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null),
|
|
168
|
+
trackShuffle: role === 'leader' ? nowPlayingShuffle : (remoteS?.trackShuffle ?? null)
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
171
|
function ambienceState() {
|
|
@@ -232,6 +235,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
232
235
|
nowPlayingPosition = null;
|
|
233
236
|
nowPlayingDuration = null;
|
|
234
237
|
nowPlayingDownload = null;
|
|
238
|
+
nowPlayingShuffle = null;
|
|
235
239
|
remoteS = null;
|
|
236
240
|
if (heartbeatTimer) {
|
|
237
241
|
clearInterval(heartbeatTimer);
|
|
@@ -471,6 +475,14 @@ export function createSharedAmbience(options = {}) {
|
|
|
471
475
|
p?.prev();
|
|
472
476
|
break;
|
|
473
477
|
}
|
|
478
|
+
case 'shuffle': {
|
|
479
|
+
// The Shuffle toggle (jatui-plbzq): routed to the leader's own
|
|
480
|
+
// playlist scape instance (its preference persists there).
|
|
481
|
+
if (!msg.id)
|
|
482
|
+
return;
|
|
483
|
+
playlistFor(msg.id)?.setShuffle((msg.arg ?? 0) === 1);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
474
486
|
case 'setVolume':
|
|
475
487
|
applyVolume(msg.arg ?? userVolume);
|
|
476
488
|
break;
|
|
@@ -644,6 +656,16 @@ export function createSharedAmbience(options = {}) {
|
|
|
644
656
|
}
|
|
645
657
|
post({ t: 'cmd', cmd: 'prev', id, from: tabId });
|
|
646
658
|
}
|
|
659
|
+
/** Shuffle transport (jatui-plbzq): set the playing playlist's shuffle
|
|
660
|
+
* state — routed to the leader's live instance when this tab is a
|
|
661
|
+
* remote (its preference persists there). */
|
|
662
|
+
function shuffleTrack(id, on) {
|
|
663
|
+
if (role === 'leader') {
|
|
664
|
+
playlistFor(id)?.setShuffle(on);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
post({ t: 'cmd', cmd: 'shuffle', id, arg: on ? 1 : 0, from: tabId });
|
|
668
|
+
}
|
|
647
669
|
function setVolume(v) {
|
|
648
670
|
applyVolume(v);
|
|
649
671
|
savePersisted(storageKey, {
|
|
@@ -713,6 +735,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
713
735
|
resume,
|
|
714
736
|
skipTrack,
|
|
715
737
|
prevTrack,
|
|
738
|
+
shuffleTrack,
|
|
716
739
|
kill: () => {
|
|
717
740
|
// A remote has nothing local to kill — tell the leader instead.
|
|
718
741
|
if (role === 'leader')
|
|
@@ -723,13 +746,14 @@ export function createSharedAmbience(options = {}) {
|
|
|
723
746
|
setVolume,
|
|
724
747
|
setMuted,
|
|
725
748
|
toggleMute,
|
|
726
|
-
setNowPlaying(line, index, position, duration, title, download) {
|
|
749
|
+
setNowPlaying(line, index, position, duration, title, download, shuffle) {
|
|
727
750
|
nowPlaying = line;
|
|
728
751
|
nowPlayingIndex = line ? (index ?? null) : null;
|
|
729
752
|
nowPlayingPosition = line ? (position ?? null) : null;
|
|
730
753
|
nowPlayingDuration = line ? (duration ?? null) : null;
|
|
731
754
|
nowPlayingTitle = line ? (title ?? null) : null;
|
|
732
755
|
nowPlayingDownload = line ? (download ?? null) : null;
|
|
756
|
+
nowPlayingShuffle = line ? (shuffle ?? null) : null;
|
|
733
757
|
if (role === 'leader') {
|
|
734
758
|
publish();
|
|
735
759
|
emitShared();
|
package/dist/ambience/types.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export interface ScapeContext {
|
|
|
19
19
|
lookahead: (cb: (now: number) => void) => () => void;
|
|
20
20
|
/** Deterministic-ish random source scoped to the scape instance. */
|
|
21
21
|
rand: () => number;
|
|
22
|
+
/** The engine's CURRENT master volume, 0..1 (jatui-plbzq). Optional —
|
|
23
|
+
* only scapes that play OUTSIDE the audio graph need it: a radio
|
|
24
|
+
* stream's plain `<audio>` element mirrors `volume() × `its own gain
|
|
25
|
+
* onto `el.volume`, refreshed on the lookahead tick, so the volume
|
|
26
|
+
* slider and cross-tab mute reach it too. Hand-made test contexts may
|
|
27
|
+
* omit it (the scape then reads full volume). */
|
|
28
|
+
volume?: () => number;
|
|
22
29
|
}
|
|
23
30
|
export interface ScapeInstance {
|
|
24
31
|
/** Begin sounding at audio time `at` (usually ctx.currentTime). */
|
|
@@ -26,12 +26,18 @@
|
|
|
26
26
|
* - A playing (or paused) scape shows a now-playing block above the
|
|
27
27
|
* volume row (jatui-153tz): for a playlist — the track title, its
|
|
28
28
|
* composer · license attribution, an elapsed/total progress bar and a
|
|
29
|
-
* prev / play-pause / next transport (
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* shuffle / prev / play-pause / next transport (shuffle is an
|
|
30
|
+
* aria-pressed IconButton, persisted per station and fresh-shuffled
|
|
31
|
+
* per session with no-repeat — jatui-plbzq; prev restarts the track
|
|
32
|
+
* when more than 3 s are in, else goes back one; play-pause freezes
|
|
33
|
+
* the station without closing the menu; next skips). Generative
|
|
34
|
+
* scapes (noise / focus) get the play/pause control only. A radio
|
|
35
|
+
* station registered with a public `streamUrl` (jatui-plbzq) gets the
|
|
36
|
+
* station name, a `Open station site` attribution link and
|
|
37
|
+
* play/pause — the stream is fetched by the browser from the station
|
|
38
|
+
* itself, never relayed. The transport joins the menu's roving order;
|
|
39
|
+
* a paused row stays visible (its play button is the resume) and a
|
|
40
|
+
* stopped one hides.
|
|
35
41
|
* - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
|
|
36
42
|
* public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
|
|
37
43
|
* marks the track `downloadable: true`) also get a Download button
|
|
@@ -98,6 +104,14 @@
|
|
|
98
104
|
return `${track.title} — ${trackAttribution(track)}`;
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/** The radio row's attribution href (jatui-plbzq): the scape's site url
|
|
108
|
+
* re-validated with `safeAmbienceHref` — the registry could be hostile
|
|
109
|
+
* on a remote tab too, and the leader is another tab, not a source of
|
|
110
|
+
* trust. Null hides the 'Open station site' link. */
|
|
111
|
+
export function radioSite(scape: RadioStreamScape | null | undefined): string | null {
|
|
112
|
+
return scape ? safeAmbienceHref(scape.href) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
/** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
|
|
102
116
|
export function formatTrackTime(seconds: number | null | undefined): string {
|
|
103
117
|
if (
|
|
@@ -243,6 +257,7 @@
|
|
|
243
257
|
import { getMotionContext } from '../motion/level';
|
|
244
258
|
import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
|
|
245
259
|
import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
|
|
260
|
+
import { isRadioScape, type RadioStreamScape, type RadioStreamState } from '../ambience/scapes/radio';
|
|
246
261
|
import {
|
|
247
262
|
isTrackDownloadable,
|
|
248
263
|
trackDownloadFilename
|
|
@@ -321,6 +336,10 @@
|
|
|
321
336
|
* now-playing row's progress bar. Null on plain/remote tabs without a
|
|
322
337
|
* local playing instance. */
|
|
323
338
|
let playlist = $state<PlaylistState | null>(null);
|
|
339
|
+
/** The current radio scape's live snapshot (jatui-plbzq) — its error
|
|
340
|
+
* line (a station that stalls or dies) and status. Null on remote tabs
|
|
341
|
+
* without a local playing instance. */
|
|
342
|
+
let radioState = $state<RadioStreamState | null>(null);
|
|
324
343
|
/** The shared player's now-playing line (jatui-lm1mf): published by the
|
|
325
344
|
* leader tab, so a remote tab shows the same track attribution without
|
|
326
345
|
* owning the playlist instance. Null on a plain engine. */
|
|
@@ -335,6 +354,9 @@
|
|
|
335
354
|
* download button reads this; null hides the button (not published, or
|
|
336
355
|
* the licence forbids redistribution). */
|
|
337
356
|
let sharedDownload = $state<SharedTrackDownload | null>(null);
|
|
357
|
+
/** The leader's published playlist shuffle state (jatui-plbzq) — a
|
|
358
|
+
* remote tab's Shuffle toggle reads this; null when unknown. */
|
|
359
|
+
let sharedShuffle = $state<boolean | null>(null);
|
|
338
360
|
/** True while a download fetch is in flight — the button shows its busy
|
|
339
361
|
* spinner and refuses re-entry. The fetch runs beside playback; nothing
|
|
340
362
|
* here touches the engine. */
|
|
@@ -367,13 +389,31 @@
|
|
|
367
389
|
* published state and routes transport commands to it. */
|
|
368
390
|
const isRemote = $derived(sharedRole === 'remote');
|
|
369
391
|
const currentIsPlaylist = $derived(isPlaylistScape(currentItem));
|
|
392
|
+
/** The current scape is a radio stream (jatui-plbzq): its now-playing
|
|
393
|
+
* row shows the station name + the 'Open station site' attribution
|
|
394
|
+
* link, and play/pause only — no tracks to skip, no download. */
|
|
395
|
+
const currentIsRadio = $derived(isRadioScape(currentItem));
|
|
396
|
+
/** The current radio scape, narrowed — carries the validated site url
|
|
397
|
+
* even on a remote tab (the registry is the same everywhere). */
|
|
398
|
+
const currentRadio = $derived(currentIsRadio ? (currentItem as unknown as RadioStreamScape) : null);
|
|
399
|
+
const radioSiteHref = $derived(radioSite(currentRadio));
|
|
400
|
+
/** The radio row's error line — leader/local only (a remote has no
|
|
401
|
+
* local instance to report through). */
|
|
402
|
+
const radioError = $derived(radioState?.status === 'error' ? (radioState.error ?? '') : '');
|
|
403
|
+
/** The Shuffle toggle's pressed state (jatui-plbzq): the playing
|
|
404
|
+
* playlist's own truth locally; the leader's published one remotely. */
|
|
405
|
+
const shufflePressed = $derived(
|
|
406
|
+
currentIsPlaylist ? (isRemote ? (sharedShuffle ?? playlist?.shuffle ?? false) : (playlist?.shuffle ?? false)) : false
|
|
407
|
+
);
|
|
370
408
|
/** The row exists while something is sounding or paused — a paused row
|
|
371
409
|
* must stay (its play button is how you resume); a stopped one hides. */
|
|
372
410
|
const showNowPlaying = $derived((playing || paused) && !!currentItem);
|
|
373
411
|
const npTitle = $derived(
|
|
374
|
-
|
|
375
|
-
? (
|
|
376
|
-
:
|
|
412
|
+
currentIsRadio
|
|
413
|
+
? (currentRadio?.label ?? '')
|
|
414
|
+
: isRemote
|
|
415
|
+
? (sharedTitle ?? '')
|
|
416
|
+
: (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
|
|
377
417
|
);
|
|
378
418
|
const npAttrib = $derived(
|
|
379
419
|
isRemote
|
|
@@ -470,6 +510,7 @@
|
|
|
470
510
|
sharedPosition = s.trackPosition;
|
|
471
511
|
sharedDuration = s.trackDuration;
|
|
472
512
|
sharedDownload = s.trackDownload ?? null;
|
|
513
|
+
sharedShuffle = s.trackShuffle;
|
|
473
514
|
});
|
|
474
515
|
});
|
|
475
516
|
|
|
@@ -505,11 +546,29 @@
|
|
|
505
546
|
license: t.license,
|
|
506
547
|
...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
|
|
507
548
|
}
|
|
508
|
-
: null
|
|
549
|
+
: null,
|
|
550
|
+
// The Shuffle toggle's truth (jatui-plbzq): published so a remote
|
|
551
|
+
// tab renders the leader's shuffle state.
|
|
552
|
+
s.shuffle
|
|
509
553
|
);
|
|
510
554
|
});
|
|
511
555
|
});
|
|
512
556
|
|
|
557
|
+
// The current scape is a radio stream (jatui-plbzq) → follow its state
|
|
558
|
+
// for the error line (a stream that stalls or dies). The row's station
|
|
559
|
+
// name and attribution link come from the registered item itself, so a
|
|
560
|
+
// remote tab needs no publication to render them.
|
|
561
|
+
$effect(() => {
|
|
562
|
+
const item = currentItem;
|
|
563
|
+
if (!isRadioScape(item)) {
|
|
564
|
+
radioState = null;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
return item.subscribe((s) => {
|
|
568
|
+
radioState = s;
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
513
572
|
// Breathing glyph: follow the engine's shared breath clock while playing,
|
|
514
573
|
// and only where ambient motion is allowed (getMotionContext().ambient is
|
|
515
574
|
// false under prefers-reduced-motion AND under data-motion="calm"). The
|
|
@@ -766,6 +825,21 @@
|
|
|
766
825
|
if (isPlaylistScape(currentItem)) currentItem.skip();
|
|
767
826
|
}
|
|
768
827
|
|
|
828
|
+
/** The Shuffle toggle (jatui-plbzq): flip the playing playlist's
|
|
829
|
+
* shuffle state — the per-station preference persists, and a session's
|
|
830
|
+
* order is freshly shuffled with recently-heard tracks last. A remote
|
|
831
|
+
* routes the command to the leader (its instance owns the running
|
|
832
|
+
* order and the persisted preference). */
|
|
833
|
+
function onShuffleClick() {
|
|
834
|
+
const next = !shufflePressed;
|
|
835
|
+
const e = engine as SharedAmbience;
|
|
836
|
+
if (isRemote && typeof e.shuffleTrack === 'function' && currentId) {
|
|
837
|
+
e.shuffleTrack(currentId, next);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (isPlaylistScape(currentItem)) currentItem.setShuffle(next);
|
|
841
|
+
}
|
|
842
|
+
|
|
769
843
|
/**
|
|
770
844
|
* The download button (jatui-0vxcu). A cross-origin `<a download>` is
|
|
771
845
|
* ignored by browsers, so the file is fetched to a Blob and saved through
|
|
@@ -1115,7 +1189,45 @@
|
|
|
1115
1189
|
{#if npTitle}
|
|
1116
1190
|
<p class="block truncate text-sm font-medium" title={npTitle}>{npTitle}</p>
|
|
1117
1191
|
{/if}
|
|
1118
|
-
{#if
|
|
1192
|
+
{#if currentIsRadio}
|
|
1193
|
+
<!-- Radio (jatui-plbzq): the station's own site is the
|
|
1194
|
+
attribution — a real link, kept even though the stream
|
|
1195
|
+
now plays right here (it is fetched by the browser from
|
|
1196
|
+
the station, never relayed). A stream that stalls or
|
|
1197
|
+
dies says so instead of playing silence. -->
|
|
1198
|
+
<div class="text-2xs mt-0.5 flex items-center gap-2">
|
|
1199
|
+
{#if radioSiteHref}
|
|
1200
|
+
<a
|
|
1201
|
+
href={radioSiteHref}
|
|
1202
|
+
target="_blank"
|
|
1203
|
+
rel="noopener noreferrer"
|
|
1204
|
+
tabindex="-1"
|
|
1205
|
+
class="inline-flex min-w-0 items-center gap-1 text-primary
|
|
1206
|
+
hover:underline focus-visible:underline"
|
|
1207
|
+
>
|
|
1208
|
+
<span class="truncate">Open station site</span>
|
|
1209
|
+
<svg
|
|
1210
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1211
|
+
viewBox="0 0 24 24"
|
|
1212
|
+
fill="none"
|
|
1213
|
+
stroke="currentColor"
|
|
1214
|
+
stroke-width="2"
|
|
1215
|
+
stroke-linecap="round"
|
|
1216
|
+
stroke-linejoin="round"
|
|
1217
|
+
class="size-3 shrink-0"
|
|
1218
|
+
aria-hidden="true"
|
|
1219
|
+
>
|
|
1220
|
+
<path d="M15 3h6v6" />
|
|
1221
|
+
<path d="M10 14 21 3" />
|
|
1222
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
1223
|
+
</svg>
|
|
1224
|
+
</a>
|
|
1225
|
+
{/if}
|
|
1226
|
+
{#if radioError}
|
|
1227
|
+
<span class="truncate text-error" title={radioError}>{radioError}</span>
|
|
1228
|
+
{/if}
|
|
1229
|
+
</div>
|
|
1230
|
+
{:else if npAttrib}
|
|
1119
1231
|
<p class="text-2xs text-base-content/55 block truncate" title={npAttrib}>{npAttrib}</p>
|
|
1120
1232
|
{/if}
|
|
1121
1233
|
{#if npDuration > 0}
|
|
@@ -1136,6 +1248,43 @@
|
|
|
1136
1248
|
</div>
|
|
1137
1249
|
{/if}
|
|
1138
1250
|
<div class="mt-1.5 flex items-center justify-center gap-1">
|
|
1251
|
+
{#if currentIsPlaylist}
|
|
1252
|
+
<!-- Shuffle (jatui-plbzq): an aria-pressed toggle over the
|
|
1253
|
+
playlist's fresh-per-session order (recently-heard tracks
|
|
1254
|
+
last). Persisted per station; a remote routes the toggle
|
|
1255
|
+
to the leader. -->
|
|
1256
|
+
<IconButton
|
|
1257
|
+
label="Shuffle"
|
|
1258
|
+
variant="ghost"
|
|
1259
|
+
size="sm"
|
|
1260
|
+
shape="circle"
|
|
1261
|
+
tooltip="Shuffle"
|
|
1262
|
+
tooltipPlacement="top"
|
|
1263
|
+
pressed={shufflePressed}
|
|
1264
|
+
tabindex={-1}
|
|
1265
|
+
data-ambience-nav
|
|
1266
|
+
onclick={onShuffleClick}
|
|
1267
|
+
>
|
|
1268
|
+
<svg
|
|
1269
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1270
|
+
viewBox="0 0 24 24"
|
|
1271
|
+
fill="none"
|
|
1272
|
+
stroke="currentColor"
|
|
1273
|
+
stroke-width="2"
|
|
1274
|
+
stroke-linecap="round"
|
|
1275
|
+
stroke-linejoin="round"
|
|
1276
|
+
class="size-4 {shufflePressed ? 'text-primary' : ''}"
|
|
1277
|
+
aria-hidden="true"
|
|
1278
|
+
>
|
|
1279
|
+
<!-- shuffle, lucide geometry -->
|
|
1280
|
+
<path d="m18 14 4 4-4 4" />
|
|
1281
|
+
<path d="m18 2 4 4-4 4" />
|
|
1282
|
+
<path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
|
|
1283
|
+
<path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
|
|
1284
|
+
<path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
|
|
1285
|
+
</svg>
|
|
1286
|
+
</IconButton>
|
|
1287
|
+
{/if}
|
|
1139
1288
|
{#if currentIsPlaylist}
|
|
1140
1289
|
<button
|
|
1141
1290
|
type="button"
|