@cinecrew/cinecrew-player 0.1.3 → 0.1.5

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.
@@ -15,7 +15,6 @@ import {
15
15
  import VLCPlayer from '../../packages/react-native-vlc-media-player/VLCPlayer.js';
16
16
  import { WebVideoPlayer } from './media/WebVideoPlayer';
17
17
  import { ElectronVideoPlayer } from './media/ElectronVideoPlayer';
18
- import { YouTubeVideoPlayer } from './media/YouTubeVideoPlayer';
19
18
  import { LiveChatDrawer } from './media/LiveChatDrawer';
20
19
  import { LiveRecordingNotice, LiveRecordingOverlay } from './media/LiveRecordingOverlay';
21
20
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -23,6 +22,8 @@ import { PlayerIcon } from './customization';
23
22
  import { isWeb, isAndroid, isElectron } from '../utils/runtimePlatform';
24
23
  import { getFontSize, getFontWeight } from '../utils/layoutUtils';
25
24
  import { isLocalMediaUri } from '../utils/mediaUtils';
25
+ import { invokePlayerAction } from '../utils/invokePlayerAction.js';
26
+ import { emitProgressBarTime } from '../utils/progressBarTime.js';
26
27
  import {
27
28
  USER_AGENT,
28
29
  calculateScreenAspectRatio,
@@ -69,6 +70,17 @@ function pickShuffleCandidate(candidates, season, episode) {
69
70
  return candidates[(hash >>> 0) % candidates.length];
70
71
  }
71
72
 
73
+ function normalizeAspectOptions(values) {
74
+ if (!Array.isArray(values) || values.length === 0) return ASPECT_OPTIONS;
75
+ const unique = new Map();
76
+ values.forEach((item) => {
77
+ const value = typeof item === 'string' ? item : item?.value;
78
+ if (!value || typeof value !== 'string') return;
79
+ unique.set(value, { value, label: typeof item === 'string' ? item : item.label || value });
80
+ });
81
+ return unique.size ? [...unique.values()] : ASPECT_OPTIONS;
82
+ }
83
+
72
84
  // Gesture events can arrive much faster than the native player can consume
73
85
  // volume updates. Coalesce them to display-rate updates for the UI and a
74
86
  // slower native volume cadence so a drag stays smooth without audio crackle.
@@ -204,49 +216,16 @@ function handleVerticalGestureMove(touches, gestureState) {
204
216
  gestureState.commitVolume.current(volume);
205
217
  }
206
218
 
207
- function handleYouTubePlaybackState(state, playback) {
208
- if (state === 'playing') {
209
- playback.isPlayingRef.current = true;
210
- playback.setIsPlaying(true);
211
- } else if (state === 'paused' || state === 'ended') {
212
- playback.isPlayingRef.current = false;
213
- playback.setIsPlaying(false);
214
- }
215
- }
216
-
217
219
  function PlatformMediaSurface(props) {
218
220
  const {
219
- youtubeVideoId, playerStreamUrl, vlcRef, isPlaying, muted, videoOnlyMode,
221
+ playerStreamUrl, vlcRef, isPlaying, muted, videoOnlyMode,
220
222
  volume, playbackRate, aspectRatio, title, posterUrl, isLive, isAudioOnly,
221
223
  selectedAudioTrack, handleTracksChanged, handleProgress, handleNativePlaying,
222
224
  handleWebBuffering, handleEpisodeEnded, handleWebError, togglePlayPause,
223
225
  handleSeekByAction, handlePlaybackRoute, exoFallback, useExoForAndroidLocalMedia,
224
226
  nativeSource, computedAspectRatio, handleNativeLoadStart,
225
- handleNativeOpen, handleNativeBuffering, isPlayingRef, setIsPlaying,
227
+ handleNativeOpen, handleNativeBuffering,
226
228
  } = props;
227
- if (youtubeVideoId) {
228
- return (
229
- <YouTubeVideoPlayer
230
- key={`youtube-${youtubeVideoId}`}
231
- ref={vlcRef}
232
- videoId={youtubeVideoId}
233
- paused={!isPlaying}
234
- muted={muted || videoOnlyMode}
235
- volume={volume / 100}
236
- playbackRate={playbackRate}
237
- onReady={(event) => {
238
- props.clearBufferingIndicator();
239
- props.handleNativeOpen(event);
240
- }}
241
- onProgress={handleProgress}
242
- onPlaying={handleNativePlaying}
243
- onStateChange={(state) => handleYouTubePlaybackState(state, { isPlayingRef, setIsPlaying })}
244
- onBuffering={handleWebBuffering}
245
- onEnded={handleEpisodeEnded}
246
- onError={handleWebError}
247
- />
248
- );
249
- }
250
229
  if (isElectron()) {
251
230
  return (
252
231
  <ElectronVideoPlayer
@@ -590,7 +569,7 @@ function FullscreenControlsPanel(props) {
590
569
  duration={props.duration}
591
570
  isAudioOnlyFeatureEnabled={props.isAudioOnlyFeatureEnabled}
592
571
  isAudioOnly={props.isAudioOnly}
593
- isVideoOnly={props.videoOnlyMode}
572
+ aspectRatios={props.aspectRatios}
594
573
  controls={props.controls}
595
574
  showAspectPicker={props.showAspectPicker}
596
575
  aspectRatio={props.aspectRatio}
@@ -611,7 +590,6 @@ function FullscreenControlsPanel(props) {
611
590
  onToggleAudioPicker={props.onToggleAudioPicker}
612
591
  onSelectAudioTrack={props.handleAudioTrackAction}
613
592
  onToggleFullscreen={props.handleFullscreenAction}
614
- onToggleVideoOnly={props.handleVideoOnlyAction}
615
593
  />
616
594
  </>
617
595
  );
@@ -695,9 +673,10 @@ function FullscreenChatLayer(props) {
695
673
  }
696
674
 
697
675
  function FullscreenRecordingLayer(props) {
676
+ const recordingEligible = props.isLive;
698
677
  return (
699
678
  <>
700
- {!props.isAudioOnly && props.isLive && (props.isScreenRecorderEnabled || props.recStatus !== 'idle') ? (
679
+ {!props.isAudioOnly && recordingEligible && (props.isScreenRecorderEnabled || props.recStatus !== 'idle') ? (
701
680
  <LiveRecordingOverlay
702
681
  status={props.recStatus}
703
682
  elapsedMs={props.recElapsedMs}
@@ -709,7 +688,7 @@ function FullscreenRecordingLayer(props) {
709
688
  onStop={props.handleStopRecording}
710
689
  />
711
690
  ) : null}
712
- {!props.isAudioOnly && props.isLive && props.isScreenRecorderEnabled ? (
691
+ {!props.isAudioOnly && recordingEligible && props.isScreenRecorderEnabled ? (
713
692
  <LiveRecordingNotice notice={props.recNotice} colors={props.colors} onDismiss={props.onDismissNotice} />
714
693
  ) : null}
715
694
  </>
@@ -761,7 +740,7 @@ function getInlinePreviewPositionStyle(rect, isValid) {
761
740
  export const MediaPlayerView = ({
762
741
  visible,
763
742
  streamUrl,
764
- youtubeVideoId,
743
+ onBack,
765
744
  title,
766
745
  mediaType = 'live',
767
746
  onClose,
@@ -790,6 +769,9 @@ export const MediaPlayerView = ({
790
769
  liveChatNonce = 0,
791
770
  messagePageSize = 50,
792
771
  drawerStyle,
772
+ aspectRatios,
773
+ defaultAspectRatio = DEFAULT_ASPECT_RATIO,
774
+ onAspectRatioChange,
793
775
  genre,
794
776
  categoryName,
795
777
  controls = {},
@@ -805,6 +787,8 @@ export const MediaPlayerView = ({
805
787
  style,
806
788
  onReady,
807
789
  onProgress,
790
+ showProgressBar = true,
791
+ onProgressBarChange,
808
792
  onPlaying,
809
793
  onBuffering,
810
794
  onError,
@@ -830,12 +814,16 @@ export const MediaPlayerView = ({
830
814
  const vlcRef = useRef(null);
831
815
  const playerRef = useRef(null);
832
816
  const invokeAction = useCallback((name, fallback, payload) => {
833
- const callback = actions?.[name];
834
- if (typeof callback === 'function') {
835
- return callback(payload, { player: playerApiRef?.current || null });
836
- }
837
- return fallback?.();
838
- }, [actions, playerApiRef]);
817
+ let callback = actions?.[name];
818
+ if (name === 'onAspectRatioChange') callback = callback || onAspectRatioChange;
819
+ if (name === 'onBack') callback = callback || onBack || onClose;
820
+ return invokePlayerAction(
821
+ fallback,
822
+ callback,
823
+ payload,
824
+ { player: playerApiRef?.current || null },
825
+ );
826
+ }, [actions, onAspectRatioChange, onBack, onClose, playerApiRef]);
839
827
  const handlePlayerHostRef = useCallback((node) => {
840
828
  playerRef.current = node;
841
829
  onPlayerHostRef?.(node);
@@ -981,8 +969,9 @@ export const MediaPlayerView = ({
981
969
  useEffect(() => {
982
970
  isLockedRef.current = isLocked;
983
971
  }, [isLocked]);
984
- const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT_RATIO);
985
- const aspectRatioRef = useRef(DEFAULT_ASPECT_RATIO);
972
+ const [aspectRatio, setAspectRatio] = useState(defaultAspectRatio || DEFAULT_ASPECT_RATIO);
973
+ const aspectRatioRef = useRef(defaultAspectRatio || DEFAULT_ASPECT_RATIO);
974
+ const aspectOptions = useMemo(() => normalizeAspectOptions(aspectRatios), [aspectRatios]);
986
975
  const [isFullscreen, setIsFullscreen] = useState(false);
987
976
 
988
977
  const [audioTracks, setAudioTracks] = useState([]);
@@ -1112,7 +1101,7 @@ export const MediaPlayerView = ({
1112
1101
  let targetRatio = val;
1113
1102
  if (val === 'FIT') {
1114
1103
  targetRatio = 'FIT';
1115
- } else if (val === 'FILL_SCREEN') {
1104
+ } else if (val === 'FILL_SCREEN' || val === 'FILL') {
1116
1105
  targetRatio = calculateScreenAspectRatio(windowWidth, windowHeight);
1117
1106
  }
1118
1107
 
@@ -1148,6 +1137,7 @@ export const MediaPlayerView = ({
1148
1137
  const isSeeking = useRef(false);
1149
1138
  const bufferingTimerRef = useRef(null);
1150
1139
  const seekCompletedAt = useRef(0);
1140
+ const lastProgressBarSecondRef = useRef(null);
1151
1141
  const lastKnownTimeRef = useRef(0);
1152
1142
  const lastKnownDurRef = useRef(Number(durationSecs) || 0);
1153
1143
  const hasResumedRef = useRef(false);
@@ -1190,6 +1180,11 @@ export const MediaPlayerView = ({
1190
1180
 
1191
1181
  const isLive = mediaType === 'live' || mediaType === 'channel';
1192
1182
  const isInlinePreview = !!inlinePreview;
1183
+ const progressBarVisible = showProgressBar !== false
1184
+ && controls.seek !== false
1185
+ && !isLive
1186
+ && !isInlinePreview;
1187
+ const progressBarCallback = progressBarVisible ? onProgressBarChange : undefined;
1193
1188
 
1194
1189
  useEffect(() => {
1195
1190
  if (!visible || !initialShowLiveChat || !isLive || !isLiveCommentsEnabled) return;
@@ -1336,6 +1331,7 @@ export const MediaPlayerView = ({
1336
1331
  setIsPlaying(!initialPaused);
1337
1332
  isPlayingRef.current = !initialPaused;
1338
1333
  setVideoOnlyMode(!!videoOnly);
1334
+ lastProgressBarSecondRef.current = null;
1339
1335
  setPlaybackUrl(streamUrl || '');
1340
1336
  setIsLocked(false);
1341
1337
  setIsAudioOnly(!!initialAudioOnly);
@@ -1374,14 +1370,14 @@ export const MediaPlayerView = ({
1374
1370
  // Reset aspect ratio & zoom state every time player opens or streamUrl changes
1375
1371
  useEffect(() => {
1376
1372
  if (visible) {
1377
- setAspectRatio(DEFAULT_ASPECT_RATIO);
1378
- aspectRatioRef.current = DEFAULT_ASPECT_RATIO;
1373
+ setAspectRatio(defaultAspectRatio || DEFAULT_ASPECT_RATIO);
1374
+ aspectRatioRef.current = defaultAspectRatio || DEFAULT_ASPECT_RATIO;
1379
1375
  setZoomScale(1);
1380
1376
  zoomScaleRef.current = 1;
1381
1377
  setZoomBadgeText('');
1382
1378
  setSeekRipple(null);
1383
1379
  }
1384
- }, [visible, streamUrl]);
1380
+ }, [visible, streamUrl, defaultAspectRatio]);
1385
1381
 
1386
1382
  // Auto-hide controls 4 seconds after inactivity
1387
1383
  const scheduleHide = useCallback(() => {
@@ -1455,9 +1451,9 @@ export const MediaPlayerView = ({
1455
1451
 
1456
1452
  const handleBackAction = useCallback(() => invokeAction(
1457
1453
  'onBack',
1458
- handleClose,
1454
+ undefined,
1459
1455
  { title, streamUrl, mediaId },
1460
- ), [invokeAction, handleClose, title, streamUrl, mediaId]);
1456
+ ), [invokeAction, title, streamUrl, mediaId]);
1461
1457
 
1462
1458
  useEffect(() => {
1463
1459
  if (!visible || isWeb()) return undefined;
@@ -1575,8 +1571,9 @@ export const MediaPlayerView = ({
1575
1571
  setSliderPos(0);
1576
1572
  lastKnownTimeRef.current = 0;
1577
1573
  seekCompletedAt.current = Date.now();
1574
+ emitProgressBarTime(0, progressBarCallback, lastProgressBarSecondRef, { force: true });
1578
1575
  if (!showControls) setShowControls(true);
1579
- }, [showControls]);
1576
+ }, [progressBarCallback, showControls]);
1580
1577
 
1581
1578
  // Episode finished: auto-advance to the next episode (sequential) or to a
1582
1579
  // random episode (shuffle mode). Last episode in the playlist stops playback.
@@ -1775,6 +1772,7 @@ export const MediaPlayerView = ({
1775
1772
  lastKnownTimeRef,
1776
1773
  setSliderPosition: setSliderPos,
1777
1774
  });
1775
+ if (progress) emitProgressBarTime(progress.seconds, progressBarCallback, lastProgressBarSecondRef);
1778
1776
  };
1779
1777
 
1780
1778
  const handleNativeOpen = (event) => {
@@ -1882,10 +1880,14 @@ export const MediaPlayerView = ({
1882
1880
  setSliderPos(target);
1883
1881
  seekCompletedAt.current = Date.now();
1884
1882
  debouncedSaveProgress();
1883
+ emitProgressBarTime(target, progressBarCallback, lastProgressBarSecondRef, { force: true });
1885
1884
  }, { seconds: Number(seconds) || 0, currentTime: lastKnownTimeRef.current },
1886
- ), [invokeAction, handleSeekTo, debouncedSaveProgress]);
1885
+ ), [invokeAction, handleSeekTo, debouncedSaveProgress, progressBarCallback]);
1887
1886
  const handleSeekByAction = (deltaSeconds) => invokeAction(
1888
- 'onSeek', () => handleSeekBy(deltaSeconds), { deltaSeconds: Number(deltaSeconds) || 0, currentTime: lastKnownTimeRef.current },
1887
+ 'onSeek', () => {
1888
+ handleSeekBy(deltaSeconds);
1889
+ emitProgressBarTime(lastKnownTimeRef.current, progressBarCallback, lastProgressBarSecondRef, { force: true });
1890
+ }, { deltaSeconds: Number(deltaSeconds) || 0, currentTime: lastKnownTimeRef.current },
1889
1891
  );
1890
1892
  const handleMuteAction = useCallback(() => invokeAction(
1891
1893
  'onMute', toggleMute, { muted: !mutedRef.current },
@@ -1912,14 +1914,17 @@ export const MediaPlayerView = ({
1912
1914
  event?.stopPropagation?.();
1913
1915
  return invokeAction(name, fallback, { title, streamUrl, mediaId });
1914
1916
  }, [invokeAction, title, streamUrl, mediaId]);
1915
- const handlePanelAction = useCallback((tab) => invokeAction(
1916
- getPanelActionName(tab),
1917
- () => {
1918
- if (showLiveChat && drawerTab === tab) setShowLiveChat(false);
1919
- else { setDrawerTab(tab); setShowLiveChat(true); }
1920
- },
1921
- { tab, isOpen: showLiveChat && drawerTab === tab },
1922
- ), [invokeAction, showLiveChat, drawerTab]);
1917
+ const handlePanelAction = useCallback((tab) => {
1918
+ const isOpen = !(showLiveChat && drawerTab === tab);
1919
+ return invokeAction(
1920
+ getPanelActionName(tab),
1921
+ () => {
1922
+ if (isOpen) { setDrawerTab(tab); setShowLiveChat(true); }
1923
+ else setShowLiveChat(false);
1924
+ },
1925
+ { tab, isOpen },
1926
+ );
1927
+ }, [invokeAction, showLiveChat, drawerTab]);
1923
1928
 
1924
1929
  useEffect(() => {
1925
1930
  if (!playerApiRef) return undefined;
@@ -1937,7 +1942,7 @@ export const MediaPlayerView = ({
1937
1942
  seekTo: handleSeekTo,
1938
1943
  seekBy: handleSeekBy,
1939
1944
  setPlaybackRate: (rate) => setPlaybackRate(clampNumber(rate, 0.25, 4, 1)),
1940
- back: handleClose,
1945
+ back: handleBackAction,
1941
1946
  getVideoElement: () => vlcRef.current?.getVideoElement?.() || null,
1942
1947
  getAudioTracks: () => audioTracks,
1943
1948
  };
@@ -1945,7 +1950,7 @@ export const MediaPlayerView = ({
1945
1950
  return () => {
1946
1951
  for (const key of Object.keys(api)) delete playerApiRef.current[key];
1947
1952
  };
1948
- }, [playerApiRef, togglePlayPause, handleRestart, toggleMute, handleSelectAspectRatio, handleAudioSelect, handleSeekTo, handleSeekBy, handleClose, audioTracks]);
1953
+ }, [playerApiRef, togglePlayPause, handleRestart, toggleMute, handleSelectAspectRatio, handleAudioSelect, handleSeekTo, handleSeekBy, handleBackAction, audioTracks]);
1949
1954
 
1950
1955
  const handleSpeedSelect = useCallback((speed) => invokeAction(
1951
1956
  'onPlaybackRateChange', () => {
@@ -2459,7 +2464,6 @@ export const MediaPlayerView = ({
2459
2464
 
2460
2465
  const videoPlayer = (
2461
2466
  <PlatformMediaSurface
2462
- youtubeVideoId={youtubeVideoId}
2463
2467
  playerStreamUrl={playerStreamUrl}
2464
2468
  vlcRef={vlcRef}
2465
2469
  isPlaying={isPlaying}
@@ -2488,11 +2492,8 @@ export const MediaPlayerView = ({
2488
2492
  computedAspectRatio={computedAspectRatio}
2489
2493
  handleNativeLoadStart={handleNativeLoadStart}
2490
2494
  handleNativeOpen={handleNativeOpen}
2491
- clearBufferingIndicator={clearBufferingIndicator}
2492
2495
  handleClose={handleClose}
2493
2496
  handleNativeBuffering={handleNativeBuffering}
2494
- isPlayingRef={isPlayingRef}
2495
- setIsPlaying={setIsPlaying}
2496
2497
  />
2497
2498
  );
2498
2499
  const isValidPreviewRect = isUsableInlinePreviewRect(inlinePreviewRect);
@@ -2568,7 +2569,7 @@ export const MediaPlayerView = ({
2568
2569
  isEpgEnabled,
2569
2570
  diagnosticsOverlayEnabled,
2570
2571
  muted,
2571
- controls,
2572
+ controls: { ...controls, seek: progressBarVisible },
2572
2573
  onClose: handleBackAction,
2573
2574
  handleRecordingAction,
2574
2575
  handleStartRecording,
@@ -2591,6 +2592,7 @@ export const MediaPlayerView = ({
2591
2592
  videoOnlyMode,
2592
2593
  showAspectPicker,
2593
2594
  aspectRatio,
2595
+ aspectRatios: aspectOptions,
2594
2596
  showSpeedPicker,
2595
2597
  playbackRate,
2596
2598
  showAudioPicker,
@@ -3,7 +3,7 @@ import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
3
3
  import { PlayerCustomizationProvider } from './customization';
4
4
  import { MediaPlayerView } from './MediaPlayerView';
5
5
  import { isElectron, isWeb } from '../utils/runtimePlatform';
6
- import { getYouTubeVideoId, useResolvedPlayerSource } from '../utils/sourceUtils';
6
+ import { useResolvedPlayerSource } from '../utils/sourceUtils';
7
7
 
8
8
  function getRuntimePlatform() {
9
9
  if (isWeb()) return 'web';
@@ -50,8 +50,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
50
50
  const streamUrl = String(media.uri || media.url || '');
51
51
  const resolvedIsLive = isLive ?? media.isLive ?? (media.mediaType === 'live');
52
52
  const resolvedMediaType = mediaType || media.mediaType || (resolvedIsLive ? 'live' : 'movie');
53
- const youtubeVideoId = getYouTubeVideoId(media);
54
- const isVisible = visible ?? Boolean(streamUrl || youtubeVideoId);
53
+ const isVisible = visible ?? Boolean(streamUrl);
55
54
 
56
55
  React.useEffect(() => {
57
56
  if (resolution.error) props.onError?.(resolution.error);
@@ -75,11 +74,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
75
74
  ...metadata,
76
75
  visible: isVisible,
77
76
  streamUrl,
78
- youtubeVideoId,
77
+ onBack,
79
78
  title: title || media.title || '',
80
79
  posterUrl: poster || posterUrl || media.poster || media.posterUrl || '',
81
80
  mediaType: resolvedMediaType,
82
- onClose: onClose || onBack || (() => {}),
81
+ onClose: onClose || (() => {}),
83
82
  initialPaused: paused ?? initialPaused ?? !autoPlay,
84
83
  initialMuted: muted ?? initialMuted ?? false,
85
84
  initialAudioOnly: audioOnly,
@@ -124,7 +124,6 @@ function LiveChatPanel({
124
124
  messages,
125
125
  flatListRef,
126
126
  renderMessageItem,
127
- hasMoreMessages,
128
127
  loadOlderMessages,
129
128
  loadingOlderMessages,
130
129
  chatError,
@@ -161,19 +160,16 @@ function LiveChatPanel({
161
160
  contentContainerStyle={styles.messagesList}
162
161
  showsVerticalScrollIndicator
163
162
  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>
163
+ ListHeaderComponent={loadingOlderMessages ? (
164
+ <View style={styles.loadingOlderMessages}>
165
+ <ActivityIndicator size="small" color="#00E5FF" />
166
+ <Text style={styles.loadingOlderMessagesText}>Loading older messages…</Text>
167
+ </View>
176
168
  ) : null}
169
+ onScroll={(event) => {
170
+ if (event.nativeEvent.contentOffset.y <= 24) loadOlderMessages();
171
+ }}
172
+ scrollEventThrottle={16}
177
173
  ListEmptyComponent={<Text style={styles.epgStatusText}>No messages yet. Start the conversation.</Text>}
178
174
  />
179
175
  )}
@@ -670,7 +666,6 @@ export const LiveChatDrawer = ({
670
666
  messages={messages}
671
667
  flatListRef={flatListRef}
672
668
  renderMessageItem={renderMessageItem}
673
- hasMoreMessages={hasMoreMessages}
674
669
  loadOlderMessages={loadOlderMessages}
675
670
  loadingOlderMessages={loadingOlderMessages}
676
671
  chatError={chatError}
@@ -994,22 +989,18 @@ const styles = StyleSheet.create({
994
989
  paddingVertical: 8,
995
990
  gap: 8,
996
991
  },
997
- loadMoreMessages: {
998
- minHeight: 40,
992
+ loadingOlderMessages: {
993
+ minHeight: 36,
999
994
  flexDirection: 'row',
1000
995
  alignItems: 'center',
1001
996
  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)',
997
+ gap: 7,
998
+ paddingVertical: 6,
1008
999
  },
1009
- loadMoreMessagesText: {
1000
+ loadingOlderMessagesText: {
1010
1001
  color: '#00E5FF',
1011
- fontSize: 13,
1012
- fontWeight: '700',
1002
+ fontSize: 12,
1003
+ fontWeight: '600',
1013
1004
  },
1014
1005
  chatErrorText: {
1015
1006
  color: '#FF7A8A',
@@ -39,6 +39,9 @@ export const WebVideoPlayer = forwardRef(({
39
39
  const playbackErrorRef = useRef(onError);
40
40
  const onEndedRef = useRef(onEnded);
41
41
  const [activeUrl, setActiveUrl] = useState('');
42
+ const [corsMode, setCorsMode] = useState('anonymous');
43
+ const corsModeRef = useRef('anonymous');
44
+ corsModeRef.current = corsMode;
42
45
  pausedRef.current = paused;
43
46
  playbackRateRef.current = playbackRate;
44
47
  onPlaybackRouteRef.current = onPlaybackRoute;
@@ -126,6 +129,8 @@ export const WebVideoPlayer = forwardRef(({
126
129
  return undefined;
127
130
  }
128
131
  setActiveUrl(streamUrl);
132
+ setCorsMode('anonymous');
133
+ corsModeRef.current = 'anonymous';
129
134
  return undefined;
130
135
  }, [streamUrl]);
131
136
 
@@ -243,6 +248,7 @@ export const WebVideoPlayer = forwardRef(({
243
248
  muted={muted}
244
249
  playsInline
245
250
  controls={false}
251
+ crossOrigin={corsMode}
246
252
  style={videoStyle}
247
253
  onPlaying={() => {
248
254
  const video = videoRef.current;
@@ -280,6 +286,13 @@ export const WebVideoPlayer = forwardRef(({
280
286
  if (video && video.readyState >= 2) {
281
287
  return;
282
288
  }
289
+ // If we were trying anonymous CORS and it failed, retry without.
290
+ // Playback will work; recording will fall back to screen capture.
291
+ if (corsModeRef.current === 'anonymous') {
292
+ setCorsMode(undefined);
293
+ corsModeRef.current = undefined;
294
+ return;
295
+ }
283
296
  playbackErrorRef.current?.({
284
297
  message: mediaError?.message || 'Failed to load stream.',
285
298
  err: mediaError || e,
@@ -48,23 +48,6 @@ function SoundWave({ isPlaying, isLandscape, accentColor }) {
48
48
  );
49
49
  }
50
50
 
51
- function AudioOnlyBackdrop({ posterUrl }) {
52
- return (
53
- <>
54
- {posterUrl ? (
55
- <Image
56
- source={{ uri: posterUrl }}
57
- style={[StyleSheet.absoluteFill, styles.audioOnlyBackdropImage]}
58
- resizeMode="cover"
59
- blurRadius={isWeb() ? 40 : 25}
60
- pointerEvents="none"
61
- />
62
- ) : null}
63
- <View style={styles.audioOnlyBackdropDim} pointerEvents="none" />
64
- </>
65
- );
66
- }
67
-
68
51
  function AudioOnlyBadge({ usesAudioProxy, palette, isLandscape }) {
69
52
  const message = usesAudioProxy
70
53
  ? 'Battery Saver Audio Mode • Screen can be locked'
@@ -101,10 +84,9 @@ export const AudioOnlyView = ({
101
84
  );
102
85
 
103
86
  return (
104
- <View style={[styles.audioOnlyContainer, { backgroundColor: palette.backgroundColor }]} pointerEvents="auto">
105
- <AudioOnlyBackdrop posterUrl={posterUrl} />
87
+ <View style={styles.audioOnlyContainer} pointerEvents="auto">
106
88
 
107
- <View style={[styles.audioOnlyCard, isLandscape && styles.audioOnlyCardLandscape, { backgroundColor: palette.surfaceColor, borderColor: palette.accentColor }]} pointerEvents="auto">
89
+ <View style={[styles.audioOnlyCard, isLandscape && styles.audioOnlyCardLandscape, { borderColor: palette.accentColor }]} pointerEvents="auto">
108
90
  <AudioArtwork posterUrl={posterUrl} isLandscape={isLandscape} palette={palette} />
109
91
 
110
92
  <SoundWave isPlaying={isPlaying} isLandscape={isLandscape} accentColor={palette.accentColor} />
@@ -153,25 +135,6 @@ const styles = StyleSheet.create({
153
135
  zIndex: 50,
154
136
  elevation: 50,
155
137
  },
156
- audioOnlyBackdropImage: {
157
- position: 'absolute',
158
- top: 0,
159
- left: 0,
160
- right: 0,
161
- bottom: 0,
162
- width: '100%',
163
- height: '100%',
164
- },
165
- audioOnlyBackdropDim: {
166
- position: 'absolute',
167
- top: 0,
168
- left: 0,
169
- right: 0,
170
- bottom: 0,
171
- width: '100%',
172
- height: '100%',
173
- backgroundColor: 'rgba(7, 9, 14, 0.88)',
174
- },
175
138
  audioOnlyCard: {
176
139
  alignItems: 'center',
177
140
  justifyContent: 'center',
@@ -180,7 +143,7 @@ const styles = StyleSheet.create({
180
143
  maxWidth: 440,
181
144
  maxHeight: '92%',
182
145
  borderRadius: 20,
183
- backgroundColor: 'rgba(15, 23, 42, 0.85)',
146
+ backgroundColor: '#0B1220',
184
147
  borderWidth: 1,
185
148
  borderColor: 'rgba(0, 229, 255, 0.25)',
186
149
  shadowColor: '#000',
@@ -21,7 +21,7 @@ function SeekControls({ isLive, controls, insets, scale, isSeeking, sliderPos, c
21
21
  );
22
22
  }
23
23
 
24
- function AspectRatioControl({ controls, open, aspectRatio, onToggle, onSelect }) {
24
+ function AspectRatioControl({ controls, open, aspectRatio, aspectRatios = ASPECT_OPTIONS, onToggle, onSelect }) {
25
25
  const palette = usePlayerColors();
26
26
  if (controls.aspectRatio === false) return null;
27
27
  const selectedStyle = { backgroundColor: palette.surfaceColor, borderWidth: 1.5, borderColor: palette.accentColor };
@@ -29,7 +29,7 @@ function AspectRatioControl({ controls, open, aspectRatio, onToggle, onSelect })
29
29
  <View style={styles.speedButtonContainer}>
30
30
  {open ? <View style={[styles.speedPickerPopup, { right: 'auto', left: 0, backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}>
31
31
  <Text style={[styles.speedPickerTitle, { color: palette.mutedColor }]}>Aspect Ratio</Text>
32
- {ASPECT_OPTIONS.map((option) => {
32
+ {aspectRatios.map((option) => {
33
33
  const selected = aspectRatio === option.value;
34
34
  return <TouchableOpacity key={option.label} style={styles.speedOption} onPress={(event) => { event.stopPropagation(); onSelect(option.value); }} activeOpacity={0.7}>
35
35
  <PlayerIcon name="check" size={15} color={selected ? palette.accentColor : 'transparent'} />
@@ -50,12 +50,6 @@ function AudioOnlyModeControl({ isAudioOnly, onToggle }) {
50
50
  return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle(); }} accessibilityLabel="Audio-Only Mode"><PlayerIcon name="headphones" size={18} color={isAudioOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
51
51
  }
52
52
 
53
- function VideoOnlyControl({ isVideoOnly, onToggle }) {
54
- const palette = usePlayerColors();
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 ? 'Disable video-only mode' : 'Video only'}><PlayerIcon name="video" size={18} color={isVideoOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
57
- }
58
-
59
53
  function SpeedControl({ enabled, open, playbackRate, onToggle, onSelect }) {
60
54
  const palette = usePlayerColors();
61
55
  if (!enabled) return null;
@@ -129,6 +123,7 @@ export const PlayerBottomBar = ({
129
123
  isAudioOnly,
130
124
  showAspectPicker,
131
125
  aspectRatio,
126
+ aspectRatios = ASPECT_OPTIONS,
132
127
  showSpeedPicker,
133
128
  playbackRate,
134
129
  showAudioPicker,
@@ -147,8 +142,6 @@ export const PlayerBottomBar = ({
147
142
  onSelectAudioTrack,
148
143
  onToggleFullscreen,
149
144
  controls = {},
150
- isVideoOnly = false,
151
- onToggleVideoOnly,
152
145
  }) => {
153
146
  return (
154
147
  <View
@@ -167,8 +160,7 @@ export const PlayerBottomBar = ({
167
160
  <View style={styles.bottomControlsRow} pointerEvents="box-none">
168
161
  <View style={styles.leftActionsContainer}>
169
162
  {isAudioOnlyFeatureEnabled && controls.audioOnly !== false ? <AudioOnlyModeControl isAudioOnly={isAudioOnly} onToggle={onToggleAudioOnly} /> : null}
170
- <AspectRatioControl controls={controls} open={showAspectPicker} aspectRatio={aspectRatio} onToggle={onToggleAspectPicker} onSelect={onSelectAspectRatio} />
171
- {controls.videoOnly ? <VideoOnlyControl isVideoOnly={isVideoOnly} onToggle={onToggleVideoOnly} /> : null}
163
+ <AspectRatioControl controls={controls} open={showAspectPicker} aspectRatio={aspectRatio} aspectRatios={aspectRatios} onToggle={onToggleAspectPicker} onSelect={onSelectAspectRatio} />
172
164
  </View>
173
165
 
174
166
  {/* RIGHT ACTIONS: Playback Speed, Audio, Fullscreen */}
@@ -38,8 +38,9 @@ export function nearestPipCorner(x, y, winW, winH, insets) {
38
38
  }
39
39
 
40
40
  export const ASPECT_OPTIONS = [
41
- { label: 'Fit Screen', value: 'FIT' },
42
- { label: 'Fill Screen', value: 'FILL_SCREEN' },
41
+ { label: 'Fit', value: 'FIT' },
42
+ { label: 'Fill', value: 'FILL' },
43
+ { label: 'Stretch', value: 'STRETCH' },
43
44
  { label: '16:9', value: '16:9' },
44
45
  { label: '4:3', value: '4:3' },
45
46
  { label: '21:9', value: '21:9' },
@@ -156,4 +157,3 @@ export function applyTrackDefaults(selected, setSelected, setTracks, tracksProp)
156
157
  const active = tracksProp.find((t) => t.selected || t.active) || tracksProp[0];
157
158
  if (active) setSelected(active.id);
158
159
  }
159
-