kasunk99-livestream-core 0.3.16 → 0.3.18
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,
|
|
1
|
+
{"version":3,"file":"LiveStreamViewerItem.d.ts","sourceRoot":"","sources":["../../src/components/LiveStreamViewerItem.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkE,MAAM,OAAO,CAAC;AAkBvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAQ/C,KAAK,yBAAyB,GAAG;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAwBF,eAAO,MAAM,oBAAoB,uDAqe/B,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
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
5
|
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
|
@@ -7,8 +7,6 @@ const CHAT_NAME_COLORS = ['#f97316', '#22c55e', '#3b82f6', '#eab308', '#ec4899',
|
|
|
7
7
|
const BOTTOM_SAFE = Platform.OS === 'ios' ? 28 : 10;
|
|
8
8
|
const BOTTOM_BAR_H = 58;
|
|
9
9
|
const CHAT_BOTTOM_DEFAULT = BOTTOM_SAFE + BOTTOM_BAR_H + 8;
|
|
10
|
-
// Chat shifts above the floating input bar when typing
|
|
11
|
-
const CHAT_BOTTOM_TYPING = BOTTOM_SAFE + 68;
|
|
12
10
|
let RTCViewComponent = null;
|
|
13
11
|
try {
|
|
14
12
|
const webrtc = require('react-native-webrtc');
|
|
@@ -36,60 +34,97 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
|
|
|
36
34
|
? displayStreamObj.toURL()
|
|
37
35
|
: undefined;
|
|
38
36
|
const showVideo = RTCViewComponent && remoteStream && !!streamURL && hasVideoTrack;
|
|
37
|
+
// chatInput persists when keyboard closes without sending — cleared only on send
|
|
39
38
|
const [chatInput, setChatInput] = useState('');
|
|
40
39
|
const [chatMessages, setChatMessages] = useState([]);
|
|
40
|
+
// isTyping: user has opened the input panel
|
|
41
41
|
const [isTyping, setIsTyping] = useState(false);
|
|
42
42
|
const seededRoomRef = useRef(null);
|
|
43
43
|
const chatListRef = useRef(null);
|
|
44
44
|
const textInputRef = useRef(null);
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
45
|
+
// Cell layout tracking — used to compensate for adjustResize shrinking the cell.
|
|
46
|
+
// Initialise baseCellH with the same estimate LiveStreamFeed uses for viewportHeight
|
|
47
|
+
// so the gap formula works correctly even before onCellLayout fires.
|
|
48
|
+
const baseCellH = useRef(SCREEN_HEIGHT - 120);
|
|
49
|
+
const currCellH = useRef(SCREEN_HEIGHT - 120);
|
|
50
|
+
const onCellLayout = useCallback((e) => {
|
|
51
|
+
const h = Math.round(e.nativeEvent.layout.height);
|
|
52
|
+
currCellH.current = h;
|
|
53
|
+
if (h > baseCellH.current)
|
|
54
|
+
baseCellH.current = h;
|
|
55
|
+
}, []);
|
|
56
|
+
// Animated values
|
|
57
|
+
const inputBottom = useRef(new Animated.Value(0)).current;
|
|
48
58
|
const chatAnim = useRef(new Animated.Value(CHAT_BOTTOM_DEFAULT)).current;
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
// the floating input bar. keyboardDidHide resets everything.
|
|
59
|
+
// Remember last offset so re-open positions instantly instead of jumping
|
|
60
|
+
const lastOffsetRef = useRef(0);
|
|
61
|
+
// Compute the correct bottom offset for the floating input.
|
|
53
62
|
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
63
|
+
// The cell sits inside a tab navigator whose height is less than the full window.
|
|
64
|
+
// `kbH` from the keyboard event is measured from the bottom of the WINDOW, not the
|
|
65
|
+
// bottom of the CELL. We subtract `cellBottomGap` (window bottom → cell bottom) to
|
|
66
|
+
// get the offset relative to the cell.
|
|
67
|
+
//
|
|
68
|
+
// If adjustResize has also shrunk the cell, we subtract that too (avoids double-offset).
|
|
69
|
+
const computeOffset = useCallback((kbH) => {
|
|
70
|
+
const base = baseCellH.current;
|
|
71
|
+
const cur = currCellH.current;
|
|
72
|
+
const cellBottomGap = Math.max(0, SCREEN_HEIGHT - base); // tab-nav area below cell
|
|
73
|
+
const shrink = Math.max(0, base - cur); // adjustResize shrink (if any)
|
|
74
|
+
return Math.max(BOTTOM_SAFE, kbH - cellBottomGap - shrink);
|
|
75
|
+
}, []);
|
|
76
|
+
// Pre-position the input immediately when typing starts (uses last known offset).
|
|
77
|
+
// Keyboard listeners own the reset when isTyping becomes false.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (isTyping && lastOffsetRef.current > 0) {
|
|
80
|
+
inputBottom.setValue(lastOffsetRef.current);
|
|
81
|
+
chatAnim.setValue(lastOffsetRef.current + 60);
|
|
82
|
+
}
|
|
83
|
+
}, [isTyping, inputBottom, chatAnim]);
|
|
84
|
+
// Keyboard event listeners — platform-specific
|
|
56
85
|
useEffect(() => {
|
|
57
86
|
if (Platform.OS === 'android') {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
87
|
+
// keyboardDidShow fires AFTER keyboard animation + AFTER adjustResize layout.
|
|
88
|
+
// currCellH.current has the post-resize value at this point.
|
|
89
|
+
const s1 = Keyboard.addListener('keyboardDidShow', (e) => {
|
|
90
|
+
const offset = computeOffset(e.endCoordinates.height);
|
|
91
|
+
lastOffsetRef.current = offset;
|
|
92
|
+
// Short animation so input slides into position rather than hard-jumping
|
|
93
|
+
Animated.parallel([
|
|
94
|
+
Animated.timing(inputBottom, { toValue: offset, duration: 120, useNativeDriver: false }),
|
|
95
|
+
Animated.timing(chatAnim, { toValue: offset + 60, duration: 120, useNativeDriver: false }),
|
|
96
|
+
]).start();
|
|
61
97
|
});
|
|
62
|
-
|
|
98
|
+
const s2 = Keyboard.addListener('keyboardDidHide', () => {
|
|
99
|
+
// Only close typing if it wasn't already closed by sendChat
|
|
100
|
+
setIsTyping((prev) => {
|
|
101
|
+
if (prev) {
|
|
102
|
+
inputBottom.setValue(0);
|
|
103
|
+
chatAnim.setValue(CHAT_BOTTOM_DEFAULT);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
return () => { s1.remove(); s2.remove(); };
|
|
63
109
|
}
|
|
64
|
-
// iOS — animate
|
|
110
|
+
// iOS — keyboardWillShow fires before keyboard so we can animate in sync
|
|
65
111
|
const s1 = Keyboard.addListener('keyboardWillShow', (e) => {
|
|
66
|
-
const
|
|
67
|
-
|
|
112
|
+
const offset = computeOffset(e.endCoordinates.height);
|
|
113
|
+
lastOffsetRef.current = offset;
|
|
68
114
|
Animated.parallel([
|
|
69
|
-
Animated.timing(inputBottom, { toValue:
|
|
70
|
-
Animated.timing(chatAnim, { toValue:
|
|
115
|
+
Animated.timing(inputBottom, { toValue: offset, duration: e.duration || 250, useNativeDriver: false }),
|
|
116
|
+
Animated.timing(chatAnim, { toValue: offset + 60, duration: e.duration || 250, useNativeDriver: false }),
|
|
71
117
|
]).start();
|
|
72
118
|
});
|
|
73
119
|
const s2 = Keyboard.addListener('keyboardWillHide', (e) => {
|
|
74
|
-
const dur = e.duration || 250;
|
|
75
120
|
Animated.parallel([
|
|
76
|
-
Animated.timing(inputBottom, { toValue:
|
|
77
|
-
Animated.timing(chatAnim, { toValue: CHAT_BOTTOM_DEFAULT, duration:
|
|
121
|
+
Animated.timing(inputBottom, { toValue: 0, duration: e.duration || 250, useNativeDriver: false }),
|
|
122
|
+
Animated.timing(chatAnim, { toValue: CHAT_BOTTOM_DEFAULT, duration: e.duration || 250, useNativeDriver: false }),
|
|
78
123
|
]).start(() => setIsTyping(false));
|
|
79
124
|
});
|
|
80
125
|
return () => { s1.remove(); s2.remove(); };
|
|
81
|
-
}, [inputBottom, chatAnim]);
|
|
82
|
-
//
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
if (Platform.OS !== 'android')
|
|
85
|
-
return;
|
|
86
|
-
Animated.timing(chatAnim, {
|
|
87
|
-
toValue: isTyping ? CHAT_BOTTOM_TYPING : CHAT_BOTTOM_DEFAULT,
|
|
88
|
-
duration: 200,
|
|
89
|
-
useNativeDriver: false,
|
|
90
|
-
}).start();
|
|
91
|
-
}, [isTyping, chatAnim]);
|
|
92
|
-
// Dismiss keyboard and reset when stream becomes inactive
|
|
126
|
+
}, [inputBottom, chatAnim, computeOffset]);
|
|
127
|
+
// Dismiss keyboard when stream becomes inactive
|
|
93
128
|
useEffect(() => {
|
|
94
129
|
if (!isActive) {
|
|
95
130
|
Keyboard.dismiss();
|
|
@@ -109,7 +144,7 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
|
|
|
109
144
|
setChatInput('');
|
|
110
145
|
setIsTyping(false);
|
|
111
146
|
Keyboard.dismiss();
|
|
112
|
-
inputBottom.setValue(
|
|
147
|
+
inputBottom.setValue(0);
|
|
113
148
|
chatAnim.setValue(CHAT_BOTTOM_DEFAULT);
|
|
114
149
|
return;
|
|
115
150
|
}
|
|
@@ -233,16 +268,17 @@ export const LiveStreamViewerItem = memo(function LiveStreamViewerItem({ stream,
|
|
|
233
268
|
const text = chatInput.trim();
|
|
234
269
|
if (!text || !socket || !joined)
|
|
235
270
|
return;
|
|
271
|
+
// Emit FIRST so the message reaches the server before any cleanup.
|
|
272
|
+
// keyboardDidHide will set isTyping=false and reset animations.
|
|
273
|
+
socket.emit('chat-message', { text });
|
|
236
274
|
setChatInput('');
|
|
237
|
-
|
|
238
|
-
if (res?.error)
|
|
239
|
-
console.log('[viewer] chat send error', res.error);
|
|
240
|
-
});
|
|
275
|
+
Keyboard.dismiss();
|
|
241
276
|
};
|
|
242
277
|
const hostLabel = stream.hostDisplayName || stream.title || 'Live';
|
|
243
278
|
const avatarLetter = hostLabel[0]?.toUpperCase() ?? 'L';
|
|
244
|
-
|
|
245
|
-
|
|
279
|
+
const hasUnsent = chatInput.trim().length > 0;
|
|
280
|
+
return (_jsxs(View, { style: styles.container, onLayout: onCellLayout, 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 }))] })), LinearGradient ? (_jsx(LinearGradient, { colors: ['rgba(0,0,0,0.70)', 'rgba(0,0,0,0.0)', 'rgba(0,0,0,0.0)', 'rgba(0,0,0,0.78)'], locations: [0, 0.28, 0.48, 1], style: styles.gradientOverlay, pointerEvents: "none" })) : null, _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() : '—' })] })] }), !isTyping && (_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" })] })] })), _jsx(Animated.View, { style: [styles.chatColumn, { bottom: chatAnim }], pointerEvents: "box-none", children: _jsx(ScrollView, { ref: chatListRef, contentContainerStyle: styles.chatContent, showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "always", 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))) }) }), !isTyping && (_jsxs(View, { style: styles.bottomBar, children: [_jsx(TouchableOpacity, { style: [styles.typeTouchable, hasUnsent && styles.typeTouchableFilled], onPress: () => { if (joined && !joining && !error)
|
|
281
|
+
setIsTyping(true); }, activeOpacity: 0.7, children: _jsx(Text, { style: [styles.typePlaceholder, hasUnsent && styles.typePlaceholderFilled], numberOfLines: 1, children: hasUnsent ? chatInput : (joined && !joining ? 'Say something...' : 'Connecting...') }) }), _jsx(TouchableOpacity, { style: styles.bottomIconBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.bottomIconGlyph, children: "\u263A" }) }), _jsx(TouchableOpacity, { style: styles.bottomIconBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.bottomIconGlyph, children: "\uD83C\uDF39" }) }), _jsx(TouchableOpacity, { style: styles.bottomIconBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.bottomIconGlyph, children: "\uD83C\uDF81" }) }), _jsx(TouchableOpacity, { style: styles.bottomIconBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.bottomIconGlyph, children: "\u2197" }) })] })), isTyping && (_jsxs(Animated.View, { style: [styles.floatingInputWrap, { bottom: inputBottom }], children: [_jsx(TextInput, { ref: textInputRef, style: styles.floatingTextInput, value: chatInput, onChangeText: setChatInput, placeholder: "Say something...", placeholderTextColor: "rgba(255,255,255,0.40)", editable: joined && !joining && !error, onSubmitEditing: sendChat, returnKeyType: "send", submitBehavior: "submit", autoFocus: true }), _jsx(TouchableOpacity, { style: [styles.floatingSendBtn, (!hasUnsent || !joined) && styles.sendBtnOff], onPress: sendChat, disabled: !joined || !hasUnsent, activeOpacity: 0.75, 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" })] }) }))] }));
|
|
246
282
|
});
|
|
247
283
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
284
|
const styles = StyleSheet.create({
|
|
@@ -375,6 +411,7 @@ const styles = StyleSheet.create({
|
|
|
375
411
|
chatUsername: { fontWeight: '700' },
|
|
376
412
|
chatMsg: { color: '#e5e5e5', fontWeight: '400' },
|
|
377
413
|
joinText: { color: 'rgba(255,255,255,0.48)', fontSize: 12, fontStyle: 'italic' },
|
|
414
|
+
// Bottom action bar
|
|
378
415
|
bottomBar: {
|
|
379
416
|
position: 'absolute',
|
|
380
417
|
bottom: BOTTOM_SAFE,
|
|
@@ -393,9 +430,20 @@ const styles = StyleSheet.create({
|
|
|
393
430
|
borderWidth: StyleSheet.hairlineWidth,
|
|
394
431
|
borderColor: 'rgba(255,255,255,0.18)',
|
|
395
432
|
},
|
|
396
|
-
|
|
433
|
+
typeTouchableFilled: {
|
|
434
|
+
backgroundColor: 'rgba(255,255,255,0.18)',
|
|
435
|
+
borderColor: 'rgba(255,255,255,0.30)',
|
|
436
|
+
},
|
|
437
|
+
typePlaceholder: {
|
|
438
|
+
color: 'rgba(255,255,255,0.50)',
|
|
439
|
+
fontSize: 14,
|
|
440
|
+
},
|
|
441
|
+
typePlaceholderFilled: {
|
|
442
|
+
color: '#fff',
|
|
443
|
+
},
|
|
397
444
|
bottomIconBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
|
398
445
|
bottomIconGlyph: { fontSize: 22 },
|
|
446
|
+
// Floating input — absolute, bottom driven by Animated.Value
|
|
399
447
|
floatingInputWrap: {
|
|
400
448
|
position: 'absolute',
|
|
401
449
|
left: 0,
|
package/package.json
CHANGED