@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 +1 -1
- package/dist/ambience/README.md +9 -2
- package/dist/ambience/default.d.ts +3 -0
- package/dist/ambience/default.js +5 -1
- package/dist/ambience/engine.d.ts +2 -0
- package/dist/ambience/engine.js +101 -4
- package/dist/ambience/index.d.ts +3 -1
- package/dist/ambience/index.js +5 -1
- package/dist/ambience/persist.d.ts +2 -0
- package/dist/ambience/scapes/playlist.d.ts +19 -0
- package/dist/ambience/scapes/playlist.js +95 -4
- package/dist/ambience/shared.d.ts +164 -0
- package/dist/ambience/shared.js +744 -0
- package/dist/ambience/types.d.ts +23 -0
- package/dist/components/AmbienceToggle.svelte +281 -9
- package/dist/components/AmbienceToggle.svelte.d.ts +20 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -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;
|