@lucaismyname/ginger 0.0.32 → 0.0.34
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/README.md +105 -5
- package/dist/hooks/useNextTrackPrefetch.d.ts +3 -0
- package/dist/hooks/useNextTrackPrefetch.d.ts.map +1 -1
- package/dist/remote/index.cjs +1 -1
- package/dist/remote/index.cjs.map +1 -1
- package/dist/remote/index.js +122 -103
- package/dist/remote/index.js.map +1 -1
- package/dist/remote/useGingerRemote.d.ts.map +1 -1
- package/dist/remote/useGingerRemote.test.d.ts +2 -0
- package/dist/remote/useGingerRemote.test.d.ts.map +1 -0
- package/dist/remote/validateGingerInitPayloadDev.d.ts +7 -0
- package/dist/remote/validateGingerInitPayloadDev.d.ts.map +1 -0
- package/dist/remote/validateGingerInitPayloadDev.test.d.ts +2 -0
- package/dist/remote/validateGingerInitPayloadDev.test.d.ts.map +1 -0
- package/dist/useGingerChapterProgress-BdaalJvX.cjs.map +1 -1
- package/dist/useGingerChapterProgress-CZdv-HiI.js.map +1 -1
- package/dist/waveform/analyzeAudioFile.d.ts.map +1 -1
- package/dist/waveform/getAudioContextConstructor.d.ts +6 -0
- package/dist/waveform/getAudioContextConstructor.d.ts.map +1 -0
- package/dist/waveform/index.cjs +1 -1
- package/dist/waveform/index.cjs.map +1 -1
- package/dist/waveform/index.js +162 -153
- package/dist/waveform/index.js.map +1 -1
- package/dist/waveform/useAudioFileAnalysis.d.ts +1 -0
- package/dist/waveform/useAudioFileAnalysis.d.ts.map +1 -1
- package/dist/waveform/useAudioPeaks.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Mount **`<Ginger.Player />`** once inside the same provider tree so the hidden a
|
|
|
56
56
|
- Streaming adapters: [`docs/guides/streaming-adapters.md`](./docs/guides/streaming-adapters.md)
|
|
57
57
|
- Components reference: [`docs/reference/components.md`](./docs/reference/components.md)
|
|
58
58
|
- Hooks reference: [`docs/reference/hooks.md`](./docs/reference/hooks.md)
|
|
59
|
-
- Subpath exports: [`docs/reference/subpaths.md`](./docs/reference/subpaths.md)
|
|
59
|
+
- Subpath exports (waveform, EQ, spatial, transcript, remote, …): [`docs/reference/subpaths.md`](./docs/reference/subpaths.md)
|
|
60
60
|
- Generated API docs: [`docs/api/index.html`](./docs/api/index.html)
|
|
61
61
|
|
|
62
62
|
## Subpath Exports
|
|
@@ -65,6 +65,9 @@ Mount **`<Ginger.Player />`** once inside the same provider tree so the hidden a
|
|
|
65
65
|
- `@lucaismyname/ginger/testing`
|
|
66
66
|
- `@lucaismyname/ginger/waveform`
|
|
67
67
|
- `@lucaismyname/ginger/equalizer`
|
|
68
|
+
- `@lucaismyname/ginger/spatial`
|
|
69
|
+
- `@lucaismyname/ginger/transcript`
|
|
70
|
+
- `@lucaismyname/ginger/remote`
|
|
68
71
|
- `@lucaismyname/ginger/experimental-gapless`
|
|
69
72
|
|
|
70
73
|
### Equalizer
|
|
@@ -105,6 +108,96 @@ function MyPlayer() {
|
|
|
105
108
|
|
|
106
109
|
The EQ and `useGingerLiveAnalyzer` share the same `AudioContext` and can be used together. EQ filters are inserted before the analyser in the Web Audio graph.
|
|
107
110
|
|
|
111
|
+
### Spatial audio (`@lucaismyname/ginger/spatial`)
|
|
112
|
+
|
|
113
|
+
Inserts an HRTF **`PannerNode`** into the same Web Audio graph as the EQ and live analyser (one `MediaElementAudioSourceNode` per `<audio>`).
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { useGingerSpatialAudio } from "@lucaismyname/ginger/spatial";
|
|
117
|
+
|
|
118
|
+
function Spatialized() {
|
|
119
|
+
const { setSourcePosition, error } = useGingerSpatialAudio({
|
|
120
|
+
panningModel: "HRTF",
|
|
121
|
+
position: [2, 0, 0],
|
|
122
|
+
listenerPosition: [0, 0, 0],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div>
|
|
127
|
+
<button type="button" onClick={() => setSourcePosition(0, 0, -2)}>
|
|
128
|
+
Move source
|
|
129
|
+
</button>
|
|
130
|
+
{error && <p>{error}</p>}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Use **`setListenerPosition`** and **`setPanningModel`** for runtime updates without rebuilding the graph.
|
|
137
|
+
|
|
138
|
+
### Transcript (`@lucaismyname/ginger/transcript`)
|
|
139
|
+
|
|
140
|
+
Parse **SRT** and **WebVTT** captions and sync cues to playback time (podcasts, video-style transcripts). HTML tags in cue text are stripped.
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
import {
|
|
144
|
+
parseSrt,
|
|
145
|
+
parseVtt,
|
|
146
|
+
useGingerTranscriptSync,
|
|
147
|
+
} from "@lucaismyname/ginger/transcript";
|
|
148
|
+
|
|
149
|
+
const vtt = `WEBVTT
|
|
150
|
+
|
|
151
|
+
00:00:01.000 --> 00:00:04.000
|
|
152
|
+
Hello from VTT
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
function TranscriptPanel() {
|
|
156
|
+
const { cues, activeCue, activeCues } = useGingerTranscriptSync({
|
|
157
|
+
transcript: vtt,
|
|
158
|
+
format: "auto",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div>
|
|
163
|
+
<p>Now: {activeCue?.text ?? "—"}</p>
|
|
164
|
+
<p>Overlapping: {activeCues.map((c) => c.text).join(" · ")}</p>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Or parse ahead of time:
|
|
170
|
+
const cuesFromSrt = parseSrt(srtString);
|
|
171
|
+
const cuesFromVtt = parseVtt(vttString);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**`useGingerTranscriptSync`** mirrors **`useGingerLyricsSync`** but uses cue **start/end** ranges and exposes **`activeCues`** for overlapping captions. **`parseTranscriptAuto`** chooses VTT when the string starts with `WEBVTT`, otherwise SRT.
|
|
175
|
+
|
|
176
|
+
### Multi-tab sync (`@lucaismyname/ginger/remote`)
|
|
177
|
+
|
|
178
|
+
Elects a **leader** tab via **`BroadcastChannel`** and pushes **`INIT`** snapshots to followers so queue and transport settings stay aligned. Mount **`Ginger.Player`** only on the leader so a single `<audio>` element plays.
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
import { Ginger } from "@lucaismyname/ginger";
|
|
182
|
+
import { useGingerRemote } from "@lucaismyname/ginger/remote";
|
|
183
|
+
|
|
184
|
+
function RemoteAwarePlayer() {
|
|
185
|
+
const { isLeader, isPending, error } = useGingerRemote({
|
|
186
|
+
channelName: "my-app-ginger",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
{error && <p role="alert">{error}</p>}
|
|
192
|
+
{isLeader && <Ginger.Player />}
|
|
193
|
+
{isPending && <p>Connecting to other tabs…</p>}
|
|
194
|
+
</>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Snapshots send the current queue order with **`isShuffled: false`** so followers do not re-randomize; the visible order matches the leader. **`claimLeadership()`** requests leadership (lexicographically smaller tab IDs win conflicts).
|
|
200
|
+
|
|
108
201
|
### Experimental Notice
|
|
109
202
|
|
|
110
203
|
`@lucaismyname/ginger/experimental-gapless` is intentionally non-production.
|
|
@@ -781,7 +874,11 @@ Example:
|
|
|
781
874
|
|
|
782
875
|
- **Buffered UI** — **`Ginger.Current.BufferRail`** shows load progress; **`Ginger.Current.TimeRail`** supports **`showBuffered`** to stack a buffered layer behind the played segment.
|
|
783
876
|
|
|
784
|
-
- **Audio analyzers** — Live Web Audio data for real-time visuals (**`useGingerLiveAnalyzer`**, main package), parametric EQ (**`useGingerEqualizer`**, `@lucaismyname/ginger/equalizer`), and whole-file grids for waveforms or spectrograms (**`useAudioFileAnalysis`** / **`analyzeAudioFile`**, `@lucaismyname/ginger/waveform`). See [Audio analyzers (visualizations)](#audio-analyzers-visualizations).
|
|
877
|
+
- **Audio analyzers** — Live Web Audio data for real-time visuals (**`useGingerLiveAnalyzer`**, main package), parametric EQ (**`useGingerEqualizer`**, `@lucaismyname/ginger/equalizer`), **spatial / HRTF panning** (**`useGingerSpatialAudio`**, `@lucaismyname/ginger/spatial`), and whole-file grids for waveforms or spectrograms (**`useAudioFileAnalysis`** / **`analyzeAudioFile`**, `@lucaismyname/ginger/waveform`). See [Audio analyzers (visualizations)](#audio-analyzers-visualizations).
|
|
878
|
+
|
|
879
|
+
- **Transcripts** — **SRT / WebVTT** parsing and sync (**`parseSrt`**, **`parseVtt`**, **`useGingerTranscriptSync`**, `@lucaismyname/ginger/transcript`); LRC / in-track lyrics remain **`useGingerLyricsSync`** and **`parseLrc()`** on the main package.
|
|
880
|
+
|
|
881
|
+
- **Multi-tab** — **`useGingerRemote`** (`@lucaismyname/ginger/remote`) coordinates playback state across browser tabs; see [Subpath exports](#subpath-exports).
|
|
785
882
|
|
|
786
883
|
Recipes below cover queue lifecycle and media edge cases.
|
|
787
884
|
|
|
@@ -1114,9 +1211,12 @@ Additional entrypoints:
|
|
|
1114
1211
|
- `@lucaismyname/ginger/client`
|
|
1115
1212
|
- `@lucaismyname/ginger/testing`
|
|
1116
1213
|
- `@lucaismyname/ginger/waveform`
|
|
1214
|
+
- `@lucaismyname/ginger/spatial`
|
|
1215
|
+
- `@lucaismyname/ginger/transcript`
|
|
1216
|
+
- `@lucaismyname/ginger/remote`
|
|
1117
1217
|
- `@lucaismyname/ginger/experimental-gapless`
|
|
1118
1218
|
|
|
1119
|
-
`experimental-gapless` is explicitly non-production and does not alter core playback.
|
|
1219
|
+
See [Subpath Exports](#subpath-exports) for **`spatial`**, **`transcript`**, and **`remote`** usage. `experimental-gapless` is explicitly non-production and does not alter core playback.
|
|
1120
1220
|
|
|
1121
1221
|
## Notes
|
|
1122
1222
|
|
|
@@ -1137,8 +1237,8 @@ See [Recipes — Updating the queue after mount](#updating-the-queue-after-mount
|
|
|
1137
1237
|
These priorities guide new work in the library; they are not a guarantee of shipping order.
|
|
1138
1238
|
|
|
1139
1239
|
1. **Music libraries and continuous listening** — Features that make track-to-track playback feel better come first: **next-track prefetch** (`useNextTrackPrefetch`), future gapless or crossfade (see `@lucaismyname/ginger/experimental-gapless`), and first-class **chapter** / **synced lyrics** UI (`Ginger.Current.Chapters`, `Ginger.Current.LyricsSynced`).
|
|
1140
|
-
2. **Podcasts and live-style streams** — **HLS / DASH** integration is emphasized when a concrete app needs it; the core package stays on native `<audio>` with optional adapters or documentation rather than hard dependencies.
|
|
1141
|
-
3. **Embedded or internal players** — **Accessibility**, persistence, and **testing** helpers are favored over heavier ecosystem integrations (Cast,
|
|
1240
|
+
2. **Podcasts and live-style streams** — **HLS / DASH** integration is emphasized when a concrete app needs it; the core package stays on native `<audio>` with optional adapters or documentation rather than hard dependencies. **SRT / WebVTT** transcripts are supported via `@lucaismyname/ginger/transcript`.
|
|
1241
|
+
3. **Embedded or internal players** — **Accessibility**, persistence, and **testing** helpers are favored over heavier ecosystem integrations (Cast, proprietary cast SDKs) unless there is a dedicated use case. **Multi-tab** web apps can use `@lucaismyname/ginger/remote` (BroadcastChannel) before reaching for OS-level remote playback.
|
|
1142
1242
|
|
|
1143
1243
|
## Monorepo Development
|
|
1144
1244
|
|
|
@@ -11,6 +11,9 @@ export type UseNextTrackPrefetchOptions = {
|
|
|
11
11
|
* Warms the browser cache for the **logical** next track (same rules as the Next control:
|
|
12
12
|
* `computeNextIndex` from queue, repeat, and playback mode) using a detached `HTMLAudioElement`
|
|
13
13
|
* with `preload="auto"`. Safe to call alongside `Ginger.Player`; it does not replace main playback.
|
|
14
|
+
*
|
|
15
|
+
* The effect depends on the `tracks` array reference from context. If the parent recreates `tracks`
|
|
16
|
+
* every render, prefetch will restart repeatedly; keep a stable queue reference (e.g. memoize) when possible.
|
|
14
17
|
*/
|
|
15
18
|
export declare function useNextTrackPrefetch(options?: UseNextTrackPrefetchOptions): void;
|
|
16
19
|
//# sourceMappingURL=useNextTrackPrefetch.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useNextTrackPrefetch.d.ts","sourceRoot":"","sources":["../../src/hooks/useNextTrackPrefetch.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,2BAA2B,GAAG;IACxC,kDAAkD;IAClD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,EAAE,GAAG,WAAW,GAAG,iBAAiB,GAAG,SAAS,CAAC;CAChE,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"useNextTrackPrefetch.d.ts","sourceRoot":"","sources":["../../src/hooks/useNextTrackPrefetch.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,2BAA2B,GAAG;IACxC,kDAAkD;IAClD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,EAAE,GAAG,WAAW,GAAG,iBAAiB,GAAG,SAAS,CAAC;CAChE,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,2BAAgC,GAAG,IAAI,CAsBpF"}
|
package/dist/remote/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const c=require("react"),U=require("../useGinger-BXgia32v.cjs"),C="ginger-remote",B=u=>u==="off"||u==="all"||u==="one",j=u=>u==="playlist"||u==="single";function x(u){if(!u||typeof u!="object")return!1;const e=u;if(!Array.isArray(e.tracks))return!1;for(const d of e.tracks){if(!d||typeof d!="object")return!1;const E=d;if(typeof E.title!="string"||typeof E.fileUrl!="string")return!1}return!(e.currentIndex!==void 0&&typeof e.currentIndex!="number"||e.isPaused!==void 0&&typeof e.isPaused!="boolean"||e.isShuffled!==void 0&&typeof e.isShuffled!="boolean"||e.repeatMode!==void 0&&!B(e.repeatMode)||e.playbackMode!==void 0&&!j(e.playbackMode)||e.volume!==void 0&&typeof e.volume!="number"||e.muted!==void 0&&typeof e.muted!="boolean"||e.playbackRate!==void 0&&typeof e.playbackRate!="number"||e.playlistMeta!==void 0&&e.playlistMeta!==null&&typeof e.playlistMeta!="object")}function F(){return typeof crypto<"u"&&typeof crypto.randomUUID=="function"?crypto.randomUUID():`ginger-tab-${Math.random().toString(36).slice(2)}`}function q(u={}){const{channelName:e=C,heartbeatMs:d=2e3,electionTimeoutMs:E=300}=u,{state:a,init:M}=U.useGinger(),t=c.useRef("");t.current===""&&(t.current=F());const[p,b]=c.useState("pending"),[_,T]=c.useState(0),[O,w]=c.useState(null),r=c.useRef(p);r.current=p;const S=c.useRef(M);S.current=M;const k=c.useRef(null),l=c.useRef(new Set),R=c.useRef(Date.now()),m=c.useRef(null),i=c.useRef(null),A=c.useRef(null),g=c.useCallback(s=>{var o;(o=k.current)==null||o.postMessage(s)},[]),y=c.useCallback(()=>{m.current&&(clearTimeout(m.current),m.current=null)},[]),N=c.useCallback(()=>{i.current&&(clearInterval(i.current),i.current=null)},[]);c.useEffect(()=>{if(typeof window>"u"||typeof BroadcastChannel>"u"){w("BroadcastChannel is not available in this environment");return}w(null);const s=new BroadcastChannel(e);k.current=s,l.current=new Set([t.current]);const o=I=>{s.postMessage(I)},v=()=>{i.current&&clearInterval(i.current),i.current=setInterval(()=>{const I=t.current;l.current.add(I);const n=l.current.size;T(n),o({type:"HEARTBEAT",tabId:I,connectedCount:n})},d)},h=()=>{y(),N(),b("follower"),r.current="follower"},H=()=>{y(),b("leader"),r.current="leader",l.current.add(t.current),o({type:"LEADER_ANNOUNCE",tabId:t.current}),v()},D=()=>{y(),m.current=setTimeout(()=>{r.current==="pending"&&(b("leader"),r.current="leader",l.current.add(t.current),o({type:"LEADER_ANNOUNCE",tabId:t.current}),v())},E)},L=I=>{const n=I.data;if(!n||typeof n!="object"||!("type"in n))return;const f=t.current;switch(n.type){case"PING":{l.current.add(n.tabId),r.current==="leader"&&o({type:"PONG",tabId:f,leaderTabId:f});break}case"PONG":{n.leaderTabId&&n.leaderTabId!==f&&(h(),R.current=Date.now());break}case"LEADER_ANNOUNCE":{if(n.tabId===f)break;l.current.add(n.tabId),n.tabId<f?(h(),R.current=Date.now()):n.tabId>f&&(r.current==="pending"||r.current==="leader")&&H();break}case"LEADER_RESIGN":{if(n.tabId===f)break;R.current=Date.now(),r.current==="follower"&&(b("pending"),r.current="pending",o({type:"PING",tabId:f}),D());break}case"HEARTBEAT":{r.current==="follower"&&(R.current=Date.now(),T(n.connectedCount));break}case"STATE_SNAPSHOT":{if(r.current==="follower"&&n.tabId!==f){if(process.env.NODE_ENV!=="production"&&!x(n.snapshot)){console.warn("[@lucaismyname/ginger] ignored STATE_SNAPSHOT: invalid GingerInitPayload");break}S.current(n.snapshot)}break}}};s.addEventListener("message",L),o({type:"PING",tabId:t.current}),m.current=setTimeout(()=>{r.current==="pending"&&(b("leader"),r.current="leader",l.current.add(t.current),o({type:"LEADER_ANNOUNCE",tabId:t.current}),v())},E),A.current=setInterval(()=>{r.current==="follower"&&Date.now()-R.current>d*2&&(b("pending"),r.current="pending",o({type:"PING",tabId:t.current}),D())},d);const P=()=>{r.current==="leader"&&o({type:"LEADER_RESIGN",tabId:t.current})};return window.addEventListener("pagehide",P),()=>{window.removeEventListener("pagehide",P),y(),N(),A.current&&(clearInterval(A.current),A.current=null),s.removeEventListener("message",L),r.current==="leader"&&o({type:"LEADER_RESIGN",tabId:t.current}),s.close(),k.current=null}},[e,y,E,d,N]),c.useEffect(()=>{if(p!=="leader")return;const s={tracks:a.tracks,currentIndex:a.currentIndex,playlistMeta:a.playlistMeta,isPaused:a.isPaused,isShuffled:!1,repeatMode:a.repeatMode,playbackMode:a.playbackMode,volume:a.volume,muted:a.muted,playbackRate:a.playbackRate};g({type:"STATE_SNAPSHOT",tabId:t.current,snapshot:s})},[p,a.tracks,a.currentIndex,a.isPaused,a.repeatMode,a.playbackMode,a.playlistMeta,a.volume,a.muted,a.playbackRate,g]);const G=c.useCallback(()=>{y(),N(),b("leader"),r.current="leader",l.current.add(t.current),g({type:"LEADER_ANNOUNCE",tabId:t.current}),i.current&&clearInterval(i.current),i.current=setInterval(()=>{const s=t.current;l.current.add(s);const o=l.current.size;T(o),g({type:"HEARTBEAT",tabId:s,connectedCount:o})},d)},[y,d,g,N]);return{isLeader:p==="leader",isFollower:p==="follower",isPending:p==="pending",connectedTabs:_,claimLeadership:G,error:O}}exports.DEFAULT_REMOTE_CHANNEL_NAME=C;exports.useGingerRemote=q;
|
|
2
2
|
//# sourceMappingURL=index.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","sources":["../../src/remote/remoteProtocol.ts","../../src/remote/useGingerRemote.ts"],"sourcesContent":["import type { GingerInitPayload } from \"../types\";\n\nexport const DEFAULT_REMOTE_CHANNEL_NAME = \"ginger-remote\";\n\n/**\n * Cross-tab messages for {@link useGingerRemote}.\n */\nexport type RemoteMessage =\n | { type: \"PING\"; tabId: string }\n | { type: \"PONG\"; tabId: string; leaderTabId: string }\n | { type: \"LEADER_ANNOUNCE\"; tabId: string }\n | { type: \"LEADER_RESIGN\"; tabId: string }\n | { type: \"HEARTBEAT\"; tabId: string; connectedCount: number }\n | { type: \"STATE_SNAPSHOT\"; tabId: string; snapshot: GingerInitPayload };\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useGinger } from \"../hooks/useGinger\";\nimport type { GingerInitPayload } from \"../types\";\nimport { DEFAULT_REMOTE_CHANNEL_NAME, type RemoteMessage } from \"./remoteProtocol\";\n\nexport type UseGingerRemoteOptions = {\n /** BroadcastChannel name. Default: `\"ginger-remote\"`. */\n channelName?: string;\n /** Leader heartbeat interval in ms. Default: `2000`. */\n heartbeatMs?: number;\n /** Time to wait for an existing leader before claiming leadership. Default: `300`. */\n electionTimeoutMs?: number;\n};\n\nexport type UseGingerRemoteResult = {\n isLeader: boolean;\n isFollower: boolean;\n /** True until a leader is elected or this tab becomes leader. */\n isPending: boolean;\n connectedTabs: number;\n /** Request leadership (other tabs may win if their `tabId` is lexicographically smaller). */\n claimLeadership: () => void;\n error: string | null;\n};\n\nfunction makeTabId(): string {\n if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n return crypto.randomUUID();\n }\n return `ginger-tab-${Math.random().toString(36).slice(2)}`;\n}\n\n/**\n * Multi-tab coordination via `BroadcastChannel`: elects a single leader tab and syncs\n * playback state to followers with `INIT` snapshots.\n *\n * Mount `Ginger.Player` only on the leader tab so one `<audio>` element plays:\n *\n * ```tsx\n * const { isLeader } = useGingerRemote();\n * return <>{isLeader && <Ginger.Player />}</>;\n * ```\n *\n * ```ts\n * import { useGingerRemote } from \"@lucaismyname/ginger/remote\";\n * ```\n */\nexport function useGingerRemote(options: UseGingerRemoteOptions = {}): UseGingerRemoteResult {\n const {\n channelName = DEFAULT_REMOTE_CHANNEL_NAME,\n heartbeatMs = 2000,\n electionTimeoutMs = 300,\n } = options;\n\n const { state, init } = useGinger();\n\n const tabIdRef = useRef<string>(\"\");\n if (tabIdRef.current === \"\") {\n tabIdRef.current = makeTabId();\n }\n\n const [role, setRole] = useState<\"pending\" | \"leader\" | \"follower\">(\"pending\");\n const [connectedTabs, setConnectedTabs] = useState(0);\n const [error, setError] = useState<string | null>(null);\n\n const roleRef = useRef(role);\n roleRef.current = role;\n\n const initRef = useRef(init);\n initRef.current = init;\n\n const channelRef = useRef<BroadcastChannel | null>(null);\n const knownTabsRef = useRef<Set<string>>(new Set());\n const lastHeartbeatAtRef = useRef<number>(Date.now());\n const electionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const leaderWatchRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n const post = useCallback((msg: RemoteMessage) => {\n channelRef.current?.postMessage(msg);\n }, []);\n\n const clearElectionTimer = useCallback(() => {\n if (electionTimerRef.current) {\n clearTimeout(electionTimerRef.current);\n electionTimerRef.current = null;\n }\n }, []);\n\n const stopHeartbeat = useCallback(() => {\n if (heartbeatTimerRef.current) {\n clearInterval(heartbeatTimerRef.current);\n heartbeatTimerRef.current = null;\n }\n }, []);\n\n useEffect(() => {\n if (typeof window === \"undefined\" || typeof BroadcastChannel === \"undefined\") {\n setError(\"BroadcastChannel is not available in this environment\");\n return;\n }\n\n setError(null);\n const ch = new BroadcastChannel(channelName);\n channelRef.current = ch;\n knownTabsRef.current = new Set([tabIdRef.current]);\n\n const postMsg = (msg: RemoteMessage) => {\n ch.postMessage(msg);\n };\n\n const startLeaderHeartbeat = () => {\n if (heartbeatTimerRef.current) {\n clearInterval(heartbeatTimerRef.current);\n }\n heartbeatTimerRef.current = setInterval(() => {\n const leaderId = tabIdRef.current;\n knownTabsRef.current.add(leaderId);\n const count = knownTabsRef.current.size;\n setConnectedTabs(count);\n postMsg({ type: \"HEARTBEAT\", tabId: leaderId, connectedCount: count });\n }, heartbeatMs);\n };\n\n const becomeFollower = () => {\n clearElectionTimer();\n stopHeartbeat();\n setRole(\"follower\");\n roleRef.current = \"follower\";\n };\n\n const becomeLeaderFromRemote = () => {\n clearElectionTimer();\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n postMsg({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n startLeaderHeartbeat();\n };\n\n const scheduleElection = () => {\n clearElectionTimer();\n electionTimerRef.current = setTimeout(() => {\n if (roleRef.current === \"pending\") {\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n postMsg({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n startLeaderHeartbeat();\n }\n }, electionTimeoutMs);\n };\n\n const onMessage = (ev: MessageEvent<RemoteMessage>) => {\n const msg = ev.data;\n if (!msg || typeof msg !== \"object\" || !(\"type\" in msg)) return;\n\n const myId = tabIdRef.current;\n\n switch (msg.type) {\n case \"PING\": {\n knownTabsRef.current.add(msg.tabId);\n if (roleRef.current === \"leader\") {\n postMsg({ type: \"PONG\", tabId: myId, leaderTabId: myId });\n }\n break;\n }\n case \"PONG\": {\n if (msg.leaderTabId && msg.leaderTabId !== myId) {\n becomeFollower();\n lastHeartbeatAtRef.current = Date.now();\n }\n break;\n }\n case \"LEADER_ANNOUNCE\": {\n if (msg.tabId === myId) break;\n knownTabsRef.current.add(msg.tabId);\n if (msg.tabId < myId) {\n becomeFollower();\n lastHeartbeatAtRef.current = Date.now();\n } else if (\n msg.tabId > myId &&\n (roleRef.current === \"pending\" || roleRef.current === \"leader\")\n ) {\n becomeLeaderFromRemote();\n }\n break;\n }\n case \"LEADER_RESIGN\": {\n if (msg.tabId === myId) break;\n lastHeartbeatAtRef.current = Date.now();\n if (roleRef.current === \"follower\") {\n setRole(\"pending\");\n roleRef.current = \"pending\";\n postMsg({ type: \"PING\", tabId: myId });\n scheduleElection();\n }\n break;\n }\n case \"HEARTBEAT\": {\n if (roleRef.current === \"follower\") {\n lastHeartbeatAtRef.current = Date.now();\n setConnectedTabs(msg.connectedCount);\n }\n break;\n }\n case \"STATE_SNAPSHOT\": {\n if (roleRef.current === \"follower\" && msg.tabId !== myId) {\n initRef.current(msg.snapshot);\n }\n break;\n }\n default:\n break;\n }\n };\n\n ch.addEventListener(\"message\", onMessage);\n\n postMsg({ type: \"PING\", tabId: tabIdRef.current });\n\n electionTimerRef.current = setTimeout(() => {\n if (roleRef.current === \"pending\") {\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n postMsg({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n startLeaderHeartbeat();\n }\n }, electionTimeoutMs);\n\n leaderWatchRef.current = setInterval(() => {\n if (roleRef.current !== \"follower\") return;\n if (Date.now() - lastHeartbeatAtRef.current > heartbeatMs * 2) {\n setRole(\"pending\");\n roleRef.current = \"pending\";\n postMsg({ type: \"PING\", tabId: tabIdRef.current });\n scheduleElection();\n }\n }, heartbeatMs);\n\n const onPageHide = () => {\n if (roleRef.current === \"leader\") {\n postMsg({ type: \"LEADER_RESIGN\", tabId: tabIdRef.current });\n }\n };\n window.addEventListener(\"pagehide\", onPageHide);\n\n return () => {\n window.removeEventListener(\"pagehide\", onPageHide);\n clearElectionTimer();\n stopHeartbeat();\n if (leaderWatchRef.current) {\n clearInterval(leaderWatchRef.current);\n leaderWatchRef.current = null;\n }\n ch.removeEventListener(\"message\", onMessage);\n if (roleRef.current === \"leader\") {\n postMsg({ type: \"LEADER_RESIGN\", tabId: tabIdRef.current });\n }\n ch.close();\n channelRef.current = null;\n };\n }, [channelName, clearElectionTimer, electionTimeoutMs, heartbeatMs, stopHeartbeat]);\n\n useEffect(() => {\n if (role !== \"leader\") return;\n const snapshot: GingerInitPayload = {\n tracks: state.tracks,\n currentIndex: state.currentIndex,\n playlistMeta: state.playlistMeta,\n isPaused: state.isPaused,\n /** Avoid `createInitialState` re-shuffling on followers; queue order is already canonical. */\n isShuffled: false,\n repeatMode: state.repeatMode,\n playbackMode: state.playbackMode,\n volume: state.volume,\n muted: state.muted,\n playbackRate: state.playbackRate,\n };\n post({\n type: \"STATE_SNAPSHOT\",\n tabId: tabIdRef.current,\n snapshot,\n });\n }, [\n role,\n state.tracks,\n state.currentIndex,\n state.isPaused,\n state.repeatMode,\n state.playbackMode,\n state.playlistMeta,\n state.volume,\n state.muted,\n state.playbackRate,\n post,\n ]);\n\n const claimLeadership = useCallback(() => {\n clearElectionTimer();\n stopHeartbeat();\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n post({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n if (heartbeatTimerRef.current) {\n clearInterval(heartbeatTimerRef.current);\n }\n heartbeatTimerRef.current = setInterval(() => {\n const leaderId = tabIdRef.current;\n knownTabsRef.current.add(leaderId);\n const count = knownTabsRef.current.size;\n setConnectedTabs(count);\n post({ type: \"HEARTBEAT\", tabId: leaderId, connectedCount: count });\n }, heartbeatMs);\n }, [clearElectionTimer, heartbeatMs, post, stopHeartbeat]);\n\n return {\n isLeader: role === \"leader\",\n isFollower: role === \"follower\",\n isPending: role === \"pending\",\n connectedTabs,\n claimLeadership,\n error,\n };\n}\n"],"names":["DEFAULT_REMOTE_CHANNEL_NAME","makeTabId","useGingerRemote","options","channelName","heartbeatMs","electionTimeoutMs","state","init","useGinger","tabIdRef","useRef","role","setRole","useState","connectedTabs","setConnectedTabs","error","setError","roleRef","initRef","channelRef","knownTabsRef","lastHeartbeatAtRef","electionTimerRef","heartbeatTimerRef","leaderWatchRef","post","useCallback","msg","_a","clearElectionTimer","stopHeartbeat","useEffect","ch","postMsg","startLeaderHeartbeat","leaderId","count","becomeFollower","becomeLeaderFromRemote","scheduleElection","onMessage","ev","myId","onPageHide","snapshot","claimLeadership"],"mappings":"gJAEaA,EAA8B,gBCuB3C,SAASC,GAAoB,CAC3B,OAAI,OAAO,OAAW,KAAe,OAAO,OAAO,YAAe,WACzD,OAAO,WAAA,EAET,cAAc,KAAK,OAAA,EAAS,SAAS,EAAE,EAAE,MAAM,CAAC,CAAC,EAC1D,CAiBO,SAASC,EAAgBC,EAAkC,GAA2B,CAC3F,KAAM,CACJ,YAAAC,EAAcJ,EACd,YAAAK,EAAc,IACd,kBAAAC,EAAoB,GAAA,EAClBH,EAEE,CAAE,MAAAI,EAAO,KAAAC,CAAA,EAASC,YAAA,EAElBC,EAAWC,EAAAA,OAAe,EAAE,EAC9BD,EAAS,UAAY,KACvBA,EAAS,QAAUT,EAAA,GAGrB,KAAM,CAACW,EAAMC,CAAO,EAAIC,EAAAA,SAA4C,SAAS,EACvE,CAACC,EAAeC,CAAgB,EAAIF,EAAAA,SAAS,CAAC,EAC9C,CAACG,EAAOC,CAAQ,EAAIJ,EAAAA,SAAwB,IAAI,EAEhDK,EAAUR,EAAAA,OAAOC,CAAI,EAC3BO,EAAQ,QAAUP,EAElB,MAAMQ,EAAUT,EAAAA,OAAOH,CAAI,EAC3BY,EAAQ,QAAUZ,EAElB,MAAMa,EAAaV,EAAAA,OAAgC,IAAI,EACjDW,EAAeX,EAAAA,OAAoB,IAAI,GAAK,EAC5CY,EAAqBZ,EAAAA,OAAe,KAAK,IAAA,CAAK,EAC9Ca,EAAmBb,EAAAA,OAA6C,IAAI,EACpEc,EAAoBd,EAAAA,OAA8C,IAAI,EACtEe,EAAiBf,EAAAA,OAA8C,IAAI,EAEnEgB,EAAOC,cAAaC,GAAuB,QAC/CC,EAAAT,EAAW,UAAX,MAAAS,EAAoB,YAAYD,EAClC,EAAG,CAAA,CAAE,EAECE,EAAqBH,EAAAA,YAAY,IAAM,CACvCJ,EAAiB,UACnB,aAAaA,EAAiB,OAAO,EACrCA,EAAiB,QAAU,KAE/B,EAAG,CAAA,CAAE,EAECQ,EAAgBJ,EAAAA,YAAY,IAAM,CAClCH,EAAkB,UACpB,cAAcA,EAAkB,OAAO,EACvCA,EAAkB,QAAU,KAEhC,EAAG,CAAA,CAAE,EAELQ,EAAAA,UAAU,IAAM,CACd,GAAI,OAAO,OAAW,KAAe,OAAO,iBAAqB,IAAa,CAC5Ef,EAAS,uDAAuD,EAChE,MACF,CAEAA,EAAS,IAAI,EACb,MAAMgB,EAAK,IAAI,iBAAiB9B,CAAW,EAC3CiB,EAAW,QAAUa,EACrBZ,EAAa,QAAU,IAAI,IAAI,CAACZ,EAAS,OAAO,CAAC,EAEjD,MAAMyB,EAAWN,GAAuB,CACtCK,EAAG,YAAYL,CAAG,CACpB,EAEMO,EAAuB,IAAM,CAC7BX,EAAkB,SACpB,cAAcA,EAAkB,OAAO,EAEzCA,EAAkB,QAAU,YAAY,IAAM,CAC5C,MAAMY,EAAW3B,EAAS,QAC1BY,EAAa,QAAQ,IAAIe,CAAQ,EACjC,MAAMC,EAAQhB,EAAa,QAAQ,KACnCN,EAAiBsB,CAAK,EACtBH,EAAQ,CAAE,KAAM,YAAa,MAAOE,EAAU,eAAgBC,EAAO,CACvE,EAAGjC,CAAW,CAChB,EAEMkC,EAAiB,IAAM,CAC3BR,EAAA,EACAC,EAAA,EACAnB,EAAQ,UAAU,EAClBM,EAAQ,QAAU,UACpB,EAEMqB,EAAyB,IAAM,CACnCT,EAAA,EACAlB,EAAQ,QAAQ,EAChBM,EAAQ,QAAU,SAClBG,EAAa,QAAQ,IAAIZ,EAAS,OAAO,EACzCyB,EAAQ,CAAE,KAAM,kBAAmB,MAAOzB,EAAS,QAAS,EAC5D0B,EAAA,CACF,EAEMK,EAAmB,IAAM,CAC7BV,EAAA,EACAP,EAAiB,QAAU,WAAW,IAAM,CACtCL,EAAQ,UAAY,YACtBN,EAAQ,QAAQ,EAChBM,EAAQ,QAAU,SAClBG,EAAa,QAAQ,IAAIZ,EAAS,OAAO,EACzCyB,EAAQ,CAAE,KAAM,kBAAmB,MAAOzB,EAAS,QAAS,EAC5D0B,EAAA,EAEJ,EAAG9B,CAAiB,CACtB,EAEMoC,EAAaC,GAAoC,CACrD,MAAMd,EAAMc,EAAG,KACf,GAAI,CAACd,GAAO,OAAOA,GAAQ,UAAY,EAAE,SAAUA,GAAM,OAEzD,MAAMe,EAAOlC,EAAS,QAEtB,OAAQmB,EAAI,KAAA,CACV,IAAK,OAAQ,CACXP,EAAa,QAAQ,IAAIO,EAAI,KAAK,EAC9BV,EAAQ,UAAY,UACtBgB,EAAQ,CAAE,KAAM,OAAQ,MAAOS,EAAM,YAAaA,EAAM,EAE1D,KACF,CACA,IAAK,OAAQ,CACPf,EAAI,aAAeA,EAAI,cAAgBe,IACzCL,EAAA,EACAhB,EAAmB,QAAU,KAAK,IAAA,GAEpC,KACF,CACA,IAAK,kBAAmB,CACtB,GAAIM,EAAI,QAAUe,EAAM,MACxBtB,EAAa,QAAQ,IAAIO,EAAI,KAAK,EAC9BA,EAAI,MAAQe,GACdL,EAAA,EACAhB,EAAmB,QAAU,KAAK,IAAA,GAElCM,EAAI,MAAQe,IACXzB,EAAQ,UAAY,WAAaA,EAAQ,UAAY,WAEtDqB,EAAA,EAEF,KACF,CACA,IAAK,gBAAiB,CACpB,GAAIX,EAAI,QAAUe,EAAM,MACxBrB,EAAmB,QAAU,KAAK,IAAA,EAC9BJ,EAAQ,UAAY,aACtBN,EAAQ,SAAS,EACjBM,EAAQ,QAAU,UAClBgB,EAAQ,CAAE,KAAM,OAAQ,MAAOS,EAAM,EACrCH,EAAA,GAEF,KACF,CACA,IAAK,YAAa,CACZtB,EAAQ,UAAY,aACtBI,EAAmB,QAAU,KAAK,IAAA,EAClCP,EAAiBa,EAAI,cAAc,GAErC,KACF,CACA,IAAK,iBAAkB,CACjBV,EAAQ,UAAY,YAAcU,EAAI,QAAUe,GAClDxB,EAAQ,QAAQS,EAAI,QAAQ,EAE9B,KACF,CAEE,CAEN,EAEAK,EAAG,iBAAiB,UAAWQ,CAAS,EAExCP,EAAQ,CAAE,KAAM,OAAQ,MAAOzB,EAAS,QAAS,EAEjDc,EAAiB,QAAU,WAAW,IAAM,CACtCL,EAAQ,UAAY,YACtBN,EAAQ,QAAQ,EAChBM,EAAQ,QAAU,SAClBG,EAAa,QAAQ,IAAIZ,EAAS,OAAO,EACzCyB,EAAQ,CAAE,KAAM,kBAAmB,MAAOzB,EAAS,QAAS,EAC5D0B,EAAA,EAEJ,EAAG9B,CAAiB,EAEpBoB,EAAe,QAAU,YAAY,IAAM,CACrCP,EAAQ,UAAY,YACpB,KAAK,IAAA,EAAQI,EAAmB,QAAUlB,EAAc,IAC1DQ,EAAQ,SAAS,EACjBM,EAAQ,QAAU,UAClBgB,EAAQ,CAAE,KAAM,OAAQ,MAAOzB,EAAS,QAAS,EACjD+B,EAAA,EAEJ,EAAGpC,CAAW,EAEd,MAAMwC,EAAa,IAAM,CACnB1B,EAAQ,UAAY,UACtBgB,EAAQ,CAAE,KAAM,gBAAiB,MAAOzB,EAAS,QAAS,CAE9D,EACA,cAAO,iBAAiB,WAAYmC,CAAU,EAEvC,IAAM,CACX,OAAO,oBAAoB,WAAYA,CAAU,EACjDd,EAAA,EACAC,EAAA,EACIN,EAAe,UACjB,cAAcA,EAAe,OAAO,EACpCA,EAAe,QAAU,MAE3BQ,EAAG,oBAAoB,UAAWQ,CAAS,EACvCvB,EAAQ,UAAY,UACtBgB,EAAQ,CAAE,KAAM,gBAAiB,MAAOzB,EAAS,QAAS,EAE5DwB,EAAG,MAAA,EACHb,EAAW,QAAU,IACvB,CACF,EAAG,CAACjB,EAAa2B,EAAoBzB,EAAmBD,EAAa2B,CAAa,CAAC,EAEnFC,EAAAA,UAAU,IAAM,CACd,GAAIrB,IAAS,SAAU,OACvB,MAAMkC,EAA8B,CAClC,OAAQvC,EAAM,OACd,aAAcA,EAAM,aACpB,aAAcA,EAAM,aACpB,SAAUA,EAAM,SAEhB,WAAY,GACZ,WAAYA,EAAM,WAClB,aAAcA,EAAM,aACpB,OAAQA,EAAM,OACd,MAAOA,EAAM,MACb,aAAcA,EAAM,YAAA,EAEtBoB,EAAK,CACH,KAAM,iBACN,MAAOjB,EAAS,QAChB,SAAAoC,CAAA,CACD,CACH,EAAG,CACDlC,EACAL,EAAM,OACNA,EAAM,aACNA,EAAM,SACNA,EAAM,WACNA,EAAM,aACNA,EAAM,aACNA,EAAM,OACNA,EAAM,MACNA,EAAM,aACNoB,CAAA,CACD,EAED,MAAMoB,EAAkBnB,EAAAA,YAAY,IAAM,CACxCG,EAAA,EACAC,EAAA,EACAnB,EAAQ,QAAQ,EAChBM,EAAQ,QAAU,SAClBG,EAAa,QAAQ,IAAIZ,EAAS,OAAO,EACzCiB,EAAK,CAAE,KAAM,kBAAmB,MAAOjB,EAAS,QAAS,EACrDe,EAAkB,SACpB,cAAcA,EAAkB,OAAO,EAEzCA,EAAkB,QAAU,YAAY,IAAM,CAC5C,MAAMY,EAAW3B,EAAS,QAC1BY,EAAa,QAAQ,IAAIe,CAAQ,EACjC,MAAMC,EAAQhB,EAAa,QAAQ,KACnCN,EAAiBsB,CAAK,EACtBX,EAAK,CAAE,KAAM,YAAa,MAAOU,EAAU,eAAgBC,EAAO,CACpE,EAAGjC,CAAW,CAChB,EAAG,CAAC0B,EAAoB1B,EAAasB,EAAMK,CAAa,CAAC,EAEzD,MAAO,CACL,SAAUpB,IAAS,SACnB,WAAYA,IAAS,WACrB,UAAWA,IAAS,UACpB,cAAAG,EACA,gBAAAgC,EACA,MAAA9B,CAAA,CAEJ"}
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../../src/remote/remoteProtocol.ts","../../src/remote/validateGingerInitPayloadDev.ts","../../src/remote/useGingerRemote.ts"],"sourcesContent":["import type { GingerInitPayload } from \"../types\";\n\nexport const DEFAULT_REMOTE_CHANNEL_NAME = \"ginger-remote\";\n\n/**\n * Cross-tab messages for {@link useGingerRemote}.\n */\nexport type RemoteMessage =\n | { type: \"PING\"; tabId: string }\n | { type: \"PONG\"; tabId: string; leaderTabId: string }\n | { type: \"LEADER_ANNOUNCE\"; tabId: string }\n | { type: \"LEADER_RESIGN\"; tabId: string }\n | { type: \"HEARTBEAT\"; tabId: string; connectedCount: number }\n | { type: \"STATE_SNAPSHOT\"; tabId: string; snapshot: GingerInitPayload };\n","import type { GingerInitPayload } from \"../types\";\n\nconst repeatOk = (v: unknown): v is \"off\" | \"all\" | \"one\" =>\n v === \"off\" || v === \"all\" || v === \"one\";\n\nconst playbackModeOk = (v: unknown): v is \"playlist\" | \"single\" =>\n v === \"playlist\" || v === \"single\";\n\n/**\n * Structural check for remote `STATE_SNAPSHOT` payloads. Used in development to catch\n * malformed cross-tab messages; production callers still rely on same-origin `BroadcastChannel`.\n */\nexport function validateGingerInitPayloadDev(snapshot: unknown): snapshot is GingerInitPayload {\n if (!snapshot || typeof snapshot !== \"object\") return false;\n const s = snapshot as Record<string, unknown>;\n if (!Array.isArray(s.tracks)) return false;\n for (const t of s.tracks) {\n if (!t || typeof t !== \"object\") return false;\n const tr = t as Record<string, unknown>;\n if (typeof tr.title !== \"string\" || typeof tr.fileUrl !== \"string\") return false;\n }\n if (s.currentIndex !== undefined && typeof s.currentIndex !== \"number\") return false;\n if (s.isPaused !== undefined && typeof s.isPaused !== \"boolean\") return false;\n if (s.isShuffled !== undefined && typeof s.isShuffled !== \"boolean\") return false;\n if (s.repeatMode !== undefined && !repeatOk(s.repeatMode)) return false;\n if (s.playbackMode !== undefined && !playbackModeOk(s.playbackMode)) return false;\n if (s.volume !== undefined && typeof s.volume !== \"number\") return false;\n if (s.muted !== undefined && typeof s.muted !== \"boolean\") return false;\n if (s.playbackRate !== undefined && typeof s.playbackRate !== \"number\") return false;\n if (\n s.playlistMeta !== undefined &&\n s.playlistMeta !== null &&\n typeof s.playlistMeta !== \"object\"\n ) {\n return false;\n }\n return true;\n}\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useGinger } from \"../hooks/useGinger\";\nimport type { GingerInitPayload } from \"../types\";\nimport { DEFAULT_REMOTE_CHANNEL_NAME, type RemoteMessage } from \"./remoteProtocol\";\nimport { validateGingerInitPayloadDev } from \"./validateGingerInitPayloadDev\";\n\nexport type UseGingerRemoteOptions = {\n /** BroadcastChannel name. Default: `\"ginger-remote\"`. */\n channelName?: string;\n /** Leader heartbeat interval in ms. Default: `2000`. */\n heartbeatMs?: number;\n /** Time to wait for an existing leader before claiming leadership. Default: `300`. */\n electionTimeoutMs?: number;\n};\n\nexport type UseGingerRemoteResult = {\n isLeader: boolean;\n isFollower: boolean;\n /** True until a leader is elected or this tab becomes leader. */\n isPending: boolean;\n connectedTabs: number;\n /** Request leadership (other tabs may win if their `tabId` is lexicographically smaller). */\n claimLeadership: () => void;\n error: string | null;\n};\n\nfunction makeTabId(): string {\n if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n return crypto.randomUUID();\n }\n return `ginger-tab-${Math.random().toString(36).slice(2)}`;\n}\n\n/**\n * Multi-tab coordination via `BroadcastChannel`: elects a single leader tab and syncs\n * playback state to followers with `INIT` snapshots.\n *\n * Mount `Ginger.Player` only on the leader tab so one `<audio>` element plays:\n *\n * ```tsx\n * const { isLeader } = useGingerRemote();\n * return <>{isLeader && <Ginger.Player />}</>;\n * ```\n *\n * ```ts\n * import { useGingerRemote } from \"@lucaismyname/ginger/remote\";\n * ```\n */\nexport function useGingerRemote(options: UseGingerRemoteOptions = {}): UseGingerRemoteResult {\n const {\n channelName = DEFAULT_REMOTE_CHANNEL_NAME,\n heartbeatMs = 2000,\n electionTimeoutMs = 300,\n } = options;\n\n const { state, init } = useGinger();\n\n const tabIdRef = useRef<string>(\"\");\n if (tabIdRef.current === \"\") {\n tabIdRef.current = makeTabId();\n }\n\n const [role, setRole] = useState<\"pending\" | \"leader\" | \"follower\">(\"pending\");\n const [connectedTabs, setConnectedTabs] = useState(0);\n const [error, setError] = useState<string | null>(null);\n\n const roleRef = useRef(role);\n roleRef.current = role;\n\n const initRef = useRef(init);\n initRef.current = init;\n\n const channelRef = useRef<BroadcastChannel | null>(null);\n const knownTabsRef = useRef<Set<string>>(new Set());\n const lastHeartbeatAtRef = useRef<number>(Date.now());\n const electionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const leaderWatchRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n const post = useCallback((msg: RemoteMessage) => {\n channelRef.current?.postMessage(msg);\n }, []);\n\n const clearElectionTimer = useCallback(() => {\n if (electionTimerRef.current) {\n clearTimeout(electionTimerRef.current);\n electionTimerRef.current = null;\n }\n }, []);\n\n const stopHeartbeat = useCallback(() => {\n if (heartbeatTimerRef.current) {\n clearInterval(heartbeatTimerRef.current);\n heartbeatTimerRef.current = null;\n }\n }, []);\n\n useEffect(() => {\n if (typeof window === \"undefined\" || typeof BroadcastChannel === \"undefined\") {\n setError(\"BroadcastChannel is not available in this environment\");\n return;\n }\n\n setError(null);\n const ch = new BroadcastChannel(channelName);\n channelRef.current = ch;\n knownTabsRef.current = new Set([tabIdRef.current]);\n\n const postMsg = (msg: RemoteMessage) => {\n ch.postMessage(msg);\n };\n\n const startLeaderHeartbeat = () => {\n if (heartbeatTimerRef.current) {\n clearInterval(heartbeatTimerRef.current);\n }\n heartbeatTimerRef.current = setInterval(() => {\n const leaderId = tabIdRef.current;\n knownTabsRef.current.add(leaderId);\n const count = knownTabsRef.current.size;\n setConnectedTabs(count);\n postMsg({ type: \"HEARTBEAT\", tabId: leaderId, connectedCount: count });\n }, heartbeatMs);\n };\n\n const becomeFollower = () => {\n clearElectionTimer();\n stopHeartbeat();\n setRole(\"follower\");\n roleRef.current = \"follower\";\n };\n\n const becomeLeaderFromRemote = () => {\n clearElectionTimer();\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n postMsg({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n startLeaderHeartbeat();\n };\n\n const scheduleElection = () => {\n clearElectionTimer();\n electionTimerRef.current = setTimeout(() => {\n if (roleRef.current === \"pending\") {\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n postMsg({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n startLeaderHeartbeat();\n }\n }, electionTimeoutMs);\n };\n\n const onMessage = (ev: MessageEvent<RemoteMessage>) => {\n const msg = ev.data;\n if (!msg || typeof msg !== \"object\" || !(\"type\" in msg)) return;\n\n const myId = tabIdRef.current;\n\n switch (msg.type) {\n case \"PING\": {\n knownTabsRef.current.add(msg.tabId);\n if (roleRef.current === \"leader\") {\n postMsg({ type: \"PONG\", tabId: myId, leaderTabId: myId });\n }\n break;\n }\n case \"PONG\": {\n if (msg.leaderTabId && msg.leaderTabId !== myId) {\n becomeFollower();\n lastHeartbeatAtRef.current = Date.now();\n }\n break;\n }\n case \"LEADER_ANNOUNCE\": {\n if (msg.tabId === myId) break;\n knownTabsRef.current.add(msg.tabId);\n if (msg.tabId < myId) {\n becomeFollower();\n lastHeartbeatAtRef.current = Date.now();\n } else if (\n msg.tabId > myId &&\n (roleRef.current === \"pending\" || roleRef.current === \"leader\")\n ) {\n becomeLeaderFromRemote();\n }\n break;\n }\n case \"LEADER_RESIGN\": {\n if (msg.tabId === myId) break;\n lastHeartbeatAtRef.current = Date.now();\n if (roleRef.current === \"follower\") {\n setRole(\"pending\");\n roleRef.current = \"pending\";\n postMsg({ type: \"PING\", tabId: myId });\n scheduleElection();\n }\n break;\n }\n case \"HEARTBEAT\": {\n if (roleRef.current === \"follower\") {\n lastHeartbeatAtRef.current = Date.now();\n setConnectedTabs(msg.connectedCount);\n }\n break;\n }\n case \"STATE_SNAPSHOT\": {\n if (roleRef.current === \"follower\" && msg.tabId !== myId) {\n if (\n process.env.NODE_ENV !== \"production\" &&\n !validateGingerInitPayloadDev(msg.snapshot)\n ) {\n console.warn(\n \"[@lucaismyname/ginger] ignored STATE_SNAPSHOT: invalid GingerInitPayload\",\n );\n break;\n }\n initRef.current(msg.snapshot);\n }\n break;\n }\n default:\n break;\n }\n };\n\n ch.addEventListener(\"message\", onMessage);\n\n postMsg({ type: \"PING\", tabId: tabIdRef.current });\n\n electionTimerRef.current = setTimeout(() => {\n if (roleRef.current === \"pending\") {\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n postMsg({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n startLeaderHeartbeat();\n }\n }, electionTimeoutMs);\n\n leaderWatchRef.current = setInterval(() => {\n if (roleRef.current !== \"follower\") return;\n if (Date.now() - lastHeartbeatAtRef.current > heartbeatMs * 2) {\n setRole(\"pending\");\n roleRef.current = \"pending\";\n postMsg({ type: \"PING\", tabId: tabIdRef.current });\n scheduleElection();\n }\n }, heartbeatMs);\n\n const onPageHide = () => {\n if (roleRef.current === \"leader\") {\n postMsg({ type: \"LEADER_RESIGN\", tabId: tabIdRef.current });\n }\n };\n window.addEventListener(\"pagehide\", onPageHide);\n\n return () => {\n window.removeEventListener(\"pagehide\", onPageHide);\n clearElectionTimer();\n stopHeartbeat();\n if (leaderWatchRef.current) {\n clearInterval(leaderWatchRef.current);\n leaderWatchRef.current = null;\n }\n ch.removeEventListener(\"message\", onMessage);\n if (roleRef.current === \"leader\") {\n postMsg({ type: \"LEADER_RESIGN\", tabId: tabIdRef.current });\n }\n ch.close();\n channelRef.current = null;\n };\n }, [channelName, clearElectionTimer, electionTimeoutMs, heartbeatMs, stopHeartbeat]);\n\n useEffect(() => {\n if (role !== \"leader\") return;\n const snapshot: GingerInitPayload = {\n tracks: state.tracks,\n currentIndex: state.currentIndex,\n playlistMeta: state.playlistMeta,\n isPaused: state.isPaused,\n /** Avoid `createInitialState` re-shuffling on followers; queue order is already canonical. */\n isShuffled: false,\n repeatMode: state.repeatMode,\n playbackMode: state.playbackMode,\n volume: state.volume,\n muted: state.muted,\n playbackRate: state.playbackRate,\n };\n post({\n type: \"STATE_SNAPSHOT\",\n tabId: tabIdRef.current,\n snapshot,\n });\n }, [\n role,\n state.tracks,\n state.currentIndex,\n state.isPaused,\n state.repeatMode,\n state.playbackMode,\n state.playlistMeta,\n state.volume,\n state.muted,\n state.playbackRate,\n post,\n ]);\n\n const claimLeadership = useCallback(() => {\n clearElectionTimer();\n stopHeartbeat();\n setRole(\"leader\");\n roleRef.current = \"leader\";\n knownTabsRef.current.add(tabIdRef.current);\n post({ type: \"LEADER_ANNOUNCE\", tabId: tabIdRef.current });\n if (heartbeatTimerRef.current) {\n clearInterval(heartbeatTimerRef.current);\n }\n heartbeatTimerRef.current = setInterval(() => {\n const leaderId = tabIdRef.current;\n knownTabsRef.current.add(leaderId);\n const count = knownTabsRef.current.size;\n setConnectedTabs(count);\n post({ type: \"HEARTBEAT\", tabId: leaderId, connectedCount: count });\n }, heartbeatMs);\n }, [clearElectionTimer, heartbeatMs, post, stopHeartbeat]);\n\n return {\n isLeader: role === \"leader\",\n isFollower: role === \"follower\",\n isPending: role === \"pending\",\n connectedTabs,\n claimLeadership,\n error,\n };\n}\n"],"names":["DEFAULT_REMOTE_CHANNEL_NAME","repeatOk","v","playbackModeOk","validateGingerInitPayloadDev","snapshot","s","t","tr","makeTabId","useGingerRemote","options","channelName","heartbeatMs","electionTimeoutMs","state","init","useGinger","tabIdRef","useRef","role","setRole","useState","connectedTabs","setConnectedTabs","error","setError","roleRef","initRef","channelRef","knownTabsRef","lastHeartbeatAtRef","electionTimerRef","heartbeatTimerRef","leaderWatchRef","post","useCallback","msg","_a","clearElectionTimer","stopHeartbeat","useEffect","ch","postMsg","startLeaderHeartbeat","leaderId","count","becomeFollower","becomeLeaderFromRemote","scheduleElection","onMessage","ev","myId","onPageHide","claimLeadership"],"mappings":"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"}
|
package/dist/remote/index.js
CHANGED
|
@@ -1,149 +1,168 @@
|
|
|
1
|
-
import { useRef as
|
|
2
|
-
import { u as
|
|
3
|
-
const
|
|
4
|
-
function
|
|
1
|
+
import { useRef as f, useState as M, useCallback as T, useEffect as O } from "react";
|
|
2
|
+
import { u as B } from "../useGinger-hpp2pAGY.js";
|
|
3
|
+
const j = "ginger-remote", F = (o) => o === "off" || o === "all" || o === "one", z = (o) => o === "playlist" || o === "single";
|
|
4
|
+
function V(o) {
|
|
5
|
+
if (!o || typeof o != "object") return !1;
|
|
6
|
+
const e = o;
|
|
7
|
+
if (!Array.isArray(e.tracks)) return !1;
|
|
8
|
+
for (const s of e.tracks) {
|
|
9
|
+
if (!s || typeof s != "object") return !1;
|
|
10
|
+
const I = s;
|
|
11
|
+
if (typeof I.title != "string" || typeof I.fileUrl != "string") return !1;
|
|
12
|
+
}
|
|
13
|
+
return !(e.currentIndex !== void 0 && typeof e.currentIndex != "number" || e.isPaused !== void 0 && typeof e.isPaused != "boolean" || e.isShuffled !== void 0 && typeof e.isShuffled != "boolean" || e.repeatMode !== void 0 && !F(e.repeatMode) || e.playbackMode !== void 0 && !z(e.playbackMode) || e.volume !== void 0 && typeof e.volume != "number" || e.muted !== void 0 && typeof e.muted != "boolean" || e.playbackRate !== void 0 && typeof e.playbackRate != "number" || e.playlistMeta !== void 0 && e.playlistMeta !== null && typeof e.playlistMeta != "object");
|
|
14
|
+
}
|
|
15
|
+
function W() {
|
|
5
16
|
return typeof crypto < "u" && typeof crypto.randomUUID == "function" ? crypto.randomUUID() : `ginger-tab-${Math.random().toString(36).slice(2)}`;
|
|
6
17
|
}
|
|
7
|
-
function
|
|
18
|
+
function J(o = {}) {
|
|
8
19
|
const {
|
|
9
|
-
channelName:
|
|
10
|
-
heartbeatMs:
|
|
11
|
-
electionTimeoutMs:
|
|
12
|
-
} =
|
|
13
|
-
|
|
14
|
-
const [
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
var
|
|
20
|
-
(
|
|
21
|
-
}, []),
|
|
22
|
-
|
|
23
|
-
}, []),
|
|
24
|
-
|
|
20
|
+
channelName: e = j,
|
|
21
|
+
heartbeatMs: s = 2e3,
|
|
22
|
+
electionTimeoutMs: I = 300
|
|
23
|
+
} = o, { state: a, init: h } = B(), t = f("");
|
|
24
|
+
t.current === "" && (t.current = W());
|
|
25
|
+
const [p, b] = M("pending"), [G, k] = M(0), [H, D] = M(null), r = f(p);
|
|
26
|
+
r.current = p;
|
|
27
|
+
const S = f(h);
|
|
28
|
+
S.current = h;
|
|
29
|
+
const v = f(null), l = f(/* @__PURE__ */ new Set()), m = f(Date.now()), g = f(null), d = f(null), A = f(null), N = T((u) => {
|
|
30
|
+
var c;
|
|
31
|
+
(c = v.current) == null || c.postMessage(u);
|
|
32
|
+
}, []), y = T(() => {
|
|
33
|
+
g.current && (clearTimeout(g.current), g.current = null);
|
|
34
|
+
}, []), R = T(() => {
|
|
35
|
+
d.current && (clearInterval(d.current), d.current = null);
|
|
25
36
|
}, []);
|
|
26
|
-
|
|
37
|
+
O(() => {
|
|
27
38
|
if (typeof window > "u" || typeof BroadcastChannel > "u") {
|
|
28
|
-
|
|
39
|
+
D("BroadcastChannel is not available in this environment");
|
|
29
40
|
return;
|
|
30
41
|
}
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
42
|
+
D(null);
|
|
43
|
+
const u = new BroadcastChannel(e);
|
|
44
|
+
v.current = u, l.current = /* @__PURE__ */ new Set([t.current]);
|
|
45
|
+
const c = (E) => {
|
|
46
|
+
u.postMessage(E);
|
|
36
47
|
}, w = () => {
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const n =
|
|
41
|
-
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
const n =
|
|
48
|
+
d.current && clearInterval(d.current), d.current = setInterval(() => {
|
|
49
|
+
const E = t.current;
|
|
50
|
+
l.current.add(E);
|
|
51
|
+
const n = l.current.size;
|
|
52
|
+
k(n), c({ type: "HEARTBEAT", tabId: E, connectedCount: n });
|
|
53
|
+
}, s);
|
|
54
|
+
}, L = () => {
|
|
55
|
+
y(), R(), b("follower"), r.current = "follower";
|
|
56
|
+
}, x = () => {
|
|
57
|
+
y(), b("leader"), r.current = "leader", l.current.add(t.current), c({ type: "LEADER_ANNOUNCE", tabId: t.current }), w();
|
|
58
|
+
}, P = () => {
|
|
59
|
+
y(), g.current = setTimeout(() => {
|
|
60
|
+
r.current === "pending" && (b("leader"), r.current = "leader", l.current.add(t.current), c({ type: "LEADER_ANNOUNCE", tabId: t.current }), w());
|
|
61
|
+
}, I);
|
|
62
|
+
}, _ = (E) => {
|
|
63
|
+
const n = E.data;
|
|
53
64
|
if (!n || typeof n != "object" || !("type" in n)) return;
|
|
54
|
-
const
|
|
65
|
+
const i = t.current;
|
|
55
66
|
switch (n.type) {
|
|
56
67
|
case "PING": {
|
|
57
|
-
|
|
68
|
+
l.current.add(n.tabId), r.current === "leader" && c({ type: "PONG", tabId: i, leaderTabId: i });
|
|
58
69
|
break;
|
|
59
70
|
}
|
|
60
71
|
case "PONG": {
|
|
61
|
-
n.leaderTabId && n.leaderTabId !==
|
|
72
|
+
n.leaderTabId && n.leaderTabId !== i && (L(), m.current = Date.now());
|
|
62
73
|
break;
|
|
63
74
|
}
|
|
64
75
|
case "LEADER_ANNOUNCE": {
|
|
65
|
-
if (n.tabId ===
|
|
66
|
-
|
|
76
|
+
if (n.tabId === i) break;
|
|
77
|
+
l.current.add(n.tabId), n.tabId < i ? (L(), m.current = Date.now()) : n.tabId > i && (r.current === "pending" || r.current === "leader") && x();
|
|
67
78
|
break;
|
|
68
79
|
}
|
|
69
80
|
case "LEADER_RESIGN": {
|
|
70
|
-
if (n.tabId ===
|
|
71
|
-
|
|
81
|
+
if (n.tabId === i) break;
|
|
82
|
+
m.current = Date.now(), r.current === "follower" && (b("pending"), r.current = "pending", c({ type: "PING", tabId: i }), P());
|
|
72
83
|
break;
|
|
73
84
|
}
|
|
74
85
|
case "HEARTBEAT": {
|
|
75
|
-
|
|
86
|
+
r.current === "follower" && (m.current = Date.now(), k(n.connectedCount));
|
|
76
87
|
break;
|
|
77
88
|
}
|
|
78
89
|
case "STATE_SNAPSHOT": {
|
|
79
|
-
|
|
90
|
+
if (r.current === "follower" && n.tabId !== i) {
|
|
91
|
+
if (process.env.NODE_ENV !== "production" && !V(n.snapshot)) {
|
|
92
|
+
console.warn(
|
|
93
|
+
"[@lucaismyname/ginger] ignored STATE_SNAPSHOT: invalid GingerInitPayload"
|
|
94
|
+
);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
S.current(n.snapshot);
|
|
98
|
+
}
|
|
80
99
|
break;
|
|
81
100
|
}
|
|
82
101
|
}
|
|
83
102
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
},
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
const
|
|
90
|
-
|
|
103
|
+
u.addEventListener("message", _), c({ type: "PING", tabId: t.current }), g.current = setTimeout(() => {
|
|
104
|
+
r.current === "pending" && (b("leader"), r.current = "leader", l.current.add(t.current), c({ type: "LEADER_ANNOUNCE", tabId: t.current }), w());
|
|
105
|
+
}, I), A.current = setInterval(() => {
|
|
106
|
+
r.current === "follower" && Date.now() - m.current > s * 2 && (b("pending"), r.current = "pending", c({ type: "PING", tabId: t.current }), P());
|
|
107
|
+
}, s);
|
|
108
|
+
const C = () => {
|
|
109
|
+
r.current === "leader" && c({ type: "LEADER_RESIGN", tabId: t.current });
|
|
91
110
|
};
|
|
92
|
-
return window.addEventListener("pagehide",
|
|
93
|
-
window.removeEventListener("pagehide",
|
|
111
|
+
return window.addEventListener("pagehide", C), () => {
|
|
112
|
+
window.removeEventListener("pagehide", C), y(), R(), A.current && (clearInterval(A.current), A.current = null), u.removeEventListener("message", _), r.current === "leader" && c({ type: "LEADER_RESIGN", tabId: t.current }), u.close(), v.current = null;
|
|
94
113
|
};
|
|
95
|
-
}, [
|
|
96
|
-
if (
|
|
97
|
-
const
|
|
98
|
-
tracks:
|
|
99
|
-
currentIndex:
|
|
100
|
-
playlistMeta:
|
|
101
|
-
isPaused:
|
|
114
|
+
}, [e, y, I, s, R]), O(() => {
|
|
115
|
+
if (p !== "leader") return;
|
|
116
|
+
const u = {
|
|
117
|
+
tracks: a.tracks,
|
|
118
|
+
currentIndex: a.currentIndex,
|
|
119
|
+
playlistMeta: a.playlistMeta,
|
|
120
|
+
isPaused: a.isPaused,
|
|
102
121
|
/** Avoid `createInitialState` re-shuffling on followers; queue order is already canonical. */
|
|
103
122
|
isShuffled: !1,
|
|
104
|
-
repeatMode:
|
|
105
|
-
playbackMode:
|
|
106
|
-
volume:
|
|
107
|
-
muted:
|
|
108
|
-
playbackRate:
|
|
123
|
+
repeatMode: a.repeatMode,
|
|
124
|
+
playbackMode: a.playbackMode,
|
|
125
|
+
volume: a.volume,
|
|
126
|
+
muted: a.muted,
|
|
127
|
+
playbackRate: a.playbackRate
|
|
109
128
|
};
|
|
110
|
-
|
|
129
|
+
N({
|
|
111
130
|
type: "STATE_SNAPSHOT",
|
|
112
|
-
tabId:
|
|
113
|
-
snapshot:
|
|
131
|
+
tabId: t.current,
|
|
132
|
+
snapshot: u
|
|
114
133
|
});
|
|
115
134
|
}, [
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
p,
|
|
136
|
+
a.tracks,
|
|
137
|
+
a.currentIndex,
|
|
138
|
+
a.isPaused,
|
|
139
|
+
a.repeatMode,
|
|
140
|
+
a.playbackMode,
|
|
141
|
+
a.playlistMeta,
|
|
142
|
+
a.volume,
|
|
143
|
+
a.muted,
|
|
144
|
+
a.playbackRate,
|
|
145
|
+
N
|
|
127
146
|
]);
|
|
128
|
-
const U =
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
},
|
|
135
|
-
}, [
|
|
147
|
+
const U = T(() => {
|
|
148
|
+
y(), R(), b("leader"), r.current = "leader", l.current.add(t.current), N({ type: "LEADER_ANNOUNCE", tabId: t.current }), d.current && clearInterval(d.current), d.current = setInterval(() => {
|
|
149
|
+
const u = t.current;
|
|
150
|
+
l.current.add(u);
|
|
151
|
+
const c = l.current.size;
|
|
152
|
+
k(c), N({ type: "HEARTBEAT", tabId: u, connectedCount: c });
|
|
153
|
+
}, s);
|
|
154
|
+
}, [y, s, N, R]);
|
|
136
155
|
return {
|
|
137
|
-
isLeader:
|
|
138
|
-
isFollower:
|
|
139
|
-
isPending:
|
|
140
|
-
connectedTabs:
|
|
156
|
+
isLeader: p === "leader",
|
|
157
|
+
isFollower: p === "follower",
|
|
158
|
+
isPending: p === "pending",
|
|
159
|
+
connectedTabs: G,
|
|
141
160
|
claimLeadership: U,
|
|
142
|
-
error:
|
|
161
|
+
error: H
|
|
143
162
|
};
|
|
144
163
|
}
|
|
145
164
|
export {
|
|
146
|
-
|
|
147
|
-
|
|
165
|
+
j as DEFAULT_REMOTE_CHANNEL_NAME,
|
|
166
|
+
J as useGingerRemote
|
|
148
167
|
};
|
|
149
168
|
//# sourceMappingURL=index.js.map
|