@joewinke/jatui 0.59.0 → 0.60.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 +9 -2
- package/dist/ambience/engine.d.ts +2 -0
- package/dist/ambience/engine.js +101 -4
- package/dist/ambience/scapes/playlist.d.ts +15 -0
- package/dist/ambience/scapes/playlist.js +81 -4
- package/dist/ambience/shared.d.ts +69 -2
- package/dist/ambience/shared.js +110 -8
- package/dist/ambience/types.d.ts +19 -0
- package/dist/components/AmbienceToggle.svelte +260 -11
- package/dist/components/AmbienceToggle.svelte.d.ts +17 -3
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -333,7 +333,7 @@ const rec = createAudioRecorder({
|
|
|
333
333
|
|
|
334
334
|
### Ambience (subpath: `jatui/ambience`, components on the root)
|
|
335
335
|
|
|
336
|
-
`createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = volume + 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. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
|
|
336
|
+
`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`.
|
|
337
337
|
|
|
338
338
|
### Sound (first-class sibling of motion)
|
|
339
339
|
|
package/dist/ambience/README.md
CHANGED
|
@@ -120,12 +120,19 @@ scape.subscribe((s) => {
|
|
|
120
120
|
// s.status: 'idle' | 'loading' | 'playing' | 'error'
|
|
121
121
|
// s.track: { title, composer, performer?, license, sourceUrl? } | null
|
|
122
122
|
// s.index, s.count, s.error?
|
|
123
|
+
// s.position, s.duration — elapsed/total seconds of the current track
|
|
124
|
+
// (0 while unknown; refreshed on the ~250 ms engine tick — jatui-153tz)
|
|
123
125
|
});
|
|
124
|
-
scape.skip();
|
|
126
|
+
scape.skip(); // crossfade to the next track now
|
|
127
|
+
scape.prev(); // restart the track when >3 s are in, else the previous track
|
|
125
128
|
scape.setShuffle(true);
|
|
126
129
|
scape.tracks(); // resolved list, or null until the manifest resolved
|
|
127
130
|
```
|
|
128
131
|
|
|
129
132
|
Cast a scape from `ambience.list()` to `PlaylistScape` in the toggle to reach
|
|
130
133
|
it. The toggle renders the attribution line — `composer · performer · license`
|
|
131
|
-
— linked to `sourceUrl` while a playlist scape is playing
|
|
134
|
+
— linked to `sourceUrl` while a playlist scape is playing, and (jatui-153tz) a
|
|
135
|
+
now-playing row above the volume with the track, a progress bar fed by
|
|
136
|
+
`position`/`duration`, and a prev / play-pause / next transport. The engine's
|
|
137
|
+
`pause()`/`resume()` freeze the scape in place — the playlist pauses its media
|
|
138
|
+
elements and keeps the track position.
|
|
@@ -12,6 +12,8 @@ export declare const TIMING: Readonly<{
|
|
|
12
12
|
RESUME_FADE: 4;
|
|
13
13
|
/** setVolume ramp (s). */
|
|
14
14
|
VOLUME_RAMP: 0.3;
|
|
15
|
+
/** pause() fade-out before the scape freezes (s). */
|
|
16
|
+
PAUSE_FADE: 0.15;
|
|
15
17
|
/** The fade handed to instance.stop() on kill — 0 = immediate. */
|
|
16
18
|
KILL_FADE: 0;
|
|
17
19
|
/** After a scape's stop fade, its un-released lookahead cbs are retired
|
package/dist/ambience/engine.js
CHANGED
|
@@ -27,6 +27,8 @@ export const TIMING = Object.freeze({
|
|
|
27
27
|
RESUME_FADE: 4,
|
|
28
28
|
/** setVolume ramp (s). */
|
|
29
29
|
VOLUME_RAMP: 0.3,
|
|
30
|
+
/** pause() fade-out before the scape freezes (s). */
|
|
31
|
+
PAUSE_FADE: 0.15,
|
|
30
32
|
/** The fade handed to instance.stop() on kill — 0 = immediate. */
|
|
31
33
|
KILL_FADE: 0,
|
|
32
34
|
/** After a scape's stop fade, its un-released lookahead cbs are retired
|
|
@@ -50,6 +52,12 @@ export function createAmbience(options = {}) {
|
|
|
50
52
|
let volume = clamp(typeof persisted.volume === 'number' ? persisted.volume : (options.defaultVolume ?? 0.6));
|
|
51
53
|
let currentId = persisted.lastScape ?? null;
|
|
52
54
|
let playing = false;
|
|
55
|
+
/** True between pause() and resume()/play()/stop(): the instance stays
|
|
56
|
+
* built and frozen (playlist position kept), the context suspended. */
|
|
57
|
+
let paused = false;
|
|
58
|
+
/** The deferred freeze at the end of pause()'s fade — a resume()/play()
|
|
59
|
+
* inside the window aborts it. */
|
|
60
|
+
let pauseTimer = null;
|
|
53
61
|
let ctx = null;
|
|
54
62
|
let chain = null;
|
|
55
63
|
let active = null;
|
|
@@ -62,7 +70,7 @@ export function createAmbience(options = {}) {
|
|
|
62
70
|
const breathSubs = new Set();
|
|
63
71
|
const lookaheadCbs = new Set();
|
|
64
72
|
function state() {
|
|
65
|
-
return { currentId, playing, volume };
|
|
73
|
+
return { currentId, playing, paused, volume };
|
|
66
74
|
}
|
|
67
75
|
function emit() {
|
|
68
76
|
const s = state();
|
|
@@ -145,8 +153,11 @@ export function createAmbience(options = {}) {
|
|
|
145
153
|
// one is suspended at `await c.resume()` invalidates it — the loser
|
|
146
154
|
// returns without building or starting anything.
|
|
147
155
|
const gen = ++playGen;
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
const wasPaused = paused;
|
|
157
|
+
paused = false; // a play() is a fresh start — never a continuation
|
|
158
|
+
// Inside the post-stop close window (or resuming from a paused scape):
|
|
159
|
+
// cancel the close / unfreeze, and fade the bus back in.
|
|
160
|
+
const resuming = closeTimer !== null || wasPaused;
|
|
150
161
|
if (closeTimer !== null) {
|
|
151
162
|
clearTimeout(closeTimer);
|
|
152
163
|
closeTimer = null;
|
|
@@ -201,7 +212,15 @@ export function createAmbience(options = {}) {
|
|
|
201
212
|
}
|
|
202
213
|
function stop() {
|
|
203
214
|
playGen++; // invalidate any in-flight play()
|
|
204
|
-
if (
|
|
215
|
+
if (pauseTimer) {
|
|
216
|
+
clearTimeout(pauseTimer);
|
|
217
|
+
pauseTimer = null;
|
|
218
|
+
}
|
|
219
|
+
const wasPaused = paused;
|
|
220
|
+
paused = false;
|
|
221
|
+
// A paused scape still owns a live (frozen) instance + context — its
|
|
222
|
+
// stop must tear them down like a playing stop would.
|
|
223
|
+
if (!ctx || (!playing && !wasPaused)) {
|
|
205
224
|
playing = false;
|
|
206
225
|
emit();
|
|
207
226
|
return;
|
|
@@ -223,12 +242,88 @@ export function createAmbience(options = {}) {
|
|
|
223
242
|
}, TIMING.CLOSE_DELAY_MS);
|
|
224
243
|
emit();
|
|
225
244
|
}
|
|
245
|
+
function pause() {
|
|
246
|
+
if (!playing)
|
|
247
|
+
return;
|
|
248
|
+
playing = false;
|
|
249
|
+
paused = true;
|
|
250
|
+
emit();
|
|
251
|
+
if (!ctx || !chain || !active)
|
|
252
|
+
return;
|
|
253
|
+
// A short fade so the freeze is click-free, then the scape freezes in
|
|
254
|
+
// place (media elements) and the context suspends — generative scapes
|
|
255
|
+
// stop evolving and the audio thread rests.
|
|
256
|
+
const now = ctx.currentTime;
|
|
257
|
+
const g = chain.bus.gain;
|
|
258
|
+
g.cancelScheduledValues(now);
|
|
259
|
+
g.setValueAtTime(g.value, now);
|
|
260
|
+
g.linearRampToValueAtTime(0, now + TIMING.PAUSE_FADE);
|
|
261
|
+
if (pauseTimer)
|
|
262
|
+
clearTimeout(pauseTimer);
|
|
263
|
+
pauseTimer = setTimeout(() => {
|
|
264
|
+
pauseTimer = null;
|
|
265
|
+
// A resume()/play()/stop() inside the fade window aborted the pause.
|
|
266
|
+
if (!paused || playing || !active || !ctx)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
active.inst.pause?.();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* a broken scape must not take the engine down */
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
void ctx.suspend();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* already closed */
|
|
279
|
+
}
|
|
280
|
+
}, TIMING.PAUSE_FADE * 1000 + 20);
|
|
281
|
+
}
|
|
282
|
+
async function resume() {
|
|
283
|
+
if (playing || !currentId || !paused)
|
|
284
|
+
return; // only a paused scape resumes
|
|
285
|
+
if (!isGesture())
|
|
286
|
+
throw new Error('resume() must be called from a user gesture');
|
|
287
|
+
const gen = ++playGen; // a stop()/kill() during the resume invalidates it
|
|
288
|
+
paused = false;
|
|
289
|
+
// Defensive: a paused engine always still owns its context + instance
|
|
290
|
+
// (kill/stop clear the paused flag) — bail rather than guess.
|
|
291
|
+
if (!ctx || !chain || !active) {
|
|
292
|
+
emit();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (ctx.state === 'suspended')
|
|
296
|
+
await ctx.resume();
|
|
297
|
+
if (gen !== playGen || !ctx || !chain || !active)
|
|
298
|
+
return; // superseded mid-resume
|
|
299
|
+
try {
|
|
300
|
+
active.inst.resume?.();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* a broken scape must not take the engine down */
|
|
304
|
+
}
|
|
305
|
+
const g = chain.bus.gain;
|
|
306
|
+
g.cancelScheduledValues(ctx.currentTime);
|
|
307
|
+
g.setValueAtTime(g.value, ctx.currentTime);
|
|
308
|
+
g.linearRampToValueAtTime(1, ctx.currentTime + TIMING.VOLUME_RAMP);
|
|
309
|
+
// A pause did not stop the breath clock, but make sure it runs.
|
|
310
|
+
if (!breath.running)
|
|
311
|
+
breath.start(ctx.currentTime);
|
|
312
|
+
playing = true;
|
|
313
|
+
persist();
|
|
314
|
+
emit();
|
|
315
|
+
}
|
|
226
316
|
function kill() {
|
|
227
317
|
playGen++; // invalidate any in-flight play()
|
|
228
318
|
if (closeTimer) {
|
|
229
319
|
clearTimeout(closeTimer);
|
|
230
320
|
closeTimer = null;
|
|
231
321
|
}
|
|
322
|
+
if (pauseTimer) {
|
|
323
|
+
clearTimeout(pauseTimer);
|
|
324
|
+
pauseTimer = null;
|
|
325
|
+
}
|
|
326
|
+
paused = false;
|
|
232
327
|
if (tick) {
|
|
233
328
|
clearInterval(tick);
|
|
234
329
|
tick = null;
|
|
@@ -298,6 +393,8 @@ export function createAmbience(options = {}) {
|
|
|
298
393
|
},
|
|
299
394
|
play,
|
|
300
395
|
stop,
|
|
396
|
+
pause,
|
|
397
|
+
resume,
|
|
301
398
|
kill,
|
|
302
399
|
setVolume,
|
|
303
400
|
get state() {
|
|
@@ -47,6 +47,9 @@ export declare const PLAYLIST: Readonly<{
|
|
|
47
47
|
/** Start the next track when at most this much of the current track is
|
|
48
48
|
* left ON TOP of the crossfade (s). */
|
|
49
49
|
END_MARGIN: 0.25;
|
|
50
|
+
/** prev() restarts the current track when more than this many seconds are
|
|
51
|
+
* in; below it, it goes back one track (jatui-153tz). */
|
|
52
|
+
PREV_RESTART: 3;
|
|
50
53
|
/** setParam('gain') ramp (s). */
|
|
51
54
|
GAIN_RAMP: 0.15;
|
|
52
55
|
/** Disconnect + pause this many ms after a stop fade ends — before the
|
|
@@ -84,6 +87,13 @@ export interface PlaylistState {
|
|
|
84
87
|
index: number;
|
|
85
88
|
/** Number of tracks in the resolved playlist (0 until resolved). */
|
|
86
89
|
count: number;
|
|
90
|
+
/** Elapsed seconds of the current track (0 while none is sounding).
|
|
91
|
+
* Updated on the engine's ~250 ms lookahead tick — the cadence the
|
|
92
|
+
* toggle's progress bar renders at (jatui-153tz). */
|
|
93
|
+
position: number;
|
|
94
|
+
/** Total seconds of the current track (0 until metadata reports one —
|
|
95
|
+
* a stream without a duration stays 0). */
|
|
96
|
+
duration: number;
|
|
87
97
|
/** Present on error. */
|
|
88
98
|
error?: string;
|
|
89
99
|
}
|
|
@@ -124,6 +134,11 @@ export interface PlaylistScape extends Scape {
|
|
|
124
134
|
subscribe(fn: (s: PlaylistState) => void): () => void;
|
|
125
135
|
/** Crossfade to the next track now. No-op while loading/released. */
|
|
126
136
|
skip(): void;
|
|
137
|
+
/** Back one step (jatui-153tz): restart the current track when more than
|
|
138
|
+
* PLAYLIST.PREV_RESTART (3 s) are in, else crossfade to the previous
|
|
139
|
+
* track in the play order (at the very first track, restart). No-op
|
|
140
|
+
* while loading/released or mid-transition. */
|
|
141
|
+
prev(): void;
|
|
127
142
|
/** Toggle shuffle; the current track stays, the rest reorder at once. */
|
|
128
143
|
setShuffle(on: boolean): void;
|
|
129
144
|
/** Crossfade to an absolute track index — used on leadership handover
|
|
@@ -8,6 +8,9 @@ export const PLAYLIST = Object.freeze({
|
|
|
8
8
|
/** Start the next track when at most this much of the current track is
|
|
9
9
|
* left ON TOP of the crossfade (s). */
|
|
10
10
|
END_MARGIN: 0.25,
|
|
11
|
+
/** prev() restarts the current track when more than this many seconds are
|
|
12
|
+
* in; below it, it goes back one track (jatui-153tz). */
|
|
13
|
+
PREV_RESTART: 3,
|
|
11
14
|
/** setParam('gain') ramp (s). */
|
|
12
15
|
GAIN_RAMP: 0.15,
|
|
13
16
|
/** Disconnect + pause this many ms after a stop fade ends — before the
|
|
@@ -43,7 +46,7 @@ export function createPlaylistScape(options) {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
/** Shared UI snapshot + subscribers. Only the latest build writes it. */
|
|
46
|
-
let snap = { status: 'idle', track: null, index: 0, count: 0 };
|
|
49
|
+
let snap = { status: 'idle', track: null, index: 0, count: 0, position: 0, duration: 0 };
|
|
47
50
|
const subs = new Set();
|
|
48
51
|
/** Handle to the most recent build — skip()/setShuffle() route here. */
|
|
49
52
|
let latest = null;
|
|
@@ -178,6 +181,12 @@ export function createPlaylistScape(options) {
|
|
|
178
181
|
if (slot.trackIndex < 0)
|
|
179
182
|
return;
|
|
180
183
|
const el = slot.el;
|
|
184
|
+
// Live progress for the toggle's now-playing row (jatui-153tz):
|
|
185
|
+
// elapsed/total on the ~250 ms tick cadence the bar renders at.
|
|
186
|
+
const position = Number.isFinite(el.currentTime) ? Math.max(0, el.currentTime) : 0;
|
|
187
|
+
const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 0;
|
|
188
|
+
if (snap.position !== position || snap.duration !== duration)
|
|
189
|
+
setSnap({ position, duration });
|
|
181
190
|
const d = el.duration;
|
|
182
191
|
// Stateless per-tick check (no while-loop to re-anchor): at most ONE
|
|
183
192
|
// transition per tick, always anchored at THIS tick's now, so a
|
|
@@ -274,7 +283,15 @@ export function createPlaylistScape(options) {
|
|
|
274
283
|
catch {
|
|
275
284
|
onTrackFailed();
|
|
276
285
|
}
|
|
277
|
-
setSnap({
|
|
286
|
+
setSnap({
|
|
287
|
+
status: 'playing',
|
|
288
|
+
track,
|
|
289
|
+
index: pos,
|
|
290
|
+
count: resolved.length,
|
|
291
|
+
position: 0,
|
|
292
|
+
duration: 0,
|
|
293
|
+
error: undefined
|
|
294
|
+
});
|
|
278
295
|
preloadNext();
|
|
279
296
|
}
|
|
280
297
|
function quietSlot(slot) {
|
|
@@ -357,6 +374,8 @@ export function createPlaylistScape(options) {
|
|
|
357
374
|
track: null,
|
|
358
375
|
index: 0,
|
|
359
376
|
count: resolved?.length ?? 0,
|
|
377
|
+
position: 0,
|
|
378
|
+
duration: 0,
|
|
360
379
|
error: undefined
|
|
361
380
|
});
|
|
362
381
|
const f = Math.max(0, fade);
|
|
@@ -432,7 +451,9 @@ export function createPlaylistScape(options) {
|
|
|
432
451
|
error: message,
|
|
433
452
|
track: null,
|
|
434
453
|
index: 0,
|
|
435
|
-
count: resolved?.length ?? 0
|
|
454
|
+
count: resolved?.length ?? 0,
|
|
455
|
+
position: 0,
|
|
456
|
+
duration: 0
|
|
436
457
|
});
|
|
437
458
|
const now = ctx.currentTime;
|
|
438
459
|
const g = mix.gain;
|
|
@@ -461,6 +482,57 @@ export function createPlaylistScape(options) {
|
|
|
461
482
|
if (ni !== -1)
|
|
462
483
|
startTrack(ni, 'cross');
|
|
463
484
|
}
|
|
485
|
+
/** Back one step (jatui-153tz): > 3 s into the track restarts it, else
|
|
486
|
+
* the previous track in the play order (the first track restarts). */
|
|
487
|
+
function prevInternal() {
|
|
488
|
+
if (released || !started || order.length === 0 || active < 0)
|
|
489
|
+
return;
|
|
490
|
+
const slot = slots[active];
|
|
491
|
+
const cur = order[pos];
|
|
492
|
+
if (slot.trackIndex !== cur)
|
|
493
|
+
return; // mid-transition — the skip owns it
|
|
494
|
+
if (slot.el.currentTime > PLAYLIST.PREV_RESTART || pos === 0) {
|
|
495
|
+
// Restart the current track: a quick fade into the idle slot
|
|
496
|
+
// loaded with the same track, play order pointer unchanged.
|
|
497
|
+
startTrack(cur, 'ended');
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
pos -= 1;
|
|
501
|
+
startTrack(order[pos], 'cross');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Engine pause(): freeze in place — both elements stop, the track and
|
|
505
|
+
* its position are kept for resume(). Gains/schedules stay as they are
|
|
506
|
+
* (the context suspension freezes the audio clock mid-ramp). */
|
|
507
|
+
function pauseInternal() {
|
|
508
|
+
if (released || !started)
|
|
509
|
+
return;
|
|
510
|
+
for (const slot of slots) {
|
|
511
|
+
try {
|
|
512
|
+
slot.el.pause();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* element already unusable */
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Engine resume(): the active element continues from where it paused.
|
|
520
|
+
* The idle (preloading) slot stays paused. */
|
|
521
|
+
function resumeInternal() {
|
|
522
|
+
if (released || !started || active < 0)
|
|
523
|
+
return;
|
|
524
|
+
const slot = slots[active];
|
|
525
|
+
if (slot.trackIndex < 0)
|
|
526
|
+
return;
|
|
527
|
+
try {
|
|
528
|
+
const p = slot.el.play();
|
|
529
|
+
if (p)
|
|
530
|
+
p.catch(() => { }); // the engine's failed-track path owns errors
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
/* element already unusable */
|
|
534
|
+
}
|
|
535
|
+
}
|
|
464
536
|
/** Leadership handover (jatui-lm1mf): crossfade to an absolute track
|
|
465
537
|
* index so the new leader tab picks up the position the old one left. */
|
|
466
538
|
function startAtInternal(index) {
|
|
@@ -489,8 +561,9 @@ export function createPlaylistScape(options) {
|
|
|
489
561
|
}
|
|
490
562
|
wireLookahead();
|
|
491
563
|
return {
|
|
492
|
-
api: { start, stop, setParam },
|
|
564
|
+
api: { start, stop, setParam, pause: pauseInternal, resume: resumeInternal },
|
|
493
565
|
skip: skipInternal,
|
|
566
|
+
prev: prevInternal,
|
|
494
567
|
startAt: startAtInternal,
|
|
495
568
|
reshuffle,
|
|
496
569
|
isReleased: () => released
|
|
@@ -519,6 +592,10 @@ export function createPlaylistScape(options) {
|
|
|
519
592
|
if (latest && !latest.isReleased())
|
|
520
593
|
latest.skip();
|
|
521
594
|
},
|
|
595
|
+
prev() {
|
|
596
|
+
if (latest && !latest.isReleased())
|
|
597
|
+
latest.prev();
|
|
598
|
+
},
|
|
522
599
|
setShuffle(on) {
|
|
523
600
|
shuffle = on;
|
|
524
601
|
if (latest && !latest.isReleased())
|
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-tab shared player (jatui-lm1mf): exactly ONE tab owns the
|
|
3
|
+
* AudioContext and plays; every other tab is a REMOTE that renders the same
|
|
4
|
+
* live state and sends commands. Open MOS in ten tabs and the tab playing
|
|
5
|
+
* ambience is no longer unfindable — any tab's rail toggle shows and controls
|
|
6
|
+
* the one that's sounding.
|
|
7
|
+
*
|
|
8
|
+
* Leader election:
|
|
9
|
+
* - Web Locks API (`navigator.locks.request(lockName)`) when available —
|
|
10
|
+
* the lock IS the leadership, so the browser guarantees mutual exclusion.
|
|
11
|
+
* - Fallback (no Web Locks): a BroadcastChannel heartbeat. The leader
|
|
12
|
+
* heartbeats every HEARTBEAT_MS; a tab that hears no beat for STALE_MS
|
|
13
|
+
* may claim, guarded by a short window in which a live leader reasserts.
|
|
14
|
+
*
|
|
15
|
+
* Transport (always BroadcastChannel 'jatui:ambience'):
|
|
16
|
+
* - `state` / `heartbeat` — the leader publishes the live shared state
|
|
17
|
+
* (playing scape, volume, muted, track attribution, its tab id).
|
|
18
|
+
* - `cmd` — a remote tells the leader to stop / pause / resume / skip / go
|
|
19
|
+
* back / setVolume / toggleMute / play (play only when the fallback
|
|
20
|
+
* election defers to a live leader).
|
|
21
|
+
* - `want` / `claim` — the heartbeat election.
|
|
22
|
+
* - `gone` — the leader's pagehide: remotes immediately mark the leader
|
|
23
|
+
* gone (playback stops — never a surprise autoplay in a background tab).
|
|
24
|
+
*
|
|
25
|
+
* Starting a scape in tab B while tab A plays makes B the leader: with
|
|
26
|
+
* Web Locks, B's user-gesture play STEALS the lock, A's held request promise
|
|
27
|
+
* rejects (AbortError) and A fades out — never two tabs audible. Playlist
|
|
28
|
+
* scapes hand over their track position (published by the leader, applied
|
|
29
|
+
* by the new leader via PlaylistScape.startAt).
|
|
30
|
+
*
|
|
31
|
+
* Resuming after the leader tab closes/reloads: playback is stopped and no
|
|
32
|
+
* remote autoplays; pressing play (the toggle's existing button — a user
|
|
33
|
+
* gesture, satisfying the autoplay policy) claims leadership and plays.
|
|
34
|
+
*
|
|
35
|
+
* Volume and mute persist in localStorage (shared by all tabs of the
|
|
36
|
+
* origin). The `storage` event is deliberately NOT the transport — it skips
|
|
37
|
+
* the writer tab; BroadcastChannel delivers to every OTHER tab.
|
|
38
|
+
*
|
|
39
|
+
* NO UI here. Hosts get everything through createAmbience({ shared: true }) —
|
|
40
|
+
* the Ambience interface is unchanged, so AmbienceToggle needs no structural
|
|
41
|
+
* changes beyond reading the shared now-playing line.
|
|
42
|
+
*/
|
|
43
|
+
import { createAmbience as createLocalEngine } from './engine';
|
|
1
44
|
import type { Ambience, AmbienceOptions } from './types';
|
|
2
45
|
/** BroadcastChannel name — the ambience control channel for the origin. */
|
|
3
46
|
export declare const AMBIENCE_CHANNEL = "jatui:ambience";
|
|
@@ -37,6 +80,18 @@ export interface SharedAmbienceState {
|
|
|
37
80
|
/** Position of the published now-playing track in its playlist (0-based),
|
|
38
81
|
* for leadership handover of playlist scapes. */
|
|
39
82
|
trackIndex: number | null;
|
|
83
|
+
/** The published track's title, so a remote can show title and
|
|
84
|
+
* attribution on their own lines (jatui-153tz). */
|
|
85
|
+
trackTitle: string | null;
|
|
86
|
+
/** Elapsed seconds within the published track (leader-published at the
|
|
87
|
+
* playlist's ~250 ms state cadence); null when unknown. */
|
|
88
|
+
trackPosition: number | null;
|
|
89
|
+
/** Total seconds of the published track; null while unknown (no metadata
|
|
90
|
+
* yet, or a stream without a duration). */
|
|
91
|
+
trackDuration: number | null;
|
|
92
|
+
/** True while the leader is PAUSED (engine.pause() — the scape is frozen
|
|
93
|
+
* in place, not torn down; resume continues it). */
|
|
94
|
+
paused: boolean;
|
|
40
95
|
}
|
|
41
96
|
/** An Ambience whose playback is shared across tabs (see module doc). */
|
|
42
97
|
export interface SharedAmbience extends Ambience {
|
|
@@ -48,8 +103,14 @@ export interface SharedAmbience extends Ambience {
|
|
|
48
103
|
setMuted(muted: boolean): void;
|
|
49
104
|
toggleMute(): void;
|
|
50
105
|
/** Leader-side hook: the host publishes the playing track's attribution
|
|
51
|
-
* (and playlist position) so remote tabs show it too.
|
|
52
|
-
|
|
106
|
+
* (and playlist position + progress) so remote tabs show it too.
|
|
107
|
+
* `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
|
|
108
|
+
* now-playing row; nulls mean unknown. */
|
|
109
|
+
setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null): void;
|
|
110
|
+
/** Now-playing-row transport (jatui-153tz): skip / go back one track on a
|
|
111
|
+
* playlist scape. A remote routes the command to the leader's instance. */
|
|
112
|
+
skipTrack(id: string): void;
|
|
113
|
+
prevTrack(id: string): void;
|
|
53
114
|
/** Release the lock, stop the heartbeat and close the channel. The local
|
|
54
115
|
* engine is killed if this tab was the leader. */
|
|
55
116
|
destroy(): void;
|
|
@@ -95,3 +156,9 @@ export declare function createSharedAmbience(options?: SharedAmbienceOptions): S
|
|
|
95
156
|
* Ambience interface (AmbienceToggle needs no changes beyond reading the
|
|
96
157
|
* shared now-playing line). */
|
|
97
158
|
export declare function createAmbience(options?: AmbienceOptions): Ambience;
|
|
159
|
+
/** The always-local engine factory — the barrel's `createAmbienceLocal`.
|
|
160
|
+
* The ambience barrel has re-exported this name since the shared player
|
|
161
|
+
* landed (jatui-lm1mf), but shared.ts never defined it: real ESM (the built
|
|
162
|
+
* dist, Node) failed to import the subpath at all (jatui-153tz rebuild). It
|
|
163
|
+
* is the plain engine: `shared` is never honored, whatever the options. */
|
|
164
|
+
export declare const createAmbienceLocal: typeof createLocalEngine;
|
package/dist/ambience/shared.js
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* Transport (always BroadcastChannel 'jatui:ambience'):
|
|
16
16
|
* - `state` / `heartbeat` — the leader publishes the live shared state
|
|
17
17
|
* (playing scape, volume, muted, track attribution, its tab id).
|
|
18
|
-
* - `cmd` — a remote tells the leader to stop /
|
|
19
|
-
* play (play only when the fallback
|
|
18
|
+
* - `cmd` — a remote tells the leader to stop / pause / resume / skip / go
|
|
19
|
+
* back / setVolume / toggleMute / play (play only when the fallback
|
|
20
|
+
* election defers to a live leader).
|
|
20
21
|
* - `want` / `claim` — the heartbeat election.
|
|
21
22
|
* - `gone` — the leader's pagehide: remotes immediately mark the leader
|
|
22
23
|
* gone (playback stops — never a surprise autoplay in a background tab).
|
|
@@ -115,6 +116,10 @@ export function createSharedAmbience(options = {}) {
|
|
|
115
116
|
/** The leader's now-playing line, published via setNowPlaying. */
|
|
116
117
|
let nowPlaying = null;
|
|
117
118
|
let nowPlayingIndex = null;
|
|
119
|
+
/** Structured now-playing parts published alongside the line (jatui-153tz). */
|
|
120
|
+
let nowPlayingTitle = null;
|
|
121
|
+
let nowPlayingPosition = null;
|
|
122
|
+
let nowPlayingDuration = null;
|
|
118
123
|
const ambienceSubs = new Set();
|
|
119
124
|
const sharedSubs = new Set();
|
|
120
125
|
function sharedState() {
|
|
@@ -122,18 +127,27 @@ export function createSharedAmbience(options = {}) {
|
|
|
122
127
|
return {
|
|
123
128
|
currentId: role === 'leader' ? st.currentId : (remoteS?.currentId ?? st.currentId),
|
|
124
129
|
playing: role === 'leader' ? st.playing : (remoteS?.playing ?? false),
|
|
130
|
+
paused: role === 'leader' ? st.paused : (remoteS?.paused ?? false),
|
|
125
131
|
volume: userVolume,
|
|
126
132
|
muted,
|
|
127
133
|
role,
|
|
128
134
|
leaderTabId: role === 'leader' ? tabId : (remoteS?.leaderTabId ?? null),
|
|
129
135
|
leaderGone: role === 'leader' ? false : leaderGone,
|
|
130
136
|
nowPlaying: role === 'leader' ? nowPlaying : (remoteS?.nowPlaying ?? null),
|
|
131
|
-
trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null)
|
|
137
|
+
trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null),
|
|
138
|
+
trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
|
|
139
|
+
trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
|
|
140
|
+
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null)
|
|
132
141
|
};
|
|
133
142
|
}
|
|
134
143
|
function ambienceState() {
|
|
135
144
|
const s = sharedState();
|
|
136
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
currentId: s.currentId,
|
|
147
|
+
playing: s.playing && !s.leaderGone,
|
|
148
|
+
paused: s.paused,
|
|
149
|
+
volume: s.volume
|
|
150
|
+
};
|
|
137
151
|
}
|
|
138
152
|
function emitShared() {
|
|
139
153
|
const s = sharedState();
|
|
@@ -189,6 +203,9 @@ export function createSharedAmbience(options = {}) {
|
|
|
189
203
|
role = 'remote';
|
|
190
204
|
nowPlaying = null;
|
|
191
205
|
nowPlayingIndex = null;
|
|
206
|
+
nowPlayingTitle = null;
|
|
207
|
+
nowPlayingPosition = null;
|
|
208
|
+
nowPlayingDuration = null;
|
|
192
209
|
remoteS = null;
|
|
193
210
|
if (heartbeatTimer) {
|
|
194
211
|
clearInterval(heartbeatTimer);
|
|
@@ -387,7 +404,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
387
404
|
// The leader tab is closing: mark it gone at once — no staleness
|
|
388
405
|
// wait, and never a surprise autoplay in a background tab.
|
|
389
406
|
if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
|
|
390
|
-
remoteS = { ...remoteS, playing: false, nowPlaying: null };
|
|
407
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
391
408
|
leaderGone = true;
|
|
392
409
|
emitShared();
|
|
393
410
|
}
|
|
@@ -400,6 +417,34 @@ export function createSharedAmbience(options = {}) {
|
|
|
400
417
|
case 'stop':
|
|
401
418
|
local.stop();
|
|
402
419
|
break;
|
|
420
|
+
case 'pause':
|
|
421
|
+
local.pause();
|
|
422
|
+
break;
|
|
423
|
+
case 'resume':
|
|
424
|
+
remoteCommand = true;
|
|
425
|
+
local
|
|
426
|
+
.resume()
|
|
427
|
+
.catch(() => {
|
|
428
|
+
if (role === 'leader')
|
|
429
|
+
publish();
|
|
430
|
+
})
|
|
431
|
+
.finally(() => {
|
|
432
|
+
remoteCommand = false;
|
|
433
|
+
});
|
|
434
|
+
break;
|
|
435
|
+
case 'skip':
|
|
436
|
+
case 'prev': {
|
|
437
|
+
// The now-playing row's transport (jatui-153tz): routed to the
|
|
438
|
+
// leader's own playlist scape instance.
|
|
439
|
+
if (!msg.id)
|
|
440
|
+
return;
|
|
441
|
+
const p = playlistFor(msg.id);
|
|
442
|
+
if (msg.cmd === 'skip')
|
|
443
|
+
p?.skip();
|
|
444
|
+
else
|
|
445
|
+
p?.prev();
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
403
448
|
case 'setVolume':
|
|
404
449
|
applyVolume(msg.arg ?? userVolume);
|
|
405
450
|
break;
|
|
@@ -442,7 +487,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
442
487
|
if (remoteS?.playing &&
|
|
443
488
|
lastBeatAt > 0 &&
|
|
444
489
|
Date.now() - lastBeatAt > staleMs) {
|
|
445
|
-
remoteS = { ...remoteS, playing: false, nowPlaying: null };
|
|
490
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
446
491
|
leaderGone = true;
|
|
447
492
|
emitShared();
|
|
448
493
|
}
|
|
@@ -526,9 +571,53 @@ export function createSharedAmbience(options = {}) {
|
|
|
526
571
|
post({ t: 'cmd', cmd: 'stop', from: tabId });
|
|
527
572
|
// Optimistic: the leader echoes the authoritative state right after.
|
|
528
573
|
if (remoteS)
|
|
529
|
-
remoteS = { ...remoteS, playing: false, nowPlaying: null };
|
|
574
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
575
|
+
emitShared();
|
|
576
|
+
}
|
|
577
|
+
/** Freeze in place (the scape is not torn down; a playlist keeps its track
|
|
578
|
+
* position). Leader-side it pauses the local engine; a remote asks the
|
|
579
|
+
* leader to — the echo of the authoritative state follows. */
|
|
580
|
+
function pause() {
|
|
581
|
+
if (role === 'leader') {
|
|
582
|
+
local.pause(); // publish happens via the engine subscription
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
post({ t: 'cmd', cmd: 'pause', from: tabId });
|
|
586
|
+
if (remoteS)
|
|
587
|
+
remoteS = { ...remoteS, playing: false, paused: true };
|
|
588
|
+
emitShared();
|
|
589
|
+
}
|
|
590
|
+
/** Continue a paused scape. No-op when nothing is paused. */
|
|
591
|
+
async function resume() {
|
|
592
|
+
if (role === 'leader') {
|
|
593
|
+
await local.resume().catch(() => {
|
|
594
|
+
if (role === 'leader')
|
|
595
|
+
publish();
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
post({ t: 'cmd', cmd: 'resume', from: tabId });
|
|
600
|
+
if (remoteS)
|
|
601
|
+
remoteS = { ...remoteS, playing: true, paused: false };
|
|
530
602
|
emitShared();
|
|
531
603
|
}
|
|
604
|
+
/** Transport for the now-playing row (jatui-153tz): skip to the next track
|
|
605
|
+
* (or go back one) on a playlist scape — routed to the leader's live
|
|
606
|
+
* instance when this tab is a remote. */
|
|
607
|
+
function skipTrack(id) {
|
|
608
|
+
if (role === 'leader') {
|
|
609
|
+
playlistFor(id)?.skip();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
post({ t: 'cmd', cmd: 'skip', id, from: tabId });
|
|
613
|
+
}
|
|
614
|
+
function prevTrack(id) {
|
|
615
|
+
if (role === 'leader') {
|
|
616
|
+
playlistFor(id)?.prev();
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
post({ t: 'cmd', cmd: 'prev', id, from: tabId });
|
|
620
|
+
}
|
|
532
621
|
function setVolume(v) {
|
|
533
622
|
applyVolume(v);
|
|
534
623
|
savePersisted(storageKey, {
|
|
@@ -594,6 +683,10 @@ export function createSharedAmbience(options = {}) {
|
|
|
594
683
|
groups: () => local.groups(),
|
|
595
684
|
play,
|
|
596
685
|
stop,
|
|
686
|
+
pause,
|
|
687
|
+
resume,
|
|
688
|
+
skipTrack,
|
|
689
|
+
prevTrack,
|
|
597
690
|
kill: () => {
|
|
598
691
|
// A remote has nothing local to kill — tell the leader instead.
|
|
599
692
|
if (role === 'leader')
|
|
@@ -604,9 +697,12 @@ export function createSharedAmbience(options = {}) {
|
|
|
604
697
|
setVolume,
|
|
605
698
|
setMuted,
|
|
606
699
|
toggleMute,
|
|
607
|
-
setNowPlaying(line, index) {
|
|
700
|
+
setNowPlaying(line, index, position, duration, title) {
|
|
608
701
|
nowPlaying = line;
|
|
609
702
|
nowPlayingIndex = line ? (index ?? null) : null;
|
|
703
|
+
nowPlayingPosition = line ? (position ?? null) : null;
|
|
704
|
+
nowPlayingDuration = line ? (duration ?? null) : null;
|
|
705
|
+
nowPlayingTitle = line ? (title ?? null) : null;
|
|
610
706
|
if (role === 'leader') {
|
|
611
707
|
publish();
|
|
612
708
|
emitShared();
|
|
@@ -640,3 +736,9 @@ export function createSharedAmbience(options = {}) {
|
|
|
640
736
|
export function createAmbience(options = {}) {
|
|
641
737
|
return options.shared ? createSharedAmbience(options) : createLocalEngine(options);
|
|
642
738
|
}
|
|
739
|
+
/** The always-local engine factory — the barrel's `createAmbienceLocal`.
|
|
740
|
+
* The ambience barrel has re-exported this name since the shared player
|
|
741
|
+
* landed (jatui-lm1mf), but shared.ts never defined it: real ESM (the built
|
|
742
|
+
* dist, Node) failed to import the subpath at all (jatui-153tz rebuild). It
|
|
743
|
+
* is the plain engine: `shared` is never honored, whatever the options. */
|
|
744
|
+
export const createAmbienceLocal = createLocalEngine;
|
package/dist/ambience/types.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface ScapeInstance {
|
|
|
27
27
|
stop(at: number, fade: number): void;
|
|
28
28
|
/** Optional live parameter (scape-defined keys). */
|
|
29
29
|
setParam?(key: string, value: number): void;
|
|
30
|
+
/** Optional live pause: freeze in place without tearing down (a playlist
|
|
31
|
+
* pauses its media elements and keeps the track position). Called by the
|
|
32
|
+
* engine's pause() after its fade; scapes without it are just faded. */
|
|
33
|
+
pause?(): void;
|
|
34
|
+
/** Optional resume counterpart of pause() — continue where you froze. */
|
|
35
|
+
resume?(): void;
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
32
38
|
* A menu section. The named members are the library's own groups (radio holds
|
|
@@ -74,6 +80,10 @@ export interface AmbienceState {
|
|
|
74
80
|
/** Currently selected scape id (persisted preselect counts), or null. */
|
|
75
81
|
currentId: string | null;
|
|
76
82
|
playing: boolean;
|
|
83
|
+
/** True while paused via pause(): the scape instance is frozen in place
|
|
84
|
+
* (a playlist keeps its track position), not torn down — resume()
|
|
85
|
+
* continues it from the same moment. Cleared by play/stop/kill. */
|
|
86
|
+
paused: boolean;
|
|
77
87
|
volume: number;
|
|
78
88
|
}
|
|
79
89
|
export interface BreathState {
|
|
@@ -97,6 +107,15 @@ export interface Ambience {
|
|
|
97
107
|
/** Fade out ~0.55 s; the AudioContext closes ~3 s later. A play() inside
|
|
98
108
|
* that window cancels the close and fades back in over ~4 s. */
|
|
99
109
|
stop(): void;
|
|
110
|
+
/** Pause without tearing down: a short fade out, then the scape freezes in
|
|
111
|
+
* place (a playlist keeps its track and position; the context suspends so
|
|
112
|
+
* generative scapes stop evolving and the CPU rests). resume() continues
|
|
113
|
+
* from the same moment; play() restarts fresh. No-op while not playing. */
|
|
114
|
+
pause(): void;
|
|
115
|
+
/** Resume a paused scape — MUST be called from a user gesture (it resumes
|
|
116
|
+
* the suspended AudioContext). No-op unless paused. Rejects like play()
|
|
117
|
+
* on a refused gesture. */
|
|
118
|
+
resume(): Promise<void>;
|
|
100
119
|
/** Immediate: no fades, context closed, timers cleared. */
|
|
101
120
|
kill(): void;
|
|
102
121
|
setVolume(v: number): void;
|
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
* real `<a target="_blank">` menu items with an external-link glyph:
|
|
24
24
|
* the station's own site or player opens in a new tab. Nothing is
|
|
25
25
|
* relayed — we never proxy a third-party stream.
|
|
26
|
+
* - A playing (or paused) scape shows a now-playing block above the
|
|
27
|
+
* volume row (jatui-153tz): for a playlist — the track title, its
|
|
28
|
+
* composer · license attribution, an elapsed/total progress bar and a
|
|
29
|
+
* prev / play-pause / next transport (prev restarts the track when more
|
|
30
|
+
* than 3 s are in, else goes back one; play-pause freezes the station
|
|
31
|
+
* without closing the menu; next skips). Generative scapes (noise /
|
|
32
|
+
* focus) get the play/pause control only. The transport joins the
|
|
33
|
+
* menu's roving order; a paused row stays visible (its play button is
|
|
34
|
+
* the resume) and a stopped one hides.
|
|
26
35
|
* - A playing playlist shows its current track's attribution (title,
|
|
27
36
|
* composer, license) under the playlist item and its section row.
|
|
28
37
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -79,14 +88,38 @@
|
|
|
79
88
|
return `${track.title} — ${trackAttribution(track)}`;
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
/** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
|
|
92
|
+
export function formatTrackTime(seconds: number | null | undefined): string {
|
|
93
|
+
if (
|
|
94
|
+
seconds === null ||
|
|
95
|
+
seconds === undefined ||
|
|
96
|
+
!Number.isFinite(seconds) ||
|
|
97
|
+
seconds < 0
|
|
98
|
+
)
|
|
99
|
+
return '0:00';
|
|
100
|
+
const s = Math.floor(seconds);
|
|
101
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The now-playing progress fill, 0–100, clamped; 0 without a duration. */
|
|
105
|
+
export function progressPercent(position: number, duration: number): number {
|
|
106
|
+
if (!Number.isFinite(position) || !Number.isFinite(duration) || duration <= 0) return 0;
|
|
107
|
+
return Math.min(100, Math.max(0, (position / duration) * 100));
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/**
|
|
83
111
|
* The live label — "Ambience: Hibiscus, playing" while sounding,
|
|
84
|
-
* "Ambience: Hibiscus,
|
|
85
|
-
* playing, "Ambience: off" before
|
|
112
|
+
* "Ambience: Hibiscus, paused" while paused, "Ambience: Hibiscus, stopped"
|
|
113
|
+
* once a scape has been selected but is not playing, "Ambience: off" before
|
|
114
|
+
* anything was selected.
|
|
86
115
|
*/
|
|
87
|
-
export function ambienceLiveLabel(
|
|
116
|
+
export function ambienceLiveLabel(
|
|
117
|
+
currentLabel: string | null,
|
|
118
|
+
playing: boolean,
|
|
119
|
+
paused = false
|
|
120
|
+
): string {
|
|
88
121
|
if (!currentLabel) return 'Ambience: off';
|
|
89
|
-
return `Ambience: ${currentLabel}, ${playing ? 'playing' : 'stopped'}`;
|
|
122
|
+
return `Ambience: ${currentLabel}, ${playing ? 'playing' : paused ? 'paused' : 'stopped'}`;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
/** Transient failure label — a rejected engine.play() (gesture probe,
|
|
@@ -201,7 +234,7 @@
|
|
|
201
234
|
import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
|
|
202
235
|
import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
|
|
203
236
|
import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
|
|
204
|
-
import type { SharedAmbience } from '../ambience/shared';
|
|
237
|
+
import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
|
|
205
238
|
|
|
206
239
|
let {
|
|
207
240
|
engine,
|
|
@@ -228,6 +261,8 @@
|
|
|
228
261
|
let playing = $state(false);
|
|
229
262
|
let currentId = $state<string | null>(null);
|
|
230
263
|
let volume = $state(0.6);
|
|
264
|
+
/** True while the engine is paused (pause() — frozen in place, resumable). */
|
|
265
|
+
let paused = $state(false);
|
|
231
266
|
let items = $state<AmbienceItem[]>([]);
|
|
232
267
|
let open = $state(false);
|
|
233
268
|
// svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
|
|
@@ -260,10 +295,20 @@
|
|
|
260
295
|
|
|
261
296
|
let playError = $state(false);
|
|
262
297
|
let track = $state<PlaylistState['track']>(null);
|
|
298
|
+
/** The current playlist scape's live snapshot (jatui-153tz): drives the
|
|
299
|
+
* now-playing row's progress bar. Null on plain/remote tabs without a
|
|
300
|
+
* local playing instance. */
|
|
301
|
+
let playlist = $state<PlaylistState | null>(null);
|
|
263
302
|
/** The shared player's now-playing line (jatui-lm1mf): published by the
|
|
264
303
|
* leader tab, so a remote tab shows the same track attribution without
|
|
265
304
|
* owning the playlist instance. Null on a plain engine. */
|
|
266
305
|
let sharedNow = $state<string | null>(null);
|
|
306
|
+
/** Shared-player extras (jatui-153tz): this tab's role and the leader's
|
|
307
|
+
* published structured now-playing parts. */
|
|
308
|
+
let sharedRole = $state<SharedAmbienceState['role'] | null>(null);
|
|
309
|
+
let sharedTitle = $state<string | null>(null);
|
|
310
|
+
let sharedPosition = $state<number | null>(null);
|
|
311
|
+
let sharedDuration = $state<number | null>(null);
|
|
267
312
|
|
|
268
313
|
// Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
|
|
269
314
|
// the pending partial notch between wheel events so trackpad pixel deltas
|
|
@@ -276,13 +321,45 @@
|
|
|
276
321
|
const currentLabel = $derived(currentItem?.label ?? currentId);
|
|
277
322
|
const groups = $derived(groupScapes(items, engine.groups?.()));
|
|
278
323
|
const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
|
|
279
|
-
const trackLine = $derived(
|
|
324
|
+
const trackLine = $derived(
|
|
325
|
+
playing || paused ? (sharedNow ?? nowPlayingLine(track)) : ''
|
|
326
|
+
);
|
|
280
327
|
const liveLabel = $derived(
|
|
281
|
-
playError
|
|
328
|
+
playError
|
|
329
|
+
? PLAY_ERROR_LABEL
|
|
330
|
+
: ambienceLiveLabel(currentLabel, playing, paused)
|
|
282
331
|
);
|
|
283
332
|
const disabled = $derived(items.length === 0);
|
|
284
333
|
const sliderValue = $derived(Math.round(volume * 100));
|
|
285
334
|
|
|
335
|
+
// --- now-playing row (jatui-153tz) ---------------------------------------
|
|
336
|
+
/** A remote tab (shared engine, not the leader): renders the leader's
|
|
337
|
+
* published state and routes transport commands to it. */
|
|
338
|
+
const isRemote = $derived(sharedRole === 'remote');
|
|
339
|
+
const currentIsPlaylist = $derived(isPlaylistScape(currentItem));
|
|
340
|
+
/** The row exists while something is sounding or paused — a paused row
|
|
341
|
+
* must stay (its play button is how you resume); a stopped one hides. */
|
|
342
|
+
const showNowPlaying = $derived((playing || paused) && !!currentItem);
|
|
343
|
+
const npTitle = $derived(
|
|
344
|
+
isRemote
|
|
345
|
+
? (sharedTitle ?? '')
|
|
346
|
+
: (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
|
|
347
|
+
);
|
|
348
|
+
const npAttrib = $derived(
|
|
349
|
+
isRemote
|
|
350
|
+
? (sharedNow && sharedTitle && sharedNow.startsWith(`${sharedTitle} — `)
|
|
351
|
+
? sharedNow.slice(sharedTitle.length + 3)
|
|
352
|
+
: (sharedNow ?? ''))
|
|
353
|
+
: (playlist?.track ? trackAttribution(playlist.track) : '')
|
|
354
|
+
);
|
|
355
|
+
const npPosition = $derived(
|
|
356
|
+
currentIsPlaylist ? (isRemote ? (sharedPosition ?? 0) : (playlist?.position ?? 0)) : 0
|
|
357
|
+
);
|
|
358
|
+
const npDuration = $derived(
|
|
359
|
+
currentIsPlaylist ? (isRemote ? (sharedDuration ?? 0) : (playlist?.duration ?? 0)) : 0
|
|
360
|
+
);
|
|
361
|
+
const npPercent = $derived(progressPercent(npPosition, npDuration));
|
|
362
|
+
|
|
286
363
|
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
287
364
|
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
288
365
|
|
|
@@ -295,6 +372,7 @@
|
|
|
295
372
|
$effect(() => {
|
|
296
373
|
return engine.subscribe((s) => {
|
|
297
374
|
playing = s.playing;
|
|
375
|
+
paused = s.paused;
|
|
298
376
|
currentId = s.currentId;
|
|
299
377
|
volume = s.volume;
|
|
300
378
|
items = snapshot();
|
|
@@ -303,32 +381,47 @@
|
|
|
303
381
|
});
|
|
304
382
|
});
|
|
305
383
|
|
|
306
|
-
// The shared player's published now-playing line (leader → remotes)
|
|
384
|
+
// The shared player's published now-playing line (leader → remotes), plus
|
|
385
|
+
// this tab's role and the structured parts the now-playing row renders.
|
|
307
386
|
$effect(() => {
|
|
308
387
|
const e = engine as SharedAmbience;
|
|
309
388
|
if (typeof e.subscribeShared !== 'function') {
|
|
310
389
|
sharedNow = null;
|
|
390
|
+
sharedRole = null;
|
|
391
|
+
sharedTitle = null;
|
|
392
|
+
sharedPosition = null;
|
|
393
|
+
sharedDuration = null;
|
|
311
394
|
return;
|
|
312
395
|
}
|
|
313
396
|
return e.subscribeShared((s) => {
|
|
314
397
|
sharedNow = s.nowPlaying;
|
|
398
|
+
sharedRole = s.role;
|
|
399
|
+
sharedTitle = s.trackTitle;
|
|
400
|
+
sharedPosition = s.trackPosition;
|
|
401
|
+
sharedDuration = s.trackDuration;
|
|
315
402
|
});
|
|
316
403
|
});
|
|
317
404
|
|
|
318
|
-
// The current scape is a playlist → follow its track for the attribution line
|
|
319
|
-
// and (leader side) publish it to the other tabs via
|
|
405
|
+
// The current scape is a playlist → follow its track for the attribution line
|
|
406
|
+
// and the progress bar, and (leader side) publish it to the other tabs via
|
|
407
|
+
// the shared engine.
|
|
320
408
|
$effect(() => {
|
|
321
409
|
const item = currentItem;
|
|
322
410
|
if (!isPlaylistScape(item)) {
|
|
323
411
|
track = null;
|
|
412
|
+
playlist = null;
|
|
324
413
|
engine.setNowPlaying?.(null);
|
|
325
414
|
return;
|
|
326
415
|
}
|
|
327
416
|
return item.subscribe((s) => {
|
|
417
|
+
playlist = s;
|
|
328
418
|
track = s.status === 'playing' ? s.track : null;
|
|
329
419
|
engine.setNowPlaying?.(
|
|
330
420
|
s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
|
|
331
|
-
s.index
|
|
421
|
+
s.index,
|
|
422
|
+
s.status === 'playing' ? s.position : null,
|
|
423
|
+
s.status === 'playing' ? s.duration : null,
|
|
424
|
+
s.track?.title ?? null
|
|
332
425
|
);
|
|
333
426
|
});
|
|
334
427
|
});
|
|
@@ -498,6 +591,11 @@
|
|
|
498
591
|
closeMenu(false);
|
|
499
592
|
if (!engine.list().length) return;
|
|
500
593
|
}
|
|
594
|
+
// Paused: the trigger resumes in place (the scape keeps its position).
|
|
595
|
+
if (paused) {
|
|
596
|
+
attemptResume();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
501
599
|
if (playing) {
|
|
502
600
|
engine.stop();
|
|
503
601
|
return;
|
|
@@ -544,6 +642,46 @@
|
|
|
544
642
|
engine.setVolume(v / 100);
|
|
545
643
|
}
|
|
546
644
|
|
|
645
|
+
// --- now-playing row transport (jatui-153tz) -----------------------------
|
|
646
|
+
/** A rejected engine.resume() sets the transient error state, like play. */
|
|
647
|
+
function attemptResume() {
|
|
648
|
+
void engine
|
|
649
|
+
.resume()
|
|
650
|
+
.then(() => {
|
|
651
|
+
playError = false;
|
|
652
|
+
})
|
|
653
|
+
.catch(() => {
|
|
654
|
+
playError = true;
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The row's play/pause: pauses without closing the menu; resumes a
|
|
659
|
+
* paused scape. The block only exists while playing or paused. */
|
|
660
|
+
function onPlayPauseClick() {
|
|
661
|
+
if (paused) attemptResume();
|
|
662
|
+
else if (playing) engine.pause();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Previous track. A remote tab routes the command to the leader. */
|
|
666
|
+
function onPrevClick() {
|
|
667
|
+
const e = engine as SharedAmbience;
|
|
668
|
+
if (isRemote && typeof e.prevTrack === 'function' && currentId) {
|
|
669
|
+
e.prevTrack(currentId);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (isPlaylistScape(currentItem)) currentItem.prev();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Next track (skip). A remote tab routes the command to the leader. */
|
|
676
|
+
function onNextClick() {
|
|
677
|
+
const e = engine as SharedAmbience;
|
|
678
|
+
if (isRemote && typeof e.skipTrack === 'function' && currentId) {
|
|
679
|
+
e.skipTrack(currentId);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (isPlaylistScape(currentItem)) currentItem.skip();
|
|
683
|
+
}
|
|
684
|
+
|
|
547
685
|
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
548
686
|
// While the menu (or one of its submenus) is open the wheel adjusts volume
|
|
549
687
|
// instead of scrolling the page — EXCEPT over a scrollable list that can
|
|
@@ -743,6 +881,13 @@
|
|
|
743
881
|
transition-colors duration-[var(--motion-instant,80ms)]
|
|
744
882
|
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
745
883
|
focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary`;
|
|
884
|
+
|
|
885
|
+
/** The now-playing row's icon transport buttons (prev / play-pause / next). */
|
|
886
|
+
const TRANSPORT_CLASS = `inline-flex size-8 items-center justify-center rounded-full
|
|
887
|
+
text-base-content/80 transition-colors duration-[var(--motion-instant,80ms)]
|
|
888
|
+
hover:bg-base-content/10 hover:text-base-content
|
|
889
|
+
focus-visible:bg-base-content/10 focus-visible:outline-2
|
|
890
|
+
focus-visible:-outline-offset-2 focus-visible:outline-primary`;
|
|
746
891
|
</script>
|
|
747
892
|
|
|
748
893
|
<div bind:this={wrapRef} class="relative inline-flex shrink-0">
|
|
@@ -835,6 +980,94 @@
|
|
|
835
980
|
onkeydown={onMenuKeydown}
|
|
836
981
|
onscroll={() => closeSubmenu(false)}
|
|
837
982
|
>
|
|
983
|
+
<!-- Now playing (jatui-153tz): what's sounding, with transport, ABOVE
|
|
984
|
+
the volume row. Playlist scapes get the track line, progress and
|
|
985
|
+
prev/play-pause/next; generative scapes get play/pause only.
|
|
986
|
+
Paused keeps the row (its play button is the resume); stopped
|
|
987
|
+
hides it. -->
|
|
988
|
+
{#if showNowPlaying}
|
|
989
|
+
<div role="none" class="now-playing mb-1 border-b border-base-content/10 px-2 pb-2 pt-1.5">
|
|
990
|
+
{#if npTitle}
|
|
991
|
+
<p class="block truncate text-sm font-medium" title={npTitle}>{npTitle}</p>
|
|
992
|
+
{/if}
|
|
993
|
+
{#if npAttrib}
|
|
994
|
+
<p class="text-2xs text-base-content/55 block truncate" title={npAttrib}>{npAttrib}</p>
|
|
995
|
+
{/if}
|
|
996
|
+
{#if npDuration > 0}
|
|
997
|
+
<div
|
|
998
|
+
class="now-progress mt-1.5 h-1 w-full overflow-hidden rounded-full bg-base-content/15"
|
|
999
|
+
role="progressbar"
|
|
1000
|
+
aria-label="Track progress"
|
|
1001
|
+
aria-valuemin={0}
|
|
1002
|
+
aria-valuemax={Math.round(npDuration)}
|
|
1003
|
+
aria-valuenow={Math.round(npPosition)}
|
|
1004
|
+
aria-valuetext="{formatTrackTime(npPosition)} of {formatTrackTime(npDuration)}"
|
|
1005
|
+
>
|
|
1006
|
+
<div class="now-progress-fill h-full rounded-full bg-primary" style:width="{npPercent}%"></div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div class="mt-1 flex justify-between text-2xs text-base-content/50 tabular-nums">
|
|
1009
|
+
<span>{formatTrackTime(npPosition)}</span>
|
|
1010
|
+
<span>{formatTrackTime(npDuration)}</span>
|
|
1011
|
+
</div>
|
|
1012
|
+
{/if}
|
|
1013
|
+
<div class="mt-1.5 flex items-center justify-center gap-1">
|
|
1014
|
+
{#if currentIsPlaylist}
|
|
1015
|
+
<button
|
|
1016
|
+
type="button"
|
|
1017
|
+
tabindex="-1"
|
|
1018
|
+
data-ambience-nav
|
|
1019
|
+
aria-label="Previous track"
|
|
1020
|
+
class={TRANSPORT_CLASS}
|
|
1021
|
+
onclick={onPrevClick}
|
|
1022
|
+
>
|
|
1023
|
+
<!-- skip-back, lucide geometry -->
|
|
1024
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1025
|
+
<polygon points="19 20 9 12 19 4 19 20" />
|
|
1026
|
+
<line x1="5" y1="19" x2="5" y2="5" />
|
|
1027
|
+
</svg>
|
|
1028
|
+
</button>
|
|
1029
|
+
{/if}
|
|
1030
|
+
<button
|
|
1031
|
+
type="button"
|
|
1032
|
+
tabindex="-1"
|
|
1033
|
+
data-ambience-nav
|
|
1034
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
1035
|
+
class="{TRANSPORT_CLASS} text-primary"
|
|
1036
|
+
onclick={onPlayPauseClick}
|
|
1037
|
+
>
|
|
1038
|
+
{#if playing}
|
|
1039
|
+
<!-- pause, lucide geometry -->
|
|
1040
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1041
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
1042
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
1043
|
+
</svg>
|
|
1044
|
+
{:else}
|
|
1045
|
+
<!-- play, lucide geometry -->
|
|
1046
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1047
|
+
<polygon points="6 3 20 12 6 21 6 3" />
|
|
1048
|
+
</svg>
|
|
1049
|
+
{/if}
|
|
1050
|
+
</button>
|
|
1051
|
+
{#if currentIsPlaylist}
|
|
1052
|
+
<button
|
|
1053
|
+
type="button"
|
|
1054
|
+
tabindex="-1"
|
|
1055
|
+
data-ambience-nav
|
|
1056
|
+
aria-label="Next track"
|
|
1057
|
+
class={TRANSPORT_CLASS}
|
|
1058
|
+
onclick={onNextClick}
|
|
1059
|
+
>
|
|
1060
|
+
<!-- skip-forward, lucide geometry -->
|
|
1061
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1062
|
+
<polygon points="5 4 15 12 5 20 5 4" />
|
|
1063
|
+
<line x1="19" y1="5" x2="19" y2="19" />
|
|
1064
|
+
</svg>
|
|
1065
|
+
</button>
|
|
1066
|
+
{/if}
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
{/if}
|
|
1070
|
+
|
|
838
1071
|
<!-- Volume: a real slider in the roving set; it owns its own arrows. -->
|
|
839
1072
|
<div role="none" bind:this={volumeRef} class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
|
|
840
1073
|
<div class="mb-1.5 flex items-center justify-between">
|
|
@@ -1067,4 +1300,20 @@
|
|
|
1067
1300
|
opacity: 0;
|
|
1068
1301
|
}
|
|
1069
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
/*
|
|
1305
|
+
* The now-playing progress (jatui-153tz): the fill updates on the playlist
|
|
1306
|
+
* state's ~250 ms tick cadence; a width transition of the same span smooths
|
|
1307
|
+
* the steps into a glide. Reduced motion drops the interpolation — the bar
|
|
1308
|
+
* still advances, in instant jumps; the position itself is never animated.
|
|
1309
|
+
*/
|
|
1310
|
+
.now-progress-fill {
|
|
1311
|
+
transition: width 250ms linear;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1315
|
+
.now-progress-fill {
|
|
1316
|
+
transition: none;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1070
1319
|
</style>
|
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* real `<a target="_blank">` menu items with an external-link glyph:
|
|
23
23
|
* the station's own site or player opens in a new tab. Nothing is
|
|
24
24
|
* relayed — we never proxy a third-party stream.
|
|
25
|
+
* - A playing (or paused) scape shows a now-playing block above the
|
|
26
|
+
* volume row (jatui-153tz): for a playlist — the track title, its
|
|
27
|
+
* composer · license attribution, an elapsed/total progress bar and a
|
|
28
|
+
* prev / play-pause / next transport (prev restarts the track when more
|
|
29
|
+
* than 3 s are in, else goes back one; play-pause freezes the station
|
|
30
|
+
* without closing the menu; next skips). Generative scapes (noise /
|
|
31
|
+
* focus) get the play/pause control only. The transport joins the
|
|
32
|
+
* menu's roving order; a paused row stays visible (its play button is
|
|
33
|
+
* the resume) and a stopped one hides.
|
|
25
34
|
* - A playing playlist shows its current track's attribution (title,
|
|
26
35
|
* composer, license) under the playlist item and its section row.
|
|
27
36
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -59,12 +68,17 @@ export type AmbienceScapeGroup = AmbienceSection;
|
|
|
59
68
|
export declare function groupScapes(items: AmbienceItem[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceScapeGroup[];
|
|
60
69
|
/** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
|
|
61
70
|
export declare function nowPlayingLine(track: PlaylistTrack | null | undefined): string;
|
|
71
|
+
/** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
|
|
72
|
+
export declare function formatTrackTime(seconds: number | null | undefined): string;
|
|
73
|
+
/** The now-playing progress fill, 0–100, clamped; 0 without a duration. */
|
|
74
|
+
export declare function progressPercent(position: number, duration: number): number;
|
|
62
75
|
/**
|
|
63
76
|
* The live label — "Ambience: Hibiscus, playing" while sounding,
|
|
64
|
-
* "Ambience: Hibiscus,
|
|
65
|
-
* playing, "Ambience: off" before
|
|
77
|
+
* "Ambience: Hibiscus, paused" while paused, "Ambience: Hibiscus, stopped"
|
|
78
|
+
* once a scape has been selected but is not playing, "Ambience: off" before
|
|
79
|
+
* anything was selected.
|
|
66
80
|
*/
|
|
67
|
-
export declare function ambienceLiveLabel(currentLabel: string | null, playing: boolean): string;
|
|
81
|
+
export declare function ambienceLiveLabel(currentLabel: string | null, playing: boolean, paused?: boolean): string;
|
|
68
82
|
/** Transient failure label — a rejected engine.play() (gesture probe,
|
|
69
83
|
* context suspend, unknown scape) must never surface as an unhandled
|
|
70
84
|
* rejection OR as silence: the trigger visibly fails to respond, so the
|