kasunk99-livestream-core 0.3.7 → 0.3.9

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.
@@ -1 +1 @@
1
- {"version":3,"file":"LiveStreamViewerItem.d.ts","sourceRoot":"","sources":["../../src/components/LiveStreamViewerItem.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAqD,MAAM,OAAO,CAAC;AAe1E,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,uDAyW/B,CAAC"}
1
+ {"version":3,"file":"LiveStreamViewerItem.d.ts","sourceRoot":"","sources":["../../src/components/LiveStreamViewerItem.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAqD,MAAM,OAAO,CAAC;AAgB1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAO/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,uDA2X/B,CAAC"}
@@ -1,25 +1,25 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useMemo, useRef, useState } from 'react';
3
- import { Animated, Dimensions, Keyboard, NativeModules, Platform, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native';
3
+ import { ActivityIndicator, Animated, Dimensions, Keyboard, NativeModules, Platform, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native';
4
4
  import { useViewerSocket } from '../hooks/useViewerSocket';
5
- const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
5
+ const SCREEN_HEIGHT = Dimensions.get('window').height;
6
6
  const CHAT_NAME_COLORS = ['#f97316', '#22c55e', '#3b82f6', '#eab308', '#ec4899', '#a855f7'];
7
+ // iOS needs bottom safe-area padding; Android auto-adjusts via adjustResize
8
+ const BOTTOM_SAFE = Platform.OS === 'ios' ? 28 : 10;
7
9
  let RTCViewComponent = null;
8
10
  try {
9
11
  const webrtc = require('react-native-webrtc');
10
12
  RTCViewComponent = webrtc.RTCView;
11
13
  }
12
14
  catch {
13
- // react-native-webrtc not available (e.g. Expo Go)
15
+ // react-native-webrtc not available in Expo Go
14
16
  }
15
17
  /**
16
18
  * Single full-screen viewer cell. When isActive, joins the stream room and consumes video/audio.
17
19
  */
18
- export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream, isActive, index, }) {
20
+ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream, isActive, index: _index, }) {
19
21
  const roomId = isActive ? stream.roomId : null;
20
- const { joined, joining, error, producerList, roomState, remoteStream, remoteVideoStream, webrtcUnavailable, consumeError, socket, viewerCount: liveViewerCount, } = useViewerSocket(roomId);
21
- // Prefer real-time socket count (updated on every join/leave event);
22
- // fall back to the HTTP-polled value from the feed for the initial render.
22
+ const { joined, joining, error, producerList, roomState, remoteStream, remoteVideoStream, webrtcUnavailable, consumeError, socket, viewerCount: liveViewerCount, streamEnded, } = useViewerSocket(roomId);
23
23
  const viewerCount = liveViewerCount > 0 ? liveViewerCount : stream.viewerCount;
24
24
  const hasVideo = producerList.some((p) => p.kind === 'video');
25
25
  const streamObj = remoteStream;
@@ -35,41 +35,35 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
35
35
  const showVideo = RTCViewComponent && remoteStream && !!streamURL && hasVideoTrack;
36
36
  const [chatInput, setChatInput] = useState('');
37
37
  const [chatMessages, setChatMessages] = useState([]);
38
- // Animated bottom offset for the chat panel.
39
- // iOS: keyboard fires willShow/willHide with exact duration — we mirror that
40
- // animation so the panel moves in perfect sync with the keyboard.
41
- // Android: windowSoftInputMode="adjustResize" shrinks the layout automatically,
42
- // so no JS offset is needed (adding one would create a double gap).
43
- const chatBottomAnim = useRef(new Animated.Value(16)).current;
38
+ // iOS keyboard animation Android handled automatically via windowSoftInputMode=adjustResize
39
+ const kbOffset = useRef(new Animated.Value(0)).current;
44
40
  useEffect(() => {
45
41
  if (Platform.OS !== 'ios')
46
- return; // Android handled by the OS via adjustResize
42
+ return;
47
43
  const show = Keyboard.addListener('keyboardWillShow', (e) => {
48
- Animated.timing(chatBottomAnim, {
49
- toValue: 16 + e.endCoordinates.height,
50
- duration: e.duration ?? 250,
44
+ Animated.timing(kbOffset, {
45
+ toValue: e.endCoordinates.height,
46
+ duration: e.duration ?? 260,
51
47
  useNativeDriver: false,
52
48
  }).start();
53
49
  });
54
50
  const hide = Keyboard.addListener('keyboardWillHide', (e) => {
55
- Animated.timing(chatBottomAnim, {
56
- toValue: 16,
57
- duration: e.duration ?? 250,
51
+ Animated.timing(kbOffset, {
52
+ toValue: 0,
53
+ duration: e.duration ?? 220,
58
54
  useNativeDriver: false,
59
55
  }).start();
60
56
  });
61
- return () => {
62
- show.remove();
63
- hide.remove();
64
- };
65
- }, [chatBottomAnim]);
57
+ return () => { show.remove(); hide.remove(); };
58
+ }, [kbOffset]);
66
59
  const seededRoomRef = useRef(null);
67
60
  const chatListRef = useRef(null);
68
61
  const seededFromRoomState = useMemo(() => {
69
- const chat = roomState && typeof roomState === 'object' ? roomState.chat : null;
62
+ const chat = roomState && typeof roomState === 'object'
63
+ ? roomState.chat
64
+ : null;
70
65
  return Array.isArray(chat) ? chat : [];
71
66
  }, [roomState]);
72
- // Reset local chat when switching rooms.
73
67
  useEffect(() => {
74
68
  if (!roomId) {
75
69
  seededRoomRef.current = null;
@@ -83,7 +77,6 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
83
77
  setChatInput('');
84
78
  }
85
79
  }, [roomId]);
86
- // Seed chat history (once per room) from roomState.chat if present.
87
80
  useEffect(() => {
88
81
  if (!roomId)
89
82
  return;
@@ -102,18 +95,18 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
102
95
  const msg = m;
103
96
  const displayName = typeof msg.displayName === 'string' ? msg.displayName : 'User';
104
97
  const text = typeof msg.text === 'string' ? msg.text : '';
105
- const timestamp = typeof msg.timestamp === 'number' ? msg.timestamp : now - (seededFromRoomState.length - i) * 1000;
98
+ const timestamp = typeof msg.timestamp === 'number'
99
+ ? msg.timestamp
100
+ : now - (seededFromRoomState.length - i) * 1000;
106
101
  if (!text)
107
102
  return null;
108
103
  const id = `${roomId}-seed-${String(msg.peerId ?? i)}-${timestamp}-${i}`;
109
104
  return { id, displayName, text, timestamp };
110
105
  })
111
106
  .filter(Boolean);
112
- // Keep a reasonable amount of history, scrollable from the start.
113
107
  return normalized;
114
108
  });
115
109
  }, [roomId, seededFromRoomState]);
116
- // Live incoming chat messages.
117
110
  useEffect(() => {
118
111
  if (!socket || !roomId || !isActive)
119
112
  return;
@@ -127,11 +120,7 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
127
120
  if (!text)
128
121
  return;
129
122
  const id = `${roomId}-${String(m.peerId ?? 'peer')}-${timestamp}-${text.slice(0, 8)}`;
130
- setChatMessages((prev) => {
131
- const next = [...prev, { id, displayName, text, timestamp }];
132
- // Allow full history to be scrollable.
133
- return next;
134
- });
123
+ setChatMessages((prev) => [...prev, { id, displayName, text, timestamp }]);
135
124
  };
136
125
  const onViewerJoined = (msg) => {
137
126
  const m = msg;
@@ -150,20 +139,16 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
150
139
  socket.off('viewer-joined', onViewerJoined);
151
140
  };
152
141
  }, [isActive, roomId, socket]);
153
- // Render messages in natural order (oldest -> newest).
154
142
  const chatData = useMemo(() => chatMessages, [chatMessages]);
155
- // Always scroll to the latest message at the bottom when chat loads/updates.
156
143
  useEffect(() => {
157
144
  if (!chatListRef.current || chatData.length === 0)
158
145
  return;
159
146
  try {
160
147
  chatListRef.current.scrollToEnd({ animated: true });
161
148
  }
162
- catch {
163
- // ignore scroll errors
164
- }
149
+ catch { /* ignore */ }
165
150
  }, [chatData.length]);
166
- // System audio playback (Android only — receives AAC chunks from host during screen sharing)
151
+ // System audio playback (Android only)
167
152
  useEffect(() => {
168
153
  if (!socket || !joined || !isActive || Platform.OS !== 'android')
169
154
  return;
@@ -198,174 +183,290 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
198
183
  return CHAT_NAME_COLORS[0];
199
184
  let hash = 0;
200
185
  for (let i = 0; i < name.length; i += 1) {
201
- // simple string hash
202
186
  hash = (hash << 5) - hash + name.charCodeAt(i);
203
187
  hash |= 0;
204
188
  }
205
- const idx = Math.abs(hash) % CHAT_NAME_COLORS.length;
206
- return CHAT_NAME_COLORS[idx];
189
+ return CHAT_NAME_COLORS[Math.abs(hash) % CHAT_NAME_COLORS.length];
207
190
  };
208
- const sendChat = async () => {
191
+ const sendChat = () => {
209
192
  const text = chatInput.trim();
210
193
  if (!text || !socket || !joined)
211
194
  return;
212
195
  setChatInput('');
213
196
  socket.emit('chat-message', { text }, (res) => {
214
- if (res?.error) {
215
- // eslint-disable-next-line no-console
197
+ if (res?.error)
216
198
  console.log('[viewer] chat send error', res.error);
217
- }
218
199
  });
219
200
  };
220
- 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.hostDisplayName || stream.title || stream.roomId] }) }), _jsx(View, { style: styles.stats, children: _jsxs(Text, { style: styles.statsText, children: ["\uD83D\uDC41 ", viewerCount] }) })] }), _jsx(Animated.View, { style: [styles.chatPanel, { bottom: chatBottomAnim }], pointerEvents: "box-none", children: _jsxs(ScrollView, { scrollEnabled: false, keyboardShouldPersistTaps: "always", contentContainerStyle: 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: [
221
- styles.chatSendButton,
222
- (!joined || joining || !!error || !chatInput.trim()) && styles.chatSendButtonDisabled,
223
- ], children: _jsx(Text, { style: styles.chatSendText, children: "\u27A4" }) })] })] }) })] })] }));
201
+ const hostLabel = stream.hostDisplayName || stream.title || 'Live';
202
+ const avatarLetter = hostLabel[0]?.toUpperCase() ?? 'L';
203
+ 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(ActivityIndicator, { size: "large", color: "rgba(255,255,255,0.6)" })), !joining && error && (_jsx(Text, { style: styles.placeholderError, children: error })), joined && !joining && !error && hasVideo && !remoteStream && (_jsx(ActivityIndicator, { size: "large", color: "rgba(255,255,255,0.45)" })), joined && !joining && !error && hasVideo && webrtcUnavailable && (_jsx(Text, { style: styles.placeholderHint, children: "Video needs a development build" })), joined && !joining && !error && hasVideo && consumeError && (_jsx(Text, { style: styles.placeholderError, children: consumeError }))] })), _jsx(View, { style: styles.topGradient, pointerEvents: "none" }), _jsx(View, { style: styles.bottomGradient, pointerEvents: "none" }), _jsxs(View, { style: styles.topBar, pointerEvents: "box-none", children: [_jsxs(View, { style: styles.hostRow, pointerEvents: "none", children: [_jsx(View, { style: styles.avatar, children: _jsx(Text, { style: styles.avatarLetter, children: avatarLetter }) }), _jsx(Text, { style: styles.hostName, numberOfLines: 1, children: hostLabel }), _jsx(View, { style: styles.livePill, children: _jsx(Text, { style: styles.livePillText, children: "LIVE" }) })] }), _jsxs(View, { style: styles.viewerChip, pointerEvents: "none", children: [_jsx(Text, { style: styles.viewerEye, children: "\uD83D\uDC41" }), _jsx(Text, { style: styles.viewerCount, children: viewerCount > 0 ? viewerCount.toLocaleString() : '—' })] })] }), _jsxs(View, { style: styles.rightColumn, pointerEvents: "box-none", children: [_jsxs(TouchableOpacity, { style: styles.actionBtn, activeOpacity: 0.75, children: [_jsx(View, { style: styles.actionCircle, children: _jsx(Text, { style: styles.actionIcon, children: "\u2665" }) }), _jsx(Text, { style: styles.actionLabel, children: "Like" })] }), _jsxs(TouchableOpacity, { style: styles.actionBtn, activeOpacity: 0.75, children: [_jsx(View, { style: styles.actionCircle, children: _jsx(Text, { style: styles.actionIcon, children: "\uD83C\uDF81" }) }), _jsx(Text, { style: styles.actionLabel, children: "Gift" })] }), _jsxs(TouchableOpacity, { style: styles.actionBtn, activeOpacity: 0.75, children: [_jsx(View, { style: styles.actionCircle, children: _jsx(Text, { style: styles.actionIcon, children: "\u2197" }) }), _jsx(Text, { style: styles.actionLabel, children: "Share" })] })] }), _jsxs(Animated.View, { style: [styles.bottomPanel, { bottom: kbOffset }], pointerEvents: "box-none", children: [_jsx(View, { style: styles.chatColumn, pointerEvents: "box-none", children: _jsx(ScrollView, { ref: chatListRef, contentContainerStyle: styles.chatContent, showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "always", keyboardDismissMode: "none", children: chatData.map((item) => item.displayName ? (_jsx(View, { style: styles.chatBubble, children: _jsxs(Text, { style: styles.chatLine, numberOfLines: 3, children: [_jsxs(Text, { style: [styles.chatUsername, { color: getNameColor(item.displayName) }], children: [item.displayName, ' '] }), _jsx(Text, { style: styles.chatMsg, children: item.text })] }) }, item.id)) : (_jsx(View, { style: styles.joinBubble, children: _jsx(Text, { style: styles.joinText, children: item.text }) }, item.id))) }) }), _jsxs(View, { style: styles.inputBar, children: [_jsx(TouchableOpacity, { style: styles.inputIconBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.inputIconGlyph, children: "\u263A" }) }), _jsx(TextInput, { style: styles.textInput, value: chatInput, onChangeText: setChatInput, placeholder: joined ? 'Say something...' : 'Connecting...', placeholderTextColor: "rgba(255,255,255,0.35)", editable: joined && !joining && !error, onSubmitEditing: sendChat, returnKeyType: "send", blurOnSubmit: false }), _jsx(TouchableOpacity, { style: styles.inputIconBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.inputIconGlyph, children: "\uD83C\uDF39" }) }), _jsx(TouchableOpacity, { style: [styles.sendBtn, (!chatInput.trim() || !joined) && styles.sendBtnOff], onPress: sendChat, activeOpacity: 0.75, disabled: !joined || !chatInput.trim(), children: _jsx(Text, { style: styles.sendIcon, children: "\u25B6" }) })] })] }), streamEnded && (_jsx(View, { style: styles.endedOverlay, children: _jsxs(View, { style: styles.endedCard, children: [_jsx(Text, { style: styles.endedTitle, children: "Stream ended" }), _jsx(Text, { style: styles.endedSub, children: "The host has ended this live stream" })] }) }))] }));
224
204
  });
205
+ // ─────────────────────────────────────────────────────────────────────────────
225
206
  const styles = StyleSheet.create({
226
207
  container: {
227
208
  flex: 1,
228
- backgroundColor: '#0a0a0a',
209
+ backgroundColor: '#000',
229
210
  },
211
+ // ── Video ─────────────────────────────────────────────────────────────────
230
212
  rtcView: {
231
- ...StyleSheet.absoluteFillObject,
232
- backgroundColor: '#0a0a0a',
213
+ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
214
+ backgroundColor: '#000',
233
215
  },
234
216
  videoPlaceholder: {
235
- ...StyleSheet.absoluteFillObject,
217
+ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
236
218
  alignItems: 'center',
237
219
  justifyContent: 'center',
238
- backgroundColor: '#1a1a1a',
220
+ backgroundColor: '#0d0d0d',
221
+ gap: 12,
239
222
  },
240
- placeholderText: {
241
- color: '#e5e5e5',
242
- fontSize: 16,
243
- marginVertical: 4,
244
- },
245
- errorText: {
223
+ placeholderError: {
246
224
  color: '#ef4444',
225
+ fontSize: 13,
226
+ textAlign: 'center',
227
+ paddingHorizontal: 28,
247
228
  },
248
- liveBadge: {
249
- color: '#ef4444',
250
- fontSize: 14,
251
- fontWeight: '700',
252
- letterSpacing: 1,
253
- marginBottom: 8,
254
- },
255
- hintText: {
256
- color: '#737373',
229
+ placeholderHint: {
230
+ color: 'rgba(255,255,255,0.38)',
257
231
  fontSize: 12,
258
- marginTop: 4,
259
232
  textAlign: 'center',
233
+ paddingHorizontal: 28,
260
234
  },
261
- warnText: {
262
- color: '#f59e0b',
263
- marginTop: 8,
264
- paddingHorizontal: 16,
265
- },
266
- overlay: {
235
+ // ── Gradient overlays ─────────────────────────────────────────────────────
236
+ topGradient: {
267
237
  position: 'absolute',
268
238
  top: 0,
269
239
  left: 0,
270
240
  right: 0,
241
+ height: 140,
242
+ backgroundColor: 'rgba(0,0,0,0.45)',
243
+ },
244
+ bottomGradient: {
245
+ position: 'absolute',
271
246
  bottom: 0,
272
- padding: 16,
273
- paddingBottom: 32,
274
- backgroundColor: 'transparent',
247
+ left: 0,
248
+ right: 0,
249
+ height: 260,
250
+ backgroundColor: 'rgba(0,0,0,0.38)',
275
251
  },
276
- topMeta: {
277
- marginTop: 6,
252
+ // ── Top bar ───────────────────────────────────────────────────────────────
253
+ topBar: {
254
+ position: 'absolute',
255
+ top: 46,
256
+ left: 14,
257
+ right: 14,
258
+ flexDirection: 'row',
259
+ alignItems: 'center',
260
+ justifyContent: 'space-between',
278
261
  },
279
- row: {
262
+ hostRow: {
280
263
  flexDirection: 'row',
281
264
  alignItems: 'center',
265
+ gap: 8,
266
+ flex: 1,
267
+ marginRight: 8,
268
+ overflow: 'hidden',
269
+ },
270
+ avatar: {
271
+ width: 36,
272
+ height: 36,
273
+ borderRadius: 18,
274
+ backgroundColor: 'rgba(255,255,255,0.15)',
275
+ borderWidth: 1.5,
276
+ borderColor: 'rgba(255,255,255,0.3)',
277
+ alignItems: 'center',
278
+ justifyContent: 'center',
279
+ flexShrink: 0,
280
+ },
281
+ avatarLetter: {
282
+ color: '#fff',
283
+ fontSize: 15,
284
+ fontWeight: '700',
282
285
  },
283
- roomId: {
284
- color: '#fafafa',
286
+ hostName: {
287
+ color: '#fff',
285
288
  fontSize: 14,
286
289
  fontWeight: '600',
290
+ flex: 1,
291
+ },
292
+ livePill: {
293
+ backgroundColor: '#ef4444',
294
+ borderRadius: 6,
295
+ paddingHorizontal: 7,
296
+ paddingVertical: 3,
297
+ flexShrink: 0,
298
+ },
299
+ livePillText: {
300
+ color: '#fff',
301
+ fontSize: 11,
302
+ fontWeight: '700',
303
+ letterSpacing: 0.8,
287
304
  },
288
- stats: {
305
+ viewerChip: {
289
306
  flexDirection: 'row',
290
307
  alignItems: 'center',
291
- marginTop: 4,
308
+ backgroundColor: 'rgba(0,0,0,0.42)',
309
+ borderRadius: 20,
310
+ paddingHorizontal: 10,
311
+ paddingVertical: 5,
312
+ gap: 5,
313
+ flexShrink: 0,
292
314
  },
293
- statsText: {
294
- color: '#a3a3a3',
315
+ viewerEye: { fontSize: 11 },
316
+ viewerCount: {
317
+ color: 'rgba(255,255,255,0.88)',
295
318
  fontSize: 12,
319
+ fontWeight: '500',
296
320
  },
297
- statsTextSpacer: {
298
- marginLeft: 8,
299
- },
300
- chatPanel: {
321
+ // ── Right action column ───────────────────────────────────────────────────
322
+ rightColumn: {
301
323
  position: 'absolute',
302
- left: 16,
303
- right: 16,
304
- // bottom is driven by chatBottomAnim (starts at 16, follows keyboard on iOS)
324
+ right: 12,
325
+ bottom: 92,
326
+ alignItems: 'center',
327
+ gap: 14,
305
328
  },
306
- chatStack: {
307
- justifyContent: 'flex-end',
329
+ actionBtn: {
330
+ alignItems: 'center',
331
+ gap: 5,
308
332
  },
309
- chatListWrapper: {
310
- // Limit chat area to below mid-screen (roughly bottom 1/3–1/2 of stream).
311
- maxHeight: Math.round(SCREEN_HEIGHT * 0.35),
312
- borderRadius: 14,
333
+ actionCircle: {
334
+ width: 48,
335
+ height: 48,
336
+ borderRadius: 24,
337
+ backgroundColor: 'rgba(255,255,255,0.14)',
338
+ borderWidth: StyleSheet.hairlineWidth,
339
+ borderColor: 'rgba(255,255,255,0.2)',
340
+ alignItems: 'center',
341
+ justifyContent: 'center',
342
+ },
343
+ actionIcon: {
344
+ fontSize: 22,
345
+ color: '#fff',
346
+ },
347
+ actionLabel: {
348
+ color: 'rgba(255,255,255,0.7)',
349
+ fontSize: 11,
350
+ fontWeight: '500',
351
+ },
352
+ // ── Bottom panel ──────────────────────────────────────────────────────────
353
+ bottomPanel: {
354
+ position: 'absolute',
355
+ left: 0,
356
+ right: 0,
313
357
  paddingHorizontal: 12,
314
- paddingTop: 12,
315
- paddingBottom: 4,
316
- // Subtle transparent background so chat is readable over video.
317
- backgroundColor: 'rgba(0,0,0,0.35)',
318
- borderWidth: 1,
319
- borderColor: 'rgba(255,255,255,0.08)',
320
- // Wrap chats in a narrower column like TikTok.
358
+ paddingBottom: BOTTOM_SAFE,
359
+ gap: 8,
360
+ },
361
+ // Chat messages
362
+ chatColumn: {
363
+ width: '66%',
364
+ maxHeight: Math.round(SCREEN_HEIGHT * 0.30),
365
+ },
366
+ chatContent: {
367
+ paddingBottom: 2,
368
+ },
369
+ chatBubble: {
321
370
  alignSelf: 'flex-start',
322
- width: '70%',
323
- overflow: 'hidden',
371
+ marginBottom: 5,
372
+ backgroundColor: 'rgba(0,0,0,0.40)',
373
+ borderRadius: 14,
374
+ paddingHorizontal: 10,
375
+ paddingVertical: 5,
376
+ maxWidth: '100%',
324
377
  },
325
- chatListContent: {
326
- paddingBottom: 4,
378
+ joinBubble: {
379
+ alignSelf: 'flex-start',
380
+ marginBottom: 5,
381
+ backgroundColor: 'rgba(255,255,255,0.08)',
382
+ borderRadius: 14,
383
+ paddingHorizontal: 10,
384
+ paddingVertical: 4,
385
+ maxWidth: '100%',
327
386
  },
328
387
  chatLine: {
329
- color: '#e5e5e5',
330
388
  fontSize: 13,
331
389
  lineHeight: 18,
332
- marginBottom: 4,
333
390
  },
334
- chatName: {
335
- color: '#fafafa',
391
+ chatUsername: {
336
392
  fontWeight: '700',
337
393
  },
338
- chatText: {
394
+ chatMsg: {
339
395
  color: '#e5e5e5',
340
396
  fontWeight: '400',
341
397
  },
342
- chatInputRow: {
343
- marginTop: 10,
398
+ joinText: {
399
+ color: 'rgba(255,255,255,0.48)',
400
+ fontSize: 12,
401
+ fontStyle: 'italic',
402
+ },
403
+ // Input bar
404
+ inputBar: {
344
405
  flexDirection: 'row',
345
406
  alignItems: 'center',
407
+ backgroundColor: 'rgba(18,18,18,0.75)',
346
408
  borderRadius: 999,
347
- overflow: 'hidden',
348
- borderWidth: 1,
409
+ borderWidth: StyleSheet.hairlineWidth,
349
410
  borderColor: 'rgba(255,255,255,0.12)',
350
- backgroundColor: 'rgba(0,0,0,0.45)',
411
+ paddingHorizontal: 4,
412
+ paddingVertical: 4,
413
+ gap: 2,
414
+ },
415
+ inputIconBtn: {
416
+ width: 40,
417
+ height: 40,
418
+ alignItems: 'center',
419
+ justifyContent: 'center',
420
+ borderRadius: 20,
351
421
  },
352
- chatInput: {
422
+ inputIconGlyph: {
423
+ fontSize: 21,
424
+ },
425
+ textInput: {
353
426
  flex: 1,
354
- paddingHorizontal: 14,
355
- paddingVertical: 10,
356
- color: '#fafafa',
427
+ color: '#fff',
357
428
  fontSize: 14,
429
+ paddingHorizontal: 6,
430
+ paddingVertical: Platform.OS === 'ios' ? 10 : 6,
431
+ minHeight: 40,
432
+ },
433
+ sendBtn: {
434
+ width: 40,
435
+ height: 40,
436
+ borderRadius: 20,
437
+ backgroundColor: '#f97316',
438
+ alignItems: 'center',
439
+ justifyContent: 'center',
358
440
  },
359
- chatSendButton: {
360
- paddingHorizontal: 14,
361
- paddingVertical: 10,
441
+ sendBtnOff: {
442
+ backgroundColor: 'rgba(255,255,255,0.1)',
362
443
  },
363
- chatSendButtonDisabled: {
364
- opacity: 0.5,
444
+ sendIcon: {
445
+ color: '#fff',
446
+ fontSize: 15,
447
+ fontWeight: '700',
365
448
  },
366
- chatSendText: {
367
- color: '#f97316',
368
- fontSize: 18,
449
+ // ── Stream ended ──────────────────────────────────────────────────────────
450
+ endedOverlay: {
451
+ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
452
+ backgroundColor: 'rgba(0,0,0,0.80)',
453
+ alignItems: 'center',
454
+ justifyContent: 'center',
455
+ },
456
+ endedCard: {
457
+ alignItems: 'center',
458
+ gap: 10,
459
+ paddingHorizontal: 36,
460
+ },
461
+ endedTitle: {
462
+ color: '#fff',
463
+ fontSize: 22,
369
464
  fontWeight: '700',
370
465
  },
466
+ endedSub: {
467
+ color: 'rgba(255,255,255,0.5)',
468
+ fontSize: 14,
469
+ textAlign: 'center',
470
+ lineHeight: 20,
471
+ },
371
472
  });
@@ -15,6 +15,8 @@ type ViewerSocketState = {
15
15
  consumeRetryKey: number;
16
16
  /** Real-time viewer count, updated via viewer-count-updated socket event. */
17
17
  viewerCount: number;
18
+ /** True once the host disconnects — triggers the "stream ended" overlay. */
19
+ streamEnded: boolean;
18
20
  };
19
21
  /**
20
22
  * Connects to the livestream signaling server and joins a room as viewer.
@@ -1 +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;IACxB,6EAA6E;IAC7E,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,iBAAiB,GAAG;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAmgBpG"}
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;IACxB,6EAA6E;IAC7E,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,iBAAiB,GAAG;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CA2gBpG"}
@@ -21,6 +21,7 @@ export function useViewerSocket(roomId) {
21
21
  consumeError: null,
22
22
  consumeRetryKey: 0,
23
23
  viewerCount: 0,
24
+ streamEnded: false,
24
25
  });
25
26
  const socketRef = useRef(null);
26
27
  const roomIdRef = useRef(roomId);
@@ -62,6 +63,7 @@ export function useViewerSocket(roomId) {
62
63
  consumeError: null,
63
64
  consumeRetryKey: 0,
64
65
  viewerCount: 0,
66
+ streamEnded: false,
65
67
  });
66
68
  consumeRetryCountRef.current = 0;
67
69
  }, []);
@@ -87,7 +89,7 @@ export function useViewerSocket(roomId) {
87
89
  setState((prev) => ({ ...prev, error: 'Livestream server not configured', joining: false }));
88
90
  return;
89
91
  }
90
- setState((prev) => ({ ...prev, joining: true, error: null }));
92
+ setState((prev) => ({ ...prev, joining: true, error: null, streamEnded: false }));
91
93
  const JOIN_TIMEOUT_MS = 12000;
92
94
  const joinTimeout = setTimeout(() => {
93
95
  if (roomIdRef.current !== roomId)
@@ -201,6 +203,11 @@ export function useViewerSocket(roomId) {
201
203
  setState((prev) => ({ ...prev, viewerCount: payload.viewerCount }));
202
204
  }
203
205
  });
206
+ socket.on('peer-left', (payload) => {
207
+ if (payload?.isHost) {
208
+ setState((prev) => ({ ...prev, streamEnded: true, producerList: [] }));
209
+ }
210
+ });
204
211
  socket.on('system-audio-chunk', (payload) => {
205
212
  const b64 = typeof payload === 'string' ? payload
206
213
  : typeof payload?.data === 'string'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasunk99-livestream-core",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Reusable livestream viewer/host module for React Native (Expo) — mediasoup + Socket.IO",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",