@fishjam-cloud/composition 0.29.0-rc.0 → 0.29.0-rc.2

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 ADDED
@@ -0,0 +1,364 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ eventBus: () => eventBus,
24
+ usePeer: () => usePeer,
25
+ usePeers: () => usePeers,
26
+ useRoom: () => useRoom,
27
+ useSpeakingState: () => useSpeakingState
28
+ });
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
356
+ var eventBus = globalThis.eventBus;
357
+ // Annotate the CommonJS export names for ESM import in node:
358
+ 0 && (module.exports = {
359
+ eventBus,
360
+ usePeer,
361
+ usePeers,
362
+ useRoom,
363
+ useSpeakingState
364
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,37 @@
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
30
+ var eventBus = globalThis.eventBus;
31
+ export {
32
+ eventBus,
33
+ usePeer,
34
+ usePeers,
35
+ useRoom,
36
+ useSpeakingState
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.0",
4
- "description": "Composition event bus contract for Fishjam templates",
3
+ "version": "0.29.0-rc.2",
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-rc.2",
67
+ "react": ">=18"
56
68
  },
57
69
  "devDependencies": {
70
+ "@fishjam-cloud/js-server-sdk": "0.29.0-rc.2",
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
  },
@@ -64,5 +81,6 @@
64
81
  "*.{js,ts,tsx,mjs,cjs}": [
65
82
  "eslint --fix --config eslint.config.mjs"
66
83
  ]
67
- }
68
- }
84
+ },
85
+ "stableVersion": "0.29.0-rc.0"
86
+ }