@fishjam-cloud/composition 0.29.0-rc.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,11 +20,345 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- eventBus: () => eventBus
23
+ eventBus: () => eventBus,
24
+ usePeer: () => usePeer,
25
+ usePeers: () => usePeers,
26
+ useRoom: () => useRoom,
27
+ useSpeakingState: () => useSpeakingState
24
28
  });
25
29
  module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/hooks.ts
32
+ var import_react = require("react");
33
+
34
+ // src/store.ts
35
+ var EMPTY_SNAPSHOT = { peers: [], vad: {} };
36
+ var assertNever = (event) => {
37
+ throw new Error(`Unhandled composition event: ${JSON.stringify(event)}`);
38
+ };
39
+ var normalizeMetadata = (raw) => {
40
+ if (raw == null) return {};
41
+ if (typeof raw === "string") {
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ return parsed && typeof parsed === "object" ? parsed : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+ return raw;
50
+ };
51
+ var splitMetadata = (raw) => {
52
+ const obj = normalizeMetadata(raw);
53
+ return { peer: obj.peer, server: obj.server };
54
+ };
55
+ var roleOf = (stream) => {
56
+ const type = stream.video?.metadata.type ?? stream.audio?.metadata.type;
57
+ if (type === "camera" || type === "microphone") return "camera";
58
+ if (type === "screenShareVideo" || type === "screenShareAudio") return "screenShare";
59
+ return "custom";
60
+ };
61
+ var CompositionStore = class {
62
+ peers = /* @__PURE__ */ new Map();
63
+ roomId;
64
+ vad = /* @__PURE__ */ new Map();
65
+ listeners = /* @__PURE__ */ new Set();
66
+ cachedSnapshot = EMPTY_SNAPSHOT;
67
+ cachedPeers = EMPTY_SNAPSHOT.peers;
68
+ subscribe = (cb) => {
69
+ this.listeners.add(cb);
70
+ return () => this.listeners.delete(cb);
71
+ };
72
+ getSnapshot = () => {
73
+ if (this.cachedSnapshot === null) {
74
+ this.cachedSnapshot = { peers: this.cachedPeers, roomId: this.roomId, vad: this.vadRecord() };
75
+ }
76
+ return this.cachedSnapshot;
77
+ };
78
+ getPeers = () => this.cachedPeers;
79
+ getPeer = (peerId) => this.getPeers().find((peer) => peer.id === peerId);
80
+ getRoomId = () => this.roomId;
81
+ getVadStatus = (peerId) => {
82
+ const peer = this.peers.get(peerId);
83
+ if (!peer) return "silence";
84
+ for (const track of peer.tracks.values())
85
+ if (track.type === "audio" && track.inputId && this.vad.get(track.inputId) === "speech") return "speech";
86
+ return "silence";
87
+ };
88
+ reset() {
89
+ this.peers.clear();
90
+ this.vad.clear();
91
+ this.roomId = void 0;
92
+ this.cachedSnapshot = null;
93
+ this.cachedPeers = [];
94
+ this.commit();
95
+ }
96
+ seedFromRoom(room) {
97
+ this.peers.clear();
98
+ this.vad.clear();
99
+ this.roomId = room.id;
100
+ for (const peer of room.peers) {
101
+ const internal = {
102
+ id: peer.id,
103
+ metadata: splitMetadata(peer.metadata),
104
+ tracks: /* @__PURE__ */ new Map()
105
+ };
106
+ for (const track of peer.tracks ?? []) {
107
+ if (!track.id) continue;
108
+ internal.tracks.set(track.id, { id: track.id, metadata: normalizeMetadata(track.metadata), type: track.type });
109
+ }
110
+ this.peers.set(peer.id, internal);
111
+ }
112
+ this.rebuildPeers();
113
+ this.commit();
114
+ }
115
+ applyNotification(event) {
116
+ if (event.data.roomId !== this.roomId) return;
117
+ let changed;
118
+ switch (event.type) {
119
+ case "peerConnected":
120
+ changed = this.onPeerConnected(event.data);
121
+ break;
122
+ case "peerDisconnected":
123
+ changed = this.onPeerDisconnected(event.data);
124
+ break;
125
+ case "peerMetadataUpdated":
126
+ changed = this.onPeerMetadataUpdated(event.data);
127
+ break;
128
+ case "trackAdded":
129
+ changed = this.onTrackAdded(event.data);
130
+ break;
131
+ case "trackRemoved":
132
+ changed = this.onTrackRemoved(event.data);
133
+ break;
134
+ case "trackMetadataUpdated":
135
+ changed = this.onTrackMetadataUpdated(event.data);
136
+ break;
137
+ case "trackForwarding":
138
+ changed = this.onTrackForwarding(event.data);
139
+ break;
140
+ case "trackForwardingRemoved":
141
+ changed = this.onTrackForwardingRemoved(event.data);
142
+ break;
143
+ case "vadNotification":
144
+ changed = this.onVadNotification(event.data);
145
+ break;
146
+ default:
147
+ return assertNever(event);
148
+ }
149
+ if (changed) this.commit();
150
+ }
151
+ ensurePeer(peerId) {
152
+ let peer = this.peers.get(peerId);
153
+ if (!peer) {
154
+ peer = { id: peerId, metadata: { peer: void 0, server: void 0 }, tracks: /* @__PURE__ */ new Map() };
155
+ this.peers.set(peerId, peer);
156
+ }
157
+ return peer;
158
+ }
159
+ onPeerConnected(data) {
160
+ if (this.peers.has(data.peerId)) return false;
161
+ this.ensurePeer(data.peerId);
162
+ this.replacePeer(data.peerId);
163
+ return true;
164
+ }
165
+ onPeerDisconnected(data) {
166
+ const peer = this.peers.get(data.peerId);
167
+ if (!peer) return false;
168
+ for (const { inputId } of peer.tracks.values()) if (inputId) this.vad.delete(inputId);
169
+ this.peers.delete(data.peerId);
170
+ this.replacePeer(data.peerId);
171
+ return true;
172
+ }
173
+ onPeerMetadataUpdated(data) {
174
+ this.ensurePeer(data.peerId).metadata = splitMetadata(data.metadata);
175
+ this.replacePeer(data.peerId);
176
+ return true;
177
+ }
178
+ onTrackAdded(data) {
179
+ if (!data.peerId || !data.track) return false;
180
+ const peer = this.ensurePeer(data.peerId);
181
+ peer.tracks.set(data.track.id, {
182
+ id: data.track.id,
183
+ metadata: normalizeMetadata(data.track.metadata),
184
+ type: data.track.type
185
+ });
186
+ this.replacePeer(data.peerId);
187
+ return true;
188
+ }
189
+ onTrackMetadataUpdated(data) {
190
+ if (!data.peerId || !data.track) return false;
191
+ const peer = this.peers.get(data.peerId);
192
+ if (!peer) return false;
193
+ const metadata = normalizeMetadata(data.track.metadata);
194
+ const inputId = peer.tracks.get(data.track.id)?.inputId;
195
+ peer.tracks.set(data.track.id, { id: data.track.id, metadata, type: data.track.type, inputId });
196
+ this.replacePeer(data.peerId);
197
+ return true;
198
+ }
199
+ onTrackRemoved(data) {
200
+ if (!data.peerId || !data.track) return false;
201
+ const peer = this.peers.get(data.peerId);
202
+ if (!peer) return false;
203
+ const track = peer.tracks.get(data.track.id);
204
+ if (!track) return false;
205
+ peer.tracks.delete(data.track.id);
206
+ if (track.type === "audio" && track.inputId) this.vad.delete(track.inputId);
207
+ this.replacePeer(data.peerId);
208
+ return true;
209
+ }
210
+ onTrackForwarding(data) {
211
+ const peer = this.ensurePeer(data.peerId);
212
+ if (data.videoTrack) {
213
+ peer.tracks.set(data.videoTrack.id, {
214
+ id: data.videoTrack.id,
215
+ metadata: normalizeMetadata(data.videoTrack.metadata),
216
+ inputId: data.inputId,
217
+ type: data.videoTrack.type
218
+ });
219
+ }
220
+ if (data.audioTrack) {
221
+ peer.tracks.set(data.audioTrack.id, {
222
+ id: data.audioTrack.id,
223
+ metadata: normalizeMetadata(data.audioTrack.metadata),
224
+ inputId: data.inputId,
225
+ type: data.audioTrack.type
226
+ });
227
+ }
228
+ this.replacePeer(data.peerId);
229
+ return true;
230
+ }
231
+ onTrackForwardingRemoved(data) {
232
+ let changed = this.vad.delete(data.inputId);
233
+ const peer = this.peers.get(data.peerId);
234
+ if (!peer) {
235
+ if (changed) this.cachedSnapshot = null;
236
+ return changed;
237
+ }
238
+ for (const track of peer.tracks.values()) {
239
+ if (track.inputId === data.inputId) {
240
+ track.inputId = void 0;
241
+ changed = true;
242
+ }
243
+ }
244
+ if (changed) this.replacePeer(data.peerId);
245
+ return changed;
246
+ }
247
+ onVadNotification(data) {
248
+ const peer = this.peers.get(data.peerId);
249
+ if (!peer) return false;
250
+ const track = peer.tracks.get(data.trackId);
251
+ if (!track || !track.inputId) return false;
252
+ if (this.vad.get(track.inputId) === data.status) return false;
253
+ this.vad.set(track.inputId, data.status);
254
+ this.cachedSnapshot = null;
255
+ return true;
256
+ }
257
+ commit() {
258
+ for (const cb of this.listeners) cb();
259
+ }
260
+ replacePeer(peerId) {
261
+ const internal = this.peers.get(peerId);
262
+ const next = this.cachedPeers.slice();
263
+ const idx = next.findIndex((peer) => peer.id === peerId);
264
+ if (!internal) {
265
+ if (idx >= 0) next.splice(idx, 1);
266
+ } else if (idx >= 0) {
267
+ next[idx] = this.derivePeer(internal);
268
+ } else {
269
+ next.push(this.derivePeer(internal));
270
+ }
271
+ this.cachedPeers = next;
272
+ this.cachedSnapshot = null;
273
+ }
274
+ rebuildPeers() {
275
+ this.cachedPeers = Array.from(this.peers.values(), (peer) => this.derivePeer(peer));
276
+ this.cachedSnapshot = null;
277
+ }
278
+ vadRecord() {
279
+ const vad = {};
280
+ for (const [inputId, status] of this.vad) vad[inputId] = status;
281
+ return vad;
282
+ }
283
+ derivePeer(peer) {
284
+ const streams = /* @__PURE__ */ new Map();
285
+ let cameraStream;
286
+ let screenShareStream;
287
+ const customStreams = [];
288
+ for (const track of peer.tracks.values()) {
289
+ if (!track.inputId) continue;
290
+ const stream = streams.get(track.inputId) || { inputId: track.inputId };
291
+ if (track.type === "audio")
292
+ stream.audio = {
293
+ id: track.id,
294
+ paused: Boolean(track.metadata.paused),
295
+ metadata: track.metadata,
296
+ type: "audio"
297
+ };
298
+ else
299
+ stream.video = {
300
+ id: track.id,
301
+ paused: Boolean(track.metadata.paused),
302
+ metadata: track.metadata,
303
+ type: "video"
304
+ };
305
+ streams.set(track.inputId, stream);
306
+ }
307
+ for (const stream of streams.values()) {
308
+ const role = roleOf(stream);
309
+ switch (role) {
310
+ case "camera":
311
+ cameraStream = stream;
312
+ break;
313
+ case "screenShare":
314
+ screenShareStream = stream;
315
+ break;
316
+ default:
317
+ customStreams.push(stream);
318
+ }
319
+ }
320
+ return {
321
+ id: peer.id,
322
+ metadata: peer.metadata,
323
+ streams: Array.from(streams.values()),
324
+ cameraStream,
325
+ screenShareStream,
326
+ customStreams
327
+ };
328
+ }
329
+ };
330
+ var compositionStore = new CompositionStore();
331
+
332
+ // src/hooks.ts
333
+ function usePeers() {
334
+ const peers = (0, import_react.useSyncExternalStore)(compositionStore.subscribe, compositionStore.getPeers, compositionStore.getPeers);
335
+ return peers;
336
+ }
337
+ function usePeer(peerId) {
338
+ const getSnapshot = (0, import_react.useCallback)(() => compositionStore.getPeer(peerId), [peerId]);
339
+ const peer = (0, import_react.useSyncExternalStore)(compositionStore.subscribe, getSnapshot, getSnapshot);
340
+ return peer;
341
+ }
342
+ function useRoom() {
343
+ const roomId = (0, import_react.useSyncExternalStore)(
344
+ compositionStore.subscribe,
345
+ compositionStore.getRoomId,
346
+ compositionStore.getRoomId
347
+ );
348
+ return roomId ? { id: roomId } : void 0;
349
+ }
350
+ function useSpeakingState(peerId) {
351
+ const getSnapshot = (0, import_react.useCallback)(() => compositionStore.getVadStatus(peerId), [peerId]);
352
+ return (0, import_react.useSyncExternalStore)(compositionStore.subscribe, getSnapshot, getSnapshot);
353
+ }
354
+
355
+ // src/eventBus.ts
26
356
  var eventBus = globalThis.eventBus;
27
357
  // Annotate the CommonJS export names for ESM import in node:
28
358
  0 && (module.exports = {
29
- eventBus
359
+ eventBus,
360
+ usePeer,
361
+ usePeers,
362
+ useRoom,
363
+ useSpeakingState
30
364
  });
package/dist/index.mjs CHANGED
@@ -1,5 +1,37 @@
1
- // src/index.ts
1
+ import {
2
+ compositionStore
3
+ } from "./chunk-XAZDKX25.mjs";
4
+
5
+ // src/hooks.ts
6
+ import { useCallback, useSyncExternalStore } from "react";
7
+ function usePeers() {
8
+ const peers = useSyncExternalStore(compositionStore.subscribe, compositionStore.getPeers, compositionStore.getPeers);
9
+ return peers;
10
+ }
11
+ function usePeer(peerId) {
12
+ const getSnapshot = useCallback(() => compositionStore.getPeer(peerId), [peerId]);
13
+ const peer = useSyncExternalStore(compositionStore.subscribe, getSnapshot, getSnapshot);
14
+ return peer;
15
+ }
16
+ function useRoom() {
17
+ const roomId = useSyncExternalStore(
18
+ compositionStore.subscribe,
19
+ compositionStore.getRoomId,
20
+ compositionStore.getRoomId
21
+ );
22
+ return roomId ? { id: roomId } : void 0;
23
+ }
24
+ function useSpeakingState(peerId) {
25
+ const getSnapshot = useCallback(() => compositionStore.getVadStatus(peerId), [peerId]);
26
+ return useSyncExternalStore(compositionStore.subscribe, getSnapshot, getSnapshot);
27
+ }
28
+
29
+ // src/eventBus.ts
2
30
  var eventBus = globalThis.eventBus;
3
31
  export {
4
- eventBus
32
+ eventBus,
33
+ usePeer,
34
+ usePeers,
35
+ useRoom,
36
+ useSpeakingState
5
37
  };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * State of a single fishjam track.
3
+ * The `paused` flag mirrors the track's mute state.
4
+ */
5
+ type TrackState = {
6
+ /** fishjam track id */
7
+ id: string;
8
+ /** mute state, read from the built-in `paused` metadata key */
9
+ paused: boolean;
10
+ metadata: Record<string, unknown>;
11
+ };
12
+ type VideoTrackState = TrackState & {
13
+ type: 'video';
14
+ };
15
+ type AudioTrackState = TrackState & {
16
+ type: 'audio';
17
+ };
18
+ /**
19
+ * A forwarded input.
20
+ * `inputId` is the handle a template passes to `<InputStream inputId={...} />`.
21
+ * A stream carries at most one video and one audio track.
22
+ */
23
+ type Stream = {
24
+ inputId: string;
25
+ video?: VideoTrackState;
26
+ audio?: AudioTrackState;
27
+ };
28
+ /**
29
+ * A room peer projected for composition templates.
30
+ * Streams are split by role: camera, screen share, or custom.
31
+ * The camera stream's audio is the peer's mic.
32
+ */
33
+ type PeerWithStreams<PeerMetadata = unknown, ServerMetadata = unknown> = {
34
+ id: string;
35
+ metadata: {
36
+ peer: PeerMetadata;
37
+ server: ServerMetadata;
38
+ };
39
+ streams: Stream[];
40
+ cameraStream?: Stream;
41
+ screenShareStream?: Stream;
42
+ customStreams: Stream[];
43
+ };
44
+
45
+ export type { AudioTrackState as A, PeerWithStreams as P, Stream as S, TrackState as T, VideoTrackState as V };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * State of a single fishjam track.
3
+ * The `paused` flag mirrors the track's mute state.
4
+ */
5
+ type TrackState = {
6
+ /** fishjam track id */
7
+ id: string;
8
+ /** mute state, read from the built-in `paused` metadata key */
9
+ paused: boolean;
10
+ metadata: Record<string, unknown>;
11
+ };
12
+ type VideoTrackState = TrackState & {
13
+ type: 'video';
14
+ };
15
+ type AudioTrackState = TrackState & {
16
+ type: 'audio';
17
+ };
18
+ /**
19
+ * A forwarded input.
20
+ * `inputId` is the handle a template passes to `<InputStream inputId={...} />`.
21
+ * A stream carries at most one video and one audio track.
22
+ */
23
+ type Stream = {
24
+ inputId: string;
25
+ video?: VideoTrackState;
26
+ audio?: AudioTrackState;
27
+ };
28
+ /**
29
+ * A room peer projected for composition templates.
30
+ * Streams are split by role: camera, screen share, or custom.
31
+ * The camera stream's audio is the peer's mic.
32
+ */
33
+ type PeerWithStreams<PeerMetadata = unknown, ServerMetadata = unknown> = {
34
+ id: string;
35
+ metadata: {
36
+ peer: PeerMetadata;
37
+ server: ServerMetadata;
38
+ };
39
+ streams: Stream[];
40
+ cameraStream?: Stream;
41
+ screenShareStream?: Stream;
42
+ customStreams: Stream[];
43
+ };
44
+
45
+ export type { AudioTrackState as A, PeerWithStreams as P, Stream as S, TrackState as T, VideoTrackState as V };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fishjam-cloud/composition",
3
- "version": "0.29.0-rc.1",
4
- "description": "Composition event bus contract for Fishjam templates",
3
+ "version": "0.29.0",
4
+ "description": "React hooks and event bus for building Fishjam composition templates",
5
5
  "homepage": "https://github.com/fishjam-cloud/js-server-sdk",
6
6
  "author": "Fishjam Team",
7
7
  "repository": {
@@ -32,10 +32,15 @@
32
32
  "types": "./dist/index.d.ts",
33
33
  "import": "./dist/index.mjs",
34
34
  "require": "./dist/index.js"
35
+ },
36
+ "./core": {
37
+ "types": "./dist/core.d.ts",
38
+ "import": "./dist/core.mjs",
39
+ "require": "./dist/core.js"
35
40
  }
36
41
  },
37
42
  "scripts": {
38
- "build": "tsup --dts-resolve",
43
+ "build": "tsup",
39
44
  "format": "prettier --write .",
40
45
  "format:check": "prettier --check .",
41
46
  "typecheck": "tsc --noEmit",
@@ -45,18 +50,30 @@
45
50
  },
46
51
  "tsup": {
47
52
  "entry": [
48
- "src/index.ts"
53
+ "src/index.ts",
54
+ "src/core.ts"
49
55
  ],
50
56
  "minify": false,
51
57
  "format": [
52
58
  "cjs",
53
59
  "esm"
54
60
  ],
55
- "outDir": "dist"
61
+ "outDir": "dist",
62
+ "dts": true,
63
+ "clean": true
64
+ },
65
+ "peerDependencies": {
66
+ "@fishjam-cloud/js-server-sdk": "^0.29.0",
67
+ "react": ">=18"
56
68
  },
57
69
  "devDependencies": {
70
+ "@fishjam-cloud/js-server-sdk": "0.29.0",
71
+ "@types/react": "^19.0.0",
72
+ "@types/react-dom": "^19.1.9",
58
73
  "eslint": "^9.33.0",
59
74
  "prettier": "^3.6.2",
75
+ "react": "^19.0.0",
76
+ "react-dom": "^19.0.0",
60
77
  "tsup": "^8.4.0",
61
78
  "vitest": "^3.0.0"
62
79
  },
@@ -65,4 +82,4 @@
65
82
  "eslint --fix --config eslint.config.mjs"
66
83
  ]
67
84
  }
68
- }
85
+ }