@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.
@@ -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 msgs = await integrations.liveChat.loadMessages({ channelId: String(videoId), limit: 50 });
164
- if (isMounted && Array.isArray(msgs)) {
165
- setMessages(msgs);
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
- // Auto-scroll to bottom on new messages
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 msgs = await integrations.liveChat?.loadMessages?.({ channelId: String(videoId), limit: 50 }).catch?.(() => null);
324
- if (Array.isArray(msgs)) setMessages(msgs);
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
- {/* Community Notice */}
463
- <View style={styles.welcomeBanner}>
464
- <PlayerIcon name="shield-check" size={16} color="#00E5FF" style={{ marginTop: 2 }} />
465
- <Text style={styles.welcomeText}>
466
- Welcome to live chat! Remember to guard your privacy and abide by community guidelines.
467
- </Text>
468
- </View>
469
-
470
- {/* Messages List */}
471
- {messagesLoading && messages.length === 0 ? (
472
- <View style={styles.chatLoadingWrap}>
473
- <ActivityIndicator size="small" color="#00E5FF" />
474
- <Text style={styles.epgStatusText}>Loading live chat…</Text>
475
- </View>
476
- ) : (
477
- <FlatList
478
- ref={flatListRef}
479
- data={messages}
480
- keyExtractor={(item, index) => `${String(item.id || 'message')}-${index}`}
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
- window.onYouTubeIframeAPIReady = () => {
21
+ const onReady = () => {
12
22
  try { previousReady?.(); } catch {}
13
- resolve(window.YT);
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 = () => reject(new Error('Could not load the YouTube player API.'));
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
- playsinline: 1,
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) => onErrorRef.current?.({ code: event.data, message: `YouTube playback failed (${event.data}).` }),
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) onErrorRef.current?.(error);
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: { position: 'absolute', inset: 0, width: '100%', height: '100%', background: '#000' },
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 ? 'Enable audio' : 'Video only'}><PlayerIcon name="video" size={18} color={isVideoOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
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 <= 1) return <View style={[styles.speedPickerPopup, { backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}><Text style={[styles.speedOptionText, { color: palette.controlColor }]}>No other audio available</Text></View>;
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: 36,
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, onMinimize, palette }) {
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 ? 'volume-off' : 'volume-high'} size={20} color={muted ? palette.errorColor : palette.controlColor} /></TouchableOpacity> : null}
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} onMinimize={onMinimize} palette={palette} />
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} />