@lucaismyname/ginger 0.0.31 → 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.
Files changed (83) hide show
  1. package/README.md +105 -5
  2. package/dist/client.cjs +1 -1
  3. package/dist/client.js +37 -36
  4. package/dist/client.js.map +1 -1
  5. package/dist/equalizer/index.cjs +1 -1
  6. package/dist/equalizer/index.cjs.map +1 -1
  7. package/dist/equalizer/index.js +16 -15
  8. package/dist/equalizer/index.js.map +1 -1
  9. package/dist/hooks/useNextTrackPrefetch.d.ts +3 -0
  10. package/dist/hooks/useNextTrackPrefetch.d.ts.map +1 -1
  11. package/dist/index.cjs +1 -1
  12. package/dist/index.js +37 -36
  13. package/dist/index.js.map +1 -1
  14. package/dist/liveAudioGraph-0cpHD_Ic.cjs +2 -0
  15. package/dist/liveAudioGraph-0cpHD_Ic.cjs.map +1 -0
  16. package/dist/liveAudioGraph-DvPaxBCP.js +105 -0
  17. package/dist/liveAudioGraph-DvPaxBCP.js.map +1 -0
  18. package/dist/remote/index.cjs +2 -0
  19. package/dist/remote/index.cjs.map +1 -0
  20. package/dist/remote/index.d.ts +5 -0
  21. package/dist/remote/index.d.ts.map +1 -0
  22. package/dist/remote/index.js +168 -0
  23. package/dist/remote/index.js.map +1 -0
  24. package/dist/remote/remoteProtocol.d.ts +28 -0
  25. package/dist/remote/remoteProtocol.d.ts.map +1 -0
  26. package/dist/remote/useGingerRemote.d.ts +35 -0
  27. package/dist/remote/useGingerRemote.d.ts.map +1 -0
  28. package/dist/remote/useGingerRemote.test.d.ts +2 -0
  29. package/dist/remote/useGingerRemote.test.d.ts.map +1 -0
  30. package/dist/remote/validateGingerInitPayloadDev.d.ts +7 -0
  31. package/dist/remote/validateGingerInitPayloadDev.d.ts.map +1 -0
  32. package/dist/remote/validateGingerInitPayloadDev.test.d.ts +2 -0
  33. package/dist/remote/validateGingerInitPayloadDev.test.d.ts.map +1 -0
  34. package/dist/spatial/index.cjs +2 -0
  35. package/dist/spatial/index.cjs.map +1 -0
  36. package/dist/spatial/index.d.ts +3 -0
  37. package/dist/spatial/index.d.ts.map +1 -0
  38. package/dist/spatial/index.js +59 -0
  39. package/dist/spatial/index.js.map +1 -0
  40. package/dist/spatial/useGingerSpatialAudio.d.ts +34 -0
  41. package/dist/spatial/useGingerSpatialAudio.d.ts.map +1 -0
  42. package/dist/spatial/useGingerSpatialAudio.test.d.ts +2 -0
  43. package/dist/spatial/useGingerSpatialAudio.test.d.ts.map +1 -0
  44. package/dist/testing/mockWebAudio.d.ts +14 -0
  45. package/dist/testing/mockWebAudio.d.ts.map +1 -1
  46. package/dist/transcript/index.cjs +8 -0
  47. package/dist/transcript/index.cjs.map +1 -0
  48. package/dist/transcript/index.d.ts +5 -0
  49. package/dist/transcript/index.d.ts.map +1 -0
  50. package/dist/transcript/index.js +99 -0
  51. package/dist/transcript/index.js.map +1 -0
  52. package/dist/transcript/parseTranscript.d.ts +27 -0
  53. package/dist/transcript/parseTranscript.d.ts.map +1 -0
  54. package/dist/transcript/parseTranscript.test.d.ts +2 -0
  55. package/dist/transcript/parseTranscript.test.d.ts.map +1 -0
  56. package/dist/transcript/useGingerTranscriptSync.d.ts +23 -0
  57. package/dist/transcript/useGingerTranscriptSync.d.ts.map +1 -0
  58. package/dist/useGinger-BXgia32v.cjs +2 -0
  59. package/dist/useGinger-BXgia32v.cjs.map +1 -0
  60. package/dist/useGinger-hpp2pAGY.js +48 -0
  61. package/dist/useGinger-hpp2pAGY.js.map +1 -0
  62. package/dist/useGingerChapterProgress-BdaalJvX.cjs +2 -0
  63. package/dist/useGingerChapterProgress-BdaalJvX.cjs.map +1 -0
  64. package/dist/{useGingerChapterProgress-DLYdGytK.js → useGingerChapterProgress-CZdv-HiI.js} +23 -22
  65. package/dist/useGingerChapterProgress-CZdv-HiI.js.map +1 -0
  66. package/dist/waveform/analyzeAudioFile.d.ts.map +1 -1
  67. package/dist/waveform/getAudioContextConstructor.d.ts +6 -0
  68. package/dist/waveform/getAudioContextConstructor.d.ts.map +1 -0
  69. package/dist/waveform/index.cjs +1 -1
  70. package/dist/waveform/index.cjs.map +1 -1
  71. package/dist/waveform/index.js +162 -153
  72. package/dist/waveform/index.js.map +1 -1
  73. package/dist/waveform/useAudioFileAnalysis.d.ts +1 -0
  74. package/dist/waveform/useAudioFileAnalysis.d.ts.map +1 -1
  75. package/dist/waveform/useAudioPeaks.d.ts.map +1 -1
  76. package/package.json +17 -2
  77. package/dist/liveAudioGraph-CmEsdLgZ.js +0 -150
  78. package/dist/liveAudioGraph-CmEsdLgZ.js.map +0 -1
  79. package/dist/liveAudioGraph-D1BXMv_u.cjs +0 -2
  80. package/dist/liveAudioGraph-D1BXMv_u.cjs.map +0 -1
  81. package/dist/useGingerChapterProgress-BOqUimE7.cjs +0 -2
  82. package/dist/useGingerChapterProgress-BOqUimE7.cjs.map +0 -1
  83. package/dist/useGingerChapterProgress-DLYdGytK.js.map +0 -1
@@ -0,0 +1,105 @@
1
+ const r = /* @__PURE__ */ new WeakMap();
2
+ function d(t) {
3
+ const e = 2 ** Math.round(Math.log2(t));
4
+ return Math.min(32768, Math.max(32, e));
5
+ }
6
+ function h(t) {
7
+ const { processingChain: e } = t;
8
+ return e.length > 0 ? e[e.length - 1] : t.source;
9
+ }
10
+ function a(t) {
11
+ const { source: e, processingChain: n, consumers: s, context: c } = t;
12
+ try {
13
+ e.disconnect();
14
+ } catch {
15
+ }
16
+ for (const o of n)
17
+ try {
18
+ o.disconnect();
19
+ } catch {
20
+ }
21
+ for (const { analyser: o } of s.values())
22
+ try {
23
+ o.disconnect(c.destination);
24
+ } catch {
25
+ }
26
+ if (n.length > 0) {
27
+ e.connect(n[0]);
28
+ for (let o = 0; o < n.length - 1; o++)
29
+ n[o].connect(n[o + 1]);
30
+ }
31
+ const i = h(t);
32
+ if (s.size === 0)
33
+ i.connect(c.destination);
34
+ else {
35
+ let o = !0;
36
+ for (const { analyser: u } of s.values())
37
+ i.connect(u), o && (u.connect(c.destination), o = !1);
38
+ }
39
+ }
40
+ function f(t) {
41
+ let e = r.get(t);
42
+ if (!e) {
43
+ const n = window.AudioContext ?? window.webkitAudioContext;
44
+ if (!n)
45
+ throw new Error("Web Audio API is not available");
46
+ const s = new n(), c = s.createMediaElementSource(t);
47
+ e = {
48
+ context: s,
49
+ source: c,
50
+ consumers: /* @__PURE__ */ new Map(),
51
+ nextId: 0,
52
+ processingChain: [],
53
+ processingActive: !1
54
+ }, r.set(t, e);
55
+ }
56
+ return e;
57
+ }
58
+ function l(t, e) {
59
+ if (e.consumers.size === 0 && !e.processingActive) {
60
+ try {
61
+ e.source.disconnect();
62
+ } catch {
63
+ }
64
+ e.context.close(), r.delete(t);
65
+ }
66
+ }
67
+ function g(t, e) {
68
+ const n = f(t), { context: s } = n, c = s.createAnalyser();
69
+ c.fftSize = d(e.fftSize), c.smoothingTimeConstant = e.smoothingTimeConstant, c.minDecibels = e.minDecibels, c.maxDecibels = e.maxDecibels;
70
+ const i = n.nextId;
71
+ return n.nextId += 1, n.consumers.set(i, { analyser: c }), a(n), { id: i, context: s, analyser: c };
72
+ }
73
+ function m(t, e) {
74
+ const n = r.get(t);
75
+ if (!n) return;
76
+ const s = n.consumers.get(e);
77
+ if (s) {
78
+ try {
79
+ s.analyser.disconnect();
80
+ } catch {
81
+ }
82
+ if (n.consumers.delete(e), n.consumers.size === 0 && !n.processingActive) {
83
+ l(t, n);
84
+ return;
85
+ }
86
+ a(n);
87
+ }
88
+ }
89
+ function y(t, e) {
90
+ if (typeof window > "u") return;
91
+ if (e.length === 0) {
92
+ const s = r.get(t);
93
+ if (!s) return;
94
+ s.processingChain = [], s.processingActive = !1, s.consumers.size === 0 ? l(t, s) : a(s);
95
+ return;
96
+ }
97
+ const n = f(t);
98
+ n.processingChain = e, n.processingActive = !0, a(n);
99
+ }
100
+ export {
101
+ g as a,
102
+ m as d,
103
+ y as s
104
+ };
105
+ //# sourceMappingURL=liveAudioGraph-DvPaxBCP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"liveAudioGraph-DvPaxBCP.js","sources":["../src/analyzer/liveAudioGraph.ts"],"sourcesContent":["/**\n * One MediaElementAudioSourceNode per HTMLAudioElement; multiple AnalyserNodes may tap the source.\n * An optional processing chain (e.g. EQ filters) can be inserted between the source and the\n * analysers via `setProcessingChain`.\n */\n\nexport type LiveAnalyserOptions = {\n fftSize: number;\n smoothingTimeConstant: number;\n minDecibels: number;\n maxDecibels: number;\n};\n\ntype Consumer = {\n analyser: AnalyserNode;\n};\n\ntype ElementEntry = {\n context: AudioContext;\n source: MediaElementAudioSourceNode;\n consumers: Map<number, Consumer>;\n nextId: number;\n /** Ordered processing nodes (e.g. BiquadFilterNode[]) inserted between source and analysers. */\n processingChain: AudioNode[];\n /** True while a processing chain is installed; prevents context teardown when no consumers exist. */\n processingActive: boolean;\n};\n\nconst entries = new WeakMap<HTMLAudioElement, ElementEntry>();\n\nfunction clampFftSize(n: number): number {\n const p = 2 ** Math.round(Math.log2(n));\n return Math.min(32768, Math.max(32, p));\n}\n\nfunction getChainTail(entry: ElementEntry): AudioNode {\n const { processingChain } = entry;\n return processingChain.length > 0 ? processingChain[processingChain.length - 1]! : entry.source;\n}\n\n/**\n * Rebuild all graph connections from scratch.\n * Call after any structural change (add/remove consumer, change processing chain).\n */\nfunction rebuildGraph(entry: ElementEntry): void {\n const { source, processingChain, consumers, context } = entry;\n\n // Disconnect source outputs\n try {\n source.disconnect();\n } catch {\n // ignore\n }\n\n // Disconnect processing chain node outputs\n for (const node of processingChain) {\n try {\n node.disconnect();\n } catch {\n // ignore\n }\n }\n\n // Disconnect all analysers from destination (we will reconnect selectively below)\n for (const { analyser } of consumers.values()) {\n try {\n analyser.disconnect(context.destination);\n } catch {\n // ignore\n }\n }\n\n // Build processing chain: source → node[0] → ... → node[N]\n if (processingChain.length > 0) {\n source.connect(processingChain[0]!);\n for (let i = 0; i < processingChain.length - 1; i++) {\n processingChain[i]!.connect(processingChain[i + 1]!);\n }\n }\n\n const tail = getChainTail(entry);\n\n if (consumers.size === 0) {\n // No analyser consumers: route tail directly to destination so audio is audible\n tail.connect(context.destination);\n } else {\n // Connect tail to all analysers; first one routes to destination (playback sink)\n let isFirst = true;\n for (const { analyser } of consumers.values()) {\n tail.connect(analyser);\n if (isFirst) {\n analyser.connect(context.destination);\n isFirst = false;\n }\n }\n }\n}\n\nfunction getOrCreateEntry(element: HTMLAudioElement): ElementEntry {\n let entry = entries.get(element);\n if (!entry) {\n const Context =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n if (!Context) {\n throw new Error(\"Web Audio API is not available\");\n }\n const context = new Context();\n const source = context.createMediaElementSource(element);\n entry = {\n context,\n source,\n consumers: new Map(),\n nextId: 0,\n processingChain: [],\n processingActive: false,\n };\n entries.set(element, entry);\n }\n return entry;\n}\n\nfunction maybeCloseEntry(element: HTMLAudioElement, entry: ElementEntry): void {\n if (entry.consumers.size === 0 && !entry.processingActive) {\n try {\n entry.source.disconnect();\n } catch {\n // ignore\n }\n void entry.context.close();\n entries.delete(element);\n }\n}\n\nexport function attachLiveAnalyser(\n element: HTMLAudioElement,\n options: LiveAnalyserOptions,\n): { id: number; context: AudioContext; analyser: AnalyserNode } {\n const entry = getOrCreateEntry(element);\n const { context } = entry;\n\n const analyser = context.createAnalyser();\n analyser.fftSize = clampFftSize(options.fftSize);\n analyser.smoothingTimeConstant = options.smoothingTimeConstant;\n analyser.minDecibels = options.minDecibels;\n analyser.maxDecibels = options.maxDecibels;\n\n const id = entry.nextId;\n entry.nextId += 1;\n entry.consumers.set(id, { analyser });\n\n rebuildGraph(entry);\n\n return { id, context, analyser };\n}\n\nexport function detachLiveAnalyser(element: HTMLAudioElement, id: number): void {\n const entry = entries.get(element);\n if (!entry) return;\n\n const consumer = entry.consumers.get(id);\n if (!consumer) return;\n\n try {\n consumer.analyser.disconnect();\n } catch {\n // ignore\n }\n entry.consumers.delete(id);\n\n if (entry.consumers.size === 0 && !entry.processingActive) {\n maybeCloseEntry(element, entry);\n return;\n }\n\n rebuildGraph(entry);\n}\n\n/**\n * Insert an ordered processing chain (e.g. BiquadFilterNode[]) between the audio source and the\n * analyser consumers. Pass an empty array to clear the chain.\n *\n * Safe to call while analyser consumers are active; the graph is rebuilt immediately.\n * Note: because `createMediaElementSource` can only be called once per element, the EQ and live\n * analyser share the same AudioContext. Calling both for the same element is supported.\n */\nexport function setProcessingChain(element: HTMLAudioElement, nodes: AudioNode[]): void {\n if (typeof window === \"undefined\") return;\n\n if (nodes.length === 0) {\n const entry = entries.get(element);\n if (!entry) return;\n entry.processingChain = [];\n entry.processingActive = false;\n if (entry.consumers.size === 0) {\n maybeCloseEntry(element, entry);\n } else {\n rebuildGraph(entry);\n }\n return;\n }\n\n const entry = getOrCreateEntry(element);\n entry.processingChain = nodes;\n entry.processingActive = true;\n rebuildGraph(entry);\n}\n"],"names":["entries","clampFftSize","n","p","getChainTail","entry","processingChain","rebuildGraph","source","consumers","context","node","analyser","i","tail","isFirst","getOrCreateEntry","element","Context","maybeCloseEntry","attachLiveAnalyser","options","id","detachLiveAnalyser","consumer","setProcessingChain","nodes"],"mappings":"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"}
@@ -0,0 +1,2 @@
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
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
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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"}
@@ -0,0 +1,5 @@
1
+ export { DEFAULT_REMOTE_CHANNEL_NAME } from './remoteProtocol';
2
+ export type { RemoteMessage } from './remoteProtocol';
3
+ export { useGingerRemote } from './useGingerRemote';
4
+ export type { UseGingerRemoteOptions, UseGingerRemoteResult } from './useGingerRemote';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/remote/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,MAAM,kBAAkB,CAAC;AAC/D,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,168 @@
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() {
16
+ return typeof crypto < "u" && typeof crypto.randomUUID == "function" ? crypto.randomUUID() : `ginger-tab-${Math.random().toString(36).slice(2)}`;
17
+ }
18
+ function J(o = {}) {
19
+ const {
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);
36
+ }, []);
37
+ O(() => {
38
+ if (typeof window > "u" || typeof BroadcastChannel > "u") {
39
+ D("BroadcastChannel is not available in this environment");
40
+ return;
41
+ }
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);
47
+ }, w = () => {
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;
64
+ if (!n || typeof n != "object" || !("type" in n)) return;
65
+ const i = t.current;
66
+ switch (n.type) {
67
+ case "PING": {
68
+ l.current.add(n.tabId), r.current === "leader" && c({ type: "PONG", tabId: i, leaderTabId: i });
69
+ break;
70
+ }
71
+ case "PONG": {
72
+ n.leaderTabId && n.leaderTabId !== i && (L(), m.current = Date.now());
73
+ break;
74
+ }
75
+ case "LEADER_ANNOUNCE": {
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();
78
+ break;
79
+ }
80
+ case "LEADER_RESIGN": {
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());
83
+ break;
84
+ }
85
+ case "HEARTBEAT": {
86
+ r.current === "follower" && (m.current = Date.now(), k(n.connectedCount));
87
+ break;
88
+ }
89
+ case "STATE_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
+ }
99
+ break;
100
+ }
101
+ }
102
+ };
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 });
110
+ };
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;
113
+ };
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,
121
+ /** Avoid `createInitialState` re-shuffling on followers; queue order is already canonical. */
122
+ isShuffled: !1,
123
+ repeatMode: a.repeatMode,
124
+ playbackMode: a.playbackMode,
125
+ volume: a.volume,
126
+ muted: a.muted,
127
+ playbackRate: a.playbackRate
128
+ };
129
+ N({
130
+ type: "STATE_SNAPSHOT",
131
+ tabId: t.current,
132
+ snapshot: u
133
+ });
134
+ }, [
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
146
+ ]);
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]);
155
+ return {
156
+ isLeader: p === "leader",
157
+ isFollower: p === "follower",
158
+ isPending: p === "pending",
159
+ connectedTabs: G,
160
+ claimLeadership: U,
161
+ error: H
162
+ };
163
+ }
164
+ export {
165
+ j as DEFAULT_REMOTE_CHANNEL_NAME,
166
+ J as useGingerRemote
167
+ };
168
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","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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"}
@@ -0,0 +1,28 @@
1
+ import { GingerInitPayload } from '../types';
2
+ export declare const DEFAULT_REMOTE_CHANNEL_NAME = "ginger-remote";
3
+ /**
4
+ * Cross-tab messages for {@link useGingerRemote}.
5
+ */
6
+ export type RemoteMessage = {
7
+ type: "PING";
8
+ tabId: string;
9
+ } | {
10
+ type: "PONG";
11
+ tabId: string;
12
+ leaderTabId: string;
13
+ } | {
14
+ type: "LEADER_ANNOUNCE";
15
+ tabId: string;
16
+ } | {
17
+ type: "LEADER_RESIGN";
18
+ tabId: string;
19
+ } | {
20
+ type: "HEARTBEAT";
21
+ tabId: string;
22
+ connectedCount: number;
23
+ } | {
24
+ type: "STATE_SNAPSHOT";
25
+ tabId: string;
26
+ snapshot: GingerInitPayload;
27
+ };
28
+ //# sourceMappingURL=remoteProtocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remoteProtocol.d.ts","sourceRoot":"","sources":["../../src/remote/remoteProtocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAElD,eAAO,MAAM,2BAA2B,kBAAkB,CAAC;AAE3D;;GAEG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAC5D;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,iBAAiB,CAAA;CAAE,CAAC"}
@@ -0,0 +1,35 @@
1
+ export type UseGingerRemoteOptions = {
2
+ /** BroadcastChannel name. Default: `"ginger-remote"`. */
3
+ channelName?: string;
4
+ /** Leader heartbeat interval in ms. Default: `2000`. */
5
+ heartbeatMs?: number;
6
+ /** Time to wait for an existing leader before claiming leadership. Default: `300`. */
7
+ electionTimeoutMs?: number;
8
+ };
9
+ export type UseGingerRemoteResult = {
10
+ isLeader: boolean;
11
+ isFollower: boolean;
12
+ /** True until a leader is elected or this tab becomes leader. */
13
+ isPending: boolean;
14
+ connectedTabs: number;
15
+ /** Request leadership (other tabs may win if their `tabId` is lexicographically smaller). */
16
+ claimLeadership: () => void;
17
+ error: string | null;
18
+ };
19
+ /**
20
+ * Multi-tab coordination via `BroadcastChannel`: elects a single leader tab and syncs
21
+ * playback state to followers with `INIT` snapshots.
22
+ *
23
+ * Mount `Ginger.Player` only on the leader tab so one `<audio>` element plays:
24
+ *
25
+ * ```tsx
26
+ * const { isLeader } = useGingerRemote();
27
+ * return <>{isLeader && <Ginger.Player />}</>;
28
+ * ```
29
+ *
30
+ * ```ts
31
+ * import { useGingerRemote } from "@lucaismyname/ginger/remote";
32
+ * ```
33
+ */
34
+ export declare function useGingerRemote(options?: UseGingerRemoteOptions): UseGingerRemoteResult;
35
+ //# sourceMappingURL=useGingerRemote.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useGingerRemote.d.ts","sourceRoot":"","sources":["../../src/remote/useGingerRemote.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,sBAAsB,GAAG;IACnC,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sFAAsF;IACtF,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,OAAO,CAAC;IACpB,iEAAiE;IACjE,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,6FAA6F;IAC7F,eAAe,EAAE,MAAM,IAAI,CAAC;IAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,CAAC;AASF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,qBAAqB,CAgS3F"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=useGingerRemote.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useGingerRemote.test.d.ts","sourceRoot":"","sources":["../../src/remote/useGingerRemote.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,7 @@
1
+ import { GingerInitPayload } from '../types';
2
+ /**
3
+ * Structural check for remote `STATE_SNAPSHOT` payloads. Used in development to catch
4
+ * malformed cross-tab messages; production callers still rely on same-origin `BroadcastChannel`.
5
+ */
6
+ export declare function validateGingerInitPayloadDev(snapshot: unknown): snapshot is GingerInitPayload;
7
+ //# sourceMappingURL=validateGingerInitPayloadDev.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateGingerInitPayloadDev.d.ts","sourceRoot":"","sources":["../../src/remote/validateGingerInitPayloadDev.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAQlD;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,OAAO,GAAG,QAAQ,IAAI,iBAAiB,CAyB7F"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=validateGingerInitPayloadDev.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateGingerInitPayloadDev.test.d.ts","sourceRoot":"","sources":["../../src/remote/validateGingerInitPayloadDev.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const r=require("react"),d=require("../liveAudioGraph-0cpHD_Ic.cjs"),k=require("../useGinger-BXgia32v.cjs");function M(t,o,c,l){t.positionX.value=o,t.positionY.value=c,t.positionZ.value=l}function R(t,o,c,l){t.listener.positionX.value=o,t.listener.positionY.value=c,t.listener.positionZ.value=l}function q(t={}){const{enabled:o=!0,panningModel:c="HRTF",distanceModel:l="inverse",refDistance:g=1,position:S=[0,0,0],listenerPosition:A=[0,0,0]}=t,{audioRef:p}=k.useGinger(),[G,v]=r.useState(null),u=r.useRef(null),f=r.useRef(null),[P,h,m]=S,[b,y,C]=A;r.useEffect(()=>{const n=p.current;if(!(!n||typeof window>"u")){if(!o){d.setProcessingChain(n,[]),u.current=null,f.current=null;return}try{const e=d.attachLiveAnalyser(n,{fftSize:32,smoothingTimeConstant:0,minDecibels:-100,maxDecibels:0}),{context:s,id:i}=e,a=s.createPanner();a.panningModel=c,a.distanceModel=l,a.refDistance=g,M(a,P,h,m),R(s,b,y,C),u.current=a,f.current=s,d.setProcessingChain(n,[a]),d.detachLiveAnalyser(n,i),v(null)}catch(e){const s=e instanceof Error?e.message:"Failed to create spatial panner";v(s),u.current=null,f.current=null}return()=>{const e=p.current;e&&d.setProcessingChain(e,[]),u.current=null,f.current=null}}},[o,c,l,g,P,h,m,b,y,C,p]);const x=r.useCallback((n,e,s)=>{const i=u.current;i&&M(i,n,e,s)},[]),D=r.useCallback((n,e,s)=>{const i=f.current;i&&R(i,n,e,s)},[]),L=r.useCallback(n=>{const e=u.current;e&&(e.panningModel=n)},[]);return{setSourcePosition:x,setListenerPosition:D,setPanningModel:L,error:G}}exports.useGingerSpatialAudio=q;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","sources":["../../src/spatial/useGingerSpatialAudio.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n attachLiveAnalyser,\n detachLiveAnalyser,\n setProcessingChain,\n} from \"../analyzer/liveAudioGraph\";\nimport { useGinger } from \"../hooks/useGinger\";\n\n/** `[x, y, z]` in Web Audio space (meters). */\nexport type SpatialPosition = [number, number, number];\n\nexport type UseGingerSpatialAudioOptions = {\n /** When false, the panner is removed and the audio path is bypassed. Default: true. */\n enabled?: boolean;\n /** Default: `\"HRTF\"`. */\n panningModel?: PanningModelType;\n /** Default: `\"inverse\"`. */\n distanceModel?: DistanceModelType;\n /** Reference distance for attenuation. Default: `1`. */\n refDistance?: number;\n /** Source position. Default: `[0, 0, 0]`. */\n position?: SpatialPosition;\n /** Listener position. Default: `[0, 0, 0]`. */\n listenerPosition?: SpatialPosition;\n};\n\nexport type UseGingerSpatialAudioResult = {\n setSourcePosition: (x: number, y: number, z: number) => void;\n setListenerPosition: (x: number, y: number, z: number) => void;\n setPanningModel: (model: PanningModelType) => void;\n error: string | null;\n};\n\nfunction setPannerPosition(panner: PannerNode, x: number, y: number, z: number): void {\n panner.positionX.value = x;\n panner.positionY.value = y;\n panner.positionZ.value = z;\n}\n\nfunction setListenerXYZ(context: AudioContext, x: number, y: number, z: number): void {\n context.listener.positionX.value = x;\n context.listener.positionY.value = y;\n context.listener.positionZ.value = z;\n}\n\n/**\n * Inserts an HRTF `PannerNode` into the Web Audio graph for the active Ginger media element.\n *\n * Shares the same `AudioContext` as `useGingerEqualizer` / `useGingerLiveAnalyzer` — only one\n * `MediaElementAudioSourceNode` per element is allowed by the browser.\n *\n * ```ts\n * import { useGingerSpatialAudio } from \"@lucaismyname/ginger/spatial\";\n * ```\n */\nexport function useGingerSpatialAudio(\n options: UseGingerSpatialAudioOptions = {},\n): UseGingerSpatialAudioResult {\n const {\n enabled = true,\n panningModel = \"HRTF\",\n distanceModel = \"inverse\",\n refDistance = 1,\n position = [0, 0, 0],\n listenerPosition = [0, 0, 0],\n } = options;\n const { audioRef } = useGinger();\n const [error, setError] = useState<string | null>(null);\n\n const pannerRef = useRef<PannerNode | null>(null);\n const contextRef = useRef<AudioContext | null>(null);\n\n const [sx, sy, sz] = position;\n const [lx, ly, lz] = listenerPosition;\n\n useEffect(() => {\n const el = audioRef.current;\n if (!el || typeof window === \"undefined\") {\n return;\n }\n\n if (!enabled) {\n setProcessingChain(el, []);\n pannerRef.current = null;\n contextRef.current = null;\n return;\n }\n\n try {\n const attached = attachLiveAnalyser(el, {\n fftSize: 32,\n smoothingTimeConstant: 0,\n minDecibels: -100,\n maxDecibels: 0,\n });\n const { context, id: tempId } = attached;\n\n const panner = context.createPanner();\n panner.panningModel = panningModel;\n panner.distanceModel = distanceModel;\n panner.refDistance = refDistance;\n setPannerPosition(panner, sx, sy, sz);\n setListenerXYZ(context, lx, ly, lz);\n\n pannerRef.current = panner;\n contextRef.current = context;\n\n setProcessingChain(el, [panner]);\n detachLiveAnalyser(el, tempId);\n\n setError(null);\n } catch (e) {\n const msg = e instanceof Error ? e.message : \"Failed to create spatial panner\";\n setError(msg);\n pannerRef.current = null;\n contextRef.current = null;\n }\n\n return () => {\n const element = audioRef.current;\n if (element) {\n setProcessingChain(element, []);\n }\n pannerRef.current = null;\n contextRef.current = null;\n };\n }, [enabled, panningModel, distanceModel, refDistance, sx, sy, sz, lx, ly, lz, audioRef]);\n\n const setSourcePosition = useCallback((x: number, y: number, z: number) => {\n const panner = pannerRef.current;\n if (panner) {\n setPannerPosition(panner, x, y, z);\n }\n }, []);\n\n const setListenerPosition = useCallback((x: number, y: number, z: number) => {\n const ctx = contextRef.current;\n if (ctx) {\n setListenerXYZ(ctx, x, y, z);\n }\n }, []);\n\n const setPanningModel = useCallback((model: PanningModelType) => {\n const panner = pannerRef.current;\n if (panner) {\n panner.panningModel = model;\n }\n }, []);\n\n return { setSourcePosition, setListenerPosition, setPanningModel, error };\n}\n"],"names":["setPannerPosition","panner","x","y","z","setListenerXYZ","context","useGingerSpatialAudio","options","enabled","panningModel","distanceModel","refDistance","position","listenerPosition","audioRef","useGinger","error","setError","useState","pannerRef","useRef","contextRef","sx","sy","sz","lx","ly","lz","useEffect","el","setProcessingChain","attached","attachLiveAnalyser","tempId","detachLiveAnalyser","msg","element","setSourcePosition","useCallback","setListenerPosition","ctx","setPanningModel","model"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export { useGingerSpatialAudio } from './useGingerSpatialAudio';
2
+ export type { SpatialPosition, UseGingerSpatialAudioOptions, UseGingerSpatialAudioResult, } from './useGingerSpatialAudio';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/spatial/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,YAAY,EACV,eAAe,EACf,4BAA4B,EAC5B,2BAA2B,GAC5B,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,59 @@
1
+ import { useState as X, useRef as R, useEffect as Y, useCallback as p } from "react";
2
+ import { s as d, a as Z, d as w } from "../liveAudioGraph-DvPaxBCP.js";
3
+ import { u as F } from "../useGinger-hpp2pAGY.js";
4
+ function b(t, o, i, c) {
5
+ t.positionX.value = o, t.positionY.value = i, t.positionZ.value = c;
6
+ }
7
+ function D(t, o, i, c) {
8
+ t.listener.positionX.value = o, t.listener.positionY.value = i, t.listener.positionZ.value = c;
9
+ }
10
+ function H(t = {}) {
11
+ const {
12
+ enabled: o = !0,
13
+ panningModel: i = "HRTF",
14
+ distanceModel: c = "inverse",
15
+ refDistance: m = 1,
16
+ position: L = [0, 0, 0],
17
+ listenerPosition: S = [0, 0, 0]
18
+ } = t, { audioRef: f } = F(), [z, g] = X(null), l = R(null), u = R(null), [v, P, h] = L, [y, M, x] = S;
19
+ Y(() => {
20
+ const e = f.current;
21
+ if (!(!e || typeof window > "u")) {
22
+ if (!o) {
23
+ d(e, []), l.current = null, u.current = null;
24
+ return;
25
+ }
26
+ try {
27
+ const n = Z(e, {
28
+ fftSize: 32,
29
+ smoothingTimeConstant: 0,
30
+ minDecibels: -100,
31
+ maxDecibels: 0
32
+ }), { context: s, id: r } = n, a = s.createPanner();
33
+ a.panningModel = i, a.distanceModel = c, a.refDistance = m, b(a, v, P, h), D(s, y, M, x), l.current = a, u.current = s, d(e, [a]), w(e, r), g(null);
34
+ } catch (n) {
35
+ const s = n instanceof Error ? n.message : "Failed to create spatial panner";
36
+ g(s), l.current = null, u.current = null;
37
+ }
38
+ return () => {
39
+ const n = f.current;
40
+ n && d(n, []), l.current = null, u.current = null;
41
+ };
42
+ }
43
+ }, [o, i, c, m, v, P, h, y, M, x, f]);
44
+ const A = p((e, n, s) => {
45
+ const r = l.current;
46
+ r && b(r, e, n, s);
47
+ }, []), C = p((e, n, s) => {
48
+ const r = u.current;
49
+ r && D(r, e, n, s);
50
+ }, []), E = p((e) => {
51
+ const n = l.current;
52
+ n && (n.panningModel = e);
53
+ }, []);
54
+ return { setSourcePosition: A, setListenerPosition: C, setPanningModel: E, error: z };
55
+ }
56
+ export {
57
+ H as useGingerSpatialAudio
58
+ };
59
+ //# sourceMappingURL=index.js.map