@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.
- package/dist/ambience/default.d.ts +3 -0
- package/dist/ambience/default.js +5 -1
- 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 +4 -0
- package/dist/ambience/scapes/playlist.js +14 -0
- package/dist/ambience/shared.d.ts +97 -0
- package/dist/ambience/shared.js +642 -0
- package/dist/ambience/types.d.ts +4 -0
- package/dist/components/AmbienceToggle.svelte +27 -4
- package/dist/components/AmbienceToggle.svelte.d.ts +3 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -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;
|
package/dist/ambience/default.js
CHANGED
|
@@ -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(
|
|
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;
|
package/dist/ambience/index.d.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* // inside a click handler:
|
|
10
10
|
* await ambience.play('rain');
|
|
11
11
|
*/
|
|
12
|
-
export { createAmbience,
|
|
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';
|
package/dist/ambience/index.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* // inside a click handler:
|
|
10
10
|
* await ambience.play('rain');
|
|
11
11
|
*/
|
|
12
|
-
|
|
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
|
+
}
|
package/dist/ambience/types.d.ts
CHANGED
|
@@ -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
|
|
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';
|