kasunk99-livestream-core 0.1.3 → 0.2.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/hooks/useHostSocket.d.ts +31 -0
- package/dist/hooks/useHostSocket.d.ts.map +1 -0
- package/dist/hooks/useHostSocket.js +565 -0
- package/dist/host/hostSession.d.ts +18 -0
- package/dist/host/hostSession.d.ts.map +1 -0
- package/dist/host/hostSession.js +10 -0
- package/dist/host/hostState.d.ts +28 -0
- package/dist/host/hostState.d.ts.map +1 -0
- package/dist/host/hostState.js +42 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/services/livestream.service.d.ts +16 -0
- package/dist/services/livestream.service.d.ts.map +1 -1
- package/dist/services/livestream.service.js +46 -0
- package/package.json +35 -35
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHostSocket — broadcaster / host lifecycle hook.
|
|
3
|
+
*
|
|
4
|
+
* Manages camera preview, mediasoup send transport, screen share, mic, and
|
|
5
|
+
* camera-flip. Session refs (socket, streams, producers) live in a module-level
|
|
6
|
+
* singleton so they survive navigation between tabs. Reactive UI state lives in a
|
|
7
|
+
* module-level store (hostState) so the component can remount without losing
|
|
8
|
+
* "live", "joining", etc.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const host = useHostSocket({ hostUserId: userId });
|
|
12
|
+
* // host.startPreview(), host.startLive(), host.stopLive(), …
|
|
13
|
+
*/
|
|
14
|
+
import { type HostState } from '../host/hostState';
|
|
15
|
+
export type UseHostSocketOptions = {
|
|
16
|
+
/** App-level user ID forwarded to the server on join-room (optional). */
|
|
17
|
+
hostUserId?: string;
|
|
18
|
+
};
|
|
19
|
+
export type UseHostSocketReturn = HostState & {
|
|
20
|
+
setDisplayName: (name: string) => void;
|
|
21
|
+
startPreview: () => Promise<void>;
|
|
22
|
+
stopPreview: () => void;
|
|
23
|
+
startLive: () => Promise<void>;
|
|
24
|
+
stopLive: () => Promise<void>;
|
|
25
|
+
switchCamera: () => void;
|
|
26
|
+
toggleMic: () => void;
|
|
27
|
+
startScreenShare: () => Promise<void>;
|
|
28
|
+
stopScreenShare: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
export declare function useHostSocket(options?: UseHostSocketOptions): UseHostSocketReturn;
|
|
31
|
+
//# sourceMappingURL=useHostSocket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useHostSocket.d.ts","sourceRoot":"","sources":["../../src/hooks/useHostSocket.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAYH,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,mBAAmB,CAAC;AAoB3B,MAAM,MAAM,oBAAoB,GAAG;IACjC,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG;IAC5C,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,YAAY,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,gBAAgB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC,CAAC;AAMF,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,mBAAmB,CA0kBrF"}
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHostSocket — broadcaster / host lifecycle hook.
|
|
3
|
+
*
|
|
4
|
+
* Manages camera preview, mediasoup send transport, screen share, mic, and
|
|
5
|
+
* camera-flip. Session refs (socket, streams, producers) live in a module-level
|
|
6
|
+
* singleton so they survive navigation between tabs. Reactive UI state lives in a
|
|
7
|
+
* module-level store (hostState) so the component can remount without losing
|
|
8
|
+
* "live", "joining", etc.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const host = useHostSocket({ hostUserId: userId });
|
|
12
|
+
* // host.startPreview(), host.startLive(), host.stopLive(), …
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
15
|
+
import { Platform, PermissionsAndroid } from 'react-native';
|
|
16
|
+
import { io } from 'socket.io-client';
|
|
17
|
+
import { mediaDevices } from 'react-native-webrtc';
|
|
18
|
+
import * as mediasoupClient from 'mediasoup-client';
|
|
19
|
+
import { getApiKey, getSignalingUrl } from '../config';
|
|
20
|
+
import { createStream } from '../services/livestream.service';
|
|
21
|
+
import { ensureMediasoupGlobals } from '../services/mediasoup-init';
|
|
22
|
+
import { hostSession } from '../host/hostSession';
|
|
23
|
+
import { getHostState, patchHostState, resetHostState, subscribeHostState, } from '../host/hostState';
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// Helpers
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
function getStreamURL(stream) {
|
|
28
|
+
const s = stream;
|
|
29
|
+
return s && typeof s.toURL === 'function' ? s.toURL() : null;
|
|
30
|
+
}
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Hook
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
export function useHostSocket(options = {}) {
|
|
35
|
+
const { hostUserId } = options;
|
|
36
|
+
// Subscribe to module-level state so all hook instances stay in sync
|
|
37
|
+
// and the state survives remount (tab navigation).
|
|
38
|
+
const [state, setLocalState] = useState(getHostState);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
// If the store has a streamURL but our local copy doesn't (fresh mount),
|
|
41
|
+
// restore it from the active stream so the preview shows immediately.
|
|
42
|
+
const current = getHostState();
|
|
43
|
+
if (!current.streamURL) {
|
|
44
|
+
const activeStream = current.captureMode === 'screen' ? hostSession.screenStream : hostSession.localStream;
|
|
45
|
+
const url = getStreamURL(activeStream);
|
|
46
|
+
if (url)
|
|
47
|
+
patchHostState({ streamURL: url });
|
|
48
|
+
}
|
|
49
|
+
return subscribeHostState(setLocalState);
|
|
50
|
+
}, []);
|
|
51
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
52
|
+
const fullCleanup = useCallback(() => {
|
|
53
|
+
// ── ORDERING IS CRITICAL — read the comments before changing ──
|
|
54
|
+
//
|
|
55
|
+
// react-native-webrtc uses a single-threaded Java executor for ALL WebRTC
|
|
56
|
+
// operations. Two deadlocks must be avoided:
|
|
57
|
+
//
|
|
58
|
+
// DEADLOCK A — transport before producers:
|
|
59
|
+
// producer.close() → pc.removeTrack() → onnegotiationneeded → createOffer
|
|
60
|
+
// queued on executor. transport.close() → pc.close() 2 ms later on the
|
|
61
|
+
// same single-threaded executor → deadlock.
|
|
62
|
+
// FIX: transport.close() first → transportClosed() marks producers
|
|
63
|
+
// _closed=true → producer.close() becomes a no-op (no pc.removeTrack).
|
|
64
|
+
//
|
|
65
|
+
// DEADLOCK B — screen capture must be stopped BEFORE this function runs:
|
|
66
|
+
// peerConnectionDispose() (triggered async after pc.close()) frees the
|
|
67
|
+
// SurfaceTextureHelper that ScreenCapturerAndroid uses. If screen
|
|
68
|
+
// track.stop() runs AFTER that, stopCapture() blocks the executor for up
|
|
69
|
+
// to 60 s waiting for a surface that is already gone → app freeze.
|
|
70
|
+
// FIX: stop screen tracks synchronously in stopLive() BEFORE calling
|
|
71
|
+
// fullCleanup(). By the time we reach here, hostSession.screenStream
|
|
72
|
+
// is already null.
|
|
73
|
+
// Step 1 — transport first (calls transportClosed on producers, sets _closed=true)
|
|
74
|
+
const transportToClose = hostSession.sendTransport;
|
|
75
|
+
hostSession.sendTransport = null;
|
|
76
|
+
try {
|
|
77
|
+
if (transportToClose && !transportToClose.closed)
|
|
78
|
+
transportToClose.close();
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
// Step 2 — producers (no-ops: transportClosed already set _closed=true)
|
|
82
|
+
try {
|
|
83
|
+
hostSession.audioProducer?.close();
|
|
84
|
+
}
|
|
85
|
+
catch { /* ignore */ }
|
|
86
|
+
hostSession.audioProducer = null;
|
|
87
|
+
try {
|
|
88
|
+
hostSession.videoProducer?.close();
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
hostSession.videoProducer = null;
|
|
92
|
+
// Snapshot stream refs and null them out so re-entrant stopScreenShare() bails.
|
|
93
|
+
const screenStreamToStop = hostSession.screenStream;
|
|
94
|
+
hostSession.screenStream = null;
|
|
95
|
+
const localStreamToStop = hostSession.localStream;
|
|
96
|
+
hostSession.localStream = null;
|
|
97
|
+
hostSession.device = null;
|
|
98
|
+
// Disconnect socket → server marks stream 'ended'. Keep object alive for reuse.
|
|
99
|
+
try {
|
|
100
|
+
hostSession.socket?.disconnect();
|
|
101
|
+
}
|
|
102
|
+
catch { /* ignore */ }
|
|
103
|
+
resetHostState();
|
|
104
|
+
// Step 3 — stop camera/mic tracks deferred: Camera2 stopCapture() is fast but
|
|
105
|
+
// we still give the executor a moment to finish the PC close first.
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
try {
|
|
108
|
+
screenStreamToStop?.getTracks()
|
|
109
|
+
?.forEach((t) => { try {
|
|
110
|
+
t.stop?.();
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ } });
|
|
113
|
+
}
|
|
114
|
+
catch { /* ignore */ }
|
|
115
|
+
try {
|
|
116
|
+
localStreamToStop?.getTracks()
|
|
117
|
+
?.forEach((t) => { try {
|
|
118
|
+
t.stop?.();
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ } });
|
|
121
|
+
}
|
|
122
|
+
catch { /* ignore */ }
|
|
123
|
+
}, 300);
|
|
124
|
+
}, []);
|
|
125
|
+
// ── Socket ─────────────────────────────────────────────────────────────────
|
|
126
|
+
// Created once, persists across navigation. On each mount we only
|
|
127
|
+
// attach/detach named handlers — we never disconnect here.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const signalingUrl = getSignalingUrl();
|
|
130
|
+
if (!signalingUrl) {
|
|
131
|
+
patchHostState({ error: 'Livestream server not configured', status: '' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!ensureMediasoupGlobals()) {
|
|
135
|
+
patchHostState({
|
|
136
|
+
error: 'WebRTC not available. Use a dev client with react-native-webrtc.',
|
|
137
|
+
status: '',
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!hostSession.socket) {
|
|
142
|
+
const apiKey = getApiKey();
|
|
143
|
+
const socket = io(signalingUrl, {
|
|
144
|
+
transports: ['websocket', 'polling'],
|
|
145
|
+
reconnection: true,
|
|
146
|
+
autoConnect: true,
|
|
147
|
+
timeout: 10000,
|
|
148
|
+
...(apiKey ? { auth: { apiKey }, extraHeaders: { 'x-api-key': apiKey } } : {}),
|
|
149
|
+
});
|
|
150
|
+
hostSession.socket = socket;
|
|
151
|
+
}
|
|
152
|
+
else if (!hostSession.socket.connected) {
|
|
153
|
+
// Voluntarily disconnected by fullCleanup — reconnect the same object.
|
|
154
|
+
hostSession.socket.connect();
|
|
155
|
+
}
|
|
156
|
+
const socket = hostSession.socket;
|
|
157
|
+
const onConnect = () => patchHostState({ error: null, status: 'Connected. Tap Start preview to begin.' });
|
|
158
|
+
const onConnectError = (err) => patchHostState({ error: err?.message ?? 'Connection failed', status: '' });
|
|
159
|
+
const onDisconnect = () => {
|
|
160
|
+
patchHostState({ status: 'Disconnected from server', live: false, joining: false });
|
|
161
|
+
fullCleanup();
|
|
162
|
+
};
|
|
163
|
+
const onIceServers = (servers) => {
|
|
164
|
+
hostSession.iceServers = Array.isArray(servers) ? servers : [];
|
|
165
|
+
};
|
|
166
|
+
socket.on('connect', onConnect);
|
|
167
|
+
socket.on('connect_error', onConnectError);
|
|
168
|
+
socket.on('disconnect', onDisconnect);
|
|
169
|
+
socket.on('ice-servers', onIceServers);
|
|
170
|
+
return () => {
|
|
171
|
+
socket.off('connect', onConnect);
|
|
172
|
+
socket.off('connect_error', onConnectError);
|
|
173
|
+
socket.off('disconnect', onDisconnect);
|
|
174
|
+
socket.off('ice-servers', onIceServers);
|
|
175
|
+
};
|
|
176
|
+
}, [fullCleanup]);
|
|
177
|
+
// ── Permissions ────────────────────────────────────────────────────────────
|
|
178
|
+
const requestCameraMicPermissions = useCallback(async () => {
|
|
179
|
+
if (Platform.OS !== 'android')
|
|
180
|
+
return;
|
|
181
|
+
const result = await PermissionsAndroid.requestMultiple([
|
|
182
|
+
PermissionsAndroid.PERMISSIONS.CAMERA,
|
|
183
|
+
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
|
184
|
+
]);
|
|
185
|
+
const camOk = result[PermissionsAndroid.PERMISSIONS.CAMERA] === PermissionsAndroid.RESULTS.GRANTED;
|
|
186
|
+
const micOk = result[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] === PermissionsAndroid.RESULTS.GRANTED;
|
|
187
|
+
if (!camOk || !micOk) {
|
|
188
|
+
throw new Error('Camera and microphone permissions are required to go live.');
|
|
189
|
+
}
|
|
190
|
+
}, []);
|
|
191
|
+
// ── Preview ────────────────────────────────────────────────────────────────
|
|
192
|
+
const startPreview = useCallback(async () => {
|
|
193
|
+
const { joining: j, live: l, isFrontCamera: front } = getHostState();
|
|
194
|
+
if (j || l || hostSession.localStream)
|
|
195
|
+
return;
|
|
196
|
+
if (hostSession.socket && !hostSession.socket.connected) {
|
|
197
|
+
hostSession.socket.connect();
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
patchHostState({ error: null, status: 'Requesting camera/mic permissions...' });
|
|
201
|
+
await requestCameraMicPermissions();
|
|
202
|
+
patchHostState({ status: 'Opening camera preview...' });
|
|
203
|
+
const stream = (await mediaDevices.getUserMedia({
|
|
204
|
+
audio: true,
|
|
205
|
+
video: {
|
|
206
|
+
facingMode: front ? 'user' : 'environment',
|
|
207
|
+
frameRate: 30,
|
|
208
|
+
width: 720,
|
|
209
|
+
height: 1280,
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
hostSession.localStream = stream;
|
|
213
|
+
patchHostState({
|
|
214
|
+
streamURL: getStreamURL(stream),
|
|
215
|
+
status: 'Preview ready. Tap Go live to start streaming.',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
patchHostState({ error: err instanceof Error ? err.message : 'Failed to open camera', status: '' });
|
|
220
|
+
fullCleanup();
|
|
221
|
+
}
|
|
222
|
+
}, [fullCleanup, requestCameraMicPermissions]);
|
|
223
|
+
const stopPreview = useCallback(() => {
|
|
224
|
+
const { joining: j, live: l } = getHostState();
|
|
225
|
+
if (j || l)
|
|
226
|
+
return;
|
|
227
|
+
fullCleanup();
|
|
228
|
+
}, [fullCleanup]);
|
|
229
|
+
// ── Camera switch ──────────────────────────────────────────────────────────
|
|
230
|
+
const switchCamera = useCallback(() => {
|
|
231
|
+
if (getHostState().captureMode !== 'camera')
|
|
232
|
+
return;
|
|
233
|
+
const stream = hostSession.localStream;
|
|
234
|
+
if (!stream)
|
|
235
|
+
return;
|
|
236
|
+
const track = stream.getVideoTracks()[0];
|
|
237
|
+
if (track?._switchCamera) {
|
|
238
|
+
track._switchCamera();
|
|
239
|
+
patchHostState({ isFrontCamera: !getHostState().isFrontCamera });
|
|
240
|
+
}
|
|
241
|
+
}, []);
|
|
242
|
+
// ── Mic mute ───────────────────────────────────────────────────────────────
|
|
243
|
+
const toggleMic = useCallback(() => {
|
|
244
|
+
const stream = hostSession.localStream;
|
|
245
|
+
if (!stream)
|
|
246
|
+
return;
|
|
247
|
+
const audioTrack = stream.getAudioTracks()[0];
|
|
248
|
+
if (!audioTrack)
|
|
249
|
+
return;
|
|
250
|
+
const newEnabled = !audioTrack.enabled;
|
|
251
|
+
audioTrack.enabled = newEnabled;
|
|
252
|
+
patchHostState({ isMicMuted: !newEnabled });
|
|
253
|
+
}, []);
|
|
254
|
+
// ── Screen share ───────────────────────────────────────────────────────────
|
|
255
|
+
//
|
|
256
|
+
// replaceTrack() is unreliable when switching between different capture types
|
|
257
|
+
// (camera → screen). Close the existing video producer and publish a new one
|
|
258
|
+
// so the server emits new-producer to viewers, who then re-consume the correct
|
|
259
|
+
// feed automatically.
|
|
260
|
+
const replaceVideoProducer = useCallback(async (newTrack) => {
|
|
261
|
+
const transport = hostSession.sendTransport;
|
|
262
|
+
if (!transport || transport.closed)
|
|
263
|
+
return; // preview-only, no transport yet
|
|
264
|
+
try {
|
|
265
|
+
hostSession.videoProducer?.close();
|
|
266
|
+
}
|
|
267
|
+
catch { /* ignore */ }
|
|
268
|
+
hostSession.videoProducer = null;
|
|
269
|
+
const producer = await transport.produce({
|
|
270
|
+
track: newTrack,
|
|
271
|
+
encodings: [{ maxBitrate: 1000000 }],
|
|
272
|
+
codecOptions: { videoSimulcastLayers: [] },
|
|
273
|
+
});
|
|
274
|
+
hostSession.videoProducer = producer;
|
|
275
|
+
}, []);
|
|
276
|
+
const stopScreenShare = useCallback(async () => {
|
|
277
|
+
if (!hostSession.screenStream)
|
|
278
|
+
return; // fullCleanup already ran, bail
|
|
279
|
+
const screenStreamToStop = hostSession.screenStream;
|
|
280
|
+
hostSession.screenStream = null;
|
|
281
|
+
try {
|
|
282
|
+
screenStreamToStop.getTracks()
|
|
283
|
+
.forEach((t) => { try {
|
|
284
|
+
t.stop?.();
|
|
285
|
+
}
|
|
286
|
+
catch { /* ignore */ } });
|
|
287
|
+
}
|
|
288
|
+
catch { /* ignore */ }
|
|
289
|
+
try {
|
|
290
|
+
const { isFrontCamera: front } = getHostState();
|
|
291
|
+
const newCamStream = (await mediaDevices.getUserMedia({
|
|
292
|
+
audio: false,
|
|
293
|
+
video: {
|
|
294
|
+
facingMode: front ? 'user' : 'environment',
|
|
295
|
+
frameRate: 30,
|
|
296
|
+
width: 720,
|
|
297
|
+
height: 1280,
|
|
298
|
+
},
|
|
299
|
+
}));
|
|
300
|
+
// fullCleanup may have run while awaiting getUserMedia
|
|
301
|
+
if (!hostSession.localStream) {
|
|
302
|
+
newCamStream.getTracks()
|
|
303
|
+
.forEach((t) => { try {
|
|
304
|
+
t.stop?.();
|
|
305
|
+
}
|
|
306
|
+
catch { /* ignore */ } });
|
|
307
|
+
patchHostState({ captureMode: 'camera' });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const newVideoTrack = newCamStream.getVideoTracks()[0];
|
|
311
|
+
if (!newVideoTrack) {
|
|
312
|
+
patchHostState({ captureMode: 'camera' });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const stream = hostSession.localStream;
|
|
316
|
+
if (stream) {
|
|
317
|
+
stream.getVideoTracks().forEach((t) => {
|
|
318
|
+
try {
|
|
319
|
+
t.stop?.();
|
|
320
|
+
}
|
|
321
|
+
catch { /* ignore */ }
|
|
322
|
+
stream.removeTrack(t);
|
|
323
|
+
});
|
|
324
|
+
stream.addTrack(newVideoTrack);
|
|
325
|
+
}
|
|
326
|
+
patchHostState({ streamURL: getStreamURL(hostSession.localStream) });
|
|
327
|
+
await replaceVideoProducer(newVideoTrack);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// ignore — worst case user taps Stop preview to recover
|
|
331
|
+
}
|
|
332
|
+
patchHostState({ captureMode: 'camera' });
|
|
333
|
+
}, [replaceVideoProducer]);
|
|
334
|
+
const startScreenShare = useCallback(async () => {
|
|
335
|
+
try {
|
|
336
|
+
const screenStream = await mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
337
|
+
const screenVideoTrack = screenStream.getVideoTracks()[0];
|
|
338
|
+
if (!screenVideoTrack) {
|
|
339
|
+
screenStream.getTracks()
|
|
340
|
+
.forEach((t) => { try {
|
|
341
|
+
t.stop?.();
|
|
342
|
+
}
|
|
343
|
+
catch { /* ignore */ } });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
hostSession.screenStream = screenStream;
|
|
347
|
+
// Free the camera hardware while screen sharing
|
|
348
|
+
hostSession.localStream?.getVideoTracks()
|
|
349
|
+
?.forEach((t) => { try {
|
|
350
|
+
t.stop?.();
|
|
351
|
+
}
|
|
352
|
+
catch { /* ignore */ } });
|
|
353
|
+
// Point preview at the native screenStream — avoids the black-screen bug
|
|
354
|
+
// caused by copying a screen-capture track into a different MediaStream object.
|
|
355
|
+
patchHostState({ streamURL: getStreamURL(screenStream), captureMode: 'screen' });
|
|
356
|
+
await replaceVideoProducer(screenVideoTrack);
|
|
357
|
+
// Auto-revert when user dismisses screen share from the Android notification bar
|
|
358
|
+
screenVideoTrack.addEventListener?.('ended', () => {
|
|
359
|
+
void stopScreenShare();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
patchHostState({ error: err instanceof Error ? err.message : 'Screen share failed' });
|
|
364
|
+
}
|
|
365
|
+
}, [replaceVideoProducer, stopScreenShare]);
|
|
366
|
+
// ── Go live ────────────────────────────────────────────────────────────────
|
|
367
|
+
const startLive = useCallback(async () => {
|
|
368
|
+
const { joining: j, live: l, displayName: dn } = getHostState();
|
|
369
|
+
if (j || l)
|
|
370
|
+
return;
|
|
371
|
+
const socket = hostSession.socket;
|
|
372
|
+
if (!socket?.connected) {
|
|
373
|
+
patchHostState({ error: 'Connecting to server... try again in a moment.' });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!hostSession.localStream) {
|
|
377
|
+
patchHostState({ error: 'Start preview first, then tap Go live.' });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
patchHostState({ joining: true, error: null, status: 'Creating stream...' });
|
|
381
|
+
try {
|
|
382
|
+
const stream = hostSession.localStream;
|
|
383
|
+
const streamRes = await createStream({ title: 'Mobile stream' });
|
|
384
|
+
if ('error' in streamRes)
|
|
385
|
+
throw new Error(streamRes.error);
|
|
386
|
+
const { roomId } = streamRes.stream;
|
|
387
|
+
patchHostState({ roomId, status: 'Joining room...' });
|
|
388
|
+
const JOIN_TIMEOUT_MS = 12000;
|
|
389
|
+
const joinRes = await Promise.race([
|
|
390
|
+
new Promise((resolve, reject) => {
|
|
391
|
+
socket.emit('join-room', {
|
|
392
|
+
roomId,
|
|
393
|
+
role: 'host',
|
|
394
|
+
displayName: dn.trim() || undefined,
|
|
395
|
+
...(hostUserId ? { hostUserId } : {}),
|
|
396
|
+
}, (res) => {
|
|
397
|
+
if (res?.error)
|
|
398
|
+
reject(new Error(res.error));
|
|
399
|
+
else
|
|
400
|
+
resolve(res);
|
|
401
|
+
});
|
|
402
|
+
}),
|
|
403
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Join timed out. Check server URL and network.')), JOIN_TIMEOUT_MS)),
|
|
404
|
+
]);
|
|
405
|
+
if (!joinRes.rtpCapabilities)
|
|
406
|
+
throw new Error('Server did not return RTP capabilities');
|
|
407
|
+
if (Array.isArray(joinRes.iceServers) && joinRes.iceServers.length > 0) {
|
|
408
|
+
hostSession.iceServers = joinRes.iceServers;
|
|
409
|
+
}
|
|
410
|
+
patchHostState({ status: 'Initializing mediasoup device...' });
|
|
411
|
+
const device = new mediasoupClient.Device();
|
|
412
|
+
await device.load({
|
|
413
|
+
routerRtpCapabilities: joinRes.rtpCapabilities,
|
|
414
|
+
});
|
|
415
|
+
hostSession.device = device;
|
|
416
|
+
patchHostState({ status: 'Creating WebRTC transport...' });
|
|
417
|
+
const transportInfo = await new Promise((resolve, reject) => {
|
|
418
|
+
socket.emit('create-webrtc-transport', {}, (res) => {
|
|
419
|
+
if (res?.error)
|
|
420
|
+
reject(new Error(res.error));
|
|
421
|
+
else if (res?.id && res.iceParameters && res.dtlsParameters)
|
|
422
|
+
resolve(res);
|
|
423
|
+
else
|
|
424
|
+
reject(new Error('Invalid transport response'));
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
const transportOptions = {
|
|
428
|
+
id: transportInfo.id,
|
|
429
|
+
iceParameters: transportInfo.iceParameters,
|
|
430
|
+
iceCandidates: transportInfo.iceCandidates ?? [],
|
|
431
|
+
dtlsParameters: transportInfo.dtlsParameters,
|
|
432
|
+
};
|
|
433
|
+
if (hostSession.iceServers.length > 0)
|
|
434
|
+
transportOptions.iceServers = hostSession.iceServers;
|
|
435
|
+
const transport = device.createSendTransport(transportOptions);
|
|
436
|
+
hostSession.sendTransport = transport;
|
|
437
|
+
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
|
438
|
+
socket.emit('connect-transport', { transportId: transport.id, dtlsParameters }, (res) => {
|
|
439
|
+
if (res?.error)
|
|
440
|
+
errback(new Error(res.error));
|
|
441
|
+
else
|
|
442
|
+
callback();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => {
|
|
446
|
+
socket.emit('produce', { transportId: transport.id, kind, rtpParameters, appData }, (res) => {
|
|
447
|
+
if (res?.error || !res?.id)
|
|
448
|
+
errback(new Error(res?.error ?? 'Produce failed'));
|
|
449
|
+
else
|
|
450
|
+
callback({ id: res.id });
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
transport.on('connectionstatechange', (connState) => {
|
|
454
|
+
// Skip stale events after fullCleanup clears the ref
|
|
455
|
+
if (hostSession.sendTransport !== transport)
|
|
456
|
+
return;
|
|
457
|
+
if (connState === 'connected') {
|
|
458
|
+
patchHostState({ status: 'You are live', live: true });
|
|
459
|
+
}
|
|
460
|
+
else if (connState === 'failed' || connState === 'closed' || connState === 'disconnected') {
|
|
461
|
+
patchHostState({ live: false, status: 'Connection lost. Check network or try again.' });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
patchHostState({ status: 'Publishing tracks...' });
|
|
465
|
+
// Audio always from localStream (mic).
|
|
466
|
+
// Video from screenStream when screen-sharing (camera track was stopped),
|
|
467
|
+
// otherwise from localStream. Skip any tracks already ended.
|
|
468
|
+
const { captureMode: mode } = getHostState();
|
|
469
|
+
const audioTrack = stream.getAudioTracks().find((t) => t.readyState !== 'ended');
|
|
470
|
+
const videoSource = mode === 'screen' && hostSession.screenStream ? hostSession.screenStream : stream;
|
|
471
|
+
const videoTrack = videoSource.getVideoTracks().find((t) => t.readyState !== 'ended');
|
|
472
|
+
const tracksToPublish = [audioTrack, videoTrack].filter((t) => t != null);
|
|
473
|
+
if (tracksToPublish.length === 0)
|
|
474
|
+
throw new Error('No live tracks available. Restart preview.');
|
|
475
|
+
for (const track of tracksToPublish) {
|
|
476
|
+
const producer = await transport.produce({
|
|
477
|
+
track: track,
|
|
478
|
+
encodings: track.kind === 'video' ? [{ maxBitrate: 1000000 }] : undefined,
|
|
479
|
+
codecOptions: track.kind === 'video'
|
|
480
|
+
? { videoSimulcastLayers: [] }
|
|
481
|
+
: undefined,
|
|
482
|
+
});
|
|
483
|
+
if (track.kind === 'video')
|
|
484
|
+
hostSession.videoProducer = producer;
|
|
485
|
+
if (track.kind === 'audio')
|
|
486
|
+
hostSession.audioProducer = producer;
|
|
487
|
+
}
|
|
488
|
+
patchHostState({ joining: false, live: true, status: 'You are live' });
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
const message = err instanceof Error ? err.message : 'Failed to start stream';
|
|
492
|
+
console.warn('[useHostSocket] startLive failed:', message);
|
|
493
|
+
patchHostState({ joining: false, live: false, error: message, status: '' });
|
|
494
|
+
fullCleanup();
|
|
495
|
+
}
|
|
496
|
+
}, [fullCleanup, hostUserId]);
|
|
497
|
+
const stopLive = useCallback(async () => {
|
|
498
|
+
const { captureMode } = getHostState();
|
|
499
|
+
if (captureMode === 'screen' && hostSession.screenStream) {
|
|
500
|
+
// ── Sequential screen-share teardown ───────────────────────────────
|
|
501
|
+
// react-native-webrtc uses a single-threaded executor for ALL WebRTC ops.
|
|
502
|
+
// ScreenCapturerAndroid.stopCapture() blocks that executor for up to 60 s
|
|
503
|
+
// while waiting for the render thread (captureStopped CountDownLatch).
|
|
504
|
+
// RTCPeerConnection.dispose() (triggered async after pc.close()) frees the
|
|
505
|
+
// SurfaceTextureHelper that the render thread writes into.
|
|
506
|
+
// If stop() runs AFTER dispose(), the render thread is stuck → 60 s freeze.
|
|
507
|
+
//
|
|
508
|
+
// Fix: stop the screen tracks HERE, wait for the MediaProjection 'ended'
|
|
509
|
+
// event (i.e. the native capture has fully drained), THEN call fullCleanup().
|
|
510
|
+
// By the time fullCleanup() runs, the screen capturer is already gone and
|
|
511
|
+
// peerConnectionDispose() has nothing to contend with.
|
|
512
|
+
const screenStream = hostSession.screenStream;
|
|
513
|
+
hostSession.screenStream = null; // guard: stopScreenShare bails on re-entry
|
|
514
|
+
patchHostState({ status: 'Stopping screen share...' });
|
|
515
|
+
await new Promise((resolve) => {
|
|
516
|
+
const videoTrack = screenStream.getVideoTracks()[0];
|
|
517
|
+
if (videoTrack && videoTrack.readyState !== 'ended' && videoTrack.addEventListener) {
|
|
518
|
+
const onEnded = () => {
|
|
519
|
+
videoTrack.removeEventListener?.('ended', onEnded);
|
|
520
|
+
resolve();
|
|
521
|
+
};
|
|
522
|
+
videoTrack.addEventListener('ended', onEnded);
|
|
523
|
+
// Stop all screen tracks — fires 'ended' when MediaProjection fully releases
|
|
524
|
+
screenStream.getTracks()
|
|
525
|
+
.forEach((t) => { try {
|
|
526
|
+
t.stop?.();
|
|
527
|
+
}
|
|
528
|
+
catch { /* ignore */ } });
|
|
529
|
+
// Fallback: if 'ended' never fires (some devices), unblock after 800 ms
|
|
530
|
+
setTimeout(() => {
|
|
531
|
+
videoTrack.removeEventListener?.('ended', onEnded);
|
|
532
|
+
resolve();
|
|
533
|
+
}, 800);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
screenStream.getTracks()
|
|
537
|
+
.forEach((t) => { try {
|
|
538
|
+
t.stop?.();
|
|
539
|
+
}
|
|
540
|
+
catch { /* ignore */ } });
|
|
541
|
+
resolve();
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
// MediaProjection is fully stopped — executor is free. Safe to close transport.
|
|
545
|
+
}
|
|
546
|
+
fullCleanup();
|
|
547
|
+
patchHostState({ status: 'Stream stopped' });
|
|
548
|
+
}, [fullCleanup]);
|
|
549
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
550
|
+
const setDisplayName = useCallback((name) => {
|
|
551
|
+
patchHostState({ displayName: name });
|
|
552
|
+
}, []);
|
|
553
|
+
return {
|
|
554
|
+
...state,
|
|
555
|
+
setDisplayName,
|
|
556
|
+
startPreview,
|
|
557
|
+
stopPreview,
|
|
558
|
+
startLive,
|
|
559
|
+
stopLive,
|
|
560
|
+
switchCamera,
|
|
561
|
+
toggleMic,
|
|
562
|
+
startScreenShare,
|
|
563
|
+
stopScreenShare,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level singleton — lives outside React so it survives component
|
|
3
|
+
* unmount/remount when the user navigates between tabs.
|
|
4
|
+
*/
|
|
5
|
+
import type { Socket } from 'socket.io-client';
|
|
6
|
+
import type * as mediasoupClient from 'mediasoup-client';
|
|
7
|
+
import type { MediaStream } from 'react-native-webrtc';
|
|
8
|
+
export declare const hostSession: {
|
|
9
|
+
socket: Socket<import("@socket.io/component-emitter").DefaultEventsMap, import("@socket.io/component-emitter").DefaultEventsMap> | null;
|
|
10
|
+
device: mediasoupClient.types.Device | null;
|
|
11
|
+
sendTransport: mediasoupClient.types.Transport<mediasoupClient.types.AppData> | null;
|
|
12
|
+
localStream: MediaStream | null;
|
|
13
|
+
screenStream: MediaStream | null;
|
|
14
|
+
audioProducer: mediasoupClient.types.Producer<mediasoupClient.types.AppData> | null;
|
|
15
|
+
videoProducer: mediasoupClient.types.Producer<mediasoupClient.types.AppData> | null;
|
|
16
|
+
iceServers: RTCIceServer[];
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=hostSession.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hostSession.d.ts","sourceRoot":"","sources":["../../src/host/hostSession.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,KAAK,eAAe,MAAM,kBAAkB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,eAAO,MAAM,WAAW;;;;;;;;;CASvB,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level reactive state for the host session.
|
|
3
|
+
*
|
|
4
|
+
* Lives outside React (like Zustand) so the UI state survives tab navigation
|
|
5
|
+
* — the component can unmount/remount without losing "live", "joining", etc.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* patchHostState({ live: true }); // update from anywhere
|
|
9
|
+
* const unsub = subscribeHostState(cb); // subscribe from a hook
|
|
10
|
+
* getHostState(); // read synchronously
|
|
11
|
+
*/
|
|
12
|
+
export type HostState = {
|
|
13
|
+
status: string;
|
|
14
|
+
error: string | null;
|
|
15
|
+
joining: boolean;
|
|
16
|
+
live: boolean;
|
|
17
|
+
roomId: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
isFrontCamera: boolean;
|
|
20
|
+
isMicMuted: boolean;
|
|
21
|
+
captureMode: 'camera' | 'screen';
|
|
22
|
+
streamURL: string | null;
|
|
23
|
+
};
|
|
24
|
+
export declare function getHostState(): HostState;
|
|
25
|
+
export declare function patchHostState(update: Partial<HostState>): void;
|
|
26
|
+
export declare function resetHostState(): void;
|
|
27
|
+
export declare function subscribeHostState(listener: (s: HostState) => void): () => void;
|
|
28
|
+
//# sourceMappingURL=hostState.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hostState.d.ts","sourceRoot":"","sources":["../../src/host/hostState.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,OAAO,CAAC;IACvB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACjC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,CAAC;AAkBF,wBAAgB,YAAY,IAAI,SAAS,CAExC;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,CAAC,SAAS,CAAC,GAAG,IAAI,CAG/D;AAED,wBAAgB,cAAc,IAAI,IAAI,CAGrC;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,GAAG,MAAM,IAAI,CAK/E"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level reactive state for the host session.
|
|
3
|
+
*
|
|
4
|
+
* Lives outside React (like Zustand) so the UI state survives tab navigation
|
|
5
|
+
* — the component can unmount/remount without losing "live", "joining", etc.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* patchHostState({ live: true }); // update from anywhere
|
|
9
|
+
* const unsub = subscribeHostState(cb); // subscribe from a hook
|
|
10
|
+
* getHostState(); // read synchronously
|
|
11
|
+
*/
|
|
12
|
+
const initial = {
|
|
13
|
+
status: '',
|
|
14
|
+
error: null,
|
|
15
|
+
joining: false,
|
|
16
|
+
live: false,
|
|
17
|
+
roomId: '',
|
|
18
|
+
displayName: '',
|
|
19
|
+
isFrontCamera: true,
|
|
20
|
+
isMicMuted: false,
|
|
21
|
+
captureMode: 'camera',
|
|
22
|
+
streamURL: null,
|
|
23
|
+
};
|
|
24
|
+
let _state = { ...initial };
|
|
25
|
+
const _listeners = new Set();
|
|
26
|
+
export function getHostState() {
|
|
27
|
+
return _state;
|
|
28
|
+
}
|
|
29
|
+
export function patchHostState(update) {
|
|
30
|
+
_state = { ..._state, ...update };
|
|
31
|
+
_listeners.forEach((l) => l(_state));
|
|
32
|
+
}
|
|
33
|
+
export function resetHostState() {
|
|
34
|
+
_state = { ...initial };
|
|
35
|
+
_listeners.forEach((l) => l(_state));
|
|
36
|
+
}
|
|
37
|
+
export function subscribeHostState(listener) {
|
|
38
|
+
_listeners.add(listener);
|
|
39
|
+
return () => {
|
|
40
|
+
_listeners.delete(listener);
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { setLivestreamConfig, getLivestreamConfig, getBaseUrl, getApiKey, getSignalingUrl, getSignalingWsUrl, getDisplayName, } from './config';
|
|
6
6
|
export type { LivestreamConfig } from './config';
|
|
7
|
-
export { fetchActiveStreams } from './services/livestream.service';
|
|
8
|
-
export type { FetchStreamsResult } from './services/livestream.service';
|
|
7
|
+
export { fetchActiveStreams, createStream, mintViewerToken } from './services/livestream.service';
|
|
8
|
+
export type { FetchStreamsResult, CreateStreamResult, MintViewerTokenResult } from './services/livestream.service';
|
|
9
9
|
export { ensureMediasoupGlobals } from './services/mediasoup-init';
|
|
10
10
|
export { useViewerSocket } from './hooks/useViewerSocket';
|
|
11
11
|
export { useLiveStreams } from './hooks/useLiveStreams';
|
|
12
12
|
export { LiveStreamFeed } from './components/LiveStreamFeed';
|
|
13
13
|
export { LiveStreamViewerItem } from './components/LiveStreamViewerItem';
|
|
14
14
|
export type { LiveStreamInfo, ProducerInfo, JoinRoomResult, RoomState } from './types';
|
|
15
|
+
export { hostSession } from './host/hostSession';
|
|
16
|
+
export { getHostState, patchHostState, resetHostState, subscribeHostState, } from './host/hostState';
|
|
17
|
+
export type { HostState } from './host/hostState';
|
|
18
|
+
export { useHostSocket } from './hooks/useHostSocket';
|
|
19
|
+
export type { UseHostSocketOptions, UseHostSocketReturn } from './hooks/useHostSocket';
|
|
15
20
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,UAAU,EACV,SAAS,EACT,eAAe,EACf,iBAAiB,EACjB,cAAc,GACf,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAEjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,UAAU,EACV,SAAS,EACT,eAAe,EACf,iBAAiB,EACjB,cAAc,GACf,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAEjD,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAClG,YAAY,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACnH,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAEnE,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAEzE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAGvF,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EACL,YAAY,EACZ,cAAc,EACd,cAAc,EACd,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
* Configure with setLivestreamConfig() before using components/hooks.
|
|
4
4
|
*/
|
|
5
5
|
export { setLivestreamConfig, getLivestreamConfig, getBaseUrl, getApiKey, getSignalingUrl, getSignalingWsUrl, getDisplayName, } from './config';
|
|
6
|
-
export { fetchActiveStreams } from './services/livestream.service';
|
|
6
|
+
export { fetchActiveStreams, createStream, mintViewerToken } from './services/livestream.service';
|
|
7
7
|
export { ensureMediasoupGlobals } from './services/mediasoup-init';
|
|
8
8
|
export { useViewerSocket } from './hooks/useViewerSocket';
|
|
9
9
|
export { useLiveStreams } from './hooks/useLiveStreams';
|
|
10
10
|
export { LiveStreamFeed } from './components/LiveStreamFeed';
|
|
11
11
|
export { LiveStreamViewerItem } from './components/LiveStreamViewerItem';
|
|
12
|
+
// ── Host / broadcaster ────────────────────────────────────────────────────────
|
|
13
|
+
export { hostSession } from './host/hostSession';
|
|
14
|
+
export { getHostState, patchHostState, resetHostState, subscribeHostState, } from './host/hostState';
|
|
15
|
+
export { useHostSocket } from './hooks/useHostSocket';
|
|
@@ -16,7 +16,23 @@ export type MintViewerTokenResult = {
|
|
|
16
16
|
token?: never;
|
|
17
17
|
error: string;
|
|
18
18
|
};
|
|
19
|
+
export type CreateStreamResult = {
|
|
20
|
+
stream: {
|
|
21
|
+
id: string;
|
|
22
|
+
roomId: string;
|
|
23
|
+
title: string | null;
|
|
24
|
+
status: string;
|
|
25
|
+
};
|
|
26
|
+
error?: never;
|
|
27
|
+
} | {
|
|
28
|
+
stream?: never;
|
|
29
|
+
error: string;
|
|
30
|
+
};
|
|
19
31
|
export declare function fetchActiveStreams(): Promise<FetchStreamsResult>;
|
|
32
|
+
export declare function createStream(params?: {
|
|
33
|
+
title?: string | null;
|
|
34
|
+
roomId?: string | null;
|
|
35
|
+
}): Promise<CreateStreamResult>;
|
|
20
36
|
export declare function mintViewerToken(params: {
|
|
21
37
|
roomId: string;
|
|
22
38
|
viewerId?: string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"livestream.service.d.ts","sourceRoot":"","sources":["../../src/services/livestream.service.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,MAAM,MAAM,kBAAkB,GAC1B;IAAE,OAAO,EAAE,cAAc,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GAC5C;IAAE,OAAO,EAAE,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAUnC,MAAM,MAAM,qBAAqB,GAC7B;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GAChC;IAAE,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAErC,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CA+BtE;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC;AAED,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
|
|
1
|
+
{"version":3,"file":"livestream.service.d.ts","sourceRoot":"","sources":["../../src/services/livestream.service.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,MAAM,MAAM,kBAAkB,GAC1B;IAAE,OAAO,EAAE,cAAc,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GAC5C;IAAE,OAAO,EAAE,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAUnC,MAAM,MAAM,qBAAqB,GAC7B;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GAChC;IAAE,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAErC,MAAM,MAAM,kBAAkB,GAC1B;IAAE,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GAC/F;IAAE,MAAM,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CA+BtE;AAED,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE;IAC1C,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAsC9B;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC;AAED,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
|
|
@@ -43,6 +43,52 @@ export async function fetchActiveStreams() {
|
|
|
43
43
|
return { streams: [], error: message };
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
export async function createStream(params) {
|
|
47
|
+
const base = getBaseUrl();
|
|
48
|
+
if (!base)
|
|
49
|
+
return { error: 'Livestream server not configured' };
|
|
50
|
+
const apiKey = getApiKey();
|
|
51
|
+
if (!apiKey)
|
|
52
|
+
return { error: 'Livestream API key not configured' };
|
|
53
|
+
try {
|
|
54
|
+
const url = `${base.replace(/\/$/, '')}/api/streams`;
|
|
55
|
+
const body = {};
|
|
56
|
+
if (typeof params?.title === 'string')
|
|
57
|
+
body.title = params.title;
|
|
58
|
+
if (typeof params?.roomId === 'string')
|
|
59
|
+
body.roomId = params.roomId;
|
|
60
|
+
const res = await fetch(url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
'content-type': 'application/json',
|
|
64
|
+
'x-api-key': apiKey,
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
return { error: `HTTP ${res.status}` };
|
|
70
|
+
const data = (await res.json());
|
|
71
|
+
if (data?.error)
|
|
72
|
+
return { error: data.error };
|
|
73
|
+
const s = data?.stream || {};
|
|
74
|
+
const id = typeof s?.id === 'string' ? s.id : '';
|
|
75
|
+
const roomId = typeof s?.roomId === 'string' ? s.roomId : typeof s?.room_id === 'string' ? s.room_id : '';
|
|
76
|
+
if (!id || !roomId)
|
|
77
|
+
return { error: 'Invalid stream response' };
|
|
78
|
+
return {
|
|
79
|
+
stream: {
|
|
80
|
+
id,
|
|
81
|
+
roomId,
|
|
82
|
+
title: typeof s?.title === 'string' ? s.title : null,
|
|
83
|
+
status: typeof s?.status === 'string' ? s.status : 'created',
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
const message = e instanceof Error ? e.message : 'Unknown error';
|
|
89
|
+
return { error: message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
46
92
|
export async function mintViewerToken(params) {
|
|
47
93
|
const base = getBaseUrl();
|
|
48
94
|
if (!base)
|
package/package.json
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "kasunk99-livestream-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Reusable livestream viewer/host module for React Native (Expo) — mediasoup + Socket.IO",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"types": "dist/index.d.ts",
|
|
7
|
-
"files": [
|
|
8
|
-
"dist",
|
|
9
|
-
"README.md",
|
|
10
|
-
"package.json"
|
|
11
|
-
],
|
|
12
|
-
"scripts": {
|
|
13
|
-
"clean": "rimraf dist",
|
|
14
|
-
"build": "tsc -p tsconfig.build.json",
|
|
15
|
-
"prepublishOnly": "npm run clean && npm run build"
|
|
16
|
-
},
|
|
17
|
-
"peerDependencies": {
|
|
18
|
-
"mediasoup-client": "^3.6.0",
|
|
19
|
-
"react": ">=18.0.0",
|
|
20
|
-
"react-native": "*",
|
|
21
|
-
"react-native-webrtc": ">=118.0.0",
|
|
22
|
-
"socket.io-client": "^4.0.0"
|
|
23
|
-
},
|
|
24
|
-
"devDependencies": {
|
|
25
|
-
"@types/node": "^25.5.0",
|
|
26
|
-
"@types/react": "^18.2.0",
|
|
27
|
-
"@types/react-native": "^0.72.8",
|
|
28
|
-
"mediasoup-client": "^3.18.7",
|
|
29
|
-
"react-native": "^0.84.1",
|
|
30
|
-
"react-native-webrtc": "^124.0.7",
|
|
31
|
-
"rimraf": "^6.1.3",
|
|
32
|
-
"socket.io-client": "^4.8.3",
|
|
33
|
-
"typescript": "~5.3.0"
|
|
34
|
-
}
|
|
35
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "kasunk99-livestream-core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reusable livestream viewer/host module for React Native (Expo) — mediasoup + Socket.IO",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"package.json"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"clean": "rimraf dist",
|
|
14
|
+
"build": "tsc -p tsconfig.build.json",
|
|
15
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"mediasoup-client": "^3.6.0",
|
|
19
|
+
"react": ">=18.0.0",
|
|
20
|
+
"react-native": "*",
|
|
21
|
+
"react-native-webrtc": ">=118.0.0",
|
|
22
|
+
"socket.io-client": "^4.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^25.5.0",
|
|
26
|
+
"@types/react": "^18.2.0",
|
|
27
|
+
"@types/react-native": "^0.72.8",
|
|
28
|
+
"mediasoup-client": "^3.18.7",
|
|
29
|
+
"react-native": "^0.84.1",
|
|
30
|
+
"react-native-webrtc": "^124.0.7",
|
|
31
|
+
"rimraf": "^6.1.3",
|
|
32
|
+
"socket.io-client": "^4.8.3",
|
|
33
|
+
"typescript": "~5.3.0"
|
|
34
|
+
}
|
|
35
|
+
}
|