@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.
@@ -0,0 +1,744 @@
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 { isPlaylistScape } from './scapes/playlist';
45
+ import { loadPersisted, savePersisted } from './persist';
46
+ /** BroadcastChannel name — the ambience control channel for the origin. */
47
+ export const AMBIENCE_CHANNEL = 'jatui:ambience';
48
+ /** Web Locks lock name — holding it IS being the leader. */
49
+ export const AMBIENCE_LOCK = 'jatui:ambience:leader';
50
+ export const SHARED_TIMING = Object.freeze({
51
+ /** Leader heartbeat (ms) — also the fallback-election liveness proof. */
52
+ HEARTBEAT_MS: 1000,
53
+ /** A leader silent this long (ms) counts as gone. */
54
+ STALE_MS: 3000,
55
+ /** After broadcasting `claim`, wait this long (ms) for a live leader's
56
+ * heartbeat reassert before taking over. */
57
+ CLAIM_GUARD_MS: 300,
58
+ /** A remote play command waits this long (ms) for the leader's echo. */
59
+ REMOTE_PLAY_TIMEOUT_MS: 2500
60
+ });
61
+ function randomTabId() {
62
+ try {
63
+ return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
64
+ }
65
+ catch {
66
+ return `tab-${Math.random().toString(36).slice(2)}`;
67
+ }
68
+ }
69
+ /** The browser truth for a local user gesture (mirrors engine.ts default). */
70
+ function defaultGesture() {
71
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
72
+ return nav?.userActivation ? nav.userActivation.isActive : true;
73
+ }
74
+ /**
75
+ * Build a shared (cross-tab) ambience engine. Every tab runs one; only the
76
+ * leader's local engine ever sounds. See the module doc for the protocol.
77
+ */
78
+ export function createSharedAmbience(options = {}) {
79
+ const channelName = options.channelName ?? AMBIENCE_CHANNEL;
80
+ const lockName = options.lockName ?? AMBIENCE_LOCK;
81
+ const tabId = options.tabId ?? randomTabId();
82
+ const heartbeatMs = options.heartbeatMs ?? SHARED_TIMING.HEARTBEAT_MS;
83
+ const staleMs = options.staleMs ?? SHARED_TIMING.STALE_MS;
84
+ const storageKey = options.storageKey ?? 'jatui:ambience';
85
+ // A remote command carries the ORIGINATING tab's gesture, not this one's —
86
+ // the leader must be allowed to apply it (its context, if running, needs
87
+ // no new activation; if it can't start, the play just fails loudly).
88
+ let remoteCommand = false;
89
+ const gestureProbe = () => remoteCommand ? true : options.isGesture ? options.isGesture() : defaultGesture();
90
+ const local = createLocalEngine({ ...options, isGesture: gestureProbe });
91
+ const createChannel = options.createChannel ??
92
+ ((name) => typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(name) : null);
93
+ const locks = 'locks' in options ? (options.locks ?? null) : defaultLocks();
94
+ function defaultLocks() {
95
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
96
+ return nav && typeof nav.locks?.request === 'function' ? nav.locks : null;
97
+ }
98
+ const bc = createChannel(channelName);
99
+ if (bc)
100
+ bc.onmessage = (e) => {
101
+ const msg = e.data;
102
+ if (msg && (msg.t === 'heartbeat' || msg.t === 'state' || msg.t === 'claim'))
103
+ lastBeatTab = msg.from;
104
+ onMsg(msg);
105
+ };
106
+ // --- state ---------------------------------------------------------------
107
+ const persisted = loadPersisted(storageKey);
108
+ /** The user's volume — independent of mute (the engine runs 0 while muted). */
109
+ let userVolume = Math.min(1, Math.max(0, persisted.volume ?? 0.6));
110
+ let muted = persisted.muted ?? false;
111
+ let role = 'unknown';
112
+ let leaderGone = false;
113
+ /** Last state published by a leader (remotes only). */
114
+ let remoteS = null;
115
+ let lastBeatAt = 0;
116
+ /** The leader's now-playing line, published via setNowPlaying. */
117
+ let nowPlaying = null;
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;
123
+ const ambienceSubs = new Set();
124
+ const sharedSubs = new Set();
125
+ function sharedState() {
126
+ const st = local.state;
127
+ return {
128
+ currentId: role === 'leader' ? st.currentId : (remoteS?.currentId ?? st.currentId),
129
+ playing: role === 'leader' ? st.playing : (remoteS?.playing ?? false),
130
+ paused: role === 'leader' ? st.paused : (remoteS?.paused ?? false),
131
+ volume: userVolume,
132
+ muted,
133
+ role,
134
+ leaderTabId: role === 'leader' ? tabId : (remoteS?.leaderTabId ?? null),
135
+ leaderGone: role === 'leader' ? false : leaderGone,
136
+ nowPlaying: role === 'leader' ? nowPlaying : (remoteS?.nowPlaying ?? 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)
141
+ };
142
+ }
143
+ function ambienceState() {
144
+ const s = sharedState();
145
+ return {
146
+ currentId: s.currentId,
147
+ playing: s.playing && !s.leaderGone,
148
+ paused: s.paused,
149
+ volume: s.volume
150
+ };
151
+ }
152
+ function emitShared() {
153
+ const s = sharedState();
154
+ for (const fn of sharedSubs)
155
+ fn(s);
156
+ const a = ambienceState();
157
+ for (const fn of ambienceSubs)
158
+ fn(a);
159
+ }
160
+ // --- transport -----------------------------------------------------------
161
+ function post(msg) {
162
+ try {
163
+ bc?.postMessage(msg);
164
+ }
165
+ catch {
166
+ /* a closed/detached channel must not take the engine down */
167
+ }
168
+ }
169
+ function publish() {
170
+ if (role !== 'leader')
171
+ return;
172
+ post({ t: 'state', from: tabId, s: sharedState() });
173
+ }
174
+ // --- leadership ----------------------------------------------------------
175
+ /** Rejects when the lock is stolen (Web Locks) or resolved on resign. */
176
+ let lostReject = null;
177
+ const lost = new Promise((_res, rej) => {
178
+ lostReject = rej;
179
+ });
180
+ lost.catch(() => { }); // never an unhandled rejection
181
+ let heartbeatTimer = null;
182
+ function becomeLeader(how) {
183
+ role = 'leader';
184
+ leaderGone = false;
185
+ remoteS = null;
186
+ // Apply the muted state the (fresh) engine can't know about yet.
187
+ if (muted)
188
+ local.setVolume(0);
189
+ else
190
+ local.setVolume(userVolume);
191
+ publish();
192
+ emitShared();
193
+ if (heartbeatTimer)
194
+ clearInterval(heartbeatTimer);
195
+ heartbeatTimer = setInterval(() => {
196
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
197
+ }, heartbeatMs);
198
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
199
+ }
200
+ function resign(reason) {
201
+ if (role !== 'leader')
202
+ return;
203
+ role = 'remote';
204
+ nowPlaying = null;
205
+ nowPlayingIndex = null;
206
+ nowPlayingTitle = null;
207
+ nowPlayingPosition = null;
208
+ nowPlayingDuration = null;
209
+ remoteS = null;
210
+ if (heartbeatTimer) {
211
+ clearInterval(heartbeatTimer);
212
+ heartbeatTimer = null;
213
+ }
214
+ // The audio must not outlive the leadership — fade out, close as usual.
215
+ local.stop();
216
+ lostReject?.(new Error(`lost leadership: ${reason}`));
217
+ emitShared();
218
+ }
219
+ /** Initial election: hold the lock if it's free, else be a remote. */
220
+ function elect() {
221
+ if (!locks) {
222
+ heartbeatElection();
223
+ return;
224
+ }
225
+ void locks
226
+ .request(lockName, { ifAvailable: true }, (lock) => {
227
+ if (!lock) {
228
+ role = 'remote';
229
+ emitShared();
230
+ return;
231
+ }
232
+ becomeLeader('lock');
233
+ return lost; // hold until stolen or destroyed
234
+ })
235
+ .then(() => {
236
+ // Settled = the lock was released (stolen) or never held.
237
+ if (role === 'leader')
238
+ resign('lock-lost');
239
+ })
240
+ .catch(() => {
241
+ if (role === 'leader')
242
+ resign('lock-lost');
243
+ });
244
+ }
245
+ // --- heartbeat fallback election (no Web Locks) ---------------------------
246
+ /** Deterministic tie-break: the lexicographically SMALLEST tabId wins.
247
+ * On a `want` from a bigger tab we claim at once; from a smaller one we
248
+ * defer and let it win. */
249
+ function smaller(other) {
250
+ return other < tabId;
251
+ }
252
+ function heartbeatElection() {
253
+ post({ t: 'want', from: tabId });
254
+ setTimeout(() => {
255
+ if (role !== 'unknown')
256
+ return; // a leader made itself known in time
257
+ becomeLeader('fallback');
258
+ post({ t: 'claim', from: tabId, s: sharedState() });
259
+ }, SHARED_TIMING.CLAIM_GUARD_MS);
260
+ }
261
+ /** A remote (or a leaderless stale tab) claims leadership — Web Locks path.
262
+ * Steal is always granted; the previous leader's held promise rejects and
263
+ * it fades out inside its own resign(). */
264
+ function claimWithLocks() {
265
+ return new Promise((resolve) => {
266
+ let decided = false;
267
+ const decide = (v) => {
268
+ if (!decided) {
269
+ decided = true;
270
+ resolve(v);
271
+ }
272
+ };
273
+ void locks
274
+ .request(lockName, { steal: true }, (lock) => {
275
+ if (lock) {
276
+ becomeLeader('steal');
277
+ decide(true);
278
+ return lost; // hold until stolen again or destroyed
279
+ }
280
+ decide(false);
281
+ })
282
+ .then(() => {
283
+ if (role === 'leader')
284
+ resign('lock-lost');
285
+ }, () => {
286
+ if (role === 'leader')
287
+ resign('lock-lost');
288
+ });
289
+ // Web Locks grants steal promptly; a missed grant resolves false.
290
+ setTimeout(() => decide(false), 1000);
291
+ });
292
+ }
293
+ /** Claim via the heartbeat election: broadcast `claim`, back off if a live
294
+ * leader reasserts within the guard window (or a SMALLER tab claims — the
295
+ * tie-break), else take over. */
296
+ function claimWithHeartbeat() {
297
+ return new Promise((resolve) => {
298
+ let leaderSeen = false;
299
+ const guard = (e) => {
300
+ if ((e.t === 'heartbeat' || e.t === 'state' || (e.t === 'claim' && smaller(e.from))) &&
301
+ e.from !== tabId)
302
+ leaderSeen = true;
303
+ };
304
+ const prevOnMessage = bc?.onmessage ?? null;
305
+ if (bc)
306
+ bc.onmessage = (e) => guard(e.data);
307
+ post({ t: 'claim', from: tabId, s: sharedState() });
308
+ setTimeout(() => {
309
+ if (bc)
310
+ bc.onmessage = prevOnMessage;
311
+ if (leaderSeen) {
312
+ role = 'remote'; // a live leader asserted itself
313
+ emitShared();
314
+ resolve(false);
315
+ }
316
+ else {
317
+ becomeLeader('fallback');
318
+ post({ t: 'claim', from: tabId, s: sharedState() });
319
+ resolve(true);
320
+ }
321
+ }, SHARED_TIMING.CLAIM_GUARD_MS);
322
+ });
323
+ }
324
+ async function claim() {
325
+ if (role === 'leader')
326
+ return true;
327
+ // A remote whose leader is ALIVE must not steal it (Web Locks steal is
328
+ // unconditional) — deferring is the fallback election's job. With locks,
329
+ // play() in a remote always takes over (task spec: tab B starting a
330
+ // scape becomes the leader).
331
+ if (locks)
332
+ return claimWithLocks();
333
+ if (role === 'remote' && !leaderGone && Date.now() - lastBeatAt < staleMs)
334
+ return false; // live leader — play via command instead
335
+ return claimWithHeartbeat();
336
+ }
337
+ // --- remote play waiter ----------------------------------------------------
338
+ const playWaiters = new Set();
339
+ function waitRemotePlay(id) {
340
+ return new Promise((resolve, reject) => {
341
+ const w = { id, resolve, reject };
342
+ playWaiters.add(w);
343
+ setTimeout(() => {
344
+ if (playWaiters.delete(w))
345
+ reject(new Error(`The tab playing ambience did not start "${id}"`));
346
+ }, SHARED_TIMING.REMOTE_PLAY_TIMEOUT_MS);
347
+ });
348
+ }
349
+ // --- messages --------------------------------------------------------------
350
+ function onMsg(msg) {
351
+ if (!msg || typeof msg !== 'object' || msg.from === tabId)
352
+ return;
353
+ switch (msg.t) {
354
+ case 'state':
355
+ case 'heartbeat': {
356
+ lastBeatAt = Date.now();
357
+ const was = JSON.stringify(remoteS);
358
+ remoteS = msg.s;
359
+ leaderGone = false;
360
+ if (role === 'unknown')
361
+ role = 'remote';
362
+ if (JSON.stringify(remoteS) !== was)
363
+ emitShared();
364
+ break;
365
+ }
366
+ case 'claim': {
367
+ lastBeatAt = Date.now();
368
+ // A live leader reasserts immediately so a racing claim backs off.
369
+ if (role === 'leader') {
370
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
371
+ }
372
+ else if (role === 'unknown') {
373
+ // Tie-break: a claim from a bigger tab loses to ours; from a
374
+ // smaller one it wins — we defer and mirror its state.
375
+ if (smaller(msg.from)) {
376
+ remoteS = msg.s;
377
+ role = 'remote';
378
+ emitShared();
379
+ }
380
+ else {
381
+ becomeLeader('fallback');
382
+ post({ t: 'claim', from: tabId, s: sharedState() });
383
+ }
384
+ }
385
+ else {
386
+ remoteS = msg.s;
387
+ emitShared();
388
+ }
389
+ break;
390
+ }
391
+ case 'want': {
392
+ // A live leader answers at once; an unknown tab resolves the tie
393
+ // deterministically (smallest tabId claims, bigger one defers).
394
+ if (role === 'leader') {
395
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
396
+ }
397
+ else if (role === 'unknown' && !smaller(msg.from)) {
398
+ becomeLeader('fallback');
399
+ post({ t: 'claim', from: tabId, s: sharedState() });
400
+ }
401
+ break;
402
+ }
403
+ case 'gone': {
404
+ // The leader tab is closing: mark it gone at once — no staleness
405
+ // wait, and never a surprise autoplay in a background tab.
406
+ if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
407
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
408
+ leaderGone = true;
409
+ emitShared();
410
+ }
411
+ break;
412
+ }
413
+ case 'cmd': {
414
+ if (role !== 'leader')
415
+ return;
416
+ switch (msg.cmd) {
417
+ case 'stop':
418
+ local.stop();
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
+ }
448
+ case 'setVolume':
449
+ applyVolume(msg.arg ?? userVolume);
450
+ break;
451
+ case 'toggleMute':
452
+ muted = !muted;
453
+ applyVolume(userVolume);
454
+ savePersisted(storageKey, { volume: userVolume, muted, lastScape: local.state.currentId ?? undefined });
455
+ publish();
456
+ emitShared();
457
+ break;
458
+ case 'play': {
459
+ if (!msg.id)
460
+ return;
461
+ remoteCommand = true;
462
+ void local
463
+ .play(msg.id)
464
+ .catch(() => {
465
+ // The leader could not start it (e.g. suspended context,
466
+ // unknown scape) — publish the unchanged state so the
467
+ // remote's waiter times out and shows the failure.
468
+ publish();
469
+ })
470
+ .finally(() => {
471
+ remoteCommand = false;
472
+ });
473
+ break;
474
+ }
475
+ }
476
+ break;
477
+ }
478
+ }
479
+ }
480
+ /** Which tab last asserted itself (heartbeat/state/claim) — a `gone` from
481
+ * it is acted on. */
482
+ let lastBeatTab = null;
483
+ // --- watchdog: heartbeat silence marks the leader gone ---------------------
484
+ const watchdog = setInterval(() => {
485
+ if (role === 'leader')
486
+ return;
487
+ if (remoteS?.playing &&
488
+ lastBeatAt > 0 &&
489
+ Date.now() - lastBeatAt > staleMs) {
490
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
491
+ leaderGone = true;
492
+ emitShared();
493
+ }
494
+ }, 500);
495
+ // --- local engine wiring ---------------------------------------------------
496
+ local.subscribe(() => {
497
+ if (role === 'leader')
498
+ publish();
499
+ emitShared();
500
+ });
501
+ // Resolve remote play waiters whenever the shared state flips to playing:
502
+ // hook emitShared (the shared-state notify) to also drain them.
503
+ const notifyWaiters = (s) => {
504
+ if (!s.playing || !s.currentId)
505
+ return;
506
+ for (const w of [...playWaiters]) {
507
+ if (w.id === s.currentId) {
508
+ playWaiters.delete(w);
509
+ w.resolve();
510
+ }
511
+ }
512
+ };
513
+ const emitSharedBase = emitShared;
514
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
515
+ emitShared = () => {
516
+ emitSharedBase();
517
+ notifyWaiters(sharedState());
518
+ };
519
+ // --- pagehide: announce our own death ---------------------------------------
520
+ function onPageHide() {
521
+ if (role === 'leader')
522
+ post({ t: 'gone', from: tabId });
523
+ }
524
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
525
+ window.addEventListener('pagehide', onPageHide);
526
+ }
527
+ // --- engine operations -------------------------------------------------------
528
+ function applyVolume(v) {
529
+ userVolume = Math.min(1, Math.max(0, v));
530
+ local.setVolume(muted ? 0 : userVolume);
531
+ }
532
+ /** Find a registered playlist scape for position handover. */
533
+ function playlistFor(id) {
534
+ const item = local.items().find((i) => i.id === id);
535
+ return item && isPlaylistScape(item) ? item : null;
536
+ }
537
+ async function play(id) {
538
+ if (role === 'leader')
539
+ return local.play(id);
540
+ // Starting a scape in a remote takes the leadership over (task spec) —
541
+ // a Web Locks steal runs inside THIS user gesture, the old leader fades
542
+ // out, never two tabs audible.
543
+ const tookOver = await claim();
544
+ if (tookOver) {
545
+ // Playlist position handover: the same scape, previously playing in
546
+ // the old leader, resumes at that track — fresh, not from zero.
547
+ const s = remoteS;
548
+ const handover = !!s && s.currentId === id && s.playing === true && s.trackIndex != null && s.trackIndex >= 0;
549
+ remoteS = null;
550
+ const p = local.play(id);
551
+ if (handover) {
552
+ const scape = playlistFor(id);
553
+ try {
554
+ scape?.startAt(s.trackIndex);
555
+ }
556
+ catch {
557
+ /* position handover is best-effort */
558
+ }
559
+ }
560
+ return p;
561
+ }
562
+ // Live leader (heartbeat fallback): send the command and wait for the echo.
563
+ post({ t: 'cmd', cmd: 'play', id, from: tabId });
564
+ return waitRemotePlay(id);
565
+ }
566
+ function stop() {
567
+ if (role === 'leader') {
568
+ local.stop(); // publish happens via the engine subscription
569
+ return;
570
+ }
571
+ post({ t: 'cmd', cmd: 'stop', from: tabId });
572
+ // Optimistic: the leader echoes the authoritative state right after.
573
+ if (remoteS)
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 };
602
+ emitShared();
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
+ }
621
+ function setVolume(v) {
622
+ applyVolume(v);
623
+ savePersisted(storageKey, {
624
+ volume: userVolume,
625
+ muted,
626
+ lastScape: local.state.currentId ?? undefined
627
+ });
628
+ if (role === 'leader')
629
+ publish();
630
+ else
631
+ post({ t: 'cmd', cmd: 'setVolume', arg: userVolume, from: tabId });
632
+ emitShared();
633
+ }
634
+ function setMuted(m) {
635
+ if (m === muted)
636
+ return;
637
+ muted = m;
638
+ local.setVolume(muted ? 0 : userVolume);
639
+ savePersisted(storageKey, {
640
+ volume: userVolume,
641
+ muted,
642
+ lastScape: local.state.currentId ?? undefined
643
+ });
644
+ if (role === 'leader')
645
+ publish();
646
+ else
647
+ post({ t: 'cmd', cmd: 'toggleMute', from: tabId });
648
+ emitShared();
649
+ }
650
+ function toggleMute() {
651
+ setMuted(!muted);
652
+ }
653
+ function destroy() {
654
+ onPageHide();
655
+ if (role === 'leader')
656
+ resign('destroy');
657
+ else
658
+ role = 'remote';
659
+ clearInterval(watchdog);
660
+ if (heartbeatTimer) {
661
+ clearInterval(heartbeatTimer);
662
+ heartbeatTimer = null;
663
+ }
664
+ if (role === 'leader')
665
+ local.kill();
666
+ local.stop();
667
+ if (bc) {
668
+ bc.onmessage = null;
669
+ try {
670
+ bc.close();
671
+ }
672
+ catch {
673
+ /* already closed */
674
+ }
675
+ }
676
+ }
677
+ // Elect on the next microtask so the constructor returns synchronously.
678
+ elect();
679
+ return {
680
+ register: (item) => local.register(item),
681
+ list: () => local.list(),
682
+ items: () => local.items(),
683
+ groups: () => local.groups(),
684
+ play,
685
+ stop,
686
+ pause,
687
+ resume,
688
+ skipTrack,
689
+ prevTrack,
690
+ kill: () => {
691
+ // A remote has nothing local to kill — tell the leader instead.
692
+ if (role === 'leader')
693
+ local.kill();
694
+ else
695
+ post({ t: 'cmd', cmd: 'stop', from: tabId });
696
+ },
697
+ setVolume,
698
+ setMuted,
699
+ toggleMute,
700
+ setNowPlaying(line, index, position, duration, title) {
701
+ nowPlaying = line;
702
+ nowPlayingIndex = line ? (index ?? null) : null;
703
+ nowPlayingPosition = line ? (position ?? null) : null;
704
+ nowPlayingDuration = line ? (duration ?? null) : null;
705
+ nowPlayingTitle = line ? (title ?? null) : null;
706
+ if (role === 'leader') {
707
+ publish();
708
+ emitShared();
709
+ }
710
+ },
711
+ get state() {
712
+ return ambienceState();
713
+ },
714
+ get sharedState() {
715
+ return sharedState();
716
+ },
717
+ subscribe(fn) {
718
+ ambienceSubs.add(fn);
719
+ fn(ambienceState());
720
+ return () => ambienceSubs.delete(fn);
721
+ },
722
+ subscribeShared(fn) {
723
+ sharedSubs.add(fn);
724
+ fn(sharedState());
725
+ return () => sharedSubs.delete(fn);
726
+ },
727
+ onBreath: (fn) => local.onBreath(fn),
728
+ destroy
729
+ };
730
+ }
731
+ /** createAmbience() with the `shared` opt-in (jatui-lm1mf): identical to the
732
+ * plain engine until a host passes `{ shared: true }`, which makes playback
733
+ * cross-tab — one leader, all other tabs remotes. Hosts keep the exact
734
+ * Ambience interface (AmbienceToggle needs no changes beyond reading the
735
+ * shared now-playing line). */
736
+ export function createAmbience(options = {}) {
737
+ return options.shared ? createSharedAmbience(options) : createLocalEngine(options);
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;