@cinecrew/cinecrew-player 0.1.1 → 0.1.3
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 +20 -16
- package/package.json +1 -2
- package/src/data/README.md +5 -0
- package/src/data/emoji-data.json +1 -0
- package/src/data/emoji.js +30 -0
- package/src/native/InlineLivePlayer.js +1 -1
- package/src/native/MediaPlayerView.js +29 -13
- package/src/native/customization.js +2 -1
- package/src/native/media/EmojiPickerModal.js +217 -21
- package/src/native/media/LiveChatDrawer.js +261 -103
- package/src/native/media/YouTubeVideoPlayer.web.js +64 -10
- package/src/native/media/player/PlayerBottomBar.js +6 -4
- package/src/native/media/player/PlayerTopBar.js +3 -5
- package/src/native/media/web/useWebVideoAspectRatio.js +11 -1
- package/src/web/index.js +506 -114
- package/src/web/styles.css +476 -20
- package/types/index.d.ts +22 -7
|
@@ -39,6 +39,35 @@ function formatMessageTime(timestamp) {
|
|
|
39
39
|
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function normalizeChatMessage(message) {
|
|
43
|
+
return {
|
|
44
|
+
...message,
|
|
45
|
+
username: message?.username || message?.userName || message?.name || 'Viewer',
|
|
46
|
+
textContent: message?.textContent || message?.comment || message?.message || message?.text || '',
|
|
47
|
+
createdAt: message?.createdAt || message?.timestamp || message?.sentAt || message?.time || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeChatPage(value) {
|
|
52
|
+
const rows = Array.isArray(value)
|
|
53
|
+
? value
|
|
54
|
+
: value?.messages || value?.items || value?.comments || value?.data || [];
|
|
55
|
+
return Array.isArray(rows) ? rows.map(normalizeChatMessage) : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function chatMessageKey(message, index) {
|
|
59
|
+
if (message?.id !== undefined && message?.id !== null) return String(message.id);
|
|
60
|
+
if (message?.messageId !== undefined && message?.messageId !== null) return String(message.messageId);
|
|
61
|
+
return `${message?.username || ''}:${message?.createdAt || ''}:${message?.textContent || ''}:${index}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mergeChatMessages(existing, incoming, prepend = false) {
|
|
65
|
+
const combined = prepend ? [...incoming, ...existing] : [...existing, ...incoming];
|
|
66
|
+
const unique = new Map();
|
|
67
|
+
combined.forEach((message, index) => unique.set(chatMessageKey(message, index), message));
|
|
68
|
+
return [...unique.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
function extractHostname(url) {
|
|
43
72
|
if (!url) return 'Xtream Server';
|
|
44
73
|
try {
|
|
@@ -90,6 +119,124 @@ const TAB_META = {
|
|
|
90
119
|
diagnostics: { icon: 'pulse', title: 'Stream Diagnostics' },
|
|
91
120
|
};
|
|
92
121
|
|
|
122
|
+
function LiveChatPanel({
|
|
123
|
+
messagesLoading,
|
|
124
|
+
messages,
|
|
125
|
+
flatListRef,
|
|
126
|
+
renderMessageItem,
|
|
127
|
+
hasMoreMessages,
|
|
128
|
+
loadOlderMessages,
|
|
129
|
+
loadingOlderMessages,
|
|
130
|
+
chatError,
|
|
131
|
+
handleQuickReaction,
|
|
132
|
+
inputText,
|
|
133
|
+
setInputText,
|
|
134
|
+
handleSend,
|
|
135
|
+
isSending,
|
|
136
|
+
setShowEmojiPicker,
|
|
137
|
+
showEmojiPicker,
|
|
138
|
+
handleSelectEmoji,
|
|
139
|
+
colors,
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<>
|
|
143
|
+
<View style={styles.welcomeBanner}>
|
|
144
|
+
<PlayerIcon name="shield-check" size={16} color="#00E5FF" style={{ marginTop: 2 }} />
|
|
145
|
+
<Text style={styles.welcomeText}>
|
|
146
|
+
Welcome to live chat! Remember to guard your privacy and abide by community guidelines.
|
|
147
|
+
</Text>
|
|
148
|
+
</View>
|
|
149
|
+
|
|
150
|
+
{messagesLoading && messages.length === 0 ? (
|
|
151
|
+
<View style={styles.chatLoadingWrap}>
|
|
152
|
+
<ActivityIndicator size="small" color="#00E5FF" />
|
|
153
|
+
<Text style={styles.epgStatusText}>Loading live chat…</Text>
|
|
154
|
+
</View>
|
|
155
|
+
) : (
|
|
156
|
+
<FlatList
|
|
157
|
+
ref={flatListRef}
|
|
158
|
+
data={messages}
|
|
159
|
+
keyExtractor={(item, index) => chatMessageKey(item, index)}
|
|
160
|
+
renderItem={renderMessageItem}
|
|
161
|
+
contentContainerStyle={styles.messagesList}
|
|
162
|
+
showsVerticalScrollIndicator
|
|
163
|
+
keyboardShouldPersistTaps="handled"
|
|
164
|
+
ListHeaderComponent={hasMoreMessages ? (
|
|
165
|
+
<TouchableOpacity
|
|
166
|
+
style={styles.loadMoreMessages}
|
|
167
|
+
onPress={loadOlderMessages}
|
|
168
|
+
disabled={loadingOlderMessages}
|
|
169
|
+
accessibilityRole="button"
|
|
170
|
+
>
|
|
171
|
+
{loadingOlderMessages ? <ActivityIndicator size="small" color="#00E5FF" /> : null}
|
|
172
|
+
<Text style={styles.loadMoreMessagesText}>
|
|
173
|
+
{loadingOlderMessages ? 'Loading older messages…' : 'See more messages'}
|
|
174
|
+
</Text>
|
|
175
|
+
</TouchableOpacity>
|
|
176
|
+
) : null}
|
|
177
|
+
ListEmptyComponent={<Text style={styles.epgStatusText}>No messages yet. Start the conversation.</Text>}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
{!!chatError && <Text style={styles.chatErrorText}>{chatError}</Text>}
|
|
181
|
+
|
|
182
|
+
<View style={styles.quickReactionsRow}>
|
|
183
|
+
{QUICK_REACTIONS.map((emoji) => (
|
|
184
|
+
<TouchableOpacity
|
|
185
|
+
key={emoji}
|
|
186
|
+
style={styles.quickReactionBtn}
|
|
187
|
+
onPress={() => handleQuickReaction(emoji)}
|
|
188
|
+
activeOpacity={0.6}
|
|
189
|
+
>
|
|
190
|
+
<Text style={styles.quickReactionEmoji}>{emoji}</Text>
|
|
191
|
+
</TouchableOpacity>
|
|
192
|
+
))}
|
|
193
|
+
</View>
|
|
194
|
+
|
|
195
|
+
<View style={styles.inputBarContainer}>
|
|
196
|
+
<View style={styles.inputPill}>
|
|
197
|
+
<TextInput
|
|
198
|
+
style={styles.textInput}
|
|
199
|
+
value={inputText}
|
|
200
|
+
onChangeText={setInputText}
|
|
201
|
+
placeholder="Chat..."
|
|
202
|
+
placeholderTextColor="rgba(255, 255, 255, 0.4)"
|
|
203
|
+
onSubmitEditing={() => handleSend()}
|
|
204
|
+
returnKeyType="send"
|
|
205
|
+
maxLength={400}
|
|
206
|
+
/>
|
|
207
|
+
<TouchableOpacity
|
|
208
|
+
style={styles.emojiToggleBtn}
|
|
209
|
+
onPress={() => setShowEmojiPicker(true)}
|
|
210
|
+
hitSlop={6}
|
|
211
|
+
>
|
|
212
|
+
<PlayerIcon name="emoticon-happy-outline" size={22} color="#FFF" />
|
|
213
|
+
</TouchableOpacity>
|
|
214
|
+
</View>
|
|
215
|
+
|
|
216
|
+
<TouchableOpacity
|
|
217
|
+
style={[styles.sendBtn, (!inputText.trim() || isSending) && styles.sendBtnDisabled]}
|
|
218
|
+
onPress={() => handleSend()}
|
|
219
|
+
disabled={!inputText.trim() || isSending}
|
|
220
|
+
hitSlop={8}
|
|
221
|
+
>
|
|
222
|
+
<PlayerIcon
|
|
223
|
+
name="send"
|
|
224
|
+
size={18}
|
|
225
|
+
color={inputText.trim() && !isSending ? '#000' : 'rgba(255, 255, 255, 0.3)'}
|
|
226
|
+
/>
|
|
227
|
+
</TouchableOpacity>
|
|
228
|
+
</View>
|
|
229
|
+
|
|
230
|
+
<EmojiPickerModal
|
|
231
|
+
visible={showEmojiPicker}
|
|
232
|
+
onClose={() => setShowEmojiPicker(false)}
|
|
233
|
+
onSelectEmoji={handleSelectEmoji}
|
|
234
|
+
colors={colors}
|
|
235
|
+
/>
|
|
236
|
+
</>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
93
240
|
export const LiveChatDrawer = ({
|
|
94
241
|
videoId,
|
|
95
242
|
userId,
|
|
@@ -109,8 +256,11 @@ export const LiveChatDrawer = ({
|
|
|
109
256
|
popupMode = false,
|
|
110
257
|
integrations = {},
|
|
111
258
|
colors,
|
|
259
|
+
messagePageSize = 50,
|
|
260
|
+
drawerStyle,
|
|
112
261
|
}) => {
|
|
113
262
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
263
|
+
const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
|
|
114
264
|
const chatAvailable = isLive && isLiveCommentsEnabled && typeof integrations.liveChat?.loadMessages === 'function';
|
|
115
265
|
const epgAvailable = isLive && isEpgEnabled && typeof integrations.epg?.loadListings === 'function';
|
|
116
266
|
const canShowDiagnostics = diagnosticsEnabled !== false;
|
|
@@ -136,7 +286,13 @@ export const LiveChatDrawer = ({
|
|
|
136
286
|
const [inputText, setInputText] = useState('');
|
|
137
287
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
|
138
288
|
const [isSending, setIsSending] = useState(false);
|
|
289
|
+
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
|
290
|
+
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
|
|
291
|
+
const [chatError, setChatError] = useState('');
|
|
139
292
|
const flatListRef = useRef(null);
|
|
293
|
+
const messageOffsetRef = useRef(0);
|
|
294
|
+
const loadedInitialPageRef = useRef(false);
|
|
295
|
+
const scrollChatToEndRef = useRef(false);
|
|
140
296
|
|
|
141
297
|
// --- Stream Diagnostics Telemetry State ---
|
|
142
298
|
const [pingLatency, setPingLatency] = useState(null);
|
|
@@ -157,14 +313,32 @@ export const LiveChatDrawer = ({
|
|
|
157
313
|
let isMounted = true;
|
|
158
314
|
let initialLoad = true;
|
|
159
315
|
setMessages([]);
|
|
316
|
+
setHasMoreMessages(false);
|
|
317
|
+
setChatError('');
|
|
318
|
+
setLoadingOlderMessages(false);
|
|
319
|
+
messageOffsetRef.current = 0;
|
|
320
|
+
loadedInitialPageRef.current = false;
|
|
321
|
+
scrollChatToEndRef.current = true;
|
|
160
322
|
setMessagesLoading(true);
|
|
161
323
|
const poll = async () => {
|
|
162
324
|
try {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
325
|
+
const response = await integrations.liveChat.loadMessages({ channelId: String(videoId), limit: pageSize, offset: 0 });
|
|
326
|
+
const msgs = normalizeChatPage(response);
|
|
327
|
+
if (isMounted) {
|
|
328
|
+
setMessages((current) => loadedInitialPageRef.current
|
|
329
|
+
? mergeChatMessages(current, msgs)
|
|
330
|
+
: msgs);
|
|
331
|
+
if (!loadedInitialPageRef.current) {
|
|
332
|
+
loadedInitialPageRef.current = true;
|
|
333
|
+
messageOffsetRef.current = msgs.length;
|
|
334
|
+
const explicitHasMore = response?.hasMore ?? response?.pagination?.hasMore;
|
|
335
|
+
setHasMoreMessages(explicitHasMore === undefined ? msgs.length >= pageSize : Boolean(explicitHasMore));
|
|
336
|
+
}
|
|
337
|
+
setChatError('');
|
|
166
338
|
}
|
|
167
|
-
} catch {
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (isMounted && initialLoad) setChatError(error?.message || 'Could not load live chat.');
|
|
341
|
+
}
|
|
168
342
|
finally {
|
|
169
343
|
if (isMounted && initialLoad) {
|
|
170
344
|
initialLoad = false;
|
|
@@ -181,7 +355,34 @@ export const LiveChatDrawer = ({
|
|
|
181
355
|
isMounted = false;
|
|
182
356
|
clearInterval(timer);
|
|
183
357
|
};
|
|
184
|
-
}, [visible, videoId, chatAvailable, integrations.liveChat]);
|
|
358
|
+
}, [visible, videoId, chatAvailable, integrations.liveChat, pageSize]);
|
|
359
|
+
|
|
360
|
+
const loadOlderMessages = useCallback(async () => {
|
|
361
|
+
if (!hasMoreMessages || loadingOlderMessages || !chatAvailable) return;
|
|
362
|
+
setLoadingOlderMessages(true);
|
|
363
|
+
setChatError('');
|
|
364
|
+
try {
|
|
365
|
+
const offset = messageOffsetRef.current;
|
|
366
|
+
const response = await integrations.liveChat.loadMessages({
|
|
367
|
+
channelId: String(videoId),
|
|
368
|
+
limit: pageSize,
|
|
369
|
+
offset,
|
|
370
|
+
});
|
|
371
|
+
const olderMessages = normalizeChatPage(response);
|
|
372
|
+
if (olderMessages.length) {
|
|
373
|
+
setMessages((current) => mergeChatMessages(current, olderMessages, true));
|
|
374
|
+
messageOffsetRef.current += olderMessages.length;
|
|
375
|
+
}
|
|
376
|
+
const explicitHasMore = response?.hasMore ?? response?.pagination?.hasMore;
|
|
377
|
+
setHasMoreMessages(explicitHasMore === undefined
|
|
378
|
+
? olderMessages.length >= pageSize
|
|
379
|
+
: Boolean(explicitHasMore));
|
|
380
|
+
} catch (error) {
|
|
381
|
+
setChatError(error?.message || 'Could not load older messages.');
|
|
382
|
+
} finally {
|
|
383
|
+
setLoadingOlderMessages(false);
|
|
384
|
+
}
|
|
385
|
+
}, [hasMoreMessages, loadingOlderMessages, chatAvailable, integrations.liveChat, videoId, pageSize]);
|
|
185
386
|
|
|
186
387
|
const epgStreamId = streamId || videoId;
|
|
187
388
|
const [epgListings, setEpgListings] = useState([]);
|
|
@@ -235,12 +436,13 @@ export const LiveChatDrawer = ({
|
|
|
235
436
|
return () => clearTimeout(timer);
|
|
236
437
|
}, [activeTab, epgListings]);
|
|
237
438
|
|
|
238
|
-
//
|
|
439
|
+
// Avoid snapping to the bottom when an older page is prepended.
|
|
239
440
|
useEffect(() => {
|
|
240
|
-
if (messages.length > 0 && flatListRef.current && activeTab === 'chat') {
|
|
441
|
+
if (scrollChatToEndRef.current && messages.length > 0 && flatListRef.current && activeTab === 'chat') {
|
|
241
442
|
setTimeout(() => {
|
|
242
443
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
243
444
|
}, 80);
|
|
445
|
+
scrollChatToEndRef.current = false;
|
|
244
446
|
}
|
|
245
447
|
}, [messages.length, activeTab]);
|
|
246
448
|
|
|
@@ -309,6 +511,7 @@ export const LiveChatDrawer = ({
|
|
|
309
511
|
const text = (textToSend || inputText).trim();
|
|
310
512
|
if (!text || isSending || !videoId) return;
|
|
311
513
|
|
|
514
|
+
scrollChatToEndRef.current = true;
|
|
312
515
|
if (!textToSend) setInputText('');
|
|
313
516
|
setIsSending(true);
|
|
314
517
|
|
|
@@ -320,14 +523,17 @@ export const LiveChatDrawer = ({
|
|
|
320
523
|
username: activeUsername,
|
|
321
524
|
comment: text,
|
|
322
525
|
});
|
|
323
|
-
const
|
|
324
|
-
if (
|
|
526
|
+
const response = await integrations.liveChat?.loadMessages?.({ channelId: String(videoId), limit: pageSize, offset: 0 }).catch?.(() => null);
|
|
527
|
+
if (response) {
|
|
528
|
+
const msgs = normalizeChatPage(response);
|
|
529
|
+
setMessages((current) => mergeChatMessages(current, msgs));
|
|
530
|
+
}
|
|
325
531
|
} catch {
|
|
326
532
|
// Error handled
|
|
327
533
|
} finally {
|
|
328
534
|
setIsSending(false);
|
|
329
535
|
}
|
|
330
|
-
}, [inputText, isSending, videoId, userId, username, integrations.liveChat]);
|
|
536
|
+
}, [inputText, isSending, videoId, userId, username, integrations.liveChat, pageSize]);
|
|
331
537
|
|
|
332
538
|
const handleSelectEmoji = useCallback((emoji) => {
|
|
333
539
|
setInputText((prev) => prev + emoji);
|
|
@@ -430,6 +636,7 @@ export const LiveChatDrawer = ({
|
|
|
430
636
|
width: Math.min(Math.max(windowWidth - 32, 280), 520),
|
|
431
637
|
height: Math.min(Math.max(windowHeight - 32, 280), 680),
|
|
432
638
|
},
|
|
639
|
+
drawerStyle,
|
|
433
640
|
]}
|
|
434
641
|
>
|
|
435
642
|
{/* Header with Close Button & Title */}
|
|
@@ -457,99 +664,27 @@ export const LiveChatDrawer = ({
|
|
|
457
664
|
</View>
|
|
458
665
|
|
|
459
666
|
{/* --- Tab 1: Live Chat --- */}
|
|
460
|
-
{activeTab === 'chat' && chatAvailable
|
|
461
|
-
|
|
462
|
-
{
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
{
|
|
471
|
-
{
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
renderItem={renderMessageItem}
|
|
482
|
-
contentContainerStyle={styles.messagesList}
|
|
483
|
-
showsVerticalScrollIndicator={true}
|
|
484
|
-
keyboardShouldPersistTaps="handled"
|
|
485
|
-
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
|
|
486
|
-
ListEmptyComponent={<Text style={styles.epgStatusText}>No messages yet. Start the conversation.</Text>}
|
|
487
|
-
/>
|
|
488
|
-
)}
|
|
489
|
-
|
|
490
|
-
{/* Quick Reaction Bar */}
|
|
491
|
-
<View style={styles.quickReactionsRow}>
|
|
492
|
-
{QUICK_REACTIONS.map((emoji) => (
|
|
493
|
-
<TouchableOpacity
|
|
494
|
-
key={emoji}
|
|
495
|
-
style={styles.quickReactionBtn}
|
|
496
|
-
onPress={() => handleQuickReaction(emoji)}
|
|
497
|
-
activeOpacity={0.6}
|
|
498
|
-
>
|
|
499
|
-
<Text style={styles.quickReactionEmoji}>{emoji}</Text>
|
|
500
|
-
</TouchableOpacity>
|
|
501
|
-
))}
|
|
502
|
-
</View>
|
|
503
|
-
|
|
504
|
-
{/* Bottom Input Bar */}
|
|
505
|
-
<View style={styles.inputBarContainer}>
|
|
506
|
-
<View style={styles.inputPill}>
|
|
507
|
-
<TextInput
|
|
508
|
-
style={styles.textInput}
|
|
509
|
-
value={inputText}
|
|
510
|
-
onChangeText={setInputText}
|
|
511
|
-
placeholder="Chat..."
|
|
512
|
-
placeholderTextColor="rgba(255, 255, 255, 0.4)"
|
|
513
|
-
onSubmitEditing={() => handleSend()}
|
|
514
|
-
returnKeyType="send"
|
|
515
|
-
maxLength={400}
|
|
516
|
-
/>
|
|
517
|
-
|
|
518
|
-
<TouchableOpacity
|
|
519
|
-
style={styles.emojiToggleBtn}
|
|
520
|
-
onPress={() => setShowEmojiPicker(true)}
|
|
521
|
-
hitSlop={6}
|
|
522
|
-
>
|
|
523
|
-
<PlayerIcon name="emoticon-happy-outline" size={22} color="#FFF" />
|
|
524
|
-
</TouchableOpacity>
|
|
525
|
-
</View>
|
|
526
|
-
|
|
527
|
-
<TouchableOpacity
|
|
528
|
-
style={[
|
|
529
|
-
styles.sendBtn,
|
|
530
|
-
(!inputText.trim() || isSending) && styles.sendBtnDisabled,
|
|
531
|
-
]}
|
|
532
|
-
onPress={() => handleSend()}
|
|
533
|
-
disabled={!inputText.trim() || isSending}
|
|
534
|
-
hitSlop={8}
|
|
535
|
-
>
|
|
536
|
-
<PlayerIcon
|
|
537
|
-
name="send"
|
|
538
|
-
size={18}
|
|
539
|
-
color={inputText.trim() && !isSending ? '#000' : 'rgba(255, 255, 255, 0.3)'}
|
|
540
|
-
/>
|
|
541
|
-
</TouchableOpacity>
|
|
542
|
-
</View>
|
|
543
|
-
|
|
544
|
-
{/* Emoji Picker Modal */}
|
|
545
|
-
<EmojiPickerModal
|
|
546
|
-
visible={showEmojiPicker}
|
|
547
|
-
onClose={() => setShowEmojiPicker(false)}
|
|
548
|
-
onSelectEmoji={handleSelectEmoji}
|
|
549
|
-
colors={colors}
|
|
550
|
-
/>
|
|
551
|
-
</>
|
|
552
|
-
)}
|
|
667
|
+
{activeTab === 'chat' && chatAvailable ? (
|
|
668
|
+
<LiveChatPanel
|
|
669
|
+
messagesLoading={messagesLoading}
|
|
670
|
+
messages={messages}
|
|
671
|
+
flatListRef={flatListRef}
|
|
672
|
+
renderMessageItem={renderMessageItem}
|
|
673
|
+
hasMoreMessages={hasMoreMessages}
|
|
674
|
+
loadOlderMessages={loadOlderMessages}
|
|
675
|
+
loadingOlderMessages={loadingOlderMessages}
|
|
676
|
+
chatError={chatError}
|
|
677
|
+
handleQuickReaction={handleQuickReaction}
|
|
678
|
+
inputText={inputText}
|
|
679
|
+
setInputText={setInputText}
|
|
680
|
+
handleSend={handleSend}
|
|
681
|
+
isSending={isSending}
|
|
682
|
+
setShowEmojiPicker={setShowEmojiPicker}
|
|
683
|
+
showEmojiPicker={showEmojiPicker}
|
|
684
|
+
handleSelectEmoji={handleSelectEmoji}
|
|
685
|
+
colors={colors}
|
|
686
|
+
/>
|
|
687
|
+
) : null}
|
|
553
688
|
|
|
554
689
|
{activeTab === 'epg' && epgAvailable && (
|
|
555
690
|
<>
|
|
@@ -859,6 +994,29 @@ const styles = StyleSheet.create({
|
|
|
859
994
|
paddingVertical: 8,
|
|
860
995
|
gap: 8,
|
|
861
996
|
},
|
|
997
|
+
loadMoreMessages: {
|
|
998
|
+
minHeight: 40,
|
|
999
|
+
flexDirection: 'row',
|
|
1000
|
+
alignItems: 'center',
|
|
1001
|
+
justifyContent: 'center',
|
|
1002
|
+
gap: 8,
|
|
1003
|
+
paddingHorizontal: 14,
|
|
1004
|
+
borderRadius: 12,
|
|
1005
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
1006
|
+
borderColor: 'rgba(0, 229, 255, 0.35)',
|
|
1007
|
+
backgroundColor: 'rgba(0, 229, 255, 0.08)',
|
|
1008
|
+
},
|
|
1009
|
+
loadMoreMessagesText: {
|
|
1010
|
+
color: '#00E5FF',
|
|
1011
|
+
fontSize: 13,
|
|
1012
|
+
fontWeight: '700',
|
|
1013
|
+
},
|
|
1014
|
+
chatErrorText: {
|
|
1015
|
+
color: '#FF7A8A',
|
|
1016
|
+
paddingHorizontal: 14,
|
|
1017
|
+
paddingBottom: 6,
|
|
1018
|
+
fontSize: 12,
|
|
1019
|
+
},
|
|
862
1020
|
chatLoadingWrap: {
|
|
863
1021
|
flex: 1,
|
|
864
1022
|
alignItems: 'center',
|
|
@@ -1,26 +1,54 @@
|
|
|
1
1
|
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
2
2
|
|
|
3
3
|
let youtubeApiPromise;
|
|
4
|
+
const YOUTUBE_API_TIMEOUT_MS = 15000;
|
|
5
|
+
const YOUTUBE_PLAYER_TIMEOUT_MS = 15000;
|
|
6
|
+
|
|
4
7
|
function loadYouTubeApi() {
|
|
5
8
|
if (typeof window === 'undefined') return Promise.reject(new Error('YouTube playback requires a browser.'));
|
|
6
9
|
if (window.YT?.Player) return Promise.resolve(window.YT);
|
|
7
10
|
if (!youtubeApiPromise) {
|
|
8
11
|
youtubeApiPromise = new Promise((resolve, reject) => {
|
|
12
|
+
let timeoutId;
|
|
13
|
+
const cleanup = () => {
|
|
14
|
+
clearTimeout(timeoutId);
|
|
15
|
+
if (window.onYouTubeIframeAPIReady === onReady) {
|
|
16
|
+
window.onYouTubeIframeAPIReady = previousReady;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
9
19
|
const existing = document.querySelector('script[data-cinecrew-youtube-api]');
|
|
10
20
|
const previousReady = window.onYouTubeIframeAPIReady;
|
|
11
|
-
|
|
21
|
+
const onReady = () => {
|
|
12
22
|
try { previousReady?.(); } catch {}
|
|
13
|
-
|
|
23
|
+
if (window.YT?.Player) {
|
|
24
|
+
cleanup();
|
|
25
|
+
resolve(window.YT);
|
|
26
|
+
} else {
|
|
27
|
+
cleanup();
|
|
28
|
+
reject(new Error('The YouTube player API loaded without becoming ready.'));
|
|
29
|
+
}
|
|
14
30
|
};
|
|
31
|
+
window.onYouTubeIframeAPIReady = onReady;
|
|
32
|
+
timeoutId = setTimeout(() => {
|
|
33
|
+
cleanup();
|
|
34
|
+
reject(new Error('Timed out loading the YouTube player API. Check your network and allow youtube.com.'));
|
|
35
|
+
}, YOUTUBE_API_TIMEOUT_MS);
|
|
15
36
|
if (!existing) {
|
|
16
37
|
const script = document.createElement('script');
|
|
17
38
|
script.src = 'https://www.youtube.com/iframe_api';
|
|
18
39
|
script.async = true;
|
|
19
40
|
script.dataset.cinecrewYoutubeApi = 'true';
|
|
20
|
-
script.onerror = () =>
|
|
41
|
+
script.onerror = () => {
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(new Error('Could not load the YouTube player API.'));
|
|
44
|
+
};
|
|
21
45
|
document.head.appendChild(script);
|
|
22
46
|
}
|
|
23
47
|
});
|
|
48
|
+
youtubeApiPromise = youtubeApiPromise.catch((error) => {
|
|
49
|
+
youtubeApiPromise = null;
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
24
52
|
}
|
|
25
53
|
return youtubeApiPromise;
|
|
26
54
|
}
|
|
@@ -40,6 +68,7 @@ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
|
|
|
40
68
|
onStateChange,
|
|
41
69
|
onError,
|
|
42
70
|
onEnded,
|
|
71
|
+
style,
|
|
43
72
|
}, ref) {
|
|
44
73
|
const hostRef = useRef(null);
|
|
45
74
|
const playerRef = useRef(null);
|
|
@@ -65,6 +94,7 @@ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
|
|
|
65
94
|
useEffect(() => {
|
|
66
95
|
let cancelled = false;
|
|
67
96
|
let progressTimer;
|
|
97
|
+
let readyTimer;
|
|
68
98
|
let player;
|
|
69
99
|
loadYouTubeApi().then((YT) => {
|
|
70
100
|
if (cancelled || !hostRef.current) return;
|
|
@@ -73,18 +103,21 @@ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
|
|
|
73
103
|
height: '100%',
|
|
74
104
|
videoId,
|
|
75
105
|
playerVars: {
|
|
76
|
-
autoplay: paused ? 0 : 1,
|
|
106
|
+
autoplay: latestPropsRef.current.paused ? 0 : 1,
|
|
77
107
|
controls: 0,
|
|
78
|
-
|
|
108
|
+
disablekb: 1,
|
|
79
109
|
enablejsapi: 1,
|
|
80
|
-
rel: 0,
|
|
81
|
-
modestbranding: 1,
|
|
82
110
|
fs: 0,
|
|
111
|
+
iv_load_policy: 3,
|
|
112
|
+
mute: latestPropsRef.current.muted ? 1 : 0,
|
|
83
113
|
origin: window.location.origin,
|
|
114
|
+
playsinline: 1,
|
|
115
|
+
rel: 0,
|
|
84
116
|
},
|
|
85
117
|
events: {
|
|
86
118
|
onReady: (event) => {
|
|
87
119
|
if (cancelled) return;
|
|
120
|
+
clearTimeout(readyTimer);
|
|
88
121
|
playerRef.current = event.target;
|
|
89
122
|
readyRef.current = true;
|
|
90
123
|
const latest = latestPropsRef.current;
|
|
@@ -111,16 +144,29 @@ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
|
|
|
111
144
|
if (state === 'playing') onPlayingRef.current?.(event);
|
|
112
145
|
if (state === 'ended') onEndedRef.current?.();
|
|
113
146
|
},
|
|
114
|
-
onError: (event) =>
|
|
147
|
+
onError: (event) => {
|
|
148
|
+
clearTimeout(readyTimer);
|
|
149
|
+
onBufferingRef.current?.(false);
|
|
150
|
+
onErrorRef.current?.({ code: event.data, message: `YouTube playback failed (${event.data}).` });
|
|
151
|
+
},
|
|
115
152
|
},
|
|
116
153
|
});
|
|
117
154
|
playerRef.current = player;
|
|
155
|
+
readyTimer = setTimeout(() => {
|
|
156
|
+
if (cancelled || readyRef.current) return;
|
|
157
|
+
onBufferingRef.current?.(false);
|
|
158
|
+
onErrorRef.current?.(new Error('The YouTube player did not become ready. Check that embedding is allowed and the page can send a referrer.'));
|
|
159
|
+
}, YOUTUBE_PLAYER_TIMEOUT_MS);
|
|
118
160
|
}).catch((error) => {
|
|
119
|
-
if (!cancelled)
|
|
161
|
+
if (!cancelled) {
|
|
162
|
+
onBufferingRef.current?.(false);
|
|
163
|
+
onErrorRef.current?.(error);
|
|
164
|
+
}
|
|
120
165
|
});
|
|
121
166
|
return () => {
|
|
122
167
|
cancelled = true;
|
|
123
168
|
clearInterval(progressTimer);
|
|
169
|
+
clearTimeout(readyTimer);
|
|
124
170
|
readyRef.current = false;
|
|
125
171
|
try { player?.destroy?.(); } catch {}
|
|
126
172
|
playerRef.current = null;
|
|
@@ -162,7 +208,15 @@ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
|
|
|
162
208
|
return React.createElement('div', {
|
|
163
209
|
ref: hostRef,
|
|
164
210
|
className: 'cinecrew-player__youtube',
|
|
165
|
-
style: {
|
|
211
|
+
style: {
|
|
212
|
+
position: 'absolute',
|
|
213
|
+
inset: 0,
|
|
214
|
+
width: '100%',
|
|
215
|
+
height: '100%',
|
|
216
|
+
background: '#000',
|
|
217
|
+
...style,
|
|
218
|
+
pointerEvents: 'none',
|
|
219
|
+
},
|
|
166
220
|
});
|
|
167
221
|
});
|
|
168
222
|
|
|
@@ -16,7 +16,7 @@ function SeekControls({ isLive, controls, insets, scale, isSeeking, sliderPos, c
|
|
|
16
16
|
<Text style={[styles.timeText, seeking && styles.timeTextSeeking, { color: seeking ? palette.accentColor : palette.controlColor, fontSize: scale?.timeFont, fontWeight: seeking ? scale?.timeSeekingWeight : scale?.timeWeight }]}>{formatTime(displayTime)}</Text>
|
|
17
17
|
<Text style={[styles.timeText, { color: palette.controlColor, fontSize: scale?.timeFont, fontWeight: scale?.timeWeight }]}>{remaining}</Text>
|
|
18
18
|
</View>
|
|
19
|
-
<Slider style={styles.slider} minimumValue={0} maximumValue={duration > 0 ? duration : 1} value={sliderPos} minimumTrackTintColor={palette.accentColor} maximumTrackTintColor="rgba(255,255,255,0.3)" thumbTintColor={palette.accentColor} onValueChange={onValueChange} onSlidingStart={onSlidingStart} onSlidingComplete={onSlidingComplete} />
|
|
19
|
+
<Slider testID="cinecrew-player-seek-slider" accessibilityLabel="Seek video" style={styles.slider} minimumValue={0} maximumValue={duration > 0 ? duration : 1} value={sliderPos} minimumTrackTintColor={palette.accentColor} maximumTrackTintColor="rgba(255,255,255,0.3)" thumbTintColor={palette.accentColor} onValueChange={onValueChange} onSlidingStart={onSlidingStart} onSlidingComplete={onSlidingComplete} />
|
|
20
20
|
</View>
|
|
21
21
|
);
|
|
22
22
|
}
|
|
@@ -53,7 +53,7 @@ function AudioOnlyModeControl({ isAudioOnly, onToggle }) {
|
|
|
53
53
|
function VideoOnlyControl({ isVideoOnly, onToggle }) {
|
|
54
54
|
const palette = usePlayerColors();
|
|
55
55
|
const selectedStyle = isVideoOnly ? { backgroundColor: palette.surfaceColor, borderWidth: 1.5, borderColor: palette.accentColor } : null;
|
|
56
|
-
return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle?.(); }} accessibilityLabel={isVideoOnly ? '
|
|
56
|
+
return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle?.(); }} accessibilityLabel={isVideoOnly ? 'Disable video-only mode' : 'Video only'}><PlayerIcon name="video" size={18} color={isVideoOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function SpeedControl({ enabled, open, playbackRate, onToggle, onSelect }) {
|
|
@@ -81,7 +81,7 @@ function SpeedControl({ enabled, open, playbackRate, onToggle, onSelect }) {
|
|
|
81
81
|
|
|
82
82
|
function AudioTrackMenu({ open, audioTracks, selectedAudioTrack, onSelect, palette }) {
|
|
83
83
|
if (!open) return null;
|
|
84
|
-
if (audioTracks.length
|
|
84
|
+
if (audioTracks.length === 0) return <View style={[styles.speedPickerPopup, { backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}><Text style={[styles.speedOptionText, { color: palette.controlColor }]}>No audio tracks available</Text></View>;
|
|
85
85
|
return (
|
|
86
86
|
<View style={[styles.speedPickerPopup, { backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}>
|
|
87
87
|
<Text style={[styles.speedPickerTitle, { color: palette.mutedColor }]}>Audio</Text>
|
|
@@ -210,7 +210,9 @@ const styles = StyleSheet.create({
|
|
|
210
210
|
},
|
|
211
211
|
slider: {
|
|
212
212
|
width: '100%',
|
|
213
|
-
height:
|
|
213
|
+
height: 48,
|
|
214
|
+
zIndex: 75,
|
|
215
|
+
elevation: 75,
|
|
214
216
|
},
|
|
215
217
|
bottomControlsRow: {
|
|
216
218
|
flexDirection: 'row',
|
|
@@ -56,13 +56,12 @@ function LiveServiceControls({ isLive, controls, showLiveChat, drawerTab, showCh
|
|
|
56
56
|
return <>{renderServiceButton('liveChat', 'chat', showChat, 'comment-text-multiple')}{renderServiceButton('epg', 'epg', showEpg, 'television-guide')}{renderServiceButton('diagnostics', 'diagnostics', showDiagnostics, 'pulse')}</>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function PlaybackSessionControls({ isLive, controls, muted, onRestart, onMute, onLock,
|
|
59
|
+
function PlaybackSessionControls({ isLive, controls, muted, onRestart, onMute, onLock, palette }) {
|
|
60
60
|
return (
|
|
61
61
|
<>
|
|
62
62
|
{!isLive && controls.restart !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onRestart(); }} hitSlop={12}><PlayerIcon name="restart" size={20} color={palette.controlColor} /></TouchableOpacity> : null}
|
|
63
|
-
{controls.mute !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onMute(); }} hitSlop={12}><PlayerIcon name={muted ? '
|
|
63
|
+
{controls.mute !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onMute(); }} hitSlop={12}><PlayerIcon name={muted ? 'mute' : 'unmute'} size={20} color={muted ? palette.errorColor : palette.controlColor} /></TouchableOpacity> : null}
|
|
64
64
|
{controls.lock !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onLock(); }} hitSlop={12}><PlayerIcon name="lock-open-variant" size={20} color={palette.controlColor} /></TouchableOpacity> : null}
|
|
65
|
-
{controls.minimize && typeof onMinimize === 'function' ? <TouchableOpacity style={styles.pill} onPress={onMinimize} hitSlop={12} accessibilityLabel="Minimize player"><PlayerIcon name="arrow-collapse" size={20} color={palette.controlColor} /></TouchableOpacity> : null}
|
|
66
65
|
</>
|
|
67
66
|
);
|
|
68
67
|
}
|
|
@@ -113,7 +112,6 @@ export const PlayerTopBar = ({
|
|
|
113
112
|
onRestart,
|
|
114
113
|
onToggleMute,
|
|
115
114
|
onToggleLock,
|
|
116
|
-
onMinimize,
|
|
117
115
|
controls = {},
|
|
118
116
|
}) => {
|
|
119
117
|
const { width, height } = useWindowDimensions();
|
|
@@ -149,7 +147,7 @@ export const PlayerTopBar = ({
|
|
|
149
147
|
<View style={styles.topRightActions}>
|
|
150
148
|
<RecordingControls isLive={isLive} enabled={isScreenRecorderEnabled} status={recStatus} loading={isLoading} controls={controls} onStart={onStartRecording} onResume={onResumeRecording} onPause={onPauseRecording} onStop={onStopRecording} palette={palette} />
|
|
151
149
|
<LiveServiceControls isLive={isLive} controls={controls} showLiveChat={showLiveChat} drawerTab={drawerTab} showChat={isLiveCommentsEnabled} showEpg={isEpgEnabled} showDiagnostics={diagnosticsOverlayEnabled} onToggle={onToggleChatTab} palette={palette} />
|
|
152
|
-
<PlaybackSessionControls isLive={isLive} controls={controls} muted={muted} onRestart={onRestart} onMute={onToggleMute} onLock={onToggleLock}
|
|
150
|
+
<PlaybackSessionControls isLive={isLive} controls={controls} muted={muted} onRestart={onRestart} onMute={onToggleMute} onLock={onToggleLock} palette={palette} />
|
|
153
151
|
</View>
|
|
154
152
|
</View>
|
|
155
153
|
<PortraitPlayerTitle isPortrait={isPortrait} displayTitle={displayTitle} episodeLabel={episodeLabel} showEpisodeSubtitle={showEpisodeSubtitle} palette={palette} scale={scale} />
|