@joewinke/jatui 0.58.0 → 0.59.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.
@@ -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;
@@ -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;
@@ -126,6 +126,10 @@ export interface PlaylistScape extends Scape {
126
126
  skip(): void;
127
127
  /** Toggle shuffle; the current track stays, the rest reorder at once. */
128
128
  setShuffle(on: boolean): void;
129
+ /** Crossfade to an absolute track index — used on leadership handover
130
+ * (jatui-lm1mf) so the new leader tab resumes the position the old one
131
+ * left. No-op while not playing. */
132
+ startAt(index: number): void;
129
133
  /** The resolved track list, or null until the manifest resolved. */
130
134
  tracks(): PlaylistTrack[] | null;
131
135
  }
@@ -461,6 +461,15 @@ export function createPlaylistScape(options) {
461
461
  if (ni !== -1)
462
462
  startTrack(ni, 'cross');
463
463
  }
464
+ /** Leadership handover (jatui-lm1mf): crossfade to an absolute track
465
+ * index so the new leader tab picks up the position the old one left. */
466
+ function startAtInternal(index) {
467
+ if (released || !started || !resolved || order.length === 0)
468
+ return;
469
+ const ni = plainOrder(resolved.length).indexOf(Math.trunc(index));
470
+ if (ni !== -1 && ni !== pos)
471
+ startTrack(ni, 'cross');
472
+ }
464
473
  /** setShuffle mid-play: with shuffle the rest reorder (current first);
465
474
  * without, the natural order resumes from the current track. */
466
475
  function reshuffle() {
@@ -482,6 +491,7 @@ export function createPlaylistScape(options) {
482
491
  return {
483
492
  api: { start, stop, setParam },
484
493
  skip: skipInternal,
494
+ startAt: startAtInternal,
485
495
  reshuffle,
486
496
  isReleased: () => released
487
497
  };
@@ -514,6 +524,10 @@ export function createPlaylistScape(options) {
514
524
  if (latest && !latest.isReleased())
515
525
  latest.reshuffle();
516
526
  },
527
+ startAt(index) {
528
+ if (latest && !latest.isReleased())
529
+ latest.startAt(index);
530
+ },
517
531
  tracks: () => (resolved ? [...resolved] : null)
518
532
  };
519
533
  return scape;
@@ -0,0 +1,97 @@
1
+ import type { Ambience, AmbienceOptions } from './types';
2
+ /** BroadcastChannel name — the ambience control channel for the origin. */
3
+ export declare const AMBIENCE_CHANNEL = "jatui:ambience";
4
+ /** Web Locks lock name — holding it IS being the leader. */
5
+ export declare const AMBIENCE_LOCK = "jatui:ambience:leader";
6
+ export declare const SHARED_TIMING: Readonly<{
7
+ /** Leader heartbeat (ms) — also the fallback-election liveness proof. */
8
+ HEARTBEAT_MS: 1000;
9
+ /** A leader silent this long (ms) counts as gone. */
10
+ STALE_MS: 3000;
11
+ /** After broadcasting `claim`, wait this long (ms) for a live leader's
12
+ * heartbeat reassert before taking over. */
13
+ CLAIM_GUARD_MS: 300;
14
+ /** A remote play command waits this long (ms) for the leader's echo. */
15
+ REMOTE_PLAY_TIMEOUT_MS: 2500;
16
+ }>;
17
+ /** The live cross-tab state every tab renders. */
18
+ export interface SharedAmbienceState {
19
+ /** Currently playing (or preselected) scape id, or null. */
20
+ currentId: string | null;
21
+ /** True while the leader is sounding. */
22
+ playing: boolean;
23
+ /** The user's volume (0..1) — independent of mute. */
24
+ volume: number;
25
+ /** True while muted (engine runs at 0 volume; the slider keeps its value). */
26
+ muted: boolean;
27
+ /** Who owns the AudioContext right now, if known. */
28
+ role: 'leader' | 'remote' | 'unknown';
29
+ /** The leader tab's id (channel-internal, for `gone` bookkeeping). */
30
+ leaderTabId: string | null;
31
+ /** True once the leader tab is gone (pagehide or heartbeat silence):
32
+ * playback has stopped; play in THIS tab claims leadership. */
33
+ leaderGone: boolean;
34
+ /** The playing track's attribution line ("Title — Composer · License"),
35
+ * published by the leader; null when unknown or nothing is playing. */
36
+ nowPlaying: string | null;
37
+ /** Position of the published now-playing track in its playlist (0-based),
38
+ * for leadership handover of playlist scapes. */
39
+ trackIndex: number | null;
40
+ }
41
+ /** An Ambience whose playback is shared across tabs (see module doc). */
42
+ export interface SharedAmbience extends Ambience {
43
+ /** The full cross-tab state (the plain `state` getter is its Ambience subset). */
44
+ get sharedState(): SharedAmbienceState;
45
+ /** Subscribe to the full cross-tab state; fires immediately. */
46
+ subscribeShared(fn: (s: SharedAmbienceState) => void): () => void;
47
+ /** Mute without losing the volume value (persisted with it). */
48
+ setMuted(muted: boolean): void;
49
+ toggleMute(): void;
50
+ /** Leader-side hook: the host publishes the playing track's attribution
51
+ * (and playlist position) so remote tabs show it too. */
52
+ setNowPlaying(line: string | null, trackIndex?: number | null): void;
53
+ /** Release the lock, stop the heartbeat and close the channel. The local
54
+ * engine is killed if this tab was the leader. */
55
+ destroy(): void;
56
+ }
57
+ export interface SharedAmbienceOptions extends AmbienceOptions {
58
+ /** Override the channel name (tests, multi-host pages). */
59
+ channelName?: string;
60
+ /** Override the lock name (tests). */
61
+ lockName?: string;
62
+ /** Injectable channel constructor for tests (default: BroadcastChannel). */
63
+ createChannel?: (name: string) => SharedChannel | null;
64
+ /** Injectable Web Locks surface for tests (default: navigator.locks). */
65
+ locks?: SharedLockManager | null;
66
+ /** Injectable tab id for tests (default: random). */
67
+ tabId?: string;
68
+ heartbeatMs?: number;
69
+ staleMs?: number;
70
+ }
71
+ /** The slice of BroadcastChannel the engine needs. */
72
+ export interface SharedChannel {
73
+ postMessage(message: unknown): void;
74
+ onmessage: ((event: {
75
+ data: unknown;
76
+ }) => void) | null;
77
+ close(): void;
78
+ }
79
+ /** The slice of the Web Locks API the engine needs. */
80
+ export interface SharedLockManager {
81
+ request(name: string, options: {
82
+ mode?: 'shared' | 'exclusive';
83
+ ifAvailable?: boolean;
84
+ steal?: boolean;
85
+ }, callback: (lock: unknown) => unknown | Promise<unknown>): Promise<unknown>;
86
+ }
87
+ /**
88
+ * Build a shared (cross-tab) ambience engine. Every tab runs one; only the
89
+ * leader's local engine ever sounds. See the module doc for the protocol.
90
+ */
91
+ export declare function createSharedAmbience(options?: SharedAmbienceOptions): SharedAmbience;
92
+ /** createAmbience() with the `shared` opt-in (jatui-lm1mf): identical to the
93
+ * plain engine until a host passes `{ shared: true }`, which makes playback
94
+ * cross-tab — one leader, all other tabs remotes. Hosts keep the exact
95
+ * Ambience interface (AmbienceToggle needs no changes beyond reading the
96
+ * shared now-playing line). */
97
+ export declare function createAmbience(options?: AmbienceOptions): Ambience;
@@ -0,0 +1,642 @@
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 / setVolume / toggleMute /
19
+ * play (play only when the fallback election defers to a live leader).
20
+ * - `want` / `claim` — the heartbeat election.
21
+ * - `gone` — the leader's pagehide: remotes immediately mark the leader
22
+ * gone (playback stops — never a surprise autoplay in a background tab).
23
+ *
24
+ * Starting a scape in tab B while tab A plays makes B the leader: with
25
+ * Web Locks, B's user-gesture play STEALS the lock, A's held request promise
26
+ * rejects (AbortError) and A fades out — never two tabs audible. Playlist
27
+ * scapes hand over their track position (published by the leader, applied
28
+ * by the new leader via PlaylistScape.startAt).
29
+ *
30
+ * Resuming after the leader tab closes/reloads: playback is stopped and no
31
+ * remote autoplays; pressing play (the toggle's existing button — a user
32
+ * gesture, satisfying the autoplay policy) claims leadership and plays.
33
+ *
34
+ * Volume and mute persist in localStorage (shared by all tabs of the
35
+ * origin). The `storage` event is deliberately NOT the transport — it skips
36
+ * the writer tab; BroadcastChannel delivers to every OTHER tab.
37
+ *
38
+ * NO UI here. Hosts get everything through createAmbience({ shared: true }) —
39
+ * the Ambience interface is unchanged, so AmbienceToggle needs no structural
40
+ * changes beyond reading the shared now-playing line.
41
+ */
42
+ import { createAmbience as createLocalEngine } from './engine';
43
+ import { isPlaylistScape } from './scapes/playlist';
44
+ import { loadPersisted, savePersisted } from './persist';
45
+ /** BroadcastChannel name — the ambience control channel for the origin. */
46
+ export const AMBIENCE_CHANNEL = 'jatui:ambience';
47
+ /** Web Locks lock name — holding it IS being the leader. */
48
+ export const AMBIENCE_LOCK = 'jatui:ambience:leader';
49
+ export const SHARED_TIMING = Object.freeze({
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
+ function randomTabId() {
61
+ try {
62
+ return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
63
+ }
64
+ catch {
65
+ return `tab-${Math.random().toString(36).slice(2)}`;
66
+ }
67
+ }
68
+ /** The browser truth for a local user gesture (mirrors engine.ts default). */
69
+ function defaultGesture() {
70
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
71
+ return nav?.userActivation ? nav.userActivation.isActive : true;
72
+ }
73
+ /**
74
+ * Build a shared (cross-tab) ambience engine. Every tab runs one; only the
75
+ * leader's local engine ever sounds. See the module doc for the protocol.
76
+ */
77
+ export function createSharedAmbience(options = {}) {
78
+ const channelName = options.channelName ?? AMBIENCE_CHANNEL;
79
+ const lockName = options.lockName ?? AMBIENCE_LOCK;
80
+ const tabId = options.tabId ?? randomTabId();
81
+ const heartbeatMs = options.heartbeatMs ?? SHARED_TIMING.HEARTBEAT_MS;
82
+ const staleMs = options.staleMs ?? SHARED_TIMING.STALE_MS;
83
+ const storageKey = options.storageKey ?? 'jatui:ambience';
84
+ // A remote command carries the ORIGINATING tab's gesture, not this one's —
85
+ // the leader must be allowed to apply it (its context, if running, needs
86
+ // no new activation; if it can't start, the play just fails loudly).
87
+ let remoteCommand = false;
88
+ const gestureProbe = () => remoteCommand ? true : options.isGesture ? options.isGesture() : defaultGesture();
89
+ const local = createLocalEngine({ ...options, isGesture: gestureProbe });
90
+ const createChannel = options.createChannel ??
91
+ ((name) => typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(name) : null);
92
+ const locks = 'locks' in options ? (options.locks ?? null) : defaultLocks();
93
+ function defaultLocks() {
94
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
95
+ return nav && typeof nav.locks?.request === 'function' ? nav.locks : null;
96
+ }
97
+ const bc = createChannel(channelName);
98
+ if (bc)
99
+ bc.onmessage = (e) => {
100
+ const msg = e.data;
101
+ if (msg && (msg.t === 'heartbeat' || msg.t === 'state' || msg.t === 'claim'))
102
+ lastBeatTab = msg.from;
103
+ onMsg(msg);
104
+ };
105
+ // --- state ---------------------------------------------------------------
106
+ const persisted = loadPersisted(storageKey);
107
+ /** The user's volume — independent of mute (the engine runs 0 while muted). */
108
+ let userVolume = Math.min(1, Math.max(0, persisted.volume ?? 0.6));
109
+ let muted = persisted.muted ?? false;
110
+ let role = 'unknown';
111
+ let leaderGone = false;
112
+ /** Last state published by a leader (remotes only). */
113
+ let remoteS = null;
114
+ let lastBeatAt = 0;
115
+ /** The leader's now-playing line, published via setNowPlaying. */
116
+ let nowPlaying = null;
117
+ let nowPlayingIndex = null;
118
+ const ambienceSubs = new Set();
119
+ const sharedSubs = new Set();
120
+ function sharedState() {
121
+ const st = local.state;
122
+ return {
123
+ currentId: role === 'leader' ? st.currentId : (remoteS?.currentId ?? st.currentId),
124
+ playing: role === 'leader' ? st.playing : (remoteS?.playing ?? false),
125
+ volume: userVolume,
126
+ muted,
127
+ role,
128
+ leaderTabId: role === 'leader' ? tabId : (remoteS?.leaderTabId ?? null),
129
+ leaderGone: role === 'leader' ? false : leaderGone,
130
+ nowPlaying: role === 'leader' ? nowPlaying : (remoteS?.nowPlaying ?? null),
131
+ trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null)
132
+ };
133
+ }
134
+ function ambienceState() {
135
+ const s = sharedState();
136
+ return { currentId: s.currentId, playing: s.playing && !s.leaderGone, volume: s.volume };
137
+ }
138
+ function emitShared() {
139
+ const s = sharedState();
140
+ for (const fn of sharedSubs)
141
+ fn(s);
142
+ const a = ambienceState();
143
+ for (const fn of ambienceSubs)
144
+ fn(a);
145
+ }
146
+ // --- transport -----------------------------------------------------------
147
+ function post(msg) {
148
+ try {
149
+ bc?.postMessage(msg);
150
+ }
151
+ catch {
152
+ /* a closed/detached channel must not take the engine down */
153
+ }
154
+ }
155
+ function publish() {
156
+ if (role !== 'leader')
157
+ return;
158
+ post({ t: 'state', from: tabId, s: sharedState() });
159
+ }
160
+ // --- leadership ----------------------------------------------------------
161
+ /** Rejects when the lock is stolen (Web Locks) or resolved on resign. */
162
+ let lostReject = null;
163
+ const lost = new Promise((_res, rej) => {
164
+ lostReject = rej;
165
+ });
166
+ lost.catch(() => { }); // never an unhandled rejection
167
+ let heartbeatTimer = null;
168
+ function becomeLeader(how) {
169
+ role = 'leader';
170
+ leaderGone = false;
171
+ remoteS = null;
172
+ // Apply the muted state the (fresh) engine can't know about yet.
173
+ if (muted)
174
+ local.setVolume(0);
175
+ else
176
+ local.setVolume(userVolume);
177
+ publish();
178
+ emitShared();
179
+ if (heartbeatTimer)
180
+ clearInterval(heartbeatTimer);
181
+ heartbeatTimer = setInterval(() => {
182
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
183
+ }, heartbeatMs);
184
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
185
+ }
186
+ function resign(reason) {
187
+ if (role !== 'leader')
188
+ return;
189
+ role = 'remote';
190
+ nowPlaying = null;
191
+ nowPlayingIndex = null;
192
+ remoteS = null;
193
+ if (heartbeatTimer) {
194
+ clearInterval(heartbeatTimer);
195
+ heartbeatTimer = null;
196
+ }
197
+ // The audio must not outlive the leadership — fade out, close as usual.
198
+ local.stop();
199
+ lostReject?.(new Error(`lost leadership: ${reason}`));
200
+ emitShared();
201
+ }
202
+ /** Initial election: hold the lock if it's free, else be a remote. */
203
+ function elect() {
204
+ if (!locks) {
205
+ heartbeatElection();
206
+ return;
207
+ }
208
+ void locks
209
+ .request(lockName, { ifAvailable: true }, (lock) => {
210
+ if (!lock) {
211
+ role = 'remote';
212
+ emitShared();
213
+ return;
214
+ }
215
+ becomeLeader('lock');
216
+ return lost; // hold until stolen or destroyed
217
+ })
218
+ .then(() => {
219
+ // Settled = the lock was released (stolen) or never held.
220
+ if (role === 'leader')
221
+ resign('lock-lost');
222
+ })
223
+ .catch(() => {
224
+ if (role === 'leader')
225
+ resign('lock-lost');
226
+ });
227
+ }
228
+ // --- heartbeat fallback election (no Web Locks) ---------------------------
229
+ /** Deterministic tie-break: the lexicographically SMALLEST tabId wins.
230
+ * On a `want` from a bigger tab we claim at once; from a smaller one we
231
+ * defer and let it win. */
232
+ function smaller(other) {
233
+ return other < tabId;
234
+ }
235
+ function heartbeatElection() {
236
+ post({ t: 'want', from: tabId });
237
+ setTimeout(() => {
238
+ if (role !== 'unknown')
239
+ return; // a leader made itself known in time
240
+ becomeLeader('fallback');
241
+ post({ t: 'claim', from: tabId, s: sharedState() });
242
+ }, SHARED_TIMING.CLAIM_GUARD_MS);
243
+ }
244
+ /** A remote (or a leaderless stale tab) claims leadership — Web Locks path.
245
+ * Steal is always granted; the previous leader's held promise rejects and
246
+ * it fades out inside its own resign(). */
247
+ function claimWithLocks() {
248
+ return new Promise((resolve) => {
249
+ let decided = false;
250
+ const decide = (v) => {
251
+ if (!decided) {
252
+ decided = true;
253
+ resolve(v);
254
+ }
255
+ };
256
+ void locks
257
+ .request(lockName, { steal: true }, (lock) => {
258
+ if (lock) {
259
+ becomeLeader('steal');
260
+ decide(true);
261
+ return lost; // hold until stolen again or destroyed
262
+ }
263
+ decide(false);
264
+ })
265
+ .then(() => {
266
+ if (role === 'leader')
267
+ resign('lock-lost');
268
+ }, () => {
269
+ if (role === 'leader')
270
+ resign('lock-lost');
271
+ });
272
+ // Web Locks grants steal promptly; a missed grant resolves false.
273
+ setTimeout(() => decide(false), 1000);
274
+ });
275
+ }
276
+ /** Claim via the heartbeat election: broadcast `claim`, back off if a live
277
+ * leader reasserts within the guard window (or a SMALLER tab claims — the
278
+ * tie-break), else take over. */
279
+ function claimWithHeartbeat() {
280
+ return new Promise((resolve) => {
281
+ let leaderSeen = false;
282
+ const guard = (e) => {
283
+ if ((e.t === 'heartbeat' || e.t === 'state' || (e.t === 'claim' && smaller(e.from))) &&
284
+ e.from !== tabId)
285
+ leaderSeen = true;
286
+ };
287
+ const prevOnMessage = bc?.onmessage ?? null;
288
+ if (bc)
289
+ bc.onmessage = (e) => guard(e.data);
290
+ post({ t: 'claim', from: tabId, s: sharedState() });
291
+ setTimeout(() => {
292
+ if (bc)
293
+ bc.onmessage = prevOnMessage;
294
+ if (leaderSeen) {
295
+ role = 'remote'; // a live leader asserted itself
296
+ emitShared();
297
+ resolve(false);
298
+ }
299
+ else {
300
+ becomeLeader('fallback');
301
+ post({ t: 'claim', from: tabId, s: sharedState() });
302
+ resolve(true);
303
+ }
304
+ }, SHARED_TIMING.CLAIM_GUARD_MS);
305
+ });
306
+ }
307
+ async function claim() {
308
+ if (role === 'leader')
309
+ return true;
310
+ // A remote whose leader is ALIVE must not steal it (Web Locks steal is
311
+ // unconditional) — deferring is the fallback election's job. With locks,
312
+ // play() in a remote always takes over (task spec: tab B starting a
313
+ // scape becomes the leader).
314
+ if (locks)
315
+ return claimWithLocks();
316
+ if (role === 'remote' && !leaderGone && Date.now() - lastBeatAt < staleMs)
317
+ return false; // live leader — play via command instead
318
+ return claimWithHeartbeat();
319
+ }
320
+ // --- remote play waiter ----------------------------------------------------
321
+ const playWaiters = new Set();
322
+ function waitRemotePlay(id) {
323
+ return new Promise((resolve, reject) => {
324
+ const w = { id, resolve, reject };
325
+ playWaiters.add(w);
326
+ setTimeout(() => {
327
+ if (playWaiters.delete(w))
328
+ reject(new Error(`The tab playing ambience did not start "${id}"`));
329
+ }, SHARED_TIMING.REMOTE_PLAY_TIMEOUT_MS);
330
+ });
331
+ }
332
+ // --- messages --------------------------------------------------------------
333
+ function onMsg(msg) {
334
+ if (!msg || typeof msg !== 'object' || msg.from === tabId)
335
+ return;
336
+ switch (msg.t) {
337
+ case 'state':
338
+ case 'heartbeat': {
339
+ lastBeatAt = Date.now();
340
+ const was = JSON.stringify(remoteS);
341
+ remoteS = msg.s;
342
+ leaderGone = false;
343
+ if (role === 'unknown')
344
+ role = 'remote';
345
+ if (JSON.stringify(remoteS) !== was)
346
+ emitShared();
347
+ break;
348
+ }
349
+ case 'claim': {
350
+ lastBeatAt = Date.now();
351
+ // A live leader reasserts immediately so a racing claim backs off.
352
+ if (role === 'leader') {
353
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
354
+ }
355
+ else if (role === 'unknown') {
356
+ // Tie-break: a claim from a bigger tab loses to ours; from a
357
+ // smaller one it wins — we defer and mirror its state.
358
+ if (smaller(msg.from)) {
359
+ remoteS = msg.s;
360
+ role = 'remote';
361
+ emitShared();
362
+ }
363
+ else {
364
+ becomeLeader('fallback');
365
+ post({ t: 'claim', from: tabId, s: sharedState() });
366
+ }
367
+ }
368
+ else {
369
+ remoteS = msg.s;
370
+ emitShared();
371
+ }
372
+ break;
373
+ }
374
+ case 'want': {
375
+ // A live leader answers at once; an unknown tab resolves the tie
376
+ // deterministically (smallest tabId claims, bigger one defers).
377
+ if (role === 'leader') {
378
+ post({ t: 'heartbeat', from: tabId, s: sharedState() });
379
+ }
380
+ else if (role === 'unknown' && !smaller(msg.from)) {
381
+ becomeLeader('fallback');
382
+ post({ t: 'claim', from: tabId, s: sharedState() });
383
+ }
384
+ break;
385
+ }
386
+ case 'gone': {
387
+ // The leader tab is closing: mark it gone at once — no staleness
388
+ // wait, and never a surprise autoplay in a background tab.
389
+ if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
390
+ remoteS = { ...remoteS, playing: false, nowPlaying: null };
391
+ leaderGone = true;
392
+ emitShared();
393
+ }
394
+ break;
395
+ }
396
+ case 'cmd': {
397
+ if (role !== 'leader')
398
+ return;
399
+ switch (msg.cmd) {
400
+ case 'stop':
401
+ local.stop();
402
+ break;
403
+ case 'setVolume':
404
+ applyVolume(msg.arg ?? userVolume);
405
+ break;
406
+ case 'toggleMute':
407
+ muted = !muted;
408
+ applyVolume(userVolume);
409
+ savePersisted(storageKey, { volume: userVolume, muted, lastScape: local.state.currentId ?? undefined });
410
+ publish();
411
+ emitShared();
412
+ break;
413
+ case 'play': {
414
+ if (!msg.id)
415
+ return;
416
+ remoteCommand = true;
417
+ void local
418
+ .play(msg.id)
419
+ .catch(() => {
420
+ // The leader could not start it (e.g. suspended context,
421
+ // unknown scape) — publish the unchanged state so the
422
+ // remote's waiter times out and shows the failure.
423
+ publish();
424
+ })
425
+ .finally(() => {
426
+ remoteCommand = false;
427
+ });
428
+ break;
429
+ }
430
+ }
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ /** Which tab last asserted itself (heartbeat/state/claim) — a `gone` from
436
+ * it is acted on. */
437
+ let lastBeatTab = null;
438
+ // --- watchdog: heartbeat silence marks the leader gone ---------------------
439
+ const watchdog = setInterval(() => {
440
+ if (role === 'leader')
441
+ return;
442
+ if (remoteS?.playing &&
443
+ lastBeatAt > 0 &&
444
+ Date.now() - lastBeatAt > staleMs) {
445
+ remoteS = { ...remoteS, playing: false, nowPlaying: null };
446
+ leaderGone = true;
447
+ emitShared();
448
+ }
449
+ }, 500);
450
+ // --- local engine wiring ---------------------------------------------------
451
+ local.subscribe(() => {
452
+ if (role === 'leader')
453
+ publish();
454
+ emitShared();
455
+ });
456
+ // Resolve remote play waiters whenever the shared state flips to playing:
457
+ // hook emitShared (the shared-state notify) to also drain them.
458
+ const notifyWaiters = (s) => {
459
+ if (!s.playing || !s.currentId)
460
+ return;
461
+ for (const w of [...playWaiters]) {
462
+ if (w.id === s.currentId) {
463
+ playWaiters.delete(w);
464
+ w.resolve();
465
+ }
466
+ }
467
+ };
468
+ const emitSharedBase = emitShared;
469
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
470
+ emitShared = () => {
471
+ emitSharedBase();
472
+ notifyWaiters(sharedState());
473
+ };
474
+ // --- pagehide: announce our own death ---------------------------------------
475
+ function onPageHide() {
476
+ if (role === 'leader')
477
+ post({ t: 'gone', from: tabId });
478
+ }
479
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
480
+ window.addEventListener('pagehide', onPageHide);
481
+ }
482
+ // --- engine operations -------------------------------------------------------
483
+ function applyVolume(v) {
484
+ userVolume = Math.min(1, Math.max(0, v));
485
+ local.setVolume(muted ? 0 : userVolume);
486
+ }
487
+ /** Find a registered playlist scape for position handover. */
488
+ function playlistFor(id) {
489
+ const item = local.items().find((i) => i.id === id);
490
+ return item && isPlaylistScape(item) ? item : null;
491
+ }
492
+ async function play(id) {
493
+ if (role === 'leader')
494
+ return local.play(id);
495
+ // Starting a scape in a remote takes the leadership over (task spec) —
496
+ // a Web Locks steal runs inside THIS user gesture, the old leader fades
497
+ // out, never two tabs audible.
498
+ const tookOver = await claim();
499
+ if (tookOver) {
500
+ // Playlist position handover: the same scape, previously playing in
501
+ // the old leader, resumes at that track — fresh, not from zero.
502
+ const s = remoteS;
503
+ const handover = !!s && s.currentId === id && s.playing === true && s.trackIndex != null && s.trackIndex >= 0;
504
+ remoteS = null;
505
+ const p = local.play(id);
506
+ if (handover) {
507
+ const scape = playlistFor(id);
508
+ try {
509
+ scape?.startAt(s.trackIndex);
510
+ }
511
+ catch {
512
+ /* position handover is best-effort */
513
+ }
514
+ }
515
+ return p;
516
+ }
517
+ // Live leader (heartbeat fallback): send the command and wait for the echo.
518
+ post({ t: 'cmd', cmd: 'play', id, from: tabId });
519
+ return waitRemotePlay(id);
520
+ }
521
+ function stop() {
522
+ if (role === 'leader') {
523
+ local.stop(); // publish happens via the engine subscription
524
+ return;
525
+ }
526
+ post({ t: 'cmd', cmd: 'stop', from: tabId });
527
+ // Optimistic: the leader echoes the authoritative state right after.
528
+ if (remoteS)
529
+ remoteS = { ...remoteS, playing: false, nowPlaying: null };
530
+ emitShared();
531
+ }
532
+ function setVolume(v) {
533
+ applyVolume(v);
534
+ savePersisted(storageKey, {
535
+ volume: userVolume,
536
+ muted,
537
+ lastScape: local.state.currentId ?? undefined
538
+ });
539
+ if (role === 'leader')
540
+ publish();
541
+ else
542
+ post({ t: 'cmd', cmd: 'setVolume', arg: userVolume, from: tabId });
543
+ emitShared();
544
+ }
545
+ function setMuted(m) {
546
+ if (m === muted)
547
+ return;
548
+ muted = m;
549
+ local.setVolume(muted ? 0 : userVolume);
550
+ savePersisted(storageKey, {
551
+ volume: userVolume,
552
+ muted,
553
+ lastScape: local.state.currentId ?? undefined
554
+ });
555
+ if (role === 'leader')
556
+ publish();
557
+ else
558
+ post({ t: 'cmd', cmd: 'toggleMute', from: tabId });
559
+ emitShared();
560
+ }
561
+ function toggleMute() {
562
+ setMuted(!muted);
563
+ }
564
+ function destroy() {
565
+ onPageHide();
566
+ if (role === 'leader')
567
+ resign('destroy');
568
+ else
569
+ role = 'remote';
570
+ clearInterval(watchdog);
571
+ if (heartbeatTimer) {
572
+ clearInterval(heartbeatTimer);
573
+ heartbeatTimer = null;
574
+ }
575
+ if (role === 'leader')
576
+ local.kill();
577
+ local.stop();
578
+ if (bc) {
579
+ bc.onmessage = null;
580
+ try {
581
+ bc.close();
582
+ }
583
+ catch {
584
+ /* already closed */
585
+ }
586
+ }
587
+ }
588
+ // Elect on the next microtask so the constructor returns synchronously.
589
+ elect();
590
+ return {
591
+ register: (item) => local.register(item),
592
+ list: () => local.list(),
593
+ items: () => local.items(),
594
+ groups: () => local.groups(),
595
+ play,
596
+ stop,
597
+ kill: () => {
598
+ // A remote has nothing local to kill — tell the leader instead.
599
+ if (role === 'leader')
600
+ local.kill();
601
+ else
602
+ post({ t: 'cmd', cmd: 'stop', from: tabId });
603
+ },
604
+ setVolume,
605
+ setMuted,
606
+ toggleMute,
607
+ setNowPlaying(line, index) {
608
+ nowPlaying = line;
609
+ nowPlayingIndex = line ? (index ?? null) : null;
610
+ if (role === 'leader') {
611
+ publish();
612
+ emitShared();
613
+ }
614
+ },
615
+ get state() {
616
+ return ambienceState();
617
+ },
618
+ get sharedState() {
619
+ return sharedState();
620
+ },
621
+ subscribe(fn) {
622
+ ambienceSubs.add(fn);
623
+ fn(ambienceState());
624
+ return () => ambienceSubs.delete(fn);
625
+ },
626
+ subscribeShared(fn) {
627
+ sharedSubs.add(fn);
628
+ fn(sharedState());
629
+ return () => sharedSubs.delete(fn);
630
+ },
631
+ onBreath: (fn) => local.onBreath(fn),
632
+ destroy
633
+ };
634
+ }
635
+ /** createAmbience() with the `shared` opt-in (jatui-lm1mf): identical to the
636
+ * plain engine until a host passes `{ shared: true }`, which makes playback
637
+ * cross-tab — one leader, all other tabs remotes. Hosts keep the exact
638
+ * Ambience interface (AmbienceToggle needs no changes beyond reading the
639
+ * shared now-playing line). */
640
+ export function createAmbience(options = {}) {
641
+ return options.shared ? createSharedAmbience(options) : createLocalEngine(options);
642
+ }
@@ -121,4 +121,8 @@ export interface AmbienceOptions {
121
121
  isGesture?: () => boolean;
122
122
  /** Context factory, injectable for tests (default: `new AudioContext()`). */
123
123
  createContext?: () => AudioContextLike;
124
+ /** Cross-tab shared player (jatui-lm1mf): one tab (the leader) owns the
125
+ * AudioContext and plays; every other tab renders the same live state and
126
+ * sends commands. See `shared.ts`. */
127
+ shared?: boolean;
124
128
  }
@@ -201,14 +201,15 @@
201
201
  import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
202
202
  import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
203
203
  import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
204
+ import type { SharedAmbience } from '../ambience/shared';
204
205
 
205
206
  let {
206
207
  engine,
207
208
  compact = false,
208
209
  placement = 'bottom-end'
209
210
  }: {
210
- /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
211
- engine: Ambience;
211
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
212
+ engine: Ambience | SharedAmbience;
212
213
  /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
213
214
  compact?: boolean;
214
215
  /** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
@@ -259,6 +260,10 @@
259
260
 
260
261
  let playError = $state(false);
261
262
  let track = $state<PlaylistState['track']>(null);
263
+ /** The shared player's now-playing line (jatui-lm1mf): published by the
264
+ * leader tab, so a remote tab shows the same track attribution without
265
+ * owning the playlist instance. Null on a plain engine. */
266
+ let sharedNow = $state<string | null>(null);
262
267
 
263
268
  // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
264
269
  // the pending partial notch between wheel events so trackpad pixel deltas
@@ -271,7 +276,7 @@
271
276
  const currentLabel = $derived(currentItem?.label ?? currentId);
272
277
  const groups = $derived(groupScapes(items, engine.groups?.()));
273
278
  const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
274
- const trackLine = $derived(playing ? nowPlayingLine(track) : '');
279
+ const trackLine = $derived(playing ? (sharedNow ?? nowPlayingLine(track)) : '');
275
280
  const liveLabel = $derived(
276
281
  playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
277
282
  );
@@ -298,15 +303,33 @@
298
303
  });
299
304
  });
300
305
 
301
- // The current scape is a playlist → follow its track for the attribution line.
306
+ // The shared player's published now-playing line (leader → remotes).
307
+ $effect(() => {
308
+ const e = engine as SharedAmbience;
309
+ if (typeof e.subscribeShared !== 'function') {
310
+ sharedNow = null;
311
+ return;
312
+ }
313
+ return e.subscribeShared((s) => {
314
+ sharedNow = s.nowPlaying;
315
+ });
316
+ });
317
+
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 the shared engine.
302
320
  $effect(() => {
303
321
  const item = currentItem;
304
322
  if (!isPlaylistScape(item)) {
305
323
  track = null;
324
+ engine.setNowPlaying?.(null);
306
325
  return;
307
326
  }
308
327
  return item.subscribe((s) => {
309
328
  track = s.status === 'playing' ? s.track : null;
329
+ engine.setNowPlaying?.(
330
+ s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
331
+ s.index
332
+ );
310
333
  });
311
334
  });
312
335
 
@@ -128,9 +128,10 @@ export declare function wheelVolumeSteps(acc: {
128
128
  */
129
129
  export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
130
130
  import type { Ambience } from '../ambience/types';
131
+ import type { SharedAmbience } from '../ambience/shared';
131
132
  type $$ComponentProps = {
132
- /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
133
- engine: Ambience;
133
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
134
+ engine: Ambience | SharedAmbience;
134
135
  /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
135
136
  compact?: boolean;
136
137
  /** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
package/dist/index.d.ts CHANGED
@@ -411,8 +411,9 @@ export { default as ImageReveal } from './components/ImageReveal.svelte';
411
411
  export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
412
412
  export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
413
413
  export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
414
- export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
414
+ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
415
415
  export type { DefaultAmbienceOptions, AmbienceSection } from './ambience';
416
+ export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from './ambience';
416
417
  export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
417
418
  export { createPlaylistScape } from './ambience';
418
419
  export { isPlaylistScape, trackAttribution } from './ambience';
package/dist/index.js CHANGED
@@ -460,7 +460,7 @@ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize
460
460
  // navigation; createDefaultAmbience() pre-registers every shipped scape
461
461
  // (hibiscus, focus, brown/pink/white noise, rain, waves).
462
462
  export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
463
- export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
463
+ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
464
464
  // Playlist scape (phase 2) — per-host, never in DEFAULT_SCAPES: build with
465
465
  // createPlaylistScape() and register on the engine yourself.
466
466
  export { createPlaylistScape } from './ambience';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",