kasunk99-livestream-core 0.1.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/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @livestream/core
2
+
3
+ Reusable livestream viewer (and later host) module for React Native / Expo. Talks to a mediasoup + Socket.IO livestream server.
4
+
5
+ ## Peer dependencies
6
+
7
+ Install in the host app:
8
+
9
+ - `react`, `react-native`
10
+ - `react-native-webrtc` (required for video; use a dev build, not Expo Go)
11
+ - `mediasoup-client`
12
+ - `socket.io-client`
13
+
14
+ ## Setup
15
+
16
+ 1. Configure before using any component or hook:
17
+
18
+ ```ts
19
+ import { setLivestreamConfig } from '@livestream/core';
20
+
21
+ setLivestreamConfig({
22
+ serverHttpUrl: 'http://localhost:3000', // or your server URL
23
+ getDisplayName: () => 'Viewer', // optional, for chat/room display
24
+ });
25
+ ```
26
+
27
+ 2. Use the feed or hooks:
28
+
29
+ ```tsx
30
+ import { LiveStreamFeed } from '@livestream/core';
31
+
32
+ <LiveStreamFeed />
33
+ ```
34
+
35
+ Or use `useLiveStreams()` and `useViewerSocket(roomId)` for custom UIs.
36
+
37
+ ## Exports
38
+
39
+ - **Config:** `setLivestreamConfig`, `getBaseUrl`, `getSignalingWsUrl`, `getDisplayName`
40
+ - **Services:** `fetchActiveStreams`, `ensureMediasoupGlobals`
41
+ - **Hooks:** `useViewerSocket`, `useLiveStreams`
42
+ - **Components:** `LiveStreamFeed`, `LiveStreamViewerItem`
43
+ - **Types:** `LiveStreamInfo`, `ProducerInfo`, `JoinRoomResult`, `RoomState`
44
+
45
+ ## Server contract
46
+
47
+ - **HTTP:** `GET {serverHttpUrl}/api/live-streams` → `{ streams: { roomId, viewerCount, producerCount }[] }`
48
+ - **WebSocket:** Socket.IO at same origin (ws/https). Events: `join-room`, `create-webrtc-transport`, `connect-transport`, `produce`, `consume`, `get-producers`, `new-producer`, `room-updated`, etc.
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ /**
3
+ * TikTok-style vertical feed: one stream per full-screen item.
4
+ * When the user scrolls to a new item, that item becomes active and joins the stream.
5
+ */
6
+ export declare function LiveStreamFeed(): React.ReactElement;
7
+ //# sourceMappingURL=LiveStreamFeed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LiveStreamFeed.d.ts","sourceRoot":"","sources":["../../src/components/LiveStreamFeed.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAwC,MAAM,OAAO,CAAC;AAoB7D;;;GAGG;AACH,wBAAgB,cAAc,IAAI,KAAK,CAAC,YAAY,CAkHnD"}
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import { Dimensions, FlatList, RefreshControl, ScrollView, StyleSheet, Text, View, } from 'react-native';
4
+ import { LiveStreamViewerItem } from './LiveStreamViewerItem';
5
+ import { useLiveStreams } from '../hooks/useLiveStreams';
6
+ const { height: SCREEN_HEIGHT } = Dimensions.get('window');
7
+ // Adjust for top header + bottom tab bar so each item fits without extra scroll.
8
+ const VIEWPORT_HEIGHT = SCREEN_HEIGHT - 120;
9
+ /**
10
+ * TikTok-style vertical feed: one stream per full-screen item.
11
+ * When the user scrolls to a new item, that item becomes active and joins the stream.
12
+ */
13
+ export function LiveStreamFeed() {
14
+ const { streams, loading, error, refetch } = useLiveStreams();
15
+ const [activeIndex, setActiveIndex] = useState(0);
16
+ const [refreshing, setRefreshing] = useState(false);
17
+ const onRefresh = useCallback(async () => {
18
+ setRefreshing(true);
19
+ await refetch();
20
+ setRefreshing(false);
21
+ }, [refetch]);
22
+ const viewabilityConfig = useRef({
23
+ itemVisiblePercentThreshold: 60,
24
+ minimumViewTime: 100,
25
+ }).current;
26
+ const onViewableItemsChanged = useRef(({ viewableItems }) => {
27
+ const first = viewableItems[0];
28
+ if (first?.index != null) {
29
+ setActiveIndex(first.index);
30
+ }
31
+ }).current;
32
+ const onMomentumScrollEnd = useCallback((e) => {
33
+ const offsetY = e.nativeEvent.contentOffset.y;
34
+ const index = Math.round(offsetY / VIEWPORT_HEIGHT);
35
+ const clamped = Math.max(0, Math.min(index, streams.length - 1));
36
+ setActiveIndex(clamped);
37
+ }, [streams.length]);
38
+ const renderItem = useCallback(({ item, index }) => (_jsx(View, { style: styles.item, children: _jsx(LiveStreamViewerItem, { stream: item, isActive: index === activeIndex, index: index }) })), [activeIndex]);
39
+ const keyExtractor = useCallback((item) => item.roomId, []);
40
+ const getItemLayout = useCallback((_, index) => ({
41
+ length: VIEWPORT_HEIGHT,
42
+ offset: VIEWPORT_HEIGHT * index,
43
+ index,
44
+ }), []);
45
+ if (loading && streams.length === 0) {
46
+ return (_jsx(View, { style: styles.center, children: _jsx(Text, { style: styles.message, children: "Loading streams..." }) }));
47
+ }
48
+ if (error && streams.length === 0) {
49
+ return (_jsxs(ScrollView, { contentContainerStyle: styles.center, refreshControl: _jsx(RefreshControl, { refreshing: refreshing, onRefresh: onRefresh, tintColor: "#e5e5e5" }), children: [_jsx(Text, { style: styles.error, children: error }), _jsx(Text, { style: styles.hint, children: "Check server URL and pull down to retry." })] }));
50
+ }
51
+ if (streams.length === 0) {
52
+ return (_jsxs(ScrollView, { contentContainerStyle: styles.center, refreshControl: _jsx(RefreshControl, { refreshing: refreshing, onRefresh: onRefresh, tintColor: "#e5e5e5" }), children: [_jsx(Text, { style: styles.message, children: "No live streams right now" }), _jsx(Text, { style: styles.hint, children: "Go live or pull down to refresh." })] }));
53
+ }
54
+ return (_jsx(FlatList, { data: streams, renderItem: renderItem, keyExtractor: keyExtractor, getItemLayout: getItemLayout, pagingEnabled: true, nestedScrollEnabled: true, showsVerticalScrollIndicator: false, decelerationRate: "fast", snapToInterval: VIEWPORT_HEIGHT, snapToAlignment: "start", onViewableItemsChanged: onViewableItemsChanged, viewabilityConfig: viewabilityConfig, onMomentumScrollEnd: onMomentumScrollEnd, initialNumToRender: 2, maxToRenderPerBatch: 2, windowSize: 3, refreshControl: _jsx(RefreshControl, { refreshing: refreshing, onRefresh: onRefresh, tintColor: "#e5e5e5" }) }));
55
+ }
56
+ const styles = StyleSheet.create({
57
+ center: {
58
+ flex: 1,
59
+ justifyContent: 'center',
60
+ alignItems: 'center',
61
+ backgroundColor: '#0a0a0a',
62
+ padding: 24,
63
+ },
64
+ item: {
65
+ height: VIEWPORT_HEIGHT,
66
+ width: '100%',
67
+ },
68
+ message: {
69
+ color: '#e5e5e5',
70
+ fontSize: 16,
71
+ textAlign: 'center',
72
+ },
73
+ error: {
74
+ color: '#ef4444',
75
+ fontSize: 14,
76
+ textAlign: 'center',
77
+ marginBottom: 8,
78
+ },
79
+ hint: {
80
+ color: '#737373',
81
+ fontSize: 12,
82
+ textAlign: 'center',
83
+ },
84
+ });
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import type { LiveStreamInfo } from '../types';
3
+ type LiveStreamViewerItemProps = {
4
+ stream: LiveStreamInfo;
5
+ isActive: boolean;
6
+ index: number;
7
+ };
8
+ /**
9
+ * Single full-screen viewer cell. When isActive, joins the stream room and consumes video/audio.
10
+ */
11
+ export declare const LiveStreamViewerItem: React.NamedExoticComponent<LiveStreamViewerItemProps>;
12
+ export {};
13
+ //# sourceMappingURL=LiveStreamViewerItem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LiveStreamViewerItem.d.ts","sourceRoot":"","sources":["../../src/components/LiveStreamViewerItem.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAqD,MAAM,OAAO,CAAC;AAa1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAK/C,KAAK,yBAAyB,GAAG;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAgBF;;GAEG;AACH,eAAO,MAAM,oBAAoB,uDAwR/B,CAAC"}
@@ -0,0 +1,304 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Dimensions, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native';
4
+ import { useViewerSocket } from '../hooks/useViewerSocket';
5
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
6
+ const CHAT_NAME_COLORS = ['#f97316', '#22c55e', '#3b82f6', '#eab308', '#ec4899', '#a855f7'];
7
+ let RTCViewComponent = null;
8
+ try {
9
+ const webrtc = require('react-native-webrtc');
10
+ RTCViewComponent = webrtc.RTCView;
11
+ }
12
+ catch {
13
+ // react-native-webrtc not available (e.g. Expo Go)
14
+ }
15
+ /**
16
+ * Single full-screen viewer cell. When isActive, joins the stream room and consumes video/audio.
17
+ */
18
+ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream, isActive, index, }) {
19
+ const roomId = isActive ? stream.roomId : null;
20
+ const { joined, joining, error, producerList, roomState, remoteStream, remoteVideoStream, webrtcUnavailable, consumeError, socket, } = useViewerSocket(roomId);
21
+ const viewerCount = roomState && typeof roomState.viewerCount === 'number'
22
+ ? roomState.viewerCount
23
+ : stream.viewerCount;
24
+ const hasVideo = producerList.some((p) => p.kind === 'video');
25
+ const streamObj = remoteStream;
26
+ const hasVideoTrack = streamObj &&
27
+ typeof streamObj.getVideoTracks === 'function' &&
28
+ streamObj.getVideoTracks().length > 0;
29
+ const trackCount = streamObj && typeof streamObj.getTracks === 'function' ? streamObj.getTracks().length : 0;
30
+ const displayStream = hasVideoTrack && remoteVideoStream ? remoteVideoStream : remoteStream;
31
+ const displayStreamObj = displayStream;
32
+ const streamURL = displayStreamObj && typeof displayStreamObj.toURL === 'function'
33
+ ? displayStreamObj.toURL()
34
+ : undefined;
35
+ const showVideo = RTCViewComponent && remoteStream && !!streamURL && hasVideoTrack;
36
+ const [chatInput, setChatInput] = useState('');
37
+ const [chatMessages, setChatMessages] = useState([]);
38
+ const seededRoomRef = useRef(null);
39
+ const chatListRef = useRef(null);
40
+ const seededFromRoomState = useMemo(() => {
41
+ const chat = roomState && typeof roomState === 'object' ? roomState.chat : null;
42
+ return Array.isArray(chat) ? chat : [];
43
+ }, [roomState]);
44
+ // Reset local chat when switching rooms.
45
+ useEffect(() => {
46
+ if (!roomId) {
47
+ seededRoomRef.current = null;
48
+ setChatMessages([]);
49
+ setChatInput('');
50
+ return;
51
+ }
52
+ if (seededRoomRef.current !== roomId) {
53
+ seededRoomRef.current = roomId;
54
+ setChatMessages([]);
55
+ setChatInput('');
56
+ }
57
+ }, [roomId]);
58
+ // Seed chat history (once per room) from roomState.chat if present.
59
+ useEffect(() => {
60
+ if (!roomId)
61
+ return;
62
+ if (seededRoomRef.current !== roomId)
63
+ return;
64
+ if (!seededFromRoomState.length)
65
+ return;
66
+ setChatMessages((prev) => {
67
+ if (prev.length > 0)
68
+ return prev;
69
+ const now = Date.now();
70
+ const normalized = seededFromRoomState
71
+ .map((m, i) => {
72
+ if (!m || typeof m !== 'object')
73
+ return null;
74
+ const msg = m;
75
+ const displayName = typeof msg.displayName === 'string' ? msg.displayName : 'User';
76
+ const text = typeof msg.text === 'string' ? msg.text : '';
77
+ const timestamp = typeof msg.timestamp === 'number' ? msg.timestamp : now - (seededFromRoomState.length - i) * 1000;
78
+ if (!text)
79
+ return null;
80
+ const id = `${roomId}-seed-${String(msg.peerId ?? i)}-${timestamp}-${i}`;
81
+ return { id, displayName, text, timestamp };
82
+ })
83
+ .filter(Boolean);
84
+ // Keep a reasonable amount of history, scrollable from the start.
85
+ return normalized;
86
+ });
87
+ }, [roomId, seededFromRoomState]);
88
+ // Live incoming chat messages.
89
+ useEffect(() => {
90
+ if (!socket || !roomId || !isActive)
91
+ return;
92
+ const onChatMessage = (msg) => {
93
+ if (!msg || typeof msg !== 'object')
94
+ return;
95
+ const m = msg;
96
+ const displayName = typeof m.displayName === 'string' ? m.displayName : 'User';
97
+ const text = typeof m.text === 'string' ? m.text : '';
98
+ const timestamp = typeof m.timestamp === 'number' ? m.timestamp : Date.now();
99
+ if (!text)
100
+ return;
101
+ const id = `${roomId}-${String(m.peerId ?? 'peer')}-${timestamp}-${text.slice(0, 8)}`;
102
+ setChatMessages((prev) => {
103
+ const next = [...prev, { id, displayName, text, timestamp }];
104
+ // Allow full history to be scrollable.
105
+ return next;
106
+ });
107
+ };
108
+ socket.on('chat-message', onChatMessage);
109
+ return () => {
110
+ socket.off('chat-message', onChatMessage);
111
+ };
112
+ }, [isActive, roomId, socket]);
113
+ // Render messages in natural order (oldest -> newest).
114
+ const chatData = useMemo(() => chatMessages, [chatMessages]);
115
+ // Always scroll to the latest message at the bottom when chat loads/updates.
116
+ useEffect(() => {
117
+ if (!chatListRef.current || chatData.length === 0)
118
+ return;
119
+ try {
120
+ chatListRef.current.scrollToEnd({ animated: true });
121
+ }
122
+ catch {
123
+ // ignore scroll errors
124
+ }
125
+ }, [chatData.length]);
126
+ const getNameColor = (name) => {
127
+ if (!name)
128
+ return CHAT_NAME_COLORS[0];
129
+ let hash = 0;
130
+ for (let i = 0; i < name.length; i += 1) {
131
+ // simple string hash
132
+ hash = (hash << 5) - hash + name.charCodeAt(i);
133
+ hash |= 0;
134
+ }
135
+ const idx = Math.abs(hash) % CHAT_NAME_COLORS.length;
136
+ return CHAT_NAME_COLORS[idx];
137
+ };
138
+ const sendChat = async () => {
139
+ const text = chatInput.trim();
140
+ if (!text || !socket || !joined)
141
+ return;
142
+ setChatInput('');
143
+ socket.emit('chat-message', { text }, (res) => {
144
+ if (res?.error) {
145
+ // eslint-disable-next-line no-console
146
+ console.log('[viewer] chat send error', res.error);
147
+ }
148
+ });
149
+ };
150
+ return (_jsxs(View, { style: styles.container, children: [showVideo && RTCViewComponent && streamURL ? (_jsx(RTCViewComponent, { streamURL: streamURL, stream: displayStream, style: styles.rtcView, objectFit: "cover", mirror: false }, `rtc-${trackCount}-${streamURL}`)) : (_jsxs(View, { style: styles.videoPlaceholder, children: [joining && _jsx(Text, { style: styles.placeholderText, children: "Joining..." }), error && _jsx(Text, { style: [styles.placeholderText, styles.errorText], children: error }), joined && !joining && !error && (_jsxs(_Fragment, { children: [_jsx(Text, { style: styles.liveBadge, children: "LIVE" }), _jsx(Text, { style: styles.placeholderText, children: stream.roomId }), hasVideo && webrtcUnavailable && (_jsxs(Text, { style: [styles.hintText, styles.warnText], children: ["Video needs a development build.", '\n', "Run: npx expo run:android"] })), hasVideo && consumeError && (_jsx(Text, { style: [styles.hintText, styles.errorText], children: consumeError })), hasVideo && !remoteStream && !webrtcUnavailable && !consumeError && (_jsx(Text, { style: styles.hintText, children: "Loading video..." })), !hasVideo && joined && producerList.length === 0 && (_jsxs(Text, { style: [styles.hintText, styles.warnText], children: ["Host has not started camera yet.", '\n', "Use a web client to stream, or wait for host to produce."] })), hasVideo && remoteStream && !RTCViewComponent && (_jsx(Text, { style: styles.hintText, children: "Video received (use dev build to see it)" })), hasVideo && remoteStream && RTCViewComponent && !hasVideoTrack && (_jsx(Text, { style: [styles.hintText, styles.warnText], children: "Audio only (no video track yet)" })), hasVideo && remoteStream && hasVideoTrack && !streamURL && (_jsx(Text, { style: [styles.hintText, styles.warnText], children: "Video track received but cannot display (need dev build with react-native-webrtc)" }))] }))] })), _jsxs(View, { style: styles.overlay, children: [_jsxs(View, { style: styles.topMeta, children: [_jsx(View, { style: styles.row, children: _jsxs(Text, { style: styles.roomId, numberOfLines: 1, children: ["LIVE \u00B7 ", stream.roomId] }) }), _jsxs(View, { style: styles.stats, children: [_jsxs(Text, { style: styles.statsText, children: ["\uD83D\uDC41 ", viewerCount] }), _jsxs(Text, { style: [styles.statsText, styles.statsTextSpacer], children: ["\u00B7 ", stream.producerCount, " track(s)"] })] })] }), _jsx(View, { style: styles.chatPanel, pointerEvents: "box-none", children: _jsx(KeyboardAvoidingView, { style: styles.chatKeyboardAvoider, behavior: Platform.OS === 'ios' ? 'padding' : 'height', keyboardVerticalOffset: 0, children: _jsxs(View, { style: styles.chatStack, children: [_jsx(View, { style: styles.chatListWrapper, children: _jsx(ScrollView, { ref: chatListRef, contentContainerStyle: styles.chatListContent, showsVerticalScrollIndicator: false, keyboardDismissMode: "on-drag", keyboardShouldPersistTaps: "always", nestedScrollEnabled: true, children: chatData.map((item) => (_jsxs(Text, { style: styles.chatLine, numberOfLines: 2, children: [_jsx(Text, { style: [styles.chatName, { color: getNameColor(item.displayName) }], children: item.displayName }), _jsxs(Text, { style: styles.chatText, children: [" ", item.text] })] }, item.id))) }) }), _jsxs(View, { style: styles.chatInputRow, children: [_jsx(TextInput, { style: styles.chatInput, value: chatInput, onChangeText: setChatInput, placeholder: joined ? 'Say something...' : 'Connecting...', placeholderTextColor: "#9ca3af", editable: joined && !joining && !error, onSubmitEditing: sendChat, returnKeyType: "send" }), _jsx(TouchableOpacity, { onPress: sendChat, disabled: !joined || joining || !!error || !chatInput.trim(), style: [
151
+ styles.chatSendButton,
152
+ (!joined || joining || !!error || !chatInput.trim()) && styles.chatSendButtonDisabled,
153
+ ], children: _jsx(Text, { style: styles.chatSendText, children: "\u27A4" }) })] })] }) }) })] })] }));
154
+ });
155
+ const styles = StyleSheet.create({
156
+ container: {
157
+ flex: 1,
158
+ backgroundColor: '#0a0a0a',
159
+ },
160
+ rtcView: {
161
+ ...StyleSheet.absoluteFillObject,
162
+ backgroundColor: '#0a0a0a',
163
+ },
164
+ videoPlaceholder: {
165
+ ...StyleSheet.absoluteFillObject,
166
+ alignItems: 'center',
167
+ justifyContent: 'center',
168
+ backgroundColor: '#1a1a1a',
169
+ },
170
+ placeholderText: {
171
+ color: '#e5e5e5',
172
+ fontSize: 16,
173
+ marginVertical: 4,
174
+ },
175
+ errorText: {
176
+ color: '#ef4444',
177
+ },
178
+ liveBadge: {
179
+ color: '#ef4444',
180
+ fontSize: 14,
181
+ fontWeight: '700',
182
+ letterSpacing: 1,
183
+ marginBottom: 8,
184
+ },
185
+ hintText: {
186
+ color: '#737373',
187
+ fontSize: 12,
188
+ marginTop: 4,
189
+ textAlign: 'center',
190
+ },
191
+ warnText: {
192
+ color: '#f59e0b',
193
+ marginTop: 8,
194
+ paddingHorizontal: 16,
195
+ },
196
+ overlay: {
197
+ position: 'absolute',
198
+ top: 0,
199
+ left: 0,
200
+ right: 0,
201
+ bottom: 0,
202
+ padding: 16,
203
+ paddingBottom: 32,
204
+ backgroundColor: 'transparent',
205
+ },
206
+ topMeta: {
207
+ marginTop: 6,
208
+ },
209
+ row: {
210
+ flexDirection: 'row',
211
+ alignItems: 'center',
212
+ },
213
+ roomId: {
214
+ color: '#fafafa',
215
+ fontSize: 14,
216
+ fontWeight: '600',
217
+ },
218
+ stats: {
219
+ flexDirection: 'row',
220
+ alignItems: 'center',
221
+ marginTop: 4,
222
+ },
223
+ statsText: {
224
+ color: '#a3a3a3',
225
+ fontSize: 12,
226
+ },
227
+ statsTextSpacer: {
228
+ marginLeft: 8,
229
+ },
230
+ chatPanel: {
231
+ position: 'absolute',
232
+ left: 16,
233
+ right: 16,
234
+ bottom: 16,
235
+ },
236
+ chatKeyboardAvoider: {
237
+ justifyContent: 'flex-end',
238
+ },
239
+ chatStack: {
240
+ justifyContent: 'flex-end',
241
+ },
242
+ chatListWrapper: {
243
+ // Limit chat area to below mid-screen (roughly bottom 1/3–1/2 of stream).
244
+ maxHeight: Math.round(SCREEN_HEIGHT * 0.35),
245
+ borderRadius: 14,
246
+ paddingHorizontal: 12,
247
+ paddingTop: 12,
248
+ paddingBottom: 4,
249
+ // Subtle transparent background so chat is readable over video.
250
+ backgroundColor: 'rgba(0,0,0,0.35)',
251
+ borderWidth: 1,
252
+ borderColor: 'rgba(255,255,255,0.08)',
253
+ // Wrap chats in a narrower column like TikTok.
254
+ alignSelf: 'flex-start',
255
+ width: '70%',
256
+ overflow: 'hidden',
257
+ },
258
+ chatListContent: {
259
+ paddingBottom: 4,
260
+ },
261
+ chatLine: {
262
+ color: '#e5e5e5',
263
+ fontSize: 13,
264
+ lineHeight: 18,
265
+ marginBottom: 4,
266
+ },
267
+ chatName: {
268
+ color: '#fafafa',
269
+ fontWeight: '700',
270
+ },
271
+ chatText: {
272
+ color: '#e5e5e5',
273
+ fontWeight: '400',
274
+ },
275
+ chatInputRow: {
276
+ marginTop: 10,
277
+ flexDirection: 'row',
278
+ alignItems: 'center',
279
+ borderRadius: 999,
280
+ overflow: 'hidden',
281
+ borderWidth: 1,
282
+ borderColor: 'rgba(255,255,255,0.12)',
283
+ backgroundColor: 'rgba(0,0,0,0.45)',
284
+ },
285
+ chatInput: {
286
+ flex: 1,
287
+ paddingHorizontal: 14,
288
+ paddingVertical: 10,
289
+ color: '#fafafa',
290
+ fontSize: 14,
291
+ },
292
+ chatSendButton: {
293
+ paddingHorizontal: 14,
294
+ paddingVertical: 10,
295
+ },
296
+ chatSendButtonDisabled: {
297
+ opacity: 0.5,
298
+ },
299
+ chatSendText: {
300
+ color: '#f97316',
301
+ fontSize: 18,
302
+ fontWeight: '700',
303
+ },
304
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Livestream module config. Host app must call setLivestreamConfig() before using the module.
3
+ */
4
+ export type LivestreamConfig = {
5
+ /** Base HTTP URL of the livestream server (e.g. http://localhost:3000) */
6
+ serverHttpUrl: string;
7
+ /** Optional: return display name for viewer in chat/room (default: 'Viewer') */
8
+ getDisplayName?: () => string;
9
+ };
10
+ export declare function setLivestreamConfig(cfg: LivestreamConfig): void;
11
+ export declare function getLivestreamConfig(): LivestreamConfig | null;
12
+ export declare function getBaseUrl(): string;
13
+ /**
14
+ * Socket.IO server URL.
15
+ *
16
+ * Important: socket.io-client expects an HTTP(S) URL (it will upgrade to WS/WSS itself).
17
+ * Using ws:// or wss:// here can cause connection issues / no fallback to polling on RN.
18
+ */
19
+ export declare function getSignalingUrl(): string;
20
+ export declare function getSignalingWsUrl(): string;
21
+ export declare function getDisplayName(): string;
22
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,0EAA0E;IAC1E,aAAa,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,MAAM,CAAC;CAC/B,CAAC;AAIF,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,CAE/D;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,IAAI,CAE7D;AASD,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAKxC;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAM1C;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC"}
package/dist/config.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Livestream module config. Host app must call setLivestreamConfig() before using the module.
3
+ */
4
+ let config = null;
5
+ export function setLivestreamConfig(cfg) {
6
+ config = cfg;
7
+ }
8
+ export function getLivestreamConfig() {
9
+ return config;
10
+ }
11
+ /** Fallback when config module is not shared (e.g. duplicate bundle); host should set this before mount. */
12
+ const getGlobalServerUrl = () => {
13
+ if (typeof globalThis === 'undefined')
14
+ return '';
15
+ const g = globalThis;
16
+ return g?.__LIVESTREAM_SERVER_HTTP_URI ?? '';
17
+ };
18
+ export function getBaseUrl() {
19
+ return config?.serverHttpUrl ?? getGlobalServerUrl();
20
+ }
21
+ /**
22
+ * Socket.IO server URL.
23
+ *
24
+ * Important: socket.io-client expects an HTTP(S) URL (it will upgrade to WS/WSS itself).
25
+ * Using ws:// or wss:// here can cause connection issues / no fallback to polling on RN.
26
+ */
27
+ export function getSignalingUrl() {
28
+ const base = getBaseUrl();
29
+ if (!base)
30
+ return '';
31
+ const url = new URL(base);
32
+ return url.origin;
33
+ }
34
+ export function getSignalingWsUrl() {
35
+ const base = getBaseUrl();
36
+ if (!base)
37
+ return '';
38
+ const url = new URL(base);
39
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ return url.origin;
41
+ }
42
+ export function getDisplayName() {
43
+ return config?.getDisplayName?.() ?? 'Viewer';
44
+ }
@@ -0,0 +1,8 @@
1
+ import type { LiveStreamInfo } from '../types';
2
+ export declare function useLiveStreams(): {
3
+ streams: LiveStreamInfo[];
4
+ loading: boolean;
5
+ error: string | null;
6
+ refetch: () => Promise<void>;
7
+ };
8
+ //# sourceMappingURL=useLiveStreams.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLiveStreams.d.ts","sourceRoot":"","sources":["../../src/hooks/useLiveStreams.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,wBAAgB,cAAc,IAAI;IAChC,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAmBA"}
@@ -0,0 +1,19 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { fetchActiveStreams } from '../services/livestream.service';
3
+ export function useLiveStreams() {
4
+ const [streams, setStreams] = useState([]);
5
+ const [loading, setLoading] = useState(true);
6
+ const [error, setError] = useState(null);
7
+ const refetch = useCallback(async () => {
8
+ setLoading(true);
9
+ setError(null);
10
+ const result = await fetchActiveStreams();
11
+ setStreams(result.streams);
12
+ setError(result.error ?? null);
13
+ setLoading(false);
14
+ }, []);
15
+ useEffect(() => {
16
+ refetch();
17
+ }, [refetch]);
18
+ return { streams, loading, error, refetch };
19
+ }
@@ -0,0 +1,25 @@
1
+ import { type Socket } from 'socket.io-client';
2
+ import type { ProducerInfo } from '../types';
3
+ type ViewerSocketState = {
4
+ joined: boolean;
5
+ joining: boolean;
6
+ error: string | null;
7
+ producerList: ProducerInfo[];
8
+ roomState: Record<string, unknown> | null;
9
+ rtpCapabilities: object | null;
10
+ remoteStream: unknown | null;
11
+ remoteVideoStream: unknown | null;
12
+ webrtcUnavailable: boolean;
13
+ consumeError: string | null;
14
+ /** Incremented to trigger one auto-retry when recv transport fails (ICE). */
15
+ consumeRetryKey: number;
16
+ };
17
+ /**
18
+ * Connects to the livestream signaling server and joins a room as viewer.
19
+ * When roomId changes, leaves the previous room and joins the new one (TikTok-style).
20
+ */
21
+ export declare function useViewerSocket(roomId: string | null): ViewerSocketState & {
22
+ socket: Socket | null;
23
+ };
24
+ export {};
25
+ //# sourceMappingURL=useViewerSocket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useViewerSocket.d.ts","sourceRoot":"","sources":["../../src/hooks/useViewerSocket.ts"],"names":[],"mappings":"AACA,OAAO,EAAM,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAInD,OAAO,KAAK,EAAkB,YAAY,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,iBAAiB,GAAG;IACvB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC1C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,iBAAiB,EAAE,OAAO,GAAG,IAAI,CAAC;IAClC,iBAAiB,EAAE,OAAO,CAAC;IAC3B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,6EAA6E;IAC7E,eAAe,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,iBAAiB,GAAG;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CA+apG"}
@@ -0,0 +1,399 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { io } from 'socket.io-client';
3
+ import { getDisplayName } from '../config';
4
+ import { ensureMediasoupGlobals } from '../services/mediasoup-init';
5
+ import { getSignalingUrl } from '../services/livestream.service';
6
+ /**
7
+ * Connects to the livestream signaling server and joins a room as viewer.
8
+ * When roomId changes, leaves the previous room and joins the new one (TikTok-style).
9
+ */
10
+ export function useViewerSocket(roomId) {
11
+ const [state, setState] = useState({
12
+ joined: false,
13
+ joining: false,
14
+ error: null,
15
+ producerList: [],
16
+ roomState: null,
17
+ rtpCapabilities: null,
18
+ remoteStream: null,
19
+ remoteVideoStream: null,
20
+ webrtcUnavailable: false,
21
+ consumeError: null,
22
+ consumeRetryKey: 0,
23
+ });
24
+ const socketRef = useRef(null);
25
+ const roomIdRef = useRef(roomId);
26
+ roomIdRef.current = roomId;
27
+ const deviceRef = useRef(null);
28
+ const transportRef = useRef(null);
29
+ const consumedProducerIdsRef = useRef(new Set());
30
+ const consumeInProgressRef = useRef(false);
31
+ const iceServersRef = useRef([]);
32
+ const consumeRetryCountRef = useRef(0);
33
+ const displayName = useMemo(() => getDisplayName(), []);
34
+ const disconnect = useCallback(() => {
35
+ consumedProducerIdsRef.current.clear();
36
+ try {
37
+ const transport = transportRef.current;
38
+ if (transport?.close)
39
+ transport.close();
40
+ transportRef.current = null;
41
+ deviceRef.current = null;
42
+ }
43
+ catch {
44
+ // ignore
45
+ }
46
+ const s = socketRef.current;
47
+ if (s) {
48
+ s.disconnect();
49
+ s.removeAllListeners();
50
+ socketRef.current = null;
51
+ }
52
+ setState({
53
+ joined: false,
54
+ joining: false,
55
+ error: null,
56
+ producerList: [],
57
+ roomState: null,
58
+ rtpCapabilities: null,
59
+ remoteStream: null,
60
+ remoteVideoStream: null,
61
+ webrtcUnavailable: false,
62
+ consumeError: null,
63
+ consumeRetryKey: 0,
64
+ });
65
+ consumeRetryCountRef.current = 0;
66
+ }, []);
67
+ const setRemoteStreams = useCallback((stream) => {
68
+ if (!stream) {
69
+ setState((prev) => ({ ...prev, remoteStream: null, remoteVideoStream: null }));
70
+ return;
71
+ }
72
+ const MediaStreamCtor = global.MediaStream;
73
+ const videoTracks = typeof stream.getVideoTracks === 'function' ? stream.getVideoTracks() : [];
74
+ const videoStream = MediaStreamCtor && Array.isArray(videoTracks) && videoTracks.length > 0
75
+ ? new MediaStreamCtor(videoTracks)
76
+ : stream;
77
+ setState((prev) => ({ ...prev, remoteStream: stream, remoteVideoStream: videoStream }));
78
+ }, []);
79
+ useEffect(() => {
80
+ if (!roomId) {
81
+ disconnect();
82
+ return;
83
+ }
84
+ const signalingUrl = getSignalingUrl();
85
+ if (!signalingUrl) {
86
+ setState((prev) => ({ ...prev, error: 'Livestream server not configured', joining: false }));
87
+ return;
88
+ }
89
+ setState((prev) => ({ ...prev, joining: true, error: null }));
90
+ const JOIN_TIMEOUT_MS = 12000;
91
+ const joinTimeout = setTimeout(() => {
92
+ if (roomIdRef.current !== roomId)
93
+ return;
94
+ setState((prev) => {
95
+ if (!prev.joining || prev.joined)
96
+ return prev;
97
+ return {
98
+ ...prev,
99
+ joining: false,
100
+ joined: false,
101
+ error: 'Join timed out. Check server URL and network.',
102
+ };
103
+ });
104
+ }, JOIN_TIMEOUT_MS);
105
+ // Match web client (Client/src/streamService.js): websocket first, then polling.
106
+ const socket = io(signalingUrl, {
107
+ transports: ['websocket', 'polling'],
108
+ reconnection: true,
109
+ autoConnect: true,
110
+ timeout: 10000,
111
+ });
112
+ socketRef.current = socket;
113
+ socket.on('connect', () => {
114
+ socket.emit('join-room', {
115
+ roomId,
116
+ role: 'viewer',
117
+ displayName,
118
+ }, (res) => {
119
+ clearTimeout(joinTimeout);
120
+ if (roomIdRef.current !== roomId)
121
+ return;
122
+ if (res?.error) {
123
+ setState((prev) => ({
124
+ ...prev,
125
+ joining: false,
126
+ joined: false,
127
+ error: res.error ?? 'Failed to join',
128
+ }));
129
+ return;
130
+ }
131
+ const iceFromJoin = res.iceServers;
132
+ if (Array.isArray(iceFromJoin) && iceFromJoin.length > 0) {
133
+ iceServersRef.current = iceFromJoin;
134
+ }
135
+ setState((prev) => ({
136
+ ...prev,
137
+ joining: false,
138
+ joined: true,
139
+ producerList: res.producerList ?? [],
140
+ roomState: res.roomState ?? null,
141
+ rtpCapabilities: res.rtpCapabilities ?? null,
142
+ error: null,
143
+ }));
144
+ });
145
+ });
146
+ socket.on('connect_error', (err) => {
147
+ clearTimeout(joinTimeout);
148
+ setState((prev) => ({
149
+ ...prev,
150
+ joining: false,
151
+ joined: false,
152
+ error: err?.message ?? 'Connection failed',
153
+ }));
154
+ });
155
+ socket.on('ice-servers', (servers) => {
156
+ iceServersRef.current = Array.isArray(servers) ? servers : [];
157
+ });
158
+ socket.on('room-updated', (payload) => {
159
+ if (payload?.producerList) {
160
+ setState((prev) => ({ ...prev, producerList: payload.producerList ?? [] }));
161
+ }
162
+ });
163
+ socket.on('new-producer', () => {
164
+ socket.emit('get-producers', null, (resp) => {
165
+ if (resp?.producerList) {
166
+ setState((prev) => ({ ...prev, producerList: resp.producerList ?? [] }));
167
+ }
168
+ });
169
+ });
170
+ return () => {
171
+ clearTimeout(joinTimeout);
172
+ disconnect();
173
+ };
174
+ }, [roomId, displayName, disconnect]);
175
+ // Consume flow: create device, recv transport, connect, consume each producer → remoteStream
176
+ useEffect(() => {
177
+ const socket = socketRef.current;
178
+ const { joined, rtpCapabilities, producerList } = state;
179
+ if (!joined || !rtpCapabilities || !producerList?.length || !socket || state.remoteStream)
180
+ return;
181
+ if (roomIdRef.current !== roomId)
182
+ return;
183
+ if (consumeInProgressRef.current)
184
+ return;
185
+ consumeInProgressRef.current = true;
186
+ let cancelled = false;
187
+ const runConsume = async () => {
188
+ if (!ensureMediasoupGlobals()) {
189
+ consumeInProgressRef.current = false;
190
+ if (!cancelled) {
191
+ setState((prev) => ({ ...prev, webrtcUnavailable: true, consumeError: null }));
192
+ }
193
+ return;
194
+ }
195
+ try {
196
+ setState((prev) => ({ ...prev, consumeError: null }));
197
+ const mediasoupClient = require('mediasoup-client');
198
+ const device = new mediasoupClient.Device();
199
+ deviceRef.current = device;
200
+ await device.load({ routerRtpCapabilities: rtpCapabilities });
201
+ if (cancelled)
202
+ return;
203
+ const transportOpts = await new Promise((resolve, reject) => {
204
+ socket.emit('create-webrtc-transport', {}, (res) => {
205
+ if (res?.error) {
206
+ reject(new Error(res.error));
207
+ return;
208
+ }
209
+ if (res?.id && res?.iceParameters && res?.dtlsParameters) {
210
+ resolve({
211
+ id: res.id,
212
+ iceParameters: res.iceParameters,
213
+ iceCandidates: res.iceCandidates ?? [],
214
+ dtlsParameters: res.dtlsParameters,
215
+ });
216
+ }
217
+ else {
218
+ reject(new Error('Invalid transport response'));
219
+ }
220
+ });
221
+ });
222
+ if (cancelled)
223
+ return;
224
+ const recvOptions = { ...transportOpts, iceTransportPolicy: 'all' };
225
+ if (iceServersRef.current.length > 0) {
226
+ recvOptions.iceServers = iceServersRef.current;
227
+ }
228
+ // Debug: log recv transport options for troubleshooting (kept small).
229
+ // eslint-disable-next-line no-console
230
+ console.log('[viewer] recv options', {
231
+ hasIceServers: !!recvOptions.iceServers && recvOptions.iceServers.length > 0,
232
+ iceServerCount: recvOptions.iceServers?.length ?? 0,
233
+ });
234
+ const transport = device.createRecvTransport(recvOptions);
235
+ transportRef.current = transport;
236
+ // Wire DTLS connect; do not block on connectionstatechange before consuming.
237
+ transport.on('connect', ({ dtlsParameters }, callback, errback) => {
238
+ socket.emit('connect-transport', { transportId: transport.id, dtlsParameters }, (res) => {
239
+ if (res?.error)
240
+ errback(new Error(res.error));
241
+ else
242
+ callback();
243
+ });
244
+ });
245
+ transport.on('connectionstatechange', (connectionState) => {
246
+ // eslint-disable-next-line no-console
247
+ console.log('[viewer] recv transport state', connectionState);
248
+ if (connectionState === 'failed' || connectionState === 'closed') {
249
+ const transportToClose = transportRef.current;
250
+ try {
251
+ if (transportToClose?.close)
252
+ transportToClose.close();
253
+ }
254
+ catch {
255
+ // ignore
256
+ }
257
+ transportRef.current = null;
258
+ deviceRef.current = null;
259
+ consumedProducerIdsRef.current.clear();
260
+ if (connectionState === 'failed' && consumeRetryCountRef.current < 1) {
261
+ consumeRetryCountRef.current += 1;
262
+ consumeInProgressRef.current = false;
263
+ setState((prev) => ({
264
+ ...prev,
265
+ consumeError: null,
266
+ consumeRetryKey: prev.consumeRetryKey + 1,
267
+ }));
268
+ }
269
+ else {
270
+ setState((prev) => ({
271
+ ...prev,
272
+ consumeError: prev.consumeError ?? 'Connection failed. Check network or try again.',
273
+ }));
274
+ }
275
+ }
276
+ });
277
+ const MediaStreamCtor = global.MediaStream;
278
+ if (!MediaStreamCtor)
279
+ return;
280
+ const stream = new MediaStreamCtor();
281
+ if (!stream)
282
+ return;
283
+ for (const producer of producerList) {
284
+ if (cancelled)
285
+ break;
286
+ const consumeRes = await new Promise((resolve, reject) => {
287
+ socket.emit('consume', { transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities }, (res) => {
288
+ if (res?.error) {
289
+ reject(new Error(res.error));
290
+ return;
291
+ }
292
+ if (res?.id && res?.producerId && res?.kind && res?.rtpParameters) {
293
+ resolve({ id: res.id, producerId: res.producerId, kind: res.kind, rtpParameters: res.rtpParameters });
294
+ }
295
+ else {
296
+ reject(new Error('Invalid consume response'));
297
+ }
298
+ });
299
+ });
300
+ const consumer = await transport.consume({
301
+ id: consumeRes.id,
302
+ producerId: consumeRes.producerId,
303
+ kind: consumeRes.kind,
304
+ rtpParameters: consumeRes.rtpParameters,
305
+ });
306
+ const track = consumer.track;
307
+ if (track)
308
+ stream.addTrack(track);
309
+ consumedProducerIdsRef.current.add(producer.id);
310
+ }
311
+ const trackCount = typeof stream.getTracks === 'function' ? stream.getTracks().length : 0;
312
+ if (!cancelled) {
313
+ consumeRetryCountRef.current = 0;
314
+ if (trackCount > 0) {
315
+ setRemoteStreams(stream);
316
+ }
317
+ else {
318
+ setState((prev) => ({
319
+ ...prev,
320
+ remoteStream: null,
321
+ consumeError: 'No video or audio track received from host.',
322
+ }));
323
+ }
324
+ }
325
+ }
326
+ catch (err) {
327
+ if (!cancelled) {
328
+ const message = err instanceof Error ? err.message : 'Video failed to load';
329
+ // eslint-disable-next-line no-console
330
+ console.log('[viewer] consume error', message);
331
+ setState((prev) => ({ ...prev, remoteStream: null, consumeError: message }));
332
+ }
333
+ }
334
+ finally {
335
+ consumeInProgressRef.current = false;
336
+ }
337
+ };
338
+ runConsume();
339
+ return () => {
340
+ cancelled = true;
341
+ };
342
+ }, [roomId, state.joined, state.rtpCapabilities, state.producerList?.length ?? 0, state.remoteStream, state.consumeRetryKey]);
343
+ // Consume new producers added after we already have a stream
344
+ useEffect(() => {
345
+ const socket = socketRef.current;
346
+ const transport = transportRef.current;
347
+ const device = deviceRef.current;
348
+ const stream = state.remoteStream;
349
+ if (!socket || !transport || !device || !stream || !state.producerList?.length)
350
+ return;
351
+ if (roomIdRef.current !== roomId)
352
+ return;
353
+ const toConsume = state.producerList.filter((p) => !consumedProducerIdsRef.current.has(p.id));
354
+ if (toConsume.length === 0)
355
+ return;
356
+ let cancelled = false;
357
+ (async () => {
358
+ for (const producer of toConsume) {
359
+ if (cancelled)
360
+ break;
361
+ try {
362
+ const consumeRes = await new Promise((resolve, reject) => {
363
+ socket.emit('consume', { transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities }, (res) => {
364
+ if (res?.error)
365
+ reject(new Error(res.error));
366
+ else if (res?.id && res?.producerId && res?.kind && res?.rtpParameters) {
367
+ resolve({ id: res.id, producerId: res.producerId, kind: res.kind, rtpParameters: res.rtpParameters });
368
+ }
369
+ else
370
+ reject(new Error('Invalid consume response'));
371
+ });
372
+ });
373
+ const consumer = await transport.consume({
374
+ id: consumeRes.id,
375
+ producerId: consumeRes.producerId,
376
+ kind: consumeRes.kind,
377
+ rtpParameters: consumeRes.rtpParameters,
378
+ });
379
+ const track = consumer.track;
380
+ if (track && !cancelled) {
381
+ stream.addTrack(track);
382
+ consumedProducerIdsRef.current.add(producer.id);
383
+ setRemoteStreams(stream);
384
+ }
385
+ }
386
+ catch {
387
+ // ignore per-producer errors
388
+ }
389
+ }
390
+ })();
391
+ return () => {
392
+ cancelled = true;
393
+ };
394
+ }, [roomId, state.remoteStream, state.producerList]);
395
+ return {
396
+ ...state,
397
+ socket: socketRef.current,
398
+ };
399
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @livestream/core — Reusable livestream module for React Native (Expo).
3
+ * Configure with setLivestreamConfig() before using components/hooks.
4
+ */
5
+ export { setLivestreamConfig, getLivestreamConfig, getBaseUrl, getSignalingUrl, getSignalingWsUrl, getDisplayName, } from './config';
6
+ export type { LivestreamConfig } from './config';
7
+ export { fetchActiveStreams } from './services/livestream.service';
8
+ export type { FetchStreamsResult } from './services/livestream.service';
9
+ export { ensureMediasoupGlobals } from './services/mediasoup-init';
10
+ export { useViewerSocket } from './hooks/useViewerSocket';
11
+ export { useLiveStreams } from './hooks/useLiveStreams';
12
+ export { LiveStreamFeed } from './components/LiveStreamFeed';
13
+ export { LiveStreamViewerItem } from './components/LiveStreamViewerItem';
14
+ export type { LiveStreamInfo, ProducerInfo, JoinRoomResult, RoomState } from './types';
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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,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;AACnE,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACxE,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"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @livestream/core — Reusable livestream module for React Native (Expo).
3
+ * Configure with setLivestreamConfig() before using components/hooks.
4
+ */
5
+ export { setLivestreamConfig, getLivestreamConfig, getBaseUrl, getSignalingUrl, getSignalingWsUrl, getDisplayName, } from './config';
6
+ export { fetchActiveStreams } from './services/livestream.service';
7
+ export { ensureMediasoupGlobals } from './services/mediasoup-init';
8
+ export { useViewerSocket } from './hooks/useViewerSocket';
9
+ export { useLiveStreams } from './hooks/useLiveStreams';
10
+ export { LiveStreamFeed } from './components/LiveStreamFeed';
11
+ export { LiveStreamViewerItem } from './components/LiveStreamViewerItem';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Livestream API: fetch active streams from HTTP; signaling URL from config.
3
+ */
4
+ import type { LiveStreamInfo } from '../types';
5
+ export type FetchStreamsResult = {
6
+ streams: LiveStreamInfo[];
7
+ error?: never;
8
+ } | {
9
+ streams: [];
10
+ error: string;
11
+ };
12
+ export declare function fetchActiveStreams(): Promise<FetchStreamsResult>;
13
+ export declare function getSignalingUrl(): string;
14
+ export declare function getSignalingWsUrl(): string;
15
+ //# sourceMappingURL=livestream.service.d.ts.map
@@ -0,0 +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;AAEnC,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAoBtE;AAED,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Livestream API: fetch active streams from HTTP; signaling URL from config.
3
+ */
4
+ import { getBaseUrl, getSignalingUrl as getUrlFromConfig } from '../config';
5
+ export async function fetchActiveStreams() {
6
+ const base = getBaseUrl();
7
+ if (!base) {
8
+ return { streams: [], error: 'Livestream server not configured' };
9
+ }
10
+ try {
11
+ const url = `${base.replace(/\/$/, '')}/api/live-streams`;
12
+ const res = await fetch(url, { method: 'GET' });
13
+ if (!res.ok) {
14
+ return { streams: [], error: `HTTP ${res.status}` };
15
+ }
16
+ const data = (await res.json());
17
+ if (data.error) {
18
+ return { streams: [], error: data.error };
19
+ }
20
+ return { streams: data.streams ?? [] };
21
+ }
22
+ catch (e) {
23
+ const message = e instanceof Error ? e.message : 'Unknown error';
24
+ return { streams: [], error: message };
25
+ }
26
+ }
27
+ export function getSignalingUrl() {
28
+ return getUrlFromConfig();
29
+ }
30
+ // Back-compat alias (socket.io-client should use HTTP(S) URL; see getSignalingUrl()).
31
+ export function getSignalingWsUrl() {
32
+ return getSignalingUrl();
33
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * One-time init for mediasoup-client in React Native.
3
+ * Call registerGlobals() from react-native-webrtc before creating any Device.
4
+ */
5
+ export declare function ensureMediasoupGlobals(): boolean;
6
+ //# sourceMappingURL=mediasoup-init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mediasoup-init.d.ts","sourceRoot":"","sources":["../../src/services/mediasoup-init.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,wBAAgB,sBAAsB,IAAI,OAAO,CAahD"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * One-time init for mediasoup-client in React Native.
3
+ * Call registerGlobals() from react-native-webrtc before creating any Device.
4
+ */
5
+ let globalsRegistered = false;
6
+ export function ensureMediasoupGlobals() {
7
+ if (globalsRegistered)
8
+ return true;
9
+ try {
10
+ const { registerGlobals } = require('react-native-webrtc');
11
+ if (typeof registerGlobals === 'function') {
12
+ registerGlobals();
13
+ globalsRegistered = true;
14
+ return true;
15
+ }
16
+ }
17
+ catch {
18
+ // react-native-webrtc not available (e.g. Expo Go without dev client)
19
+ }
20
+ return false;
21
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Livestream module types.
3
+ * Stream list from GET /api/live-streams; room signaling via Socket.IO.
4
+ */
5
+ export type LiveStreamInfo = {
6
+ roomId: string;
7
+ viewerCount: number;
8
+ producerCount: number;
9
+ };
10
+ export type ProducerInfo = {
11
+ id: string;
12
+ kind: 'audio' | 'video';
13
+ peerId: string;
14
+ };
15
+ export type JoinRoomPayload = {
16
+ roomId: string;
17
+ role: 'host' | 'viewer';
18
+ displayName?: string;
19
+ };
20
+ export type JoinRoomResult = {
21
+ rtpCapabilities?: object;
22
+ producerList?: ProducerInfo[];
23
+ roomState?: RoomState;
24
+ error?: string;
25
+ };
26
+ export type RoomState = {
27
+ chat?: unknown[];
28
+ likes?: number;
29
+ viewerCount?: number;
30
+ [key: string]: unknown;
31
+ };
32
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Livestream module types.
3
+ * Stream list from GET /api/live-streams; room signaling via Socket.IO.
4
+ */
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "kasunk99-livestream-core",
3
+ "version": "0.1.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
+ }