@joewinke/jatui 0.58.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 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
 
@@ -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(); // crossfade to the next track now
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.
@@ -6,5 +6,8 @@ export interface DefaultAmbienceOptions {
6
6
  storageKey?: string;
7
7
  /** Menu section order/labels, passed through to `createAmbience`. */
8
8
  groups?: AmbienceOptions['groups'];
9
+ /** Cross-tab shared player (jatui-lm1mf): one tab plays, every other tab
10
+ * is a remote showing the same live state. */
11
+ shared?: boolean;
9
12
  }
10
13
  export declare function createDefaultAmbience(options?: DefaultAmbienceOptions): Ambience;
@@ -28,7 +28,11 @@ export const DEFAULT_SCAPES = Object.freeze([
28
28
  waves
29
29
  ]);
30
30
  export function createDefaultAmbience(options = {}) {
31
- const ambience = createAmbience(options);
31
+ const ambience = createAmbience({
32
+ storageKey: options.storageKey,
33
+ groups: options.groups,
34
+ shared: options.shared
35
+ });
32
36
  for (const scape of DEFAULT_SCAPES)
33
37
  ambience.register(scape);
34
38
  return ambience;
@@ -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
@@ -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
- // Inside the post-stop close window: cancel the close, fade back in.
149
- const resuming = closeTimer !== null;
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 (!ctx || !playing) {
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() {
@@ -9,7 +9,9 @@
9
9
  * // inside a click handler:
10
10
  * await ambience.play('rain');
11
11
  */
12
- export { createAmbience, TIMING } from './engine';
12
+ export { createAmbience, createAmbienceLocal } from './shared';
13
+ export { TIMING } from './engine';
14
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager } from './shared';
13
15
  export { createDefaultAmbience, DEFAULT_SCAPES, type DefaultAmbienceOptions } from './default';
14
16
  export { hibiscusScape } from './scapes/hibiscus';
15
17
  export { focusScape } from './scapes/focus';
@@ -9,7 +9,11 @@
9
9
  * // inside a click handler:
10
10
  * await ambience.play('rain');
11
11
  */
12
- export { createAmbience, TIMING } from './engine';
12
+ // createAmbience() is the shared-aware factory (jatui-lm1mf): identical to
13
+ // the plain engine until a host passes { shared: true }.
14
+ export { createAmbience, createAmbienceLocal } from './shared';
15
+ export { TIMING } from './engine';
16
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING } from './shared';
13
17
  export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
14
18
  // The shipped scapes — hosts that want a hand-picked registry import them
15
19
  // individually instead of using createDefaultAmbience().
@@ -6,6 +6,8 @@
6
6
  export interface PersistedAmbience {
7
7
  volume?: number;
8
8
  lastScape?: string;
9
+ /** Cross-tab mute (jatui-lm1mf) — shared by every tab of the origin. */
10
+ muted?: boolean;
9
11
  }
10
12
  export declare function loadPersisted(key: string): PersistedAmbience;
11
13
  export declare function savePersisted(key: string, value: PersistedAmbience): void;
@@ -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,8 +134,17 @@ 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;
144
+ /** Crossfade to an absolute track index — used on leadership handover
145
+ * (jatui-lm1mf) so the new leader tab resumes the position the old one
146
+ * left. No-op while not playing. */
147
+ startAt(index: number): void;
129
148
  /** The resolved track list, or null until the manifest resolved. */
130
149
  tracks(): PlaylistTrack[] | null;
131
150
  }
@@ -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({ status: 'playing', track, index: pos, count: resolved.length, error: undefined });
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,66 @@ 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
+ }
536
+ /** Leadership handover (jatui-lm1mf): crossfade to an absolute track
537
+ * index so the new leader tab picks up the position the old one left. */
538
+ function startAtInternal(index) {
539
+ if (released || !started || !resolved || order.length === 0)
540
+ return;
541
+ const ni = plainOrder(resolved.length).indexOf(Math.trunc(index));
542
+ if (ni !== -1 && ni !== pos)
543
+ startTrack(ni, 'cross');
544
+ }
464
545
  /** setShuffle mid-play: with shuffle the rest reorder (current first);
465
546
  * without, the natural order resumes from the current track. */
466
547
  function reshuffle() {
@@ -480,8 +561,10 @@ export function createPlaylistScape(options) {
480
561
  }
481
562
  wireLookahead();
482
563
  return {
483
- api: { start, stop, setParam },
564
+ api: { start, stop, setParam, pause: pauseInternal, resume: resumeInternal },
484
565
  skip: skipInternal,
566
+ prev: prevInternal,
567
+ startAt: startAtInternal,
485
568
  reshuffle,
486
569
  isReleased: () => released
487
570
  };
@@ -509,11 +592,19 @@ export function createPlaylistScape(options) {
509
592
  if (latest && !latest.isReleased())
510
593
  latest.skip();
511
594
  },
595
+ prev() {
596
+ if (latest && !latest.isReleased())
597
+ latest.prev();
598
+ },
512
599
  setShuffle(on) {
513
600
  shuffle = on;
514
601
  if (latest && !latest.isReleased())
515
602
  latest.reshuffle();
516
603
  },
604
+ startAt(index) {
605
+ if (latest && !latest.isReleased())
606
+ latest.startAt(index);
607
+ },
517
608
  tracks: () => (resolved ? [...resolved] : null)
518
609
  };
519
610
  return scape;
@@ -0,0 +1,164 @@
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';
44
+ import type { Ambience, AmbienceOptions } from './types';
45
+ /** BroadcastChannel name — the ambience control channel for the origin. */
46
+ export declare const AMBIENCE_CHANNEL = "jatui:ambience";
47
+ /** Web Locks lock name — holding it IS being the leader. */
48
+ export declare const AMBIENCE_LOCK = "jatui:ambience:leader";
49
+ export declare const SHARED_TIMING: Readonly<{
50
+ /** Leader heartbeat (ms) — also the fallback-election liveness proof. */
51
+ HEARTBEAT_MS: 1000;
52
+ /** A leader silent this long (ms) counts as gone. */
53
+ STALE_MS: 3000;
54
+ /** After broadcasting `claim`, wait this long (ms) for a live leader's
55
+ * heartbeat reassert before taking over. */
56
+ CLAIM_GUARD_MS: 300;
57
+ /** A remote play command waits this long (ms) for the leader's echo. */
58
+ REMOTE_PLAY_TIMEOUT_MS: 2500;
59
+ }>;
60
+ /** The live cross-tab state every tab renders. */
61
+ export interface SharedAmbienceState {
62
+ /** Currently playing (or preselected) scape id, or null. */
63
+ currentId: string | null;
64
+ /** True while the leader is sounding. */
65
+ playing: boolean;
66
+ /** The user's volume (0..1) — independent of mute. */
67
+ volume: number;
68
+ /** True while muted (engine runs at 0 volume; the slider keeps its value). */
69
+ muted: boolean;
70
+ /** Who owns the AudioContext right now, if known. */
71
+ role: 'leader' | 'remote' | 'unknown';
72
+ /** The leader tab's id (channel-internal, for `gone` bookkeeping). */
73
+ leaderTabId: string | null;
74
+ /** True once the leader tab is gone (pagehide or heartbeat silence):
75
+ * playback has stopped; play in THIS tab claims leadership. */
76
+ leaderGone: boolean;
77
+ /** The playing track's attribution line ("Title — Composer · License"),
78
+ * published by the leader; null when unknown or nothing is playing. */
79
+ nowPlaying: string | null;
80
+ /** Position of the published now-playing track in its playlist (0-based),
81
+ * for leadership handover of playlist scapes. */
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;
95
+ }
96
+ /** An Ambience whose playback is shared across tabs (see module doc). */
97
+ export interface SharedAmbience extends Ambience {
98
+ /** The full cross-tab state (the plain `state` getter is its Ambience subset). */
99
+ get sharedState(): SharedAmbienceState;
100
+ /** Subscribe to the full cross-tab state; fires immediately. */
101
+ subscribeShared(fn: (s: SharedAmbienceState) => void): () => void;
102
+ /** Mute without losing the volume value (persisted with it). */
103
+ setMuted(muted: boolean): void;
104
+ toggleMute(): void;
105
+ /** Leader-side hook: the host publishes the playing track's attribution
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;
114
+ /** Release the lock, stop the heartbeat and close the channel. The local
115
+ * engine is killed if this tab was the leader. */
116
+ destroy(): void;
117
+ }
118
+ export interface SharedAmbienceOptions extends AmbienceOptions {
119
+ /** Override the channel name (tests, multi-host pages). */
120
+ channelName?: string;
121
+ /** Override the lock name (tests). */
122
+ lockName?: string;
123
+ /** Injectable channel constructor for tests (default: BroadcastChannel). */
124
+ createChannel?: (name: string) => SharedChannel | null;
125
+ /** Injectable Web Locks surface for tests (default: navigator.locks). */
126
+ locks?: SharedLockManager | null;
127
+ /** Injectable tab id for tests (default: random). */
128
+ tabId?: string;
129
+ heartbeatMs?: number;
130
+ staleMs?: number;
131
+ }
132
+ /** The slice of BroadcastChannel the engine needs. */
133
+ export interface SharedChannel {
134
+ postMessage(message: unknown): void;
135
+ onmessage: ((event: {
136
+ data: unknown;
137
+ }) => void) | null;
138
+ close(): void;
139
+ }
140
+ /** The slice of the Web Locks API the engine needs. */
141
+ export interface SharedLockManager {
142
+ request(name: string, options: {
143
+ mode?: 'shared' | 'exclusive';
144
+ ifAvailable?: boolean;
145
+ steal?: boolean;
146
+ }, callback: (lock: unknown) => unknown | Promise<unknown>): Promise<unknown>;
147
+ }
148
+ /**
149
+ * Build a shared (cross-tab) ambience engine. Every tab runs one; only the
150
+ * leader's local engine ever sounds. See the module doc for the protocol.
151
+ */
152
+ export declare function createSharedAmbience(options?: SharedAmbienceOptions): SharedAmbience;
153
+ /** createAmbience() with the `shared` opt-in (jatui-lm1mf): identical to the
154
+ * plain engine until a host passes `{ shared: true }`, which makes playback
155
+ * cross-tab — one leader, all other tabs remotes. Hosts keep the exact
156
+ * Ambience interface (AmbienceToggle needs no changes beyond reading the
157
+ * shared now-playing line). */
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;