@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 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, remote playback modules) unless there is a dedicated use case.
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;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,2BAAgC,GAAG,IAAI,CAsBpF"}
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"}
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const a=require("react"),U=require("../useGinger-BXgia32v.cjs"),S="ginger-remote";function B(){return typeof crypto<"u"&&typeof crypto.randomUUID=="function"?crypto.randomUUID():`ginger-tab-${Math.random().toString(36).slice(2)}`}function F(_={}){const{channelName:w=S,heartbeatMs:b=2e3,electionTimeoutMs:N=300}=_,{state:r,init:k}=U.useGinger(),e=a.useRef("");e.current===""&&(e.current=B());const[l,i]=a.useState("pending"),[P,g]=a.useState(0),[G,M]=a.useState(null),t=a.useRef(l);t.current=l;const L=a.useRef(k);L.current=k;const T=a.useRef(null),o=a.useRef(new Set),E=a.useRef(Date.now()),I=a.useRef(null),s=a.useRef(null),m=a.useRef(null),R=a.useCallback(u=>{var c;(c=T.current)==null||c.postMessage(u)},[]),f=a.useCallback(()=>{I.current&&(clearTimeout(I.current),I.current=null)},[]),y=a.useCallback(()=>{s.current&&(clearInterval(s.current),s.current=null)},[]);a.useEffect(()=>{if(typeof window>"u"||typeof BroadcastChannel>"u"){M("BroadcastChannel is not available in this environment");return}M(null);const u=new BroadcastChannel(w);T.current=u,o.current=new Set([e.current]);const c=p=>{u.postMessage(p)},A=()=>{s.current&&clearInterval(s.current),s.current=setInterval(()=>{const p=e.current;o.current.add(p);const n=o.current.size;g(n),c({type:"HEARTBEAT",tabId:p,connectedCount:n})},b)},h=()=>{f(),y(),i("follower"),t.current="follower"},H=()=>{f(),i("leader"),t.current="leader",o.current.add(e.current),c({type:"LEADER_ANNOUNCE",tabId:e.current}),A()},v=()=>{f(),I.current=setTimeout(()=>{t.current==="pending"&&(i("leader"),t.current="leader",o.current.add(e.current),c({type:"LEADER_ANNOUNCE",tabId:e.current}),A())},N)},C=p=>{const n=p.data;if(!n||typeof n!="object"||!("type"in n))return;const d=e.current;switch(n.type){case"PING":{o.current.add(n.tabId),t.current==="leader"&&c({type:"PONG",tabId:d,leaderTabId:d});break}case"PONG":{n.leaderTabId&&n.leaderTabId!==d&&(h(),E.current=Date.now());break}case"LEADER_ANNOUNCE":{if(n.tabId===d)break;o.current.add(n.tabId),n.tabId<d?(h(),E.current=Date.now()):n.tabId>d&&(t.current==="pending"||t.current==="leader")&&H();break}case"LEADER_RESIGN":{if(n.tabId===d)break;E.current=Date.now(),t.current==="follower"&&(i("pending"),t.current="pending",c({type:"PING",tabId:d}),v());break}case"HEARTBEAT":{t.current==="follower"&&(E.current=Date.now(),g(n.connectedCount));break}case"STATE_SNAPSHOT":{t.current==="follower"&&n.tabId!==d&&L.current(n.snapshot);break}}};u.addEventListener("message",C),c({type:"PING",tabId:e.current}),I.current=setTimeout(()=>{t.current==="pending"&&(i("leader"),t.current="leader",o.current.add(e.current),c({type:"LEADER_ANNOUNCE",tabId:e.current}),A())},N),m.current=setInterval(()=>{t.current==="follower"&&Date.now()-E.current>b*2&&(i("pending"),t.current="pending",c({type:"PING",tabId:e.current}),v())},b);const D=()=>{t.current==="leader"&&c({type:"LEADER_RESIGN",tabId:e.current})};return window.addEventListener("pagehide",D),()=>{window.removeEventListener("pagehide",D),f(),y(),m.current&&(clearInterval(m.current),m.current=null),u.removeEventListener("message",C),t.current==="leader"&&c({type:"LEADER_RESIGN",tabId:e.current}),u.close(),T.current=null}},[w,f,N,b,y]),a.useEffect(()=>{if(l!=="leader")return;const u={tracks:r.tracks,currentIndex:r.currentIndex,playlistMeta:r.playlistMeta,isPaused:r.isPaused,isShuffled:!1,repeatMode:r.repeatMode,playbackMode:r.playbackMode,volume:r.volume,muted:r.muted,playbackRate:r.playbackRate};R({type:"STATE_SNAPSHOT",tabId:e.current,snapshot:u})},[l,r.tracks,r.currentIndex,r.isPaused,r.repeatMode,r.playbackMode,r.playlistMeta,r.volume,r.muted,r.playbackRate,R]);const O=a.useCallback(()=>{f(),y(),i("leader"),t.current="leader",o.current.add(e.current),R({type:"LEADER_ANNOUNCE",tabId:e.current}),s.current&&clearInterval(s.current),s.current=setInterval(()=>{const u=e.current;o.current.add(u);const c=o.current.size;g(c),R({type:"HEARTBEAT",tabId:u,connectedCount:c})},b)},[f,b,R,y]);return{isLeader:l==="leader",isFollower:l==="follower",isPending:l==="pending",connectedTabs:P,claimLeadership:O,error:G}}exports.DEFAULT_REMOTE_CHANNEL_NAME=S;exports.useGingerRemote=F;
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":"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"}
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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"}
@@ -1,149 +1,168 @@
1
- import { useRef as s, useState as k, useCallback as N, useEffect as _ } from "react";
2
- import { u as x } from "../useGinger-hpp2pAGY.js";
3
- const F = "ginger-remote";
4
- function z() {
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 $(G = {}) {
18
+ function J(o = {}) {
8
19
  const {
9
- channelName: h = F,
10
- heartbeatMs: p = 2e3,
11
- electionTimeoutMs: T = 300
12
- } = G, { state: r, init: v } = x(), e = s("");
13
- e.current === "" && (e.current = z());
14
- const [l, i] = k("pending"), [H, g] = k(0), [O, L] = k(null), t = s(l);
15
- t.current = l;
16
- const M = s(v);
17
- M.current = v;
18
- const A = s(null), o = s(/* @__PURE__ */ new Set()), I = s(Date.now()), E = s(null), u = s(null), R = s(null), m = N((c) => {
19
- var a;
20
- (a = A.current) == null || a.postMessage(c);
21
- }, []), f = N(() => {
22
- E.current && (clearTimeout(E.current), E.current = null);
23
- }, []), y = N(() => {
24
- u.current && (clearInterval(u.current), u.current = null);
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
- L("BroadcastChannel is not available in this environment");
39
+ D("BroadcastChannel is not available in this environment");
29
40
  return;
30
41
  }
31
- L(null);
32
- const c = new BroadcastChannel(h);
33
- A.current = c, o.current = /* @__PURE__ */ new Set([e.current]);
34
- const a = (b) => {
35
- c.postMessage(b);
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
- u.current && clearInterval(u.current), u.current = setInterval(() => {
38
- const b = e.current;
39
- o.current.add(b);
40
- const n = o.current.size;
41
- g(n), a({ type: "HEARTBEAT", tabId: b, connectedCount: n });
42
- }, p);
43
- }, D = () => {
44
- f(), y(), i("follower"), t.current = "follower";
45
- }, B = () => {
46
- f(), i("leader"), t.current = "leader", o.current.add(e.current), a({ type: "LEADER_ANNOUNCE", tabId: e.current }), w();
47
- }, C = () => {
48
- f(), E.current = setTimeout(() => {
49
- t.current === "pending" && (i("leader"), t.current = "leader", o.current.add(e.current), a({ type: "LEADER_ANNOUNCE", tabId: e.current }), w());
50
- }, T);
51
- }, S = (b) => {
52
- const n = b.data;
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 d = e.current;
65
+ const i = t.current;
55
66
  switch (n.type) {
56
67
  case "PING": {
57
- o.current.add(n.tabId), t.current === "leader" && a({ type: "PONG", tabId: d, leaderTabId: d });
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 !== d && (D(), I.current = Date.now());
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 === d) break;
66
- o.current.add(n.tabId), n.tabId < d ? (D(), I.current = Date.now()) : n.tabId > d && (t.current === "pending" || t.current === "leader") && B();
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 === d) break;
71
- I.current = Date.now(), t.current === "follower" && (i("pending"), t.current = "pending", a({ type: "PING", tabId: d }), C());
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
- t.current === "follower" && (I.current = Date.now(), g(n.connectedCount));
86
+ r.current === "follower" && (m.current = Date.now(), k(n.connectedCount));
76
87
  break;
77
88
  }
78
89
  case "STATE_SNAPSHOT": {
79
- t.current === "follower" && n.tabId !== d && M.current(n.snapshot);
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
- c.addEventListener("message", S), a({ type: "PING", tabId: e.current }), E.current = setTimeout(() => {
85
- t.current === "pending" && (i("leader"), t.current = "leader", o.current.add(e.current), a({ type: "LEADER_ANNOUNCE", tabId: e.current }), w());
86
- }, T), R.current = setInterval(() => {
87
- t.current === "follower" && Date.now() - I.current > p * 2 && (i("pending"), t.current = "pending", a({ type: "PING", tabId: e.current }), C());
88
- }, p);
89
- const P = () => {
90
- t.current === "leader" && a({ type: "LEADER_RESIGN", tabId: e.current });
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", P), () => {
93
- window.removeEventListener("pagehide", P), f(), y(), R.current && (clearInterval(R.current), R.current = null), c.removeEventListener("message", S), t.current === "leader" && a({ type: "LEADER_RESIGN", tabId: e.current }), c.close(), A.current = null;
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
- }, [h, f, T, p, y]), _(() => {
96
- if (l !== "leader") return;
97
- const c = {
98
- tracks: r.tracks,
99
- currentIndex: r.currentIndex,
100
- playlistMeta: r.playlistMeta,
101
- isPaused: r.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: r.repeatMode,
105
- playbackMode: r.playbackMode,
106
- volume: r.volume,
107
- muted: r.muted,
108
- playbackRate: r.playbackRate
123
+ repeatMode: a.repeatMode,
124
+ playbackMode: a.playbackMode,
125
+ volume: a.volume,
126
+ muted: a.muted,
127
+ playbackRate: a.playbackRate
109
128
  };
110
- m({
129
+ N({
111
130
  type: "STATE_SNAPSHOT",
112
- tabId: e.current,
113
- snapshot: c
131
+ tabId: t.current,
132
+ snapshot: u
114
133
  });
115
134
  }, [
116
- l,
117
- r.tracks,
118
- r.currentIndex,
119
- r.isPaused,
120
- r.repeatMode,
121
- r.playbackMode,
122
- r.playlistMeta,
123
- r.volume,
124
- r.muted,
125
- r.playbackRate,
126
- m
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 = N(() => {
129
- f(), y(), i("leader"), t.current = "leader", o.current.add(e.current), m({ type: "LEADER_ANNOUNCE", tabId: e.current }), u.current && clearInterval(u.current), u.current = setInterval(() => {
130
- const c = e.current;
131
- o.current.add(c);
132
- const a = o.current.size;
133
- g(a), m({ type: "HEARTBEAT", tabId: c, connectedCount: a });
134
- }, p);
135
- }, [f, p, m, y]);
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: l === "leader",
138
- isFollower: l === "follower",
139
- isPending: l === "pending",
140
- connectedTabs: H,
156
+ isLeader: p === "leader",
157
+ isFollower: p === "follower",
158
+ isPending: p === "pending",
159
+ connectedTabs: G,
141
160
  claimLeadership: U,
142
- error: O
161
+ error: H
143
162
  };
144
163
  }
145
164
  export {
146
- F as DEFAULT_REMOTE_CHANNEL_NAME,
147
- $ as useGingerRemote
165
+ j as DEFAULT_REMOTE_CHANNEL_NAME,
166
+ J as useGingerRemote
148
167
  };
149
168
  //# sourceMappingURL=index.js.map