@cinecrew/cinecrew-player 0.1.1

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.
Files changed (66) hide show
  1. package/CineCrewPlayer.podspec +22 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +6 -0
  4. package/README.md +574 -0
  5. package/android/build.gradle +48 -0
  6. package/android/proguard-rules.pro +29 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/yuanzhou/vlc/ReactVlcPlayerPackage.java +28 -0
  9. package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/ReactVlcPlayerView.java +988 -0
  10. package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/ReactVlcPlayerViewManager.java +208 -0
  11. package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/VideoEventEmitter.java +136 -0
  12. package/android/src/main/res/values/strings.xml +3 -0
  13. package/app.plugin.cjs +1 -0
  14. package/assets/cinecrew-player-logo.svg +44 -0
  15. package/package.json +132 -0
  16. package/packages/react-native-vlc-media-player/LICENSE +21 -0
  17. package/packages/react-native-vlc-media-player/VLCPlayer.js +207 -0
  18. package/packages/react-native-vlc-media-player/expo/android/withGradleTasks.cjs +49 -0
  19. package/packages/react-native-vlc-media-player/expo/ios/withMobileVlcKit.cjs +28 -0
  20. package/packages/react-native-vlc-media-player/expo/withVlcMediaPlayer.cjs +10 -0
  21. package/packages/react-native-vlc-media-player/index.d.ts +354 -0
  22. package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayer.h +36 -0
  23. package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayer.m +635 -0
  24. package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayerManager.h +5 -0
  25. package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayerManager.m +100 -0
  26. package/src/native/InlineLivePlayer.js +321 -0
  27. package/src/native/MediaPlayerView.js +3093 -0
  28. package/src/native/customization.js +44 -0
  29. package/src/native/index.js +107 -0
  30. package/src/native/media/ElectronVideoPlayer.js +288 -0
  31. package/src/native/media/EmojiPickerModal.js +30 -0
  32. package/src/native/media/LiveChatDrawer.js +1177 -0
  33. package/src/native/media/LiveRecordingOverlay.js +319 -0
  34. package/src/native/media/WebVideoPlayer.js +320 -0
  35. package/src/native/media/YouTubeVideoPlayer.native.js +120 -0
  36. package/src/native/media/YouTubeVideoPlayer.web.js +169 -0
  37. package/src/native/media/player/AudioOnlyView.js +337 -0
  38. package/src/native/media/player/CenterControls.js +94 -0
  39. package/src/native/media/player/ExoVideoFallback.js +329 -0
  40. package/src/native/media/player/PipOverlay.js +1 -0
  41. package/src/native/media/player/PlayerBottomBar.js +295 -0
  42. package/src/native/media/player/PlayerTopBar.js +255 -0
  43. package/src/native/media/player/VLCBoundary.js +41 -0
  44. package/src/native/media/player/VerticalIndicator.js +184 -0
  45. package/src/native/media/player/index.js +9 -0
  46. package/src/native/media/player/playerConstants.js +159 -0
  47. package/src/native/media/web/useWebAc3AudioPlayback.native.js +6 -0
  48. package/src/native/media/web/useWebAc3AudioPlayback.web.js +219 -0
  49. package/src/native/media/web/useWebHlsPlayback.native.js +3 -0
  50. package/src/native/media/web/useWebHlsPlayback.web.js +66 -0
  51. package/src/native/media/web/useWebMediaSession.js +90 -0
  52. package/src/native/media/web/useWebMpegTsPlayback.native.js +3 -0
  53. package/src/native/media/web/useWebMpegTsPlayback.web.js +356 -0
  54. package/src/native/media/web/useWebVideoAspectRatio.js +52 -0
  55. package/src/native/media/web/webPlaybackErrors.js +4 -0
  56. package/src/utils/layoutUtils.js +12 -0
  57. package/src/utils/mediaUtils.js +7 -0
  58. package/src/utils/recording.js +8 -0
  59. package/src/utils/runtimePlatform.js +19 -0
  60. package/src/utils/sourceUtils.js +99 -0
  61. package/src/utils/youtubeHtml.js +34 -0
  62. package/src/web/index.js +1129 -0
  63. package/src/web/styles.css +378 -0
  64. package/types/index.d.ts +268 -0
  65. package/vendor/mpegts.js/LICENSE +202 -0
  66. package/vendor/mpegts.js/mpegts.js +3 -0
@@ -0,0 +1,3093 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import {
3
+ StyleSheet,
4
+ View,
5
+ Text,
6
+ TouchableOpacity,
7
+ ActivityIndicator,
8
+ Alert,
9
+ StatusBar,
10
+ useWindowDimensions,
11
+ PanResponder,
12
+ BackHandler,
13
+ Modal,
14
+ } from 'react-native';
15
+ import VLCPlayer from '../../packages/react-native-vlc-media-player/VLCPlayer.js';
16
+ import { WebVideoPlayer } from './media/WebVideoPlayer';
17
+ import { ElectronVideoPlayer } from './media/ElectronVideoPlayer';
18
+ import { YouTubeVideoPlayer } from './media/YouTubeVideoPlayer';
19
+ import { LiveChatDrawer } from './media/LiveChatDrawer';
20
+ import { LiveRecordingNotice, LiveRecordingOverlay } from './media/LiveRecordingOverlay';
21
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
22
+ import { PlayerIcon } from './customization';
23
+ import { isWeb, isAndroid, isElectron } from '../utils/runtimePlatform';
24
+ import { getFontSize, getFontWeight } from '../utils/layoutUtils';
25
+ import { isLocalMediaUri } from '../utils/mediaUtils';
26
+ import {
27
+ USER_AGENT,
28
+ calculateScreenAspectRatio,
29
+ isSafariOrIOS,
30
+ getWebPoint,
31
+ applyTrackDefaults,
32
+ VLC_AVAILABLE,
33
+ VLCBoundary,
34
+ ExoVideoFallback,
35
+ AudioOnlyView,
36
+ CenterControls,
37
+ PlayerTopBar,
38
+ PlayerBottomBar,
39
+ } from './media/player';
40
+
41
+ const clampNumber = (value, min, max, fallback) => {
42
+ const numeric = Number(value);
43
+ if (!Number.isFinite(numeric)) return fallback;
44
+ return Math.max(min, Math.min(max, numeric));
45
+ };
46
+
47
+ const normalizeBrightness = (value) => Number(clampNumber(value, 0.1, 1, 1).toFixed(2));
48
+ const normalizeVolume = (value) => Math.round(clampNumber(value, 0, 100, 100));
49
+
50
+ function pickShuffleCandidate(candidates, season, episode) {
51
+ if (candidates.length < 2) return candidates[0] || null;
52
+
53
+ const cryptoProvider = globalThis.crypto;
54
+ if (typeof cryptoProvider?.getRandomValues === 'function') {
55
+ try {
56
+ const randomValue = new Uint32Array(1);
57
+ cryptoProvider.getRandomValues(randomValue);
58
+ return candidates[randomValue[0] % candidates.length];
59
+ } catch {
60
+ // The fallback only selects an episode; it is not used for security tokens.
61
+ }
62
+ }
63
+
64
+ const seed = `${season ?? ''}:${episode ?? ''}:${Date.now()}`;
65
+ let hash = 2166136261;
66
+ for (const character of seed) {
67
+ hash = Math.imul(hash ^ character.codePointAt(0), 16777619);
68
+ }
69
+ return candidates[(hash >>> 0) % candidates.length];
70
+ }
71
+
72
+ // Gesture events can arrive much faster than the native player can consume
73
+ // volume updates. Coalesce them to display-rate updates for the UI and a
74
+ // slower native volume cadence so a drag stays smooth without audio crackle.
75
+ const scheduleControlFrame = (callback) => {
76
+ if (typeof requestAnimationFrame === 'function') {
77
+ return { kind: 'raf', id: requestAnimationFrame(callback) };
78
+ }
79
+ return { kind: 'timeout', id: setTimeout(callback, 16) };
80
+ };
81
+
82
+ const cancelControlFrame = (handle) => {
83
+ if (!handle) return;
84
+ if (handle.kind === 'raf' && typeof cancelAnimationFrame === 'function') {
85
+ cancelAnimationFrame(handle.id);
86
+ } else {
87
+ clearTimeout(handle.id);
88
+ }
89
+ };
90
+
91
+ const scheduleNativeVolumeFrame = (callback) => ({
92
+ kind: 'timeout',
93
+ id: setTimeout(callback, 32),
94
+ });
95
+
96
+ // Preserve the full video frame by default on every platform. Users can still
97
+ // choose Fill Screen from the player controls when they explicitly want crop.
98
+ const DEFAULT_ASPECT_RATIO = 'FIT';
99
+
100
+ function getPanelActionName(tab) {
101
+ if (tab === 'chat') return 'onLiveChatOpen';
102
+ if (tab === 'epg') return 'onEpgOpen';
103
+ return 'onDiagnosticsOpen';
104
+ }
105
+
106
+ function normalizeProgressEvent(data, audioOffsetMs) {
107
+ if (!data) return null;
108
+ const payload = data.nativeEvent || data;
109
+ let currentMs = payload.currentTime;
110
+ let durationMs = payload.duration;
111
+ const usePositionFallback = currentMs === null || currentMs === undefined || currentMs === 0;
112
+ if (usePositionFallback && payload.position !== null && payload.position !== undefined && durationMs > 0) {
113
+ currentMs = payload.position * durationMs;
114
+ }
115
+ if (audioOffsetMs > 0) {
116
+ currentMs = Number(currentMs || 0) + audioOffsetMs;
117
+ if (Number(durationMs) > 0) durationMs = Number(durationMs) + audioOffsetMs;
118
+ }
119
+ return {
120
+ currentMs,
121
+ durationMs,
122
+ hasPosition: Number(currentMs) > 0 || Number(payload.position) > 0,
123
+ seconds: Math.max(0, Math.floor((currentMs || 0) / 1000)),
124
+ durationSeconds: Math.max(0, Math.floor((durationMs || 0) / 1000)),
125
+ };
126
+ }
127
+
128
+ function applyPendingSeek(pendingSeekRef, lastKnownDurationRef, audioOffsetSeconds, playerRef) {
129
+ if (pendingSeekRef.current === null || pendingSeekRef.current === undefined || lastKnownDurationRef.current <= 0) return;
130
+ const playerDuration = Math.max(0, lastKnownDurationRef.current - audioOffsetSeconds);
131
+ const playerTarget = Math.max(0, Number(pendingSeekRef.current) - audioOffsetSeconds);
132
+ const ratio = Math.max(0, Math.min(1, playerTarget / Math.max(playerDuration, 1)));
133
+ pendingSeekRef.current = null;
134
+ try {
135
+ if (typeof playerRef.current?.seek === 'function') playerRef.current.seek(ratio);
136
+ } catch {
137
+ // Native seek is best-effort while the player is transitioning.
138
+ }
139
+ }
140
+
141
+ function applyProgressState(progress, state) {
142
+ if (!progress) return;
143
+ if (progress.hasPosition) {
144
+ state.hasStartedPlaybackRef.current = true;
145
+ state.clearAudioOnlyFallbackTimer();
146
+ state.clearBufferingIndicator();
147
+ } else if (state.hasStartedPlaybackRef.current) {
148
+ state.clearBufferingIndicator();
149
+ }
150
+ if (progress.durationSeconds > 0) {
151
+ state.setDuration(progress.durationSeconds);
152
+ state.lastKnownDurationRef.current = progress.durationSeconds;
153
+ }
154
+
155
+ applyPendingSeek(state.pendingSeekRef, state.lastKnownDurationRef, state.audioOffsetSeconds, state.playerRef);
156
+ const now = Date.now();
157
+ if (state.isSeeking.current && now - state.seekCompletedAt.current > 2000) state.isSeeking.current = false;
158
+ if (!state.isSeeking.current && now - state.seekCompletedAt.current > 1000) {
159
+ state.setCurrentTime(progress.seconds);
160
+ state.lastKnownTimeRef.current = progress.seconds;
161
+ state.setSliderPosition(progress.seconds);
162
+ }
163
+ }
164
+
165
+ function handlePinchMove(touches, zoomState) {
166
+ if (touches?.length !== 2) return false;
167
+ const [firstTouch, secondTouch] = touches;
168
+ const currentDistance = Math.hypot(firstTouch.pageX - secondTouch.pageX, firstTouch.pageY - secondTouch.pageY);
169
+ if (zoomState.initialDistance.current === 0) {
170
+ zoomState.initialDistance.current = currentDistance;
171
+ zoomState.initialScale.current = zoomState.scale.current;
172
+ return true;
173
+ }
174
+ if (zoomState.initialDistance.current > 0 && currentDistance > 0) {
175
+ const ratio = currentDistance / zoomState.initialDistance.current;
176
+ const nextScale = Math.max(0.25, Math.min(3.5, zoomState.initialScale.current * ratio));
177
+ zoomState.scale.current = nextScale;
178
+ zoomState.setScale(nextScale);
179
+ zoomState.setBadge(`${Math.round(nextScale * 100)}% Zoom`);
180
+ if (zoomState.badgeTimer.current) clearTimeout(zoomState.badgeTimer.current);
181
+ zoomState.badgeTimer.current = setTimeout(() => zoomState.setBadge(''), 1500);
182
+ }
183
+ return true;
184
+ }
185
+
186
+ function handleVerticalGestureMove(touches, gestureState) {
187
+ if (!isWeb() || gestureState.isLocked.current) return;
188
+ const touch = touches?.[0];
189
+ if (!touch) return;
190
+ const deltaY = gestureState.startY.current - touch.pageY;
191
+ const deltaX = Math.abs(touch.pageX - gestureState.startX.current);
192
+ if (!gestureState.isSwiping.current && Math.abs(deltaY) > 10 && Math.abs(deltaY) > deltaX * 0.8) {
193
+ gestureState.isSwiping.current = true;
194
+ }
195
+ if (!gestureState.isSwiping.current) return;
196
+ const dragHeight = Math.max(180, (gestureState.windowHeight.current || 400) * 0.55);
197
+ const deltaPercent = (deltaY / dragHeight) * 100;
198
+ if (gestureState.side.current === 'brightness') {
199
+ const value = Math.max(0.1, Math.min(1, Number(((gestureState.startValue.current * 100 + deltaPercent) / 100).toFixed(2))));
200
+ gestureState.commitBrightness.current(value);
201
+ return;
202
+ }
203
+ const volume = Math.max(0, Math.min(100, Math.round(gestureState.startValue.current + deltaPercent)));
204
+ gestureState.commitVolume.current(volume);
205
+ }
206
+
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
+ function PlatformMediaSurface(props) {
218
+ const {
219
+ youtubeVideoId, playerStreamUrl, vlcRef, isPlaying, muted, videoOnlyMode,
220
+ volume, playbackRate, aspectRatio, title, posterUrl, isLive, isAudioOnly,
221
+ selectedAudioTrack, handleTracksChanged, handleProgress, handleNativePlaying,
222
+ handleWebBuffering, handleEpisodeEnded, handleWebError, togglePlayPause,
223
+ handleSeekByAction, handlePlaybackRoute, exoFallback, useExoForAndroidLocalMedia,
224
+ nativeSource, computedAspectRatio, handleNativeLoadStart,
225
+ handleNativeOpen, handleNativeBuffering, isPlayingRef, setIsPlaying,
226
+ } = 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
+ if (isElectron()) {
251
+ return (
252
+ <ElectronVideoPlayer
253
+ key={`electron-vlc-${playerStreamUrl}`}
254
+ ref={vlcRef}
255
+ streamUrl={playerStreamUrl}
256
+ paused={!isPlaying}
257
+ muted={muted || videoOnlyMode}
258
+ volume={muted || videoOnlyMode ? 0 : volume}
259
+ playbackRate={playbackRate}
260
+ videoAspectRatio={aspectRatio}
261
+ title={title}
262
+ posterUrl={posterUrl}
263
+ isLive={isLive}
264
+ audioOnly={isAudioOnly}
265
+ audioTrack={selectedAudioTrack}
266
+ onTracksChanged={handleTracksChanged}
267
+ onProgress={handleProgress}
268
+ onPlaying={handleNativePlaying}
269
+ onPlaybackStateChange={togglePlayPause}
270
+ onBuffering={handleWebBuffering}
271
+ onEnded={handleEpisodeEnded}
272
+ onError={handleWebError}
273
+ onClose={props.handleClose}
274
+ onPlaybackRoute={handlePlaybackRoute}
275
+ />
276
+ );
277
+ }
278
+ if (isWeb()) {
279
+ return (
280
+ <WebVideoPlayer
281
+ key={`web-${playerStreamUrl}`}
282
+ ref={vlcRef}
283
+ streamUrl={playerStreamUrl}
284
+ paused={!isPlaying}
285
+ muted={muted || videoOnlyMode}
286
+ volume={muted || videoOnlyMode ? 0 : volume}
287
+ playbackRate={playbackRate}
288
+ videoAspectRatio={aspectRatio}
289
+ title={title}
290
+ posterUrl={posterUrl}
291
+ isLive={isLive}
292
+ audioOnly={isAudioOnly}
293
+ videoOnly={videoOnlyMode}
294
+ audioTrack={selectedAudioTrack}
295
+ onTracksChanged={handleTracksChanged}
296
+ onProgress={handleProgress}
297
+ onPlaying={handleNativePlaying}
298
+ onBuffering={handleWebBuffering}
299
+ onEnded={handleEpisodeEnded}
300
+ onError={handleWebError}
301
+ onTogglePlayPause={togglePlayPause}
302
+ onSeekBy={handleSeekByAction}
303
+ onPlaybackRoute={handlePlaybackRoute}
304
+ />
305
+ );
306
+ }
307
+ if (VLC_AVAILABLE && !useExoForAndroidLocalMedia) {
308
+ return (
309
+ <VLCBoundary key={`vlcb-${playerStreamUrl}`} fallback={exoFallback}>
310
+ <VLCPlayer
311
+ rate={playbackRate}
312
+ key={`vlc-${playerStreamUrl}`}
313
+ ref={vlcRef}
314
+ style={styles.video}
315
+ autoAspectRatio={false}
316
+ videoAspectRatio={computedAspectRatio}
317
+ audioTrack={selectedAudioTrack}
318
+ autoplay={true}
319
+ paused={!isPlaying}
320
+ muted={false}
321
+ volume={muted || videoOnlyMode ? 0 : volume}
322
+ playInBackground={isAudioOnly}
323
+ playWhenInactive={isAudioOnly}
324
+ source={nativeSource}
325
+ onLoadStart={handleNativeLoadStart}
326
+ onProgress={handleProgress}
327
+ onVLCProgress={handleProgress}
328
+ onPlaying={handleNativePlaying}
329
+ onVLCPlaying={handleNativePlaying}
330
+ onOpen={handleNativeOpen}
331
+ onVLCOpened={handleNativeOpen}
332
+ onEnd={handleEpisodeEnded}
333
+ onBuffering={handleNativeBuffering}
334
+ onVLCBuffering={handleNativeBuffering}
335
+ onError={handleWebError}
336
+ onVLCError={handleWebError}
337
+ />
338
+ </VLCBoundary>
339
+ );
340
+ }
341
+ return exoFallback;
342
+ }
343
+
344
+ function InlinePreviewTopActions({ controls, muted, videoOnlyMode, handleMuteAction }) {
345
+ return (
346
+ <View style={styles.inlinePreviewTopRow} pointerEvents="box-none">
347
+ <View style={styles.inlineLiveBadge} pointerEvents="none">
348
+ <View style={styles.inlineLiveDot} />
349
+ <Text style={styles.inlineLiveText}>LIVE</Text>
350
+ </View>
351
+ <View style={{ flex: 1 }} />
352
+ {controls.mute !== false ? (
353
+ <TouchableOpacity
354
+ style={styles.inlinePreviewIconButton}
355
+ activeOpacity={0.8}
356
+ accessibilityRole="button"
357
+ accessibilityLabel={muted ? 'Unmute video' : 'Mute video'}
358
+ onPress={(event) => {
359
+ event?.stopPropagation?.();
360
+ handleMuteAction();
361
+ }}
362
+ >
363
+ <PlayerIcon name={muted || videoOnlyMode ? 'volume-off' : 'volume-high'} size={19} color="#FFF" />
364
+ </TouchableOpacity>
365
+ ) : null}
366
+ </View>
367
+ );
368
+ }
369
+
370
+ function InlinePreviewCenterAction({ controls, colors, isPlaying, handlePlayPauseAction }) {
371
+ return (
372
+ <View style={styles.inlinePreviewCenterControls} pointerEvents="box-none">
373
+ {controls.playPause !== false ? (
374
+ <TouchableOpacity
375
+ style={[styles.inlinePreviewCenterButton, { backgroundColor: colors?.brandAccent || (colors?.mode === 'dark' ? '#FF9A86' : '#D95045') }]}
376
+ activeOpacity={0.8}
377
+ accessibilityRole="button"
378
+ accessibilityLabel={isPlaying ? 'Pause preview' : 'Play preview'}
379
+ onPress={(event) => {
380
+ event?.stopPropagation?.();
381
+ handlePlayPauseAction();
382
+ }}
383
+ >
384
+ <PlayerIcon name={isPlaying ? 'pause' : 'play'} size={27} color="#FFF" />
385
+ </TouchableOpacity>
386
+ ) : null}
387
+ </View>
388
+ );
389
+ }
390
+
391
+ function InlinePreviewBottomActions({
392
+ controls, title, showInlineChatButton, showLiveChat, handlePanelAction,
393
+ isFullscreen, handleFullscreenAction, invokeAction, onPromotePreview, mediaId,
394
+ }) {
395
+ return (
396
+ <View style={styles.inlinePreviewBottomRow} pointerEvents="box-none">
397
+ <Text style={styles.inlinePreviewTitle} numberOfLines={1} pointerEvents="none">{title || 'Live TV'}</Text>
398
+ <View style={styles.inlinePreviewActions} pointerEvents="box-none">
399
+ {controls.liveChat !== false && showInlineChatButton && !showLiveChat ? (
400
+ <TouchableOpacity
401
+ style={styles.inlineChatButton}
402
+ activeOpacity={0.84}
403
+ accessibilityRole="button"
404
+ accessibilityLabel="Open live chat"
405
+ onPress={(event) => {
406
+ event?.stopPropagation?.();
407
+ handlePanelAction('chat');
408
+ }}
409
+ >
410
+ <PlayerIcon name="comment-text-outline" size={17} color="#FFF" />
411
+ <Text style={styles.inlineChatButtonText}>Live Chat</Text>
412
+ </TouchableOpacity>
413
+ ) : null}
414
+ {controls.fullscreen !== false ? (
415
+ <TouchableOpacity
416
+ style={styles.inlinePreviewIconButton}
417
+ activeOpacity={0.8}
418
+ accessibilityRole="button"
419
+ accessibilityLabel={isFullscreen ? 'Exit fullscreen' : 'Open live player'}
420
+ onPress={(event) => {
421
+ event?.stopPropagation?.();
422
+ if (isFullscreen) handleFullscreenAction();
423
+ else invokeAction('onFullscreen', onPromotePreview, { title, mediaId });
424
+ }}
425
+ >
426
+ <PlayerIcon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen'} size={20} color="#FFF" />
427
+ </TouchableOpacity>
428
+ ) : null}
429
+ </View>
430
+ </View>
431
+ );
432
+ }
433
+
434
+ function InlinePreviewFeedback({ isLoading, errorMessage }) {
435
+ if (isLoading && !errorMessage) {
436
+ return (
437
+ <View pointerEvents="none" style={styles.inlinePreviewLoading}>
438
+ <View style={styles.inlinePreviewLoadingPill}>
439
+ <ActivityIndicator size="small" color="#FFF" />
440
+ <Text style={styles.inlinePreviewLoadingText}>Loading stream…</Text>
441
+ </View>
442
+ </View>
443
+ );
444
+ }
445
+ if (!errorMessage) return null;
446
+ return (
447
+ <View pointerEvents="none" style={styles.inlinePreviewError}>
448
+ <PlayerIcon name="alert-circle-outline" size={20} color="#FFF" />
449
+ <Text style={styles.inlinePreviewErrorText} numberOfLines={2}>{errorMessage}</Text>
450
+ </View>
451
+ );
452
+ }
453
+
454
+ function InlinePreviewFrame({
455
+ hostRef, isValidPreviewRect, positionStyle, videoPlayer, title, onPromotePreview,
456
+ controls, colors, muted, videoOnlyMode, handleMuteAction, isPlaying,
457
+ handlePlayPauseAction, showInlineChatButton, showLiveChat, handlePanelAction,
458
+ isFullscreen, handleFullscreenAction, invokeAction, mediaId, isLoading,
459
+ errorMessage, children,
460
+ }) {
461
+ return (
462
+ <View
463
+ ref={hostRef}
464
+ collapsable={false}
465
+ pointerEvents={!isValidPreviewRect ? 'box-none' : 'auto'}
466
+ style={[styles.playerHost, styles.playerHostInline, positionStyle]}
467
+ >
468
+ <View collapsable={false} pointerEvents="auto" style={styles.inlineVideoStage}>
469
+ <View collapsable={false} pointerEvents="none" style={styles.videoContainer}>{videoPlayer}</View>
470
+ <View style={styles.inlinePreviewChrome} pointerEvents="box-none">
471
+ <TouchableOpacity
472
+ style={StyleSheet.absoluteFill}
473
+ activeOpacity={1}
474
+ accessibilityRole="button"
475
+ accessibilityLabel={`Open ${title || 'live channel'} in the video player`}
476
+ onPress={onPromotePreview}
477
+ />
478
+ <InlinePreviewTopActions controls={controls} muted={muted} videoOnlyMode={videoOnlyMode} handleMuteAction={handleMuteAction} />
479
+ <InlinePreviewCenterAction controls={controls} colors={colors} isPlaying={isPlaying} handlePlayPauseAction={handlePlayPauseAction} />
480
+ <InlinePreviewBottomActions
481
+ controls={controls}
482
+ title={title}
483
+ showInlineChatButton={showInlineChatButton}
484
+ showLiveChat={showLiveChat}
485
+ handlePanelAction={handlePanelAction}
486
+ isFullscreen={isFullscreen}
487
+ handleFullscreenAction={handleFullscreenAction}
488
+ invokeAction={invokeAction}
489
+ onPromotePreview={onPromotePreview}
490
+ mediaId={mediaId}
491
+ />
492
+ <InlinePreviewFeedback isLoading={isLoading} errorMessage={errorMessage} />
493
+ </View>
494
+ </View>
495
+ {children}
496
+ </View>
497
+ );
498
+ }
499
+
500
+ function FullscreenVideoLayer({ videoPlayer, zoomScale, isAudioOnly }) {
501
+ return (
502
+ <View
503
+ collapsable={false}
504
+ pointerEvents="none"
505
+ style={[
506
+ styles.videoContainer,
507
+ styles.videoWrapFullscreen,
508
+ zoomScale !== 1 && { transform: [{ scale: zoomScale }] },
509
+ isAudioOnly && { opacity: 0 },
510
+ ]}
511
+ >
512
+ {videoPlayer}
513
+ </View>
514
+ );
515
+ }
516
+
517
+ function FullscreenGestureLayer({ isAudioOnly, panResponder, handlers }) {
518
+ if (isAudioOnly) return null;
519
+ if (!isWeb() && panResponder) {
520
+ return (
521
+ <View collapsable={false} style={[StyleSheet.absoluteFill, styles.gestureCatcher]} {...panResponder.panHandlers} />
522
+ );
523
+ }
524
+ return (
525
+ <View
526
+ collapsable={false}
527
+ style={[StyleSheet.absoluteFill, styles.gestureCatcher]}
528
+ onPointerDown={handlers.onPointerDown}
529
+ onPointerMove={handlers.onPointerMove}
530
+ onPointerUp={handlers.onPointerUp}
531
+ onPointerCancel={handlers.onPointerUp}
532
+ onMouseDown={handlers.onMouseDown}
533
+ onTouchStart={handlers.onTouchStart}
534
+ onTouchMove={handlers.onTouchMove}
535
+ onTouchEnd={handlers.onTouchEnd}
536
+ />
537
+ );
538
+ }
539
+
540
+ function FullscreenControlsLayer({ showControls, isLocked, isAudioOnly, children }) {
541
+ if (!showControls || isLocked || isAudioOnly) return null;
542
+ return <View style={styles.controlsShell} pointerEvents="box-none">{children}</View>;
543
+ }
544
+
545
+ function FullscreenControlsPanel(props) {
546
+ return (
547
+ <>
548
+ <PlayerTopBar
549
+ insets={props.insets}
550
+ scale={props.scale}
551
+ title={props.title}
552
+ episodeLabel={props.episodeLabel}
553
+ isLive={props.isLive}
554
+ isScreenRecorderEnabled={props.isScreenRecorderEnabled}
555
+ recStatus={props.recStatus}
556
+ isLoading={props.isLoading}
557
+ showLiveChat={props.showLiveChat}
558
+ drawerTab={props.drawerTab}
559
+ isLiveCommentsEnabled={props.isLiveCommentsEnabled}
560
+ isEpgEnabled={props.isEpgEnabled}
561
+ diagnosticsOverlayEnabled={props.diagnosticsOverlayEnabled}
562
+ muted={props.muted}
563
+ controls={props.controls}
564
+ onClose={props.onClose}
565
+ onStartRecording={(event) => props.handleRecordingAction('onRecordingStart', props.handleStartRecording, event)}
566
+ onResumeRecording={(event) => props.handleRecordingAction('onRecordingResume', props.handleResumeRecording, event)}
567
+ onPauseRecording={(event) => props.handleRecordingAction('onRecordingPause', props.handlePauseRecording, event)}
568
+ onStopRecording={(event) => props.handleRecordingAction('onRecordingStop', props.handleStopRecording, event)}
569
+ onToggleChatTab={props.handlePanelAction}
570
+ onRestart={props.handleRestartAction}
571
+ onToggleMute={props.handleMuteAction}
572
+ onToggleLock={props.handleLockAction}
573
+ onMinimize={props.handleMinimizeAction}
574
+ />
575
+ <CenterControls
576
+ visible={!props.isAudioOnly && props.controls.playPause !== false}
577
+ isLive={props.isLive}
578
+ isPlaying={props.isPlaying}
579
+ onSeekBy={props.handleSeekByAction}
580
+ onTogglePlayPause={props.handlePlayPauseAction}
581
+ />
582
+ <PlayerBottomBar
583
+ isLive={props.isLive}
584
+ insets={props.insets}
585
+ scale={props.scale}
586
+ isSeeking={props.isSeeking}
587
+ sliderPos={props.sliderPos}
588
+ currentTime={props.currentTime}
589
+ duration={props.duration}
590
+ isAudioOnlyFeatureEnabled={props.isAudioOnlyFeatureEnabled}
591
+ isAudioOnly={props.isAudioOnly}
592
+ isVideoOnly={props.videoOnlyMode}
593
+ controls={props.controls}
594
+ showAspectPicker={props.showAspectPicker}
595
+ aspectRatio={props.aspectRatio}
596
+ showSpeedPicker={props.showSpeedPicker}
597
+ playbackRate={props.playbackRate}
598
+ showAudioPicker={props.showAudioPicker}
599
+ audioTracks={props.audioTracks}
600
+ selectedAudioTrack={props.selectedAudioTrack}
601
+ isFullscreen={props.isFullscreen}
602
+ onSliderValueChange={props.onSliderValueChange}
603
+ onSliderSlidingStart={props.onSliderSlidingStart}
604
+ onSliderSlidingComplete={props.onSliderSlidingComplete}
605
+ onToggleAudioOnly={props.onToggleAudioOnly}
606
+ onToggleAspectPicker={props.onToggleAspectPicker}
607
+ onSelectAspectRatio={props.onSelectAspectRatio}
608
+ onToggleSpeedPicker={props.onToggleSpeedPicker}
609
+ onSelectSpeed={props.handleSpeedSelect}
610
+ onToggleAudioPicker={props.onToggleAudioPicker}
611
+ onSelectAudioTrack={props.handleAudioTrackAction}
612
+ onToggleFullscreen={props.handleFullscreenAction}
613
+ onToggleVideoOnly={props.handleVideoOnlyAction}
614
+ />
615
+ </>
616
+ );
617
+ }
618
+
619
+ function FullscreenVisualFeedback({ isAudioOnly, isWebPlatform, brightness, zoomBadgeText, seekRipple }) {
620
+ return (
621
+ <>
622
+ {isWebPlatform && !isAudioOnly && brightness < 1 ? (
623
+ <View
624
+ style={[styles.brightnessDimOverlay, { opacity: Math.max(0, Math.min(0.88, (1 - brightness) * 0.95)) }]}
625
+ pointerEvents="none"
626
+ />
627
+ ) : null}
628
+ {zoomBadgeText && !isAudioOnly ? (
629
+ <View style={styles.zoomBadge} pointerEvents="none"><Text style={styles.zoomBadgeText}>{zoomBadgeText}</Text></View>
630
+ ) : null}
631
+ {seekRipple && !isAudioOnly ? (
632
+ <View style={[styles.seekRippleOverlay, seekRipple.side === 'left' ? styles.seekRippleLeft : styles.seekRippleRight]} pointerEvents="none">
633
+ <View style={styles.seekRippleCircle}>
634
+ <PlayerIcon name={seekRipple.side === 'left' ? 'rewind-10' : 'fast-forward-10'} size={48} color="#FFFFFF" />
635
+ <Text style={styles.seekRippleText}>{seekRipple.text}</Text>
636
+ </View>
637
+ </View>
638
+ ) : null}
639
+ </>
640
+ );
641
+ }
642
+
643
+ function FullscreenStatusLayer({
644
+ isAudioOnly, audioOnlyProps, isLoading, errorMessage, scale, handleClose,
645
+ }) {
646
+ return (
647
+ <>
648
+ {isAudioOnly ? <AudioOnlyView {...audioOnlyProps} /> : null}
649
+ {isLoading && !errorMessage ? (
650
+ <View pointerEvents="none" style={styles.loadingLayer}>
651
+ <ActivityIndicator size="large" color="#00E5FF" />
652
+ <Text style={[styles.loadingText, { fontSize: scale.loadingFont, fontWeight: scale.loadingWeight }]}>Loading stream...</Text>
653
+ </View>
654
+ ) : null}
655
+ {errorMessage ? (
656
+ <View style={styles.centeredOverlay}>
657
+ <PlayerIcon name="alert-circle-outline" size={48} color="#FF5252" />
658
+ <Text style={[styles.errorTitle, { fontSize: scale.errorTitleFont, fontWeight: scale.errorTitleWeight }]}>Playback Error</Text>
659
+ <Text style={[styles.errorMsg, { fontSize: scale.errorMsgFont }]}>{errorMessage}</Text>
660
+ <TouchableOpacity style={styles.errorBtn} onPress={handleClose}>
661
+ <Text style={[styles.errorBtnText, { fontWeight: scale.errorBtnWeight }]}>Close</Text>
662
+ </TouchableOpacity>
663
+ </View>
664
+ ) : null}
665
+ </>
666
+ );
667
+ }
668
+
669
+ function FullscreenChatLayer(props) {
670
+ if (!props.visible || props.isAudioOnly) return null;
671
+ return (
672
+ <LiveChatDrawer
673
+ videoId={props.mediaId || props.title || 'live'}
674
+ userId={props.currentUser.id}
675
+ username={props.currentUser.username}
676
+ visible={props.visible}
677
+ onClose={props.onClose}
678
+ isLandscape={props.isLandscape}
679
+ initialTab={props.drawerTab}
680
+ streamUrl={props.playbackUrl || props.streamUrl || ''}
681
+ serverUrl={props.playbackUrl || props.streamUrl || ''}
682
+ isLive={props.isLive}
683
+ isLiveCommentsEnabled={props.isLiveCommentsEnabled}
684
+ isEpgEnabled={props.isEpgEnabled}
685
+ diagnosticsEnabled={props.diagnosticsEnabled}
686
+ title={props.title}
687
+ streamId={props.mediaId}
688
+ integrations={props.integrations}
689
+ colors={props.colors}
690
+ />
691
+ );
692
+ }
693
+
694
+ function FullscreenRecordingLayer(props) {
695
+ return (
696
+ <>
697
+ {!props.isAudioOnly && props.isLive && (props.isScreenRecorderEnabled || props.recStatus !== 'idle') ? (
698
+ <LiveRecordingOverlay
699
+ status={props.recStatus}
700
+ elapsedMs={props.recElapsedMs}
701
+ colors={props.colors}
702
+ topInset={Math.max(props.insets?.top || 0, 12) + 8}
703
+ showTransport
704
+ onPause={props.handlePauseRecording}
705
+ onResume={props.handleResumeRecording}
706
+ onStop={props.handleStopRecording}
707
+ />
708
+ ) : null}
709
+ {!props.isAudioOnly && props.isLive && props.isScreenRecorderEnabled ? (
710
+ <LiveRecordingNotice notice={props.recNotice} colors={props.colors} onDismiss={props.onDismissNotice} />
711
+ ) : null}
712
+ </>
713
+ );
714
+ }
715
+
716
+ function FullscreenSessionLayer({ locked, controlsVisible, isAudioOnly, insets, scale, onToggleLock }) {
717
+ if (!locked || !controlsVisible || isAudioOnly) return null;
718
+ return locked && controlsVisible ? (
719
+ <TouchableOpacity
720
+ style={[styles.floatingLockBtn, {
721
+ top: Math.max(insets?.top || 0, 24),
722
+ right: Math.max(insets?.left || 0, insets?.right || 0, 20),
723
+ }]}
724
+ onPress={(event) => {
725
+ event.stopPropagation();
726
+ onToggleLock();
727
+ }}
728
+ activeOpacity={0.8}
729
+ hitSlop={16}
730
+ >
731
+ <PlayerIcon name="lock" size={22} color="#FF5252" />
732
+ <Text style={[styles.floatingLockText, { fontSize: scale.lockTextFont, fontWeight: scale.lockTextWeight }]}>Locked</Text>
733
+ </TouchableOpacity>
734
+ ) : null;
735
+ }
736
+
737
+ function isUsableInlinePreviewRect(rect) {
738
+ return Boolean(rect && rect.width > 20 && rect.height > 20 && rect.x > -1000 && rect.y > -1000);
739
+ }
740
+
741
+ function getInlinePreviewPositionStyle(rect, isValid) {
742
+ if (!isValid) return { opacity: 0 };
743
+ const clippingStyle = isWeb() && rect.clipTop > 0
744
+ ? { clipPath: `inset(${rect.clipTop}px 0 0 0)`, WebkitClipPath: `inset(${rect.clipTop}px 0 0 0)` }
745
+ : {};
746
+ return {
747
+ position: isWeb() ? 'fixed' : 'absolute',
748
+ top: rect.y,
749
+ left: rect.x,
750
+ right: null,
751
+ bottom: null,
752
+ width: rect.width,
753
+ height: rect.height,
754
+ ...clippingStyle,
755
+ };
756
+ }
757
+
758
+ export const MediaPlayerView = ({
759
+ visible,
760
+ streamUrl,
761
+ youtubeVideoId,
762
+ title,
763
+ mediaType = 'live',
764
+ onClose,
765
+ colors,
766
+ initialShowLiveChat = false,
767
+ initialMuted = false,
768
+ initialVolume = 100,
769
+ initialPlaybackRate = 1,
770
+ showInlineChatButton = true,
771
+ onPlayerHostRef,
772
+ onInlinePreviewWheel,
773
+ inlinePreview = false,
774
+ inlinePreviewRect = null,
775
+ onPromotePreview,
776
+ onMinimize,
777
+ mediaId,
778
+ posterUrl,
779
+ episodeLabel,
780
+ resumePosition = 0,
781
+ durationSecs = 0,
782
+ season,
783
+ episode,
784
+ onCwRefresh,
785
+ playlist,
786
+ shuffle,
787
+ onNextEpisode,
788
+ liveChatNonce = 0,
789
+ genre,
790
+ categoryName,
791
+ controls = {},
792
+ features = {},
793
+ actions = {},
794
+ integrations = {},
795
+ theme,
796
+ icons,
797
+ videoOnly = false,
798
+ initialAudioOnly = false,
799
+ initialPaused = false,
800
+ playerApiRef,
801
+ style,
802
+ onReady,
803
+ onProgress,
804
+ onPlaying,
805
+ onBuffering,
806
+ onError,
807
+ onEnded,
808
+ onPlaybackRoute,
809
+ }) => {
810
+ colors = theme?.colors || (theme ? {
811
+ ...colors,
812
+ mode: theme.mode || colors?.mode,
813
+ brandAccent: theme.accentColor || theme.brandAccent || colors?.brandAccent,
814
+ primary: theme.accentColor || theme.primary || colors?.primary,
815
+ surface: theme.surfaceColor || colors?.surface,
816
+ onSurfacePrimary: theme.textColor || colors?.onSurfacePrimary,
817
+ onSurfaceSecondary: theme.mutedTextColor || colors?.onSurfaceSecondary,
818
+ outline: theme.borderColor || colors?.outline,
819
+ } : colors);
820
+ const insets = useSafeAreaInsets();
821
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
822
+ const windowSizeRef = useRef({ w: windowWidth, h: windowHeight });
823
+ const insetsRef = useRef(insets);
824
+ windowSizeRef.current = { w: windowWidth, h: windowHeight };
825
+ insetsRef.current = insets;
826
+ const vlcRef = useRef(null);
827
+ const playerRef = useRef(null);
828
+ const invokeAction = useCallback((name, fallback, payload) => {
829
+ const callback = actions?.[name];
830
+ if (typeof callback === 'function') {
831
+ return callback(payload, { player: playerApiRef?.current || null });
832
+ }
833
+ return fallback?.();
834
+ }, [actions, playerApiRef]);
835
+ const handlePlayerHostRef = useCallback((node) => {
836
+ playerRef.current = node;
837
+ onPlayerHostRef?.(node);
838
+ }, [onPlayerHostRef]);
839
+
840
+ const [showSpeedPicker, setShowSpeedPicker] = useState(false);
841
+ const [playbackRate, setPlaybackRate] = useState(() => clampNumber(initialPlaybackRate, 0.25, 4, 1));
842
+ const [isLoading, setIsLoading] = useState(true);
843
+ const [errorMessage, setErrorMessage] = useState(null);
844
+ const [isPlaying, setIsPlaying] = useState(!initialPaused);
845
+ const isPlayingRef = useRef(!initialPaused);
846
+ isPlayingRef.current = isPlaying;
847
+ const [playbackUrl, setPlaybackUrl] = useState(streamUrl || '');
848
+ const [muted, setMuted] = useState(!!initialMuted);
849
+ const [videoOnlyMode, setVideoOnlyMode] = useState(!!videoOnly);
850
+ const mutedRef = useRef(!!initialMuted);
851
+ useEffect(() => {
852
+ mutedRef.current = muted;
853
+ }, [muted]);
854
+
855
+ // VLC-Style Vertical Swipe Brightness & Volume Gestures
856
+ const [brightness, setBrightness] = useState(1.0); // 0.1 to 1.0 (10% - 100%)
857
+ const brightnessRef = useRef(1.0);
858
+ const brightnessFrameRef = useRef(null);
859
+ const pendingBrightnessRef = useRef(1.0);
860
+ useEffect(() => {
861
+ brightnessRef.current = brightness;
862
+ }, [brightness]);
863
+
864
+ const [volume, setVolume] = useState(() => normalizeVolume(initialVolume)); // 0 to 100
865
+ const volumeRef = useRef(100);
866
+ const volumeFrameRef = useRef(null);
867
+ const pendingVolumeRef = useRef(100);
868
+ const pendingUnmuteRef = useRef(false);
869
+ useEffect(() => {
870
+ if (!visible) return;
871
+ const nextMuted = !!initialMuted;
872
+ mutedRef.current = nextMuted;
873
+ pendingUnmuteRef.current = false;
874
+ setMuted(nextMuted);
875
+ }, [initialMuted, streamUrl, visible]);
876
+ useEffect(() => {
877
+ volumeRef.current = volume;
878
+ }, [volume]);
879
+ useEffect(() => {
880
+ const nextVolume = normalizeVolume(initialVolume);
881
+ volumeRef.current = nextVolume;
882
+ pendingVolumeRef.current = nextVolume;
883
+ setVolume(nextVolume);
884
+ }, [initialVolume]);
885
+
886
+ const gestureStartXRef = useRef(0);
887
+ const gestureStartYRef = useRef(0);
888
+ const gestureSideRef = useRef(null); // 'brightness' | 'volume'
889
+ const gestureStartValRef = useRef(0);
890
+ const isSwipingRef = useRef(false);
891
+ const webTouchStartRef = useRef(null);
892
+
893
+ const commitBrightness = useCallback((value) => {
894
+ const next = normalizeBrightness(value);
895
+ brightnessRef.current = next;
896
+ pendingBrightnessRef.current = next;
897
+
898
+ if (!brightnessFrameRef.current) {
899
+ brightnessFrameRef.current = scheduleControlFrame(() => {
900
+ brightnessFrameRef.current = null;
901
+ setBrightness(pendingBrightnessRef.current);
902
+ });
903
+ }
904
+ }, []);
905
+
906
+ const commitVolume = useCallback((value, { unmute = true } = {}) => {
907
+ const next = normalizeVolume(value);
908
+ volumeRef.current = next;
909
+ pendingVolumeRef.current = next;
910
+
911
+ // A swipe away from zero is also an explicit unmute. Update the ref
912
+ // immediately so the next gesture starts from the value the user sees,
913
+ // without waiting for React's render/effect cycle.
914
+ if (unmute && mutedRef.current && next > 0) {
915
+ mutedRef.current = false;
916
+ pendingUnmuteRef.current = true;
917
+ }
918
+
919
+ if (!volumeFrameRef.current) {
920
+ volumeFrameRef.current = scheduleNativeVolumeFrame(() => {
921
+ volumeFrameRef.current = null;
922
+ setVolume(pendingVolumeRef.current);
923
+ if (pendingUnmuteRef.current) {
924
+ pendingUnmuteRef.current = false;
925
+ setMuted(false);
926
+ }
927
+ });
928
+ }
929
+ }, []);
930
+
931
+ const commitBrightnessRef = useRef(commitBrightness);
932
+ commitBrightnessRef.current = commitBrightness;
933
+ const commitVolumeRef = useRef(commitVolume);
934
+ commitVolumeRef.current = commitVolume;
935
+
936
+ useEffect(() => {
937
+ return () => {
938
+ cancelControlFrame(brightnessFrameRef.current);
939
+ cancelControlFrame(volumeFrameRef.current);
940
+ brightnessFrameRef.current = null;
941
+ volumeFrameRef.current = null;
942
+ };
943
+ }, []);
944
+
945
+ const [showLiveChat, setShowLiveChat] = useState(false);
946
+ const [drawerTab, setDrawerTab] = useState('chat');
947
+ const isLiveCommentsEnabled = controls.liveChat ?? Boolean(integrations.liveChat?.loadMessages && integrations.liveChat?.sendMessage);
948
+ const isEpgEnabled = controls.epg ?? Boolean(integrations.epg?.loadListings);
949
+ const diagnosticsOverlayEnabled = Boolean(features.diagnostics);
950
+ const isAudioOnlyFeatureEnabled = controls.audioOnly ?? true;
951
+ const isScreenRecorderEnabled = controls.recording ?? Boolean(integrations.recording);
952
+ const recording = integrations.recording;
953
+ const [currentUser, setCurrentUser] = useState(integrations.user || { id: '0', username: 'Viewer' });
954
+
955
+ const badgeService = useMemo(() => ({
956
+ emit: (name, payload) => Promise.resolve(integrations.onEvent?.({ name, payload })).catch(() => {}),
957
+ recordProgress: (payload) => Promise.resolve(integrations.onProgress?.(payload)).catch(() => {}),
958
+ }), [integrations]);
959
+
960
+ useEffect(() => {
961
+ let active = true;
962
+ (async () => {
963
+ try {
964
+ const user = await integrations.getUser?.();
965
+ if (active && user) setCurrentUser(user);
966
+ } catch {
967
+ // ignore
968
+ }
969
+ })();
970
+ return () => {
971
+ active = false;
972
+ };
973
+ }, [integrations]);
974
+
975
+ const [isLocked, setIsLocked] = useState(false);
976
+ const isLockedRef = useRef(false);
977
+ useEffect(() => {
978
+ isLockedRef.current = isLocked;
979
+ }, [isLocked]);
980
+ const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT_RATIO);
981
+ const aspectRatioRef = useRef(DEFAULT_ASPECT_RATIO);
982
+ const [isFullscreen, setIsFullscreen] = useState(false);
983
+
984
+ const [audioTracks, setAudioTracks] = useState([]);
985
+ const [selectedAudioTrack, setSelectedAudioTrack] = useState(null);
986
+ const [showAudioPicker, setShowAudioPicker] = useState(false);
987
+ const [showAspectPicker, setShowAspectPicker] = useState(false);
988
+
989
+ // Pinch-to-Zoom & Double-Tap Seek (Mobile)
990
+ const [zoomScale, setZoomScale] = useState(1);
991
+ const zoomScaleRef = useRef(1);
992
+ const [zoomBadgeText, setZoomBadgeText] = useState('');
993
+ const zoomBadgeTimer = useRef(null);
994
+
995
+ const [seekRipple, setSeekRipple] = useState(null); // { side: 'left' | 'right', text: string }
996
+ const seekRippleTimer = useRef(null);
997
+
998
+ const initialDistanceRef = useRef(0);
999
+ const initialScaleRef = useRef(1);
1000
+ const lastTapRef = useRef({ time: 0, x: 0, y: 0, side: null });
1001
+ const singleTapTimerRef = useRef(null);
1002
+ const touchHandledRef = useRef(false);
1003
+
1004
+ useEffect(() => {
1005
+ if (!isWeb() || typeof document === 'undefined') return;
1006
+ const handleFsChange = () => {
1007
+ const fsElem = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
1008
+ const node = playerRef.current;
1009
+ setIsFullscreen(!!node && !!fsElem && (fsElem === node || node.contains(fsElem)));
1010
+ };
1011
+ document.addEventListener('fullscreenchange', handleFsChange);
1012
+ document.addEventListener('webkitfullscreenchange', handleFsChange);
1013
+ document.addEventListener('mozfullscreenchange', handleFsChange);
1014
+ document.addEventListener('MSFullscreenChange', handleFsChange);
1015
+ return () => {
1016
+ document.removeEventListener('fullscreenchange', handleFsChange);
1017
+ document.removeEventListener('webkitfullscreenchange', handleFsChange);
1018
+ document.removeEventListener('mozfullscreenchange', handleFsChange);
1019
+ document.removeEventListener('MSFullscreenChange', handleFsChange);
1020
+ };
1021
+ }, []);
1022
+
1023
+ const exitFullscreen = useCallback(() => {
1024
+ if (!isWeb() || typeof document === 'undefined') return;
1025
+ const fsElem = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
1026
+ if (!fsElem) return;
1027
+ if (document.exitFullscreen) {
1028
+ document.exitFullscreen().catch(() => {});
1029
+ } else if (document.webkitExitFullscreen) {
1030
+ document.webkitExitFullscreen();
1031
+ } else if (document.mozCancelFullScreen) {
1032
+ document.mozCancelFullScreen();
1033
+ } else if (document.msExitFullscreen) {
1034
+ document.msExitFullscreen();
1035
+ }
1036
+ }, []);
1037
+
1038
+ const toggleFullscreen = useCallback(() => {
1039
+ if (isWeb() && typeof document !== 'undefined') {
1040
+ const node = playerRef.current;
1041
+ if (!node) return;
1042
+ const fsElem = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
1043
+ if (!fsElem) {
1044
+ if (node.requestFullscreen) {
1045
+ node.requestFullscreen().catch(() => {});
1046
+ } else if (node.webkitRequestFullscreen) {
1047
+ node.webkitRequestFullscreen();
1048
+ } else if (node.mozRequestFullScreen) {
1049
+ node.mozRequestFullScreen();
1050
+ } else if (node.msRequestFullscreen) {
1051
+ node.msRequestFullscreen();
1052
+ }
1053
+ } else {
1054
+ exitFullscreen();
1055
+ }
1056
+ } else {
1057
+ setIsFullscreen((prev) => !prev);
1058
+ }
1059
+ badgeService.emit('player.fullscreen', {}).catch(() => {});
1060
+ }, [exitFullscreen]);
1061
+
1062
+ const computedAspectRatio = useMemo(() => {
1063
+ if (aspectRatio === 'FIT') {
1064
+ return 'FIT_SCREEN';
1065
+ }
1066
+ if (aspectRatio === 'FILL_SCREEN') {
1067
+ return calculateScreenAspectRatio(windowWidth, windowHeight);
1068
+ }
1069
+ return aspectRatio;
1070
+ }, [aspectRatio, windowWidth, windowHeight]);
1071
+
1072
+ const scale = useMemo(() => ({
1073
+ backFont: getFontSize(14, windowWidth, windowHeight),
1074
+ titleFont: getFontSize(16, windowWidth, windowHeight),
1075
+ lockTextFont: getFontSize(14, windowWidth, windowHeight),
1076
+ timeFont: getFontSize(13, windowWidth, windowHeight),
1077
+ aspectBubbleFont: getFontSize(13, windowWidth, windowHeight),
1078
+ loadingFont: getFontSize(14, windowWidth, windowHeight),
1079
+ errorTitleFont: getFontSize(18, windowWidth, windowHeight),
1080
+ errorMsgFont: getFontSize(14, windowWidth, windowHeight),
1081
+ backWeight: getFontWeight('600', windowWidth, windowHeight),
1082
+ titleWeight: getFontWeight('700', windowWidth, windowHeight),
1083
+ lockTextWeight: getFontWeight('700', windowWidth, windowHeight),
1084
+ timeWeight: getFontWeight('600', windowWidth, windowHeight),
1085
+ timeSeekingWeight: getFontWeight('800', windowWidth, windowHeight),
1086
+ aspectBubbleWeight: getFontWeight('600', windowWidth, windowHeight),
1087
+ aspectBubbleSelectedWeight: getFontWeight('800', windowWidth, windowHeight),
1088
+ loadingWeight: getFontWeight('600', windowWidth, windowHeight),
1089
+ errorTitleWeight: getFontWeight('800', windowWidth, windowHeight),
1090
+ errorBtnWeight: getFontWeight('800', windowWidth, windowHeight),
1091
+ }), [windowWidth, windowHeight]);
1092
+
1093
+ const handleSelectAspectRatio = useCallback(
1094
+ (val) => {
1095
+ setZoomScale(1);
1096
+ zoomScaleRef.current = 1;
1097
+
1098
+ const aspectChanged = val !== aspectRatioRef.current;
1099
+ const preservedPosition =
1100
+ mediaType !== 'live' && mediaType !== 'channel'
1101
+ ? Number(lastKnownTimeRef.current || 0)
1102
+ : 0;
1103
+ if (aspectChanged) {
1104
+ aspectRatioRef.current = val;
1105
+ setAspectRatio(val);
1106
+ }
1107
+
1108
+ let targetRatio = val;
1109
+ if (val === 'FIT') {
1110
+ targetRatio = 'FIT';
1111
+ } else if (val === 'FILL_SCREEN') {
1112
+ targetRatio = calculateScreenAspectRatio(windowWidth, windowHeight);
1113
+ }
1114
+
1115
+ // Live update — the video keeps playing; only the picture shape changes.
1116
+ // The expo-video fallback and web player understand the raw mode
1117
+ // ('FIT', 'FILL_SCREEN', '16:9'); native VLC expects a ratio string.
1118
+ try {
1119
+ if (vlcRef.current && typeof vlcRef.current.changeVideoAspectRatio === 'function') {
1120
+ const useRawMode = isWeb() || !VLC_AVAILABLE;
1121
+ vlcRef.current.changeVideoAspectRatio(useRawMode ? val : targetRatio);
1122
+ }
1123
+ } catch (e) {
1124
+ // aspect-ratio change is best-effort
1125
+ }
1126
+ if (aspectChanged) {
1127
+ if (preservedPosition > 0) {
1128
+ // Some Android VLC builds briefly recreate the video output when
1129
+ // the aspect ratio prop changes. Re-apply the exact current
1130
+ // position after the output settles so a dimension tap never jumps
1131
+ // back to the original resume point.
1132
+ restorePositionRef.current?.(preservedPosition, 120);
1133
+ }
1134
+ badgeService.emit('player.aspect', { aspect: val }).catch(() => {});
1135
+ }
1136
+ },
1137
+ [windowWidth, windowHeight, mediaType]
1138
+ );
1139
+
1140
+ const [currentTime, setCurrentTime] = useState(0);
1141
+ const [duration, setDuration] = useState(Number(durationSecs) || 0);
1142
+ const [sliderPos, setSliderPos] = useState(0);
1143
+
1144
+ const isSeeking = useRef(false);
1145
+ const bufferingTimerRef = useRef(null);
1146
+ const seekCompletedAt = useRef(0);
1147
+ const lastKnownTimeRef = useRef(0);
1148
+ const lastKnownDurRef = useRef(Number(durationSecs) || 0);
1149
+ const hasResumedRef = useRef(false);
1150
+ const hasStartedPlaybackRef = useRef(false);
1151
+ const restoreTimerRef = useRef(null);
1152
+
1153
+ useEffect(() => {
1154
+ if (Number(durationSecs) > 0) {
1155
+ setDuration(Number(durationSecs));
1156
+ lastKnownDurRef.current = Number(durationSecs);
1157
+ }
1158
+ }, [durationSecs]);
1159
+
1160
+ const clearBufferingIndicator = useCallback(() => {
1161
+ if (bufferingTimerRef.current) {
1162
+ clearTimeout(bufferingTimerRef.current);
1163
+ bufferingTimerRef.current = null;
1164
+ }
1165
+ setIsLoading((previous) => (previous ? false : previous));
1166
+ }, []);
1167
+
1168
+ const scheduleBufferingIndicator = useCallback(() => {
1169
+ if (!isPlayingRef.current || isSeeking.current) return;
1170
+ if (bufferingTimerRef.current) clearTimeout(bufferingTimerRef.current);
1171
+ bufferingTimerRef.current = setTimeout(() => {
1172
+ bufferingTimerRef.current = null;
1173
+ if (isPlayingRef.current && !isSeeking.current) {
1174
+ setIsLoading(true);
1175
+ }
1176
+ }, 450);
1177
+ }, []);
1178
+
1179
+ useEffect(() => () => {
1180
+ if (bufferingTimerRef.current) clearTimeout(bufferingTimerRef.current);
1181
+ if (restoreTimerRef.current) clearTimeout(restoreTimerRef.current);
1182
+ }, []);
1183
+
1184
+ const [showControls, setShowControls] = useState(true);
1185
+ const hideTimer = useRef(null);
1186
+
1187
+ const isLive = mediaType === 'live' || mediaType === 'channel';
1188
+ const isInlinePreview = !!inlinePreview;
1189
+
1190
+ useEffect(() => {
1191
+ if (!visible || !initialShowLiveChat || !isLive || !isLiveCommentsEnabled) return;
1192
+ setDrawerTab('chat');
1193
+ setShowLiveChat(true);
1194
+ }, [visible, mediaId, initialShowLiveChat, isLive, isLiveCommentsEnabled]);
1195
+
1196
+ const lastLiveChatNonceRef = useRef(0);
1197
+ useEffect(() => {
1198
+ if (!liveChatNonce || liveChatNonce === lastLiveChatNonceRef.current) return;
1199
+ lastLiveChatNonceRef.current = liveChatNonce;
1200
+ if (!visible || !isLive || !isLiveCommentsEnabled) return;
1201
+ setDrawerTab('chat');
1202
+ setShowLiveChat(true);
1203
+ }, [liveChatNonce, visible, isLive, isLiveCommentsEnabled]);
1204
+
1205
+ const [recStatus, setRecStatus] = useState('idle');
1206
+ const [recElapsedMs, setRecElapsedMs] = useState(0);
1207
+ const [recNotice, setRecNotice] = useState(null);
1208
+ const recNoticeTimer = useRef(null);
1209
+ const recStatusRef = useRef('idle');
1210
+ recStatusRef.current = recStatus;
1211
+
1212
+ const [isAudioOnly, setIsAudioOnly] = useState(!!initialAudioOnly);
1213
+ const [audioOnlyStreamUrl, setAudioOnlyStreamUrl] = useState('');
1214
+ const [audioOnlyFallback, setAudioOnlyFallback] = useState(false);
1215
+ const isAudioOnlyRef = useRef(false);
1216
+ const audioOnlyStreamUrlRef = useRef('');
1217
+ const audioOnlyFallbackRef = useRef(false);
1218
+ const audioOnlyFallbackTimerRef = useRef(null);
1219
+ const restorePositionRef = useRef(null);
1220
+ const sourceRestorePositionRef = useRef(0);
1221
+ const audioModeStartPositionRef = useRef(0);
1222
+ useEffect(() => {
1223
+ isAudioOnlyRef.current = isAudioOnly;
1224
+ }, [isAudioOnly]);
1225
+ audioOnlyStreamUrlRef.current = audioOnlyStreamUrl;
1226
+ audioOnlyFallbackRef.current = audioOnlyFallback;
1227
+
1228
+ // Audio mode is an in-place visual overlay across all platforms:
1229
+ // keeps the stream playing continuously without interrupting or reloading.
1230
+ const audioOnlyUsesProxy = false;
1231
+
1232
+ const clearAudioOnlyFallbackTimer = useCallback(() => {
1233
+ if (audioOnlyFallbackTimerRef.current) {
1234
+ clearTimeout(audioOnlyFallbackTimerRef.current);
1235
+ audioOnlyFallbackTimerRef.current = null;
1236
+ }
1237
+ }, []);
1238
+
1239
+ const activateNativeAudioFallback = useCallback(() => {
1240
+ return false;
1241
+ }, []);
1242
+
1243
+ const brightnessBadgeReady = useRef(false);
1244
+ useEffect(() => {
1245
+ if (!brightnessBadgeReady.current) {
1246
+ brightnessBadgeReady.current = true;
1247
+ return;
1248
+ }
1249
+ badgeService.emit('player.brightness', {}).catch(() => {});
1250
+ badgeService.emit('player.gesture', {}).catch(() => {});
1251
+ }, [brightness]);
1252
+
1253
+ const volumeBadgeReady = useRef(false);
1254
+ useEffect(() => {
1255
+ if (!volumeBadgeReady.current) {
1256
+ volumeBadgeReady.current = true;
1257
+ return;
1258
+ }
1259
+ badgeService.emit('player.volume', {}).catch(() => {});
1260
+ badgeService.emit('player.gesture', {}).catch(() => {});
1261
+ }, [volume]);
1262
+
1263
+ const toggleAudioOnly = useCallback(() => {
1264
+ const next = !isAudioOnlyRef.current;
1265
+ isAudioOnlyRef.current = next;
1266
+ setIsAudioOnly(next);
1267
+ if (next) badgeService.emit('player.audioOnly', { mediaType }).catch(() => {});
1268
+ }, [mediaType]);
1269
+
1270
+ const debouncedSaveTimerRef = useRef(null);
1271
+
1272
+ const saveCurrentProgress = useCallback(() => {
1273
+ const curTime = lastKnownTimeRef.current;
1274
+ const durTime = lastKnownDurRef.current;
1275
+ const offline = isLocalMediaUri(streamUrl);
1276
+ const progress = {
1277
+ mediaId,
1278
+ title,
1279
+ posterUrl,
1280
+ mediaType: mediaType || (isLive ? 'channel' : 'movie'),
1281
+ season,
1282
+ episode,
1283
+ currentTime: curTime,
1284
+ duration: isLive ? 0 : durTime,
1285
+ streamUrl,
1286
+ genre,
1287
+ categoryName,
1288
+ playlist,
1289
+ playbackRate,
1290
+ offline,
1291
+ isLive,
1292
+ };
1293
+ if (offline || isLive || (mediaId && curTime > 5)) {
1294
+ badgeService.recordProgress(progress);
1295
+ }
1296
+ }, [isLive, mediaId, title, posterUrl, streamUrl, mediaType, season, episode, genre, categoryName, playlist, playbackRate, badgeService]);
1297
+
1298
+ const debouncedSaveProgress = useCallback(() => {
1299
+ if (debouncedSaveTimerRef.current) {
1300
+ clearTimeout(debouncedSaveTimerRef.current);
1301
+ }
1302
+ debouncedSaveTimerRef.current = setTimeout(() => {
1303
+ saveCurrentProgress();
1304
+ }, 1000);
1305
+ }, [saveCurrentProgress]);
1306
+
1307
+ // Periodic auto-save every 30s & save on unmount
1308
+ useEffect(() => {
1309
+ if (!visible) return;
1310
+ const interval = setInterval(() => {
1311
+ saveCurrentProgress();
1312
+ }, 30000);
1313
+
1314
+ return () => {
1315
+ clearInterval(interval);
1316
+ if (debouncedSaveTimerRef.current) {
1317
+ clearTimeout(debouncedSaveTimerRef.current);
1318
+ }
1319
+ saveCurrentProgress();
1320
+ };
1321
+ }, [isLive, visible, saveCurrentProgress]);
1322
+
1323
+ // Reset player state when streamUrl changes
1324
+ useEffect(() => {
1325
+ clearAudioOnlyFallbackTimer();
1326
+ if (bufferingTimerRef.current) {
1327
+ clearTimeout(bufferingTimerRef.current);
1328
+ bufferingTimerRef.current = null;
1329
+ }
1330
+ setIsLoading(true);
1331
+ setErrorMessage(null);
1332
+ setIsPlaying(!initialPaused);
1333
+ isPlayingRef.current = !initialPaused;
1334
+ setVideoOnlyMode(!!videoOnly);
1335
+ setPlaybackUrl(streamUrl || '');
1336
+ setIsLocked(false);
1337
+ setIsAudioOnly(!!initialAudioOnly);
1338
+ isAudioOnlyRef.current = !!initialAudioOnly;
1339
+ setAudioOnlyStreamUrl('');
1340
+ setAudioOnlyFallback(false);
1341
+ audioOnlyStreamUrlRef.current = '';
1342
+ audioOnlyFallbackRef.current = false;
1343
+ sourceRestorePositionRef.current = 0;
1344
+ audioModeStartPositionRef.current = 0;
1345
+ setCurrentTime(0);
1346
+ setDuration(0);
1347
+ setSliderPos(0);
1348
+ setPlaybackRate(clampNumber(initialPlaybackRate, 0.25, 4, 1));
1349
+ setShowControls(true);
1350
+ isSeeking.current = false;
1351
+ seekCompletedAt.current = 0;
1352
+ lastKnownTimeRef.current = 0;
1353
+ lastKnownDurRef.current = 0;
1354
+ hasResumedRef.current = false;
1355
+ hasStartedPlaybackRef.current = false;
1356
+ pendingSeekRef.current = null;
1357
+ if (restoreTimerRef.current) {
1358
+ clearTimeout(restoreTimerRef.current);
1359
+ restoreTimerRef.current = null;
1360
+ }
1361
+ return () => {
1362
+ if (bufferingTimerRef.current) {
1363
+ clearTimeout(bufferingTimerRef.current);
1364
+ bufferingTimerRef.current = null;
1365
+ }
1366
+ clearAudioOnlyFallbackTimer();
1367
+ };
1368
+ }, [clearAudioOnlyFallbackTimer, streamUrl, visible, initialPaused, initialPlaybackRate, videoOnly, initialAudioOnly]);
1369
+
1370
+ // Reset aspect ratio & zoom state every time player opens or streamUrl changes
1371
+ useEffect(() => {
1372
+ if (visible) {
1373
+ setAspectRatio(DEFAULT_ASPECT_RATIO);
1374
+ aspectRatioRef.current = DEFAULT_ASPECT_RATIO;
1375
+ setZoomScale(1);
1376
+ zoomScaleRef.current = 1;
1377
+ setZoomBadgeText('');
1378
+ setSeekRipple(null);
1379
+ }
1380
+ }, [visible, streamUrl]);
1381
+
1382
+ // Auto-hide controls 4 seconds after inactivity
1383
+ const scheduleHide = useCallback(() => {
1384
+ if (hideTimer.current) clearTimeout(hideTimer.current);
1385
+ hideTimer.current = setTimeout(() => {
1386
+ setShowControls(false);
1387
+ setShowSpeedPicker(false);
1388
+ setShowAspectPicker(false);
1389
+ setShowAudioPicker(false);
1390
+ }, 4000);
1391
+ }, []);
1392
+
1393
+ useEffect(() => {
1394
+ if (showControls && isPlaying) {
1395
+ scheduleHide();
1396
+ } else if (hideTimer.current) clearTimeout(hideTimer.current);
1397
+ return () => {
1398
+ if (hideTimer.current) clearTimeout(hideTimer.current);
1399
+ };
1400
+ }, [showControls, isPlaying, scheduleHide]);
1401
+
1402
+ // Broadcast playback presence (watching title) to friends lobby
1403
+ useEffect(() => {
1404
+ if (!visible || !title) return;
1405
+
1406
+ const currentType = mediaType || (isLive ? 'channel' : 'movie');
1407
+ const broadcast = (online, t, mType) => integrations.onPresence?.({
1408
+ online,
1409
+ title: t || '',
1410
+ mediaType: mType || '',
1411
+ mediaId: mediaId || null,
1412
+ });
1413
+
1414
+ broadcast(true, title, currentType);
1415
+
1416
+ const heartbeat = setInterval(() => {
1417
+ broadcast(true, title, currentType);
1418
+ }, 45000);
1419
+
1420
+ return () => {
1421
+ clearInterval(heartbeat);
1422
+ broadcast(true, '', '');
1423
+ };
1424
+ }, [visible, title, mediaType, isLive, integrations, mediaId]);
1425
+
1426
+ const handleClose = useCallback(async () => {
1427
+ setIsAudioOnly(false);
1428
+ isAudioOnlyRef.current = false;
1429
+ clearAudioOnlyFallbackTimer();
1430
+ audioOnlyStreamUrlRef.current = '';
1431
+ audioOnlyFallbackRef.current = false;
1432
+ setAudioOnlyStreamUrl('');
1433
+ setAudioOnlyFallback(false);
1434
+ integrations.onPresence?.({ online: false, title: '', mediaType: '', mediaId: mediaId || null });
1435
+ setShowLiveChat(false);
1436
+ exitFullscreen();
1437
+ saveCurrentProgress();
1438
+ onCwRefresh?.();
1439
+ if (recording?.isActive?.()) {
1440
+ try {
1441
+ const result = await recording.stop?.();
1442
+ if (result?.filename) {
1443
+ Alert.alert('Recording saved', result.filename);
1444
+ }
1445
+ } catch (err) {
1446
+ Alert.alert('Recording', err?.message || 'Could not save the recording.');
1447
+ }
1448
+ }
1449
+ onClose();
1450
+ }, [clearAudioOnlyFallbackTimer, exitFullscreen, saveCurrentProgress, onCwRefresh, onClose, integrations, mediaId, recording]);
1451
+
1452
+ const handleBackAction = useCallback(() => invokeAction(
1453
+ 'onBack',
1454
+ handleClose,
1455
+ { title, streamUrl, mediaId },
1456
+ ), [invokeAction, handleClose, title, streamUrl, mediaId]);
1457
+
1458
+ useEffect(() => {
1459
+ if (!visible || isWeb()) return undefined;
1460
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
1461
+ handleBackAction();
1462
+ return true;
1463
+ });
1464
+ return () => sub.remove();
1465
+ }, [visible, handleBackAction]);
1466
+
1467
+ // Sleep Timer listener - automatically close video playback when timer expires
1468
+ useEffect(() => {
1469
+ if (!visible) return undefined;
1470
+ const unsubscribe = integrations.onSleepTimerExpired?.(handleClose);
1471
+ return unsubscribe;
1472
+ }, [visible, handleClose, integrations]);
1473
+
1474
+ useEffect(() => recording?.subscribe?.((snap) => {
1475
+ setRecStatus(snap.status);
1476
+ setRecElapsedMs(snap.elapsedMs);
1477
+ }), [recording]);
1478
+
1479
+ useEffect(() => () => {
1480
+ if (recNoticeTimer.current) clearTimeout(recNoticeTimer.current);
1481
+ if (recording?.isActive?.()) {
1482
+ Promise.resolve(recording.stop?.()).catch(() => {});
1483
+ }
1484
+ }, [streamUrl, visible, recording]);
1485
+
1486
+ const showRecNotice = useCallback((notice) => {
1487
+ if (recNoticeTimer.current) clearTimeout(recNoticeTimer.current);
1488
+ setRecNotice(notice);
1489
+ recNoticeTimer.current = setTimeout(() => setRecNotice(null), 5600);
1490
+ }, []);
1491
+
1492
+ const handleStartRecording = useCallback(async (e) => {
1493
+ e?.stopPropagation?.();
1494
+ if (!isScreenRecorderEnabled || !isLive || recStatusRef.current !== 'idle') return;
1495
+ try {
1496
+ await recording?.start?.({
1497
+ getVideoElement: () => {
1498
+ const ref = vlcRef.current;
1499
+ if (typeof ref?.getVideoElement === 'function') return ref.getVideoElement();
1500
+ return null;
1501
+ },
1502
+ streamUrl: playbackUrl || streamUrl,
1503
+ title,
1504
+ });
1505
+ if (muted) {
1506
+ showRecNotice({
1507
+ type: 'info',
1508
+ message: 'Recording started. Unmute the player if you want live audio in the file.',
1509
+ });
1510
+ }
1511
+ } catch (err) {
1512
+ showRecNotice({ type: 'error', message: err?.message || 'Could not start recording.' });
1513
+ }
1514
+ }, [isLive, isScreenRecorderEnabled, muted, playbackUrl, showRecNotice, streamUrl, title, recording]);
1515
+
1516
+ const handlePauseRecording = useCallback(async (e) => {
1517
+ e?.stopPropagation?.();
1518
+ try {
1519
+ await recording?.pause?.();
1520
+ } catch (err) {
1521
+ showRecNotice({ type: 'error', message: err?.message || 'Could not pause recording.' });
1522
+ }
1523
+ }, [showRecNotice, recording]);
1524
+
1525
+ const handleResumeRecording = useCallback(async (e) => {
1526
+ e?.stopPropagation?.();
1527
+ try {
1528
+ await recording?.resume?.();
1529
+ } catch (err) {
1530
+ showRecNotice({ type: 'error', message: err?.message || 'Could not resume recording.' });
1531
+ }
1532
+ }, [showRecNotice, recording]);
1533
+
1534
+ const handleStopRecording = useCallback(async (e) => {
1535
+ e?.stopPropagation?.();
1536
+ try {
1537
+ const result = await recording?.stop?.();
1538
+ if (result?.filename) {
1539
+ let where;
1540
+ if (result.platform === 'web') {
1541
+ if (result.saveMethod === 'share') {
1542
+ where = `Share sheet opened for ${result.filename}`;
1543
+ } else if (result.saveMethod === 'open') {
1544
+ where = `Opened ${result.filename}. Use Share to save it on this iPad.`;
1545
+ } else {
1546
+ where = `Saved as ${result.filename}`;
1547
+ }
1548
+ } else {
1549
+ where = `Saved on this device as ${result.filename}`;
1550
+ }
1551
+ showRecNotice({ type: 'saved', message: where });
1552
+ }
1553
+ } catch (err) {
1554
+ showRecNotice({ type: 'error', message: err?.message || 'Could not save the recording.' });
1555
+ }
1556
+ }, [showRecNotice, recording]);
1557
+
1558
+ /**
1559
+ * Restart the movie/episode from the beginning (seek to 00:00).
1560
+ */
1561
+ const handleRestart = useCallback(() => {
1562
+ try {
1563
+ if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
1564
+ vlcRef.current.seek(0);
1565
+ }
1566
+ } catch (e) {
1567
+ // restart seek is best-effort
1568
+ }
1569
+ isSeeking.current = false;
1570
+ setCurrentTime(0);
1571
+ setSliderPos(0);
1572
+ lastKnownTimeRef.current = 0;
1573
+ seekCompletedAt.current = Date.now();
1574
+ if (!showControls) setShowControls(true);
1575
+ }, [showControls]);
1576
+
1577
+ // Episode finished: auto-advance to the next episode (sequential) or to a
1578
+ // random episode (shuffle mode). Last episode in the playlist stops playback.
1579
+ const handleEpisodeEnded = useCallback(() => {
1580
+ onEnded?.();
1581
+ const eps = Array.isArray(playlist) ? playlist : [];
1582
+ if (!eps.length) return;
1583
+ const curSeason = season !== null && season !== undefined ? Number(season) : null;
1584
+ const curEp = episode !== null && episode !== undefined ? Number(episode) : null;
1585
+ let next = null;
1586
+ if (shuffle) {
1587
+ const candidates = eps.filter((p) => !(p.season === curSeason && p.episode === curEp));
1588
+ if (candidates.length === 0) return;
1589
+ next = pickShuffleCandidate(candidates, curSeason, curEp);
1590
+ } else {
1591
+ const idx = eps.findIndex((p) => p.season === curSeason && p.episode === curEp);
1592
+ next = idx >= 0 ? eps[idx + 1] : null;
1593
+ if (!next) return;
1594
+ }
1595
+ saveCurrentProgress();
1596
+ if (typeof onNextEpisode === 'function') onNextEpisode(next);
1597
+ badgeService.emit('player.next', { autoplay: true, mediaType: 'series' }).catch(() => {});
1598
+ }, [playlist, shuffle, season, episode, saveCurrentProgress, onNextEpisode, onEnded]);
1599
+
1600
+ const toggleControls = useCallback(() => {
1601
+ setShowSpeedPicker(false);
1602
+ setShowAspectPicker(false);
1603
+ setShowAudioPicker(false);
1604
+ setShowControls((prev) => {
1605
+ if (!prev && isPlaying) scheduleHide();
1606
+ return !prev;
1607
+ });
1608
+ }, [isPlaying, scheduleHide]);
1609
+
1610
+ const triggerSeekRipple = useCallback((side, text) => {
1611
+ if (seekRippleTimer.current) clearTimeout(seekRippleTimer.current);
1612
+ setSeekRipple({ side, text });
1613
+ seekRippleTimer.current = setTimeout(() => {
1614
+ setSeekRipple(null);
1615
+ }, 300);
1616
+ }, []);
1617
+
1618
+ const togglePlayPause = useCallback((nextPlay) => {
1619
+ const wasPlaying = isPlayingRef.current;
1620
+ const next = typeof nextPlay === 'boolean' ? nextPlay : !wasPlaying;
1621
+ if (next && !mutedRef.current) {
1622
+ vlcRef.current?.activateAudio?.(volumeRef.current || 100);
1623
+ }
1624
+ isPlayingRef.current = next;
1625
+ if (wasPlaying && !next) {
1626
+ badgeService.emit('player.pause', { mediaType }).catch(() => {});
1627
+ if (mediaType === 'live' || mediaType === 'channel') {
1628
+ badgeService.emit('live.pauseResume', {}).catch(() => {});
1629
+ }
1630
+ clearBufferingIndicator();
1631
+ }
1632
+ setIsPlaying(next);
1633
+ if (!showControls) setShowControls(true);
1634
+ }, [showControls, mediaType, clearBufferingIndicator]);
1635
+
1636
+ const toggleMute = useCallback(() => {
1637
+ const next = !mutedRef.current;
1638
+ if (isWeb()) {
1639
+ if (next) {
1640
+ vlcRef.current?.deactivateAudio?.();
1641
+ } else {
1642
+ vlcRef.current?.activateAudio?.(volumeRef.current || 100);
1643
+ }
1644
+ }
1645
+ mutedRef.current = next;
1646
+ if (next) pendingUnmuteRef.current = false;
1647
+ setMuted(next);
1648
+
1649
+ if (!next && volumeRef.current === 0) {
1650
+ commitVolume(100, { unmute: false });
1651
+ }
1652
+
1653
+ badgeService.emit('player.mute', {}).catch(() => {});
1654
+ }, [commitVolume]);
1655
+
1656
+ const toggleLock = useCallback(() => {
1657
+ setIsLocked((prev) => {
1658
+ const next = !prev;
1659
+ setShowControls(true);
1660
+ if (isPlaying) {
1661
+ scheduleHide();
1662
+ }
1663
+ return next;
1664
+ });
1665
+ }, [isPlaying, scheduleHide]);
1666
+
1667
+ const pendingSeekRef = useRef(null);
1668
+
1669
+ const restorePlaybackPosition = useCallback((position, delay = 100) => {
1670
+ if (isLive || !visible) return;
1671
+ const target = Number(position);
1672
+ if (!Number.isFinite(target) || target <= 0) return;
1673
+
1674
+ if (restoreTimerRef.current) clearTimeout(restoreTimerRef.current);
1675
+ const apply = () => {
1676
+ restoreTimerRef.current = null;
1677
+ if (!visible || !vlcRef.current) return;
1678
+ const dur = Number(lastKnownDurRef.current || 0);
1679
+ if (dur <= 0) {
1680
+ pendingSeekRef.current = target;
1681
+ return;
1682
+ }
1683
+ const audioOffset = isAudioOnly && !audioOnlyFallback && !isLive
1684
+ ? Number(audioModeStartPositionRef.current || 0)
1685
+ : 0;
1686
+ const playerDuration = Math.max(0, dur - audioOffset);
1687
+ const playerTarget = Math.max(0, target - audioOffset);
1688
+ const ratio = Math.max(0, Math.min(1, playerTarget / Math.max(playerDuration, 1)));
1689
+ try {
1690
+ vlcRef.current.seek(ratio);
1691
+ lastKnownTimeRef.current = target;
1692
+ setCurrentTime(target);
1693
+ setSliderPos(target);
1694
+ seekCompletedAt.current = Date.now();
1695
+ } catch {
1696
+ // Player-specific seek APIs are best-effort during native transitions.
1697
+ }
1698
+ };
1699
+ if (delay > 0) {
1700
+ restoreTimerRef.current = setTimeout(apply, delay);
1701
+ } else {
1702
+ apply();
1703
+ }
1704
+ }, [audioOnlyFallback, isAudioOnly, isLive, visible]);
1705
+ restorePositionRef.current = restorePlaybackPosition;
1706
+
1707
+ const handleSeekTo = (secs) => {
1708
+ scheduleBufferingIndicator();
1709
+ try {
1710
+ if (vlcRef.current) {
1711
+ const audioOffset = isAudioOnly && !audioOnlyFallback && !isLive
1712
+ ? Number(audioModeStartPositionRef.current || 0)
1713
+ : 0;
1714
+ const playerDuration = Math.max(0, duration - audioOffset);
1715
+ const playerTarget = Math.max(0, Number(secs) - audioOffset);
1716
+ if (playerDuration > 0) {
1717
+ const ratio = Math.max(0, Math.min(1, playerTarget / playerDuration));
1718
+ vlcRef.current.seek(ratio);
1719
+ } else {
1720
+ // Native seek expects a 0-1 ratio; defer until duration is known.
1721
+ pendingSeekRef.current = secs;
1722
+ }
1723
+ }
1724
+ } catch (e) {
1725
+ // seek is best-effort; ignore player-specific failures
1726
+ }
1727
+ };
1728
+
1729
+ const handleSeekBy = useCallback((secs) => {
1730
+ const dur = lastKnownDurRef.current || duration || 0;
1731
+ const cur = lastKnownTimeRef.current || currentTime || 0;
1732
+ const target = Math.max(0, Math.min(dur > 0 ? dur : 999999, cur + secs));
1733
+ isSeeking.current = false;
1734
+ setCurrentTime(target);
1735
+ setSliderPos(target);
1736
+ lastKnownTimeRef.current = target;
1737
+ seekCompletedAt.current = Date.now();
1738
+ handleSeekTo(target);
1739
+ debouncedSaveProgress();
1740
+ if (!showControls) setShowControls(true);
1741
+ badgeService.emit('player.seek', {
1742
+ deltaSec: secs,
1743
+ rewind10: secs === -10,
1744
+ forward30: secs === 30,
1745
+ doubleTap: Math.abs(secs) === 10,
1746
+ }).catch(() => {});
1747
+ if ((mediaType === 'live' || mediaType === 'channel') && secs <= -900) {
1748
+ badgeService.emit('live.rewind', { rewindSec: Math.abs(secs) }).catch(() => {});
1749
+ }
1750
+ }, [duration, currentTime, showControls, mediaType, debouncedSaveProgress]);
1751
+
1752
+ const handleProgress = (data) => {
1753
+ onProgress?.(data);
1754
+ setErrorMessage(null);
1755
+ const audioOffsetSeconds = audioOnlyUsesProxy && !isLive
1756
+ ? Number(audioModeStartPositionRef.current || 0)
1757
+ : 0;
1758
+ const progress = normalizeProgressEvent(data, audioOffsetSeconds * 1000);
1759
+ applyProgressState(progress, {
1760
+ hasStartedPlaybackRef,
1761
+ clearAudioOnlyFallbackTimer,
1762
+ clearBufferingIndicator,
1763
+ setDuration,
1764
+ lastKnownDurationRef: lastKnownDurRef,
1765
+ pendingSeekRef,
1766
+ audioOffsetSeconds,
1767
+ playerRef: vlcRef,
1768
+ isSeeking,
1769
+ seekCompletedAt,
1770
+ setCurrentTime,
1771
+ lastKnownTimeRef,
1772
+ setSliderPosition: setSliderPos,
1773
+ });
1774
+ };
1775
+
1776
+ const handleNativeOpen = (event) => {
1777
+ setErrorMessage(null);
1778
+
1779
+ const payload = event?.nativeEvent || event || {};
1780
+ onReady?.(payload);
1781
+ applyTrackDefaults(selectedAudioTrack, setSelectedAudioTrack, setAudioTracks, payload.audioTracks);
1782
+
1783
+ // VLC only honors aspect ratio changes once the video output is active;
1784
+ // movies start slower than live streams, so re-apply after open/seek.
1785
+ // Audio-only playback has no video output to resize; asking VLC to change
1786
+ // its aspect ratio while the audio source is opening can interrupt it.
1787
+ if (!isAudioOnly) {
1788
+ setTimeout(() => handleSelectAspectRatio(aspectRatio), 400);
1789
+ }
1790
+
1791
+ const restoredSourcePosition = Number(sourceRestorePositionRef.current || 0);
1792
+ if (!isLive && restoredSourcePosition > 5) {
1793
+ sourceRestorePositionRef.current = 0;
1794
+ setTimeout(() => restorePlaybackPosition(restoredSourcePosition, 0), 250);
1795
+ return;
1796
+ }
1797
+
1798
+
1799
+ const targetPos = Number(resumePosition || 0);
1800
+ if (!isLive && targetPos > 5 && !hasResumedRef.current) {
1801
+ hasResumedRef.current = true;
1802
+ setTimeout(() => {
1803
+ handleSeekTo(targetPos);
1804
+ setTimeout(() => handleSelectAspectRatio(aspectRatio), 600);
1805
+ }, 600);
1806
+ }
1807
+ };
1808
+
1809
+ const handleNativePlaying = (event) => {
1810
+ hasStartedPlaybackRef.current = true;
1811
+ clearAudioOnlyFallbackTimer();
1812
+ clearBufferingIndicator();
1813
+ setErrorMessage(null);
1814
+ onPlaying?.(event?.nativeEvent || event || {});
1815
+ handleNativeOpen(event);
1816
+ };
1817
+
1818
+ const handleNativeLoadStart = useCallback(() => {
1819
+ hasStartedPlaybackRef.current = false;
1820
+ setIsLoading(true);
1821
+ setErrorMessage(null);
1822
+ }, []);
1823
+
1824
+ const handleNativeBuffering = (event) => {
1825
+ const payload = event?.nativeEvent || event || {};
1826
+ const rate = payload?.bufferRate ?? payload?.buffering;
1827
+ if (typeof payload?.isBuffering === 'boolean') onBuffering?.(payload.isBuffering);
1828
+ else if (rate !== undefined && rate !== null) onBuffering?.(rate < 100);
1829
+ if (rate !== undefined && rate !== null) {
1830
+ if (rate < 100) {
1831
+ scheduleBufferingIndicator();
1832
+ } else if (hasStartedPlaybackRef.current) {
1833
+ clearBufferingIndicator();
1834
+ }
1835
+ } else if (typeof payload?.isBuffering === 'boolean') {
1836
+ if (payload.isBuffering) {
1837
+ scheduleBufferingIndicator();
1838
+ } else if (hasStartedPlaybackRef.current) {
1839
+ clearBufferingIndicator();
1840
+ }
1841
+ }
1842
+ };
1843
+
1844
+ const handleTracksChanged = useCallback((tracks) => {
1845
+ if (!tracks.audioTracks || tracks.audioTracks.length === 0) return;
1846
+ setAudioTracks(tracks.audioTracks);
1847
+ setSelectedAudioTrack((prev) => {
1848
+ if (prev !== null && tracks.audioTracks.some((t) => String(t.id) === String(prev))) {
1849
+ return prev;
1850
+ }
1851
+ const active = tracks.audioTracks.find((t) => t.selected || t.enabled || t.active) || tracks.audioTracks[0];
1852
+ return active ? active.id : prev;
1853
+ });
1854
+ }, []);
1855
+
1856
+ const handleAudioSelect = useCallback((id) => {
1857
+ setSelectedAudioTrack(id);
1858
+ setShowAudioPicker(false);
1859
+ if (vlcRef.current && typeof vlcRef.current.setAudioTrack === 'function') {
1860
+ vlcRef.current.setAudioTrack(id);
1861
+ }
1862
+ scheduleBufferingIndicator();
1863
+ scheduleHide();
1864
+ }, [scheduleBufferingIndicator, scheduleHide]);
1865
+
1866
+ const handleRestartAction = useCallback(() => invokeAction(
1867
+ 'onRestart', handleRestart, { title, streamUrl, currentTime: lastKnownTimeRef.current },
1868
+ ), [invokeAction, handleRestart, title, streamUrl]);
1869
+ const handlePlayPauseAction = useCallback((nextPlay) => invokeAction(
1870
+ 'onPlayPause', () => togglePlayPause(nextPlay), { isPlaying: nextPlay ?? !isPlayingRef.current },
1871
+ ), [invokeAction, togglePlayPause]);
1872
+ const handleSeekAction = useCallback((seconds) => invokeAction(
1873
+ 'onSeek', () => {
1874
+ const target = Math.max(0, Number(seconds) || 0);
1875
+ handleSeekTo(target);
1876
+ lastKnownTimeRef.current = target;
1877
+ setCurrentTime(target);
1878
+ setSliderPos(target);
1879
+ seekCompletedAt.current = Date.now();
1880
+ debouncedSaveProgress();
1881
+ }, { seconds: Number(seconds) || 0, currentTime: lastKnownTimeRef.current },
1882
+ ), [invokeAction, handleSeekTo, debouncedSaveProgress]);
1883
+ const handleSeekByAction = (deltaSeconds) => invokeAction(
1884
+ 'onSeek', () => handleSeekBy(deltaSeconds), { deltaSeconds: Number(deltaSeconds) || 0, currentTime: lastKnownTimeRef.current },
1885
+ );
1886
+ const handleMuteAction = useCallback(() => invokeAction(
1887
+ 'onMute', toggleMute, { muted: !mutedRef.current },
1888
+ ), [invokeAction, toggleMute]);
1889
+ const handleLockAction = useCallback(() => invokeAction(
1890
+ 'onLock', toggleLock, { locked: !isLockedRef.current },
1891
+ ), [invokeAction, toggleLock]);
1892
+ const handleAudioOnlyAction = useCallback(() => invokeAction(
1893
+ 'onAudioOnlyChange', toggleAudioOnly, { enabled: !isAudioOnlyRef.current },
1894
+ ), [invokeAction, toggleAudioOnly]);
1895
+ const handleVideoOnlyAction = useCallback(() => invokeAction(
1896
+ 'onVideoOnlyChange', () => setVideoOnlyMode((current) => !current), { enabled: !videoOnlyMode },
1897
+ ), [invokeAction, videoOnlyMode]);
1898
+ const handleAspectRatioAction = useCallback((value) => invokeAction(
1899
+ 'onAspectRatioChange', () => handleSelectAspectRatio(value), { aspectRatio: value },
1900
+ ), [invokeAction, handleSelectAspectRatio]);
1901
+ const handleAudioTrackAction = useCallback((trackId) => invokeAction(
1902
+ 'onAudioTrackChange', () => handleAudioSelect(trackId), { trackId },
1903
+ ), [invokeAction, handleAudioSelect]);
1904
+ const handleFullscreenAction = useCallback(() => invokeAction(
1905
+ 'onFullscreen', toggleFullscreen, { isFullscreen: !isFullscreen },
1906
+ ), [invokeAction, toggleFullscreen, isFullscreen]);
1907
+ const handleMinimizeAction = useCallback(() => invokeAction(
1908
+ 'onMinimize', onMinimize, { title, mediaId },
1909
+ ), [invokeAction, onMinimize, title, mediaId]);
1910
+ const handleRecordingAction = useCallback((name, fallback, event) => {
1911
+ event?.stopPropagation?.();
1912
+ return invokeAction(name, fallback, { title, streamUrl, mediaId });
1913
+ }, [invokeAction, title, streamUrl, mediaId]);
1914
+ const handlePanelAction = useCallback((tab) => invokeAction(
1915
+ getPanelActionName(tab),
1916
+ () => {
1917
+ if (showLiveChat && drawerTab === tab) setShowLiveChat(false);
1918
+ else { setDrawerTab(tab); setShowLiveChat(true); }
1919
+ },
1920
+ { tab, isOpen: showLiveChat && drawerTab === tab },
1921
+ ), [invokeAction, showLiveChat, drawerTab]);
1922
+
1923
+ useEffect(() => {
1924
+ if (!playerApiRef) return undefined;
1925
+ const api = {
1926
+ play: () => togglePlayPause(true),
1927
+ pause: () => togglePlayPause(false),
1928
+ togglePlayPause,
1929
+ restart: handleRestart,
1930
+ setMuted: (next) => setMuted(!!next),
1931
+ toggleMute,
1932
+ setAspectRatio: handleSelectAspectRatio,
1933
+ setAudioTrack: handleAudioSelect,
1934
+ setAudioOnly: (next) => setIsAudioOnly(!!next),
1935
+ setVideoOnly: (next) => setVideoOnlyMode(!!next),
1936
+ seekTo: handleSeekTo,
1937
+ seekBy: handleSeekBy,
1938
+ setPlaybackRate: (rate) => setPlaybackRate(clampNumber(rate, 0.25, 4, 1)),
1939
+ back: handleClose,
1940
+ minimize: handleMinimizeAction,
1941
+ getVideoElement: () => vlcRef.current?.getVideoElement?.() || null,
1942
+ getAudioTracks: () => audioTracks,
1943
+ };
1944
+ Object.assign(playerApiRef.current, api);
1945
+ return () => {
1946
+ for (const key of Object.keys(api)) delete playerApiRef.current[key];
1947
+ };
1948
+ }, [playerApiRef, togglePlayPause, handleRestart, toggleMute, handleSelectAspectRatio, handleAudioSelect, handleSeekTo, handleSeekBy, handleClose, handleMinimizeAction, audioTracks]);
1949
+
1950
+ const handleSpeedSelect = useCallback((speed) => invokeAction(
1951
+ 'onPlaybackRateChange', () => {
1952
+ const preservedPosition =
1953
+ mediaType !== 'live' && mediaType !== 'channel'
1954
+ ? Number(lastKnownTimeRef.current || 0)
1955
+ : 0;
1956
+ setPlaybackRate(speed);
1957
+ if (preservedPosition > 0) restorePositionRef.current?.(preservedPosition, 120);
1958
+ setShowSpeedPicker(false);
1959
+ badgeService.emit('player.rate', { rate: speed, mediaType }).catch(() => {});
1960
+ },
1961
+ { playbackRate: speed, currentTime: lastKnownTimeRef.current },
1962
+ ), [invokeAction, mediaType]);
1963
+
1964
+ const handleWebBuffering = useCallback((isBuffering) => {
1965
+ onBuffering?.(!!isBuffering);
1966
+ if (isBuffering) {
1967
+ scheduleBufferingIndicator();
1968
+ } else if (hasStartedPlaybackRef.current) {
1969
+ clearBufferingIndicator();
1970
+ }
1971
+ }, [clearBufferingIndicator, scheduleBufferingIndicator, onBuffering]);
1972
+
1973
+ const handleWebError = useCallback((err) => {
1974
+ onError?.(err);
1975
+ if (err?.blockPlayback) {
1976
+ clearBufferingIndicator();
1977
+ setErrorMessage(err?.message || 'Web playback is unavailable for this stream.');
1978
+ badgeService.emit('player.error', {}).catch(() => {});
1979
+ return;
1980
+ }
1981
+ if (activateNativeAudioFallback()) {
1982
+ return;
1983
+ }
1984
+ // If video is already playing or progress has started, do not show error modal
1985
+ if (lastKnownTimeRef.current > 0) {
1986
+ return;
1987
+ }
1988
+ // Give player 2.5s grace period to allow video-only fallback or auto-reconnect to mount
1989
+ setTimeout(() => {
1990
+ if (lastKnownTimeRef.current <= 0) {
1991
+ clearBufferingIndicator();
1992
+ setErrorMessage(err?.message || 'Failed to load stream.');
1993
+ badgeService.emit('player.error', {}).catch(() => {});
1994
+ }
1995
+ }, 2500);
1996
+ }, [activateNativeAudioFallback, clearBufferingIndicator, onError]);
1997
+
1998
+ const handlePlaybackRoute = (url = '') => {
1999
+ setPlaybackUrl(url);
2000
+ onPlaybackRoute?.(url);
2001
+ };
2002
+
2003
+ const windowWidthRef = useRef(windowWidth);
2004
+ windowWidthRef.current = windowWidth;
2005
+ const windowHeightRef = useRef(windowHeight);
2006
+ windowHeightRef.current = windowHeight;
2007
+ const handleSeekByRef = useRef(handleSeekByAction);
2008
+ handleSeekByRef.current = handleSeekByAction;
2009
+ const triggerSeekRippleRef = useRef(triggerSeekRipple);
2010
+ triggerSeekRippleRef.current = triggerSeekRipple;
2011
+ const togglePlayPauseRef = useRef(togglePlayPause);
2012
+ togglePlayPauseRef.current = togglePlayPause;
2013
+ const toggleControlsRef = useRef(toggleControls);
2014
+ toggleControlsRef.current = toggleControls;
2015
+ const scheduleHideRef = useRef(scheduleHide);
2016
+ scheduleHideRef.current = scheduleHide;
2017
+
2018
+ // PanResponder for Mobile Pinch-to-Zoom & VLC-Style Vertical Swipe Gestures (Brightness / Volume)
2019
+ const panResponder = useMemo(() => {
2020
+ if (isWeb()) return null;
2021
+
2022
+ return PanResponder.create({
2023
+ onStartShouldSetPanResponder: () => true,
2024
+ onStartShouldSetPanResponderCapture: () => false,
2025
+ onMoveShouldSetPanResponder: (_, gestureState) => {
2026
+ return Math.abs(gestureState.dx) > 3 || Math.abs(gestureState.dy) > 3;
2027
+ },
2028
+ onMoveShouldSetPanResponderCapture: () => false,
2029
+ onPanResponderGrant: (evt) => {
2030
+ const touches = evt.nativeEvent.touches;
2031
+ if (touches?.length === 2) {
2032
+ const [t1, t2] = touches;
2033
+ const dx = t1.pageX - t2.pageX;
2034
+ const dy = t1.pageY - t2.pageY;
2035
+ initialDistanceRef.current = Math.hypot(dx, dy);
2036
+ initialScaleRef.current = zoomScaleRef.current;
2037
+ isSwipingRef.current = false;
2038
+ } else if (touches?.length === 1) {
2039
+ initialDistanceRef.current = 0;
2040
+ const t = touches[0];
2041
+ gestureStartXRef.current = t.pageX;
2042
+ gestureStartYRef.current = t.pageY;
2043
+ const isLeft = t.pageX < windowWidthRef.current * 0.5;
2044
+ const currentVolumeVal = mutedRef.current ? 0 : volumeRef.current;
2045
+ gestureSideRef.current = isLeft ? 'brightness' : 'volume';
2046
+ gestureStartValRef.current = isLeft ? brightnessRef.current : currentVolumeVal;
2047
+ isSwipingRef.current = false;
2048
+ } else {
2049
+ initialDistanceRef.current = 0;
2050
+ isSwipingRef.current = false;
2051
+ }
2052
+ },
2053
+ onPanResponderMove: (evt, _gestureState) => {
2054
+ const touches = evt.nativeEvent.touches;
2055
+ const zoomState = {
2056
+ initialDistance: initialDistanceRef,
2057
+ initialScale: initialScaleRef,
2058
+ scale: zoomScaleRef,
2059
+ setScale: setZoomScale,
2060
+ setBadge: setZoomBadgeText,
2061
+ badgeTimer: zoomBadgeTimer,
2062
+ };
2063
+ if (handlePinchMove(touches, zoomState)) return;
2064
+
2065
+ // Brightness/volume swipes remain disabled on native platforms while
2066
+ // pinch zoom and tap-to-toggle controls stay enabled.
2067
+ handleVerticalGestureMove(touches, {
2068
+ isLocked: isLockedRef,
2069
+ startY: gestureStartYRef,
2070
+ startX: gestureStartXRef,
2071
+ isSwiping: isSwipingRef,
2072
+ windowHeight: windowHeightRef,
2073
+ side: gestureSideRef,
2074
+ startValue: gestureStartValRef,
2075
+ commitBrightness: commitBrightnessRef,
2076
+ commitVolume: commitVolumeRef,
2077
+ });
2078
+ },
2079
+ onPanResponderTerminate: () => {
2080
+ initialDistanceRef.current = 0;
2081
+ isSwipingRef.current = false;
2082
+ },
2083
+ onPanResponderRelease: (evt, gestureState) => {
2084
+ const touches = evt.nativeEvent?.touches;
2085
+
2086
+ if (initialDistanceRef.current > 0) {
2087
+ initialDistanceRef.current = 0;
2088
+ return;
2089
+ }
2090
+
2091
+ if (touches && touches.length > 0) return;
2092
+
2093
+ if (isSwipingRef.current) {
2094
+ isSwipingRef.current = false;
2095
+ return; // It was a vertical swipe, don't trigger tap
2096
+ }
2097
+
2098
+ if (Math.abs(gestureState.dx) > 15 || Math.abs(gestureState.dy) > 15) {
2099
+ return; // It was a pan/drag, not a tap
2100
+ }
2101
+
2102
+ const pageX = evt.nativeEvent?.pageX ?? gestureStartXRef.current ?? gestureState.x0 ?? 0;
2103
+ const pageY = evt.nativeEvent?.pageY ?? gestureStartYRef.current ?? gestureState.y0 ?? 0;
2104
+ handleTap(pageX, pageY);
2105
+ },
2106
+ });
2107
+ }, []);
2108
+
2109
+ const handleTap = useCallback((pageX, pageY) => {
2110
+ if (isLockedRef.current) {
2111
+ setShowControls((prev) => {
2112
+ const next = !prev;
2113
+ if (next && isPlaying) {
2114
+ scheduleHideRef.current();
2115
+ }
2116
+ return next;
2117
+ });
2118
+ return;
2119
+ }
2120
+
2121
+ const now = Date.now();
2122
+ const prev = lastTapRef.current;
2123
+ const dt = now - prev.time;
2124
+ const w = windowWidthRef.current || 400;
2125
+
2126
+ let side = 'center';
2127
+ if (pageX < w * 0.40) side = 'left';
2128
+ else if (pageX > w * 0.60) side = 'right';
2129
+
2130
+ // Must be a legitimate second tap within 50ms to 320ms on the same side
2131
+ const isDoubleTap = dt > 50 && dt < 320 && prev.side === side;
2132
+
2133
+ if (isDoubleTap) {
2134
+ if (singleTapTimerRef.current) {
2135
+ clearTimeout(singleTapTimerRef.current);
2136
+ singleTapTimerRef.current = null;
2137
+ }
2138
+ lastTapRef.current = { time: 0, x: 0, y: 0, side: null };
2139
+
2140
+ if (side === 'left') {
2141
+ handleSeekByRef.current(-10);
2142
+ triggerSeekRippleRef.current('left', '-10s');
2143
+ } else if (side === 'right') {
2144
+ handleSeekByRef.current(10);
2145
+ triggerSeekRippleRef.current('right', '+10s');
2146
+ } else {
2147
+ togglePlayPauseRef.current();
2148
+ }
2149
+ } else {
2150
+ lastTapRef.current = { time: now, x: pageX, y: pageY, side };
2151
+ if (singleTapTimerRef.current) {
2152
+ clearTimeout(singleTapTimerRef.current);
2153
+ }
2154
+ singleTapTimerRef.current = setTimeout(() => {
2155
+ toggleControlsRef.current();
2156
+ singleTapTimerRef.current = null;
2157
+ lastTapRef.current = { time: 0, x: 0, y: 0, side: null };
2158
+ }, 260);
2159
+ }
2160
+ }, []);
2161
+
2162
+ const handleWebTouchStart = useCallback((e) => {
2163
+ const point = getWebPoint(e);
2164
+ if (!point) return;
2165
+ touchHandledRef.current = true;
2166
+ if (isLockedRef.current) {
2167
+ webTouchStartRef.current = {
2168
+ x: point.x,
2169
+ y: point.y,
2170
+ side: 'center',
2171
+ startVal: 0,
2172
+ dragged: false,
2173
+ };
2174
+ return;
2175
+ }
2176
+ const isLeft = point.x < windowWidthRef.current * 0.5;
2177
+ if (!isLeft && isSafariOrIOS()) {
2178
+ webTouchStartRef.current = {
2179
+ x: point.x,
2180
+ y: point.y,
2181
+ side: 'none',
2182
+ startVal: 0,
2183
+ dragged: false,
2184
+ };
2185
+ return;
2186
+ }
2187
+ const currentVolumeVal = mutedRef.current ? 0 : volumeRef.current;
2188
+ webTouchStartRef.current = {
2189
+ x: point.x,
2190
+ y: point.y,
2191
+ side: isLeft ? 'brightness' : 'volume',
2192
+ startVal: isLeft ? brightnessRef.current : currentVolumeVal,
2193
+ dragged: false,
2194
+ };
2195
+ }, []);
2196
+
2197
+ const handleWebTouchMove = useCallback((e) => {
2198
+ if (isLockedRef.current) return;
2199
+ const point = getWebPoint(e);
2200
+ const state = webTouchStartRef.current;
2201
+ if (!point || !state) return;
2202
+ const dy = state.y - point.y;
2203
+ const dx = Math.abs(point.x - state.x);
2204
+ if (!state.dragged && (Math.abs(dy) > 8 || dx > 8)) {
2205
+ state.dragged = true;
2206
+ }
2207
+ if (state.dragged) {
2208
+ if (typeof e.preventDefault === 'function') e.preventDefault();
2209
+ const dragHeight = Math.max(180, (windowHeightRef.current || 400) * 0.55);
2210
+ const deltaPercent = (dy / dragHeight) * 100;
2211
+ if (state.side === 'brightness') {
2212
+ const next = Math.max(0.1, Math.min(1.0, Number(((state.startVal * 100 + deltaPercent) / 100).toFixed(2))));
2213
+ commitBrightness(next);
2214
+ } else {
2215
+ const next = Math.max(0, Math.min(100, Math.round(state.startVal + deltaPercent)));
2216
+ commitVolume(next);
2217
+ }
2218
+ }
2219
+ }, [commitBrightness, commitVolume]);
2220
+
2221
+ const handleWebTouchEnd = useCallback(() => {
2222
+ const state = webTouchStartRef.current;
2223
+ webTouchStartRef.current = null;
2224
+ if (state && !state.dragged) {
2225
+ handleTap(state.x, state.y);
2226
+ }
2227
+ setTimeout(() => {
2228
+ touchHandledRef.current = false;
2229
+ }, 400);
2230
+ }, [handleTap]);
2231
+
2232
+ const handleWebPointerDown = useCallback((e) => {
2233
+ if (e.button !== null && e.button !== 0) return;
2234
+ handleWebTouchStart(e);
2235
+ }, [handleWebTouchStart]);
2236
+
2237
+ const handleWebPointerMove = useCallback((e) => {
2238
+ if (!webTouchStartRef.current) return;
2239
+ handleWebTouchMove(e);
2240
+ }, [handleWebTouchMove]);
2241
+
2242
+ const handleWebPointerUp = useCallback((e) => {
2243
+ handleWebTouchEnd(e);
2244
+ }, [handleWebTouchEnd]);
2245
+
2246
+ const handleWebMouseDown = useCallback((e) => {
2247
+ if (touchHandledRef.current) return;
2248
+ const point = getWebPoint(e);
2249
+ if (!point) return;
2250
+ if (isLockedRef.current) {
2251
+ const onLockedMouseUp = () => {
2252
+ window.removeEventListener('mouseup', onLockedMouseUp);
2253
+ handleTap(point.x, point.y);
2254
+ };
2255
+ window.addEventListener('mouseup', onLockedMouseUp);
2256
+ return;
2257
+ }
2258
+ const startX = point.x;
2259
+ const startY = point.y;
2260
+ const isLeft = startX < windowWidthRef.current * 0.5;
2261
+ if (!isLeft && isSafariOrIOS()) {
2262
+ const onMouseUp = () => {
2263
+ window.removeEventListener('mouseup', onMouseUp);
2264
+ if (touchHandledRef.current) return;
2265
+ handleTap(startX, startY);
2266
+ };
2267
+ window.addEventListener('mouseup', onMouseUp);
2268
+ return;
2269
+ }
2270
+ const side = isLeft ? 'brightness' : 'volume';
2271
+ const currentVolumeVal = mutedRef.current ? 0 : volumeRef.current;
2272
+ const startVal = isLeft ? brightnessRef.current : currentVolumeVal;
2273
+ let hasDragged = false;
2274
+
2275
+ const onMouseMove = (moveEvent) => {
2276
+ const dy = startY - moveEvent.clientY;
2277
+ const dx = Math.abs(moveEvent.clientX - startX);
2278
+ if (!hasDragged && (Math.abs(dy) > 6 || dx > 6)) {
2279
+ hasDragged = true;
2280
+ }
2281
+ if (hasDragged) {
2282
+ const dragHeight = Math.max(180, (windowHeightRef.current || 400) * 0.55);
2283
+ const deltaPercent = (dy / dragHeight) * 100;
2284
+ if (side === 'brightness') {
2285
+ const next = Math.max(0.1, Math.min(1.0, Number(((startVal * 100 + deltaPercent) / 100).toFixed(2))));
2286
+ commitBrightness(next);
2287
+ } else {
2288
+ const next = Math.max(0, Math.min(100, Math.round(startVal + deltaPercent)));
2289
+ commitVolume(next);
2290
+ }
2291
+ }
2292
+ };
2293
+
2294
+ const onMouseUp = () => {
2295
+ window.removeEventListener('mousemove', onMouseMove);
2296
+ window.removeEventListener('mouseup', onMouseUp);
2297
+ if (touchHandledRef.current) return;
2298
+ if (!hasDragged) {
2299
+ handleTap(startX, startY);
2300
+ }
2301
+ };
2302
+
2303
+ window.addEventListener('mousemove', onMouseMove);
2304
+ window.addEventListener('mouseup', onMouseUp);
2305
+ }, [handleTap, commitBrightness, commitVolume]);
2306
+
2307
+ // Web keyboard listeners for seeking and playback, and trackpad pinch-to-zoom / scroll gestures
2308
+ useEffect(() => {
2309
+ if (!isWeb() || !visible) return;
2310
+
2311
+ const handleKeyDown = (e) => {
2312
+ // Don't interfere with form inputs if there are any
2313
+ if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
2314
+ if (isLockedRef.current) return;
2315
+
2316
+ if (e.key === 'ArrowRight') {
2317
+ handleSeekByRef.current(10);
2318
+ triggerSeekRippleRef.current('right', '+10s');
2319
+ e.preventDefault();
2320
+ } else if (e.key === 'ArrowLeft') {
2321
+ handleSeekByRef.current(-10);
2322
+ triggerSeekRippleRef.current('left', '-10s');
2323
+ e.preventDefault();
2324
+ } else if (e.key === 'ArrowUp') {
2325
+ commitVolume(volumeRef.current + 5);
2326
+ e.preventDefault();
2327
+ } else if (e.key === 'ArrowDown') {
2328
+ commitVolume(volumeRef.current - 5);
2329
+ e.preventDefault();
2330
+ } else if (e.key === ' ') {
2331
+ togglePlayPauseRef.current();
2332
+ e.preventDefault();
2333
+ }
2334
+ };
2335
+
2336
+ const handleWheel = (e) => {
2337
+ if (isInlinePreview) {
2338
+ if (typeof onInlinePreviewWheel === 'function') {
2339
+ e.preventDefault();
2340
+ onInlinePreviewWheel(e.deltaY);
2341
+ }
2342
+ return;
2343
+ }
2344
+
2345
+ if (e.ctrlKey) {
2346
+ e.preventDefault(); // Prevent browser scaling
2347
+ const delta = e.deltaY;
2348
+ setZoomScale((prev) => {
2349
+ let newScale = prev - delta * 0.01;
2350
+ newScale = Math.max(0.25, Math.min(4, newScale));
2351
+ zoomScaleRef.current = newScale;
2352
+
2353
+ setZoomBadgeText(`${Math.round(newScale * 100)}% Zoom`);
2354
+ if (zoomBadgeTimer.current) clearTimeout(zoomBadgeTimer.current);
2355
+ zoomBadgeTimer.current = setTimeout(() => {
2356
+ setZoomBadgeText('');
2357
+ }, 1500);
2358
+
2359
+ return newScale;
2360
+ });
2361
+ return;
2362
+ }
2363
+
2364
+ if (isLockedRef.current) return;
2365
+ const cursorX = e.clientX;
2366
+ const isLeft = cursorX < windowWidthRef.current * 0.5;
2367
+ const delta = -e.deltaY;
2368
+ const step = delta > 0 ? 5 : -5;
2369
+
2370
+ if (isLeft) {
2371
+ commitBrightness(brightnessRef.current + (step / 100));
2372
+ } else {
2373
+ commitVolume(volumeRef.current + step);
2374
+ }
2375
+ };
2376
+
2377
+ if (isWeb() && typeof document !== 'undefined') {
2378
+ document.addEventListener('keydown', handleKeyDown);
2379
+ }
2380
+
2381
+ const node = playerRef.current;
2382
+ node?.addEventListener?.('wheel', handleWheel, { passive: false });
2383
+
2384
+ return () => {
2385
+ if (isWeb() && typeof document !== 'undefined') {
2386
+ document.removeEventListener('keydown', handleKeyDown);
2387
+ }
2388
+ node?.removeEventListener?.('wheel', handleWheel);
2389
+ };
2390
+ }, [visible, commitBrightness, commitVolume, isInlinePreview, onInlinePreviewWheel]);
2391
+
2392
+ const rawPlayerStream = audioOnlyUsesProxy ? audioOnlyStreamUrl : streamUrl;
2393
+ const playerStreamUrl = rawPlayerStream || '';
2394
+
2395
+ const nativeMediaOptions = useMemo(() => [
2396
+ `--user-agent=${USER_AGENT}`,
2397
+ `:http-user-agent=${USER_AGENT}`,
2398
+ ':network-caching=3000',
2399
+ ':live-caching=3000',
2400
+ ':drop-late-frames',
2401
+ ':skip-frames',
2402
+ // react-native-vlc-media-player currently omits the last mediaOptions entry.
2403
+ // Keep duplicate as sentinel so VLC receives all settings.
2404
+ ':live-caching=3000',
2405
+ ], []);
2406
+
2407
+ const nativeSource = useMemo(() => ({
2408
+ uri: playerStreamUrl,
2409
+ initType: 1,
2410
+ hwDecoderEnabled: 1,
2411
+ hwDecoderForced: 1,
2412
+ // Keep this source object stable while Android audio mode is toggled so
2413
+ // VLC does not release and reopen the active stream.
2414
+ mediaOptions: nativeMediaOptions,
2415
+ }), [playerStreamUrl, nativeMediaOptions]);
2416
+
2417
+ // Android DownloadManager / local storage can expose downloads as
2418
+ // content:// or file:// URIs. Media3/Expo (ExoPlayer) can consume both provider URIs
2419
+ // and local filesystem URIs directly with full hardware acceleration;
2420
+ // this VLC build's native local-string path cannot, so route local media to ExoVideoFallback.
2421
+ const useExoForAndroidLocalMedia = isAndroid()
2422
+ && isLocalMediaUri(playerStreamUrl);
2423
+
2424
+ if (!visible || !streamUrl) return null;
2425
+
2426
+ const exoFallback = (
2427
+ <ExoVideoFallback
2428
+ key={`exo-${playerStreamUrl}`}
2429
+ ref={vlcRef}
2430
+ streamUrl={playerStreamUrl}
2431
+ paused={!isPlaying}
2432
+ muted={muted || videoOnlyMode}
2433
+ volume={muted || videoOnlyMode ? 0 : volume}
2434
+ playbackRate={playbackRate}
2435
+ videoAspectRatio={aspectRatio}
2436
+ audioTrack={selectedAudioTrack}
2437
+ onTracksChanged={handleTracksChanged}
2438
+ onProgress={handleProgress}
2439
+ onPlaying={handleNativePlaying}
2440
+ onBuffering={handleWebBuffering}
2441
+ audioOnly={isAudioOnly}
2442
+ onEnded={handleEpisodeEnded}
2443
+ onError={handleWebError}
2444
+ />
2445
+ );
2446
+
2447
+ const videoPlayer = (
2448
+ <PlatformMediaSurface
2449
+ youtubeVideoId={youtubeVideoId}
2450
+ playerStreamUrl={playerStreamUrl}
2451
+ vlcRef={vlcRef}
2452
+ isPlaying={isPlaying}
2453
+ muted={muted}
2454
+ videoOnlyMode={videoOnlyMode}
2455
+ volume={volume}
2456
+ playbackRate={playbackRate}
2457
+ aspectRatio={aspectRatio}
2458
+ title={title}
2459
+ posterUrl={posterUrl}
2460
+ isLive={isLive}
2461
+ isAudioOnly={isAudioOnly}
2462
+ selectedAudioTrack={selectedAudioTrack}
2463
+ handleTracksChanged={handleTracksChanged}
2464
+ handleProgress={handleProgress}
2465
+ handleNativePlaying={handleNativePlaying}
2466
+ handleWebBuffering={handleWebBuffering}
2467
+ handleEpisodeEnded={handleEpisodeEnded}
2468
+ handleWebError={handleWebError}
2469
+ togglePlayPause={togglePlayPause}
2470
+ handleSeekByAction={handleSeekByAction}
2471
+ handlePlaybackRoute={handlePlaybackRoute}
2472
+ exoFallback={exoFallback}
2473
+ useExoForAndroidLocalMedia={useExoForAndroidLocalMedia}
2474
+ nativeSource={nativeSource}
2475
+ computedAspectRatio={computedAspectRatio}
2476
+ handleNativeLoadStart={handleNativeLoadStart}
2477
+ handleNativeOpen={handleNativeOpen}
2478
+ clearBufferingIndicator={clearBufferingIndicator}
2479
+ handleClose={handleClose}
2480
+ handleNativeBuffering={handleNativeBuffering}
2481
+ isPlayingRef={isPlayingRef}
2482
+ setIsPlaying={setIsPlaying}
2483
+ />
2484
+ );
2485
+ const isValidPreviewRect = isUsableInlinePreviewRect(inlinePreviewRect);
2486
+ const inlinePreviewPositionStyle = getInlinePreviewPositionStyle(inlinePreviewRect, isValidPreviewRect);
2487
+
2488
+ if (isInlinePreview) {
2489
+ const liveChatDrawer = showLiveChat ? (
2490
+ <LiveChatDrawer
2491
+ videoId={mediaId || title || 'live'}
2492
+ userId={currentUser.id}
2493
+ username={currentUser.username}
2494
+ visible={showLiveChat}
2495
+ onClose={() => setShowLiveChat(false)}
2496
+ isLandscape={windowWidth >= windowHeight}
2497
+ initialTab="chat"
2498
+ streamUrl={playbackUrl || streamUrl || ''}
2499
+ serverUrl={playbackUrl || streamUrl || ''}
2500
+ isLive={isLive}
2501
+ isLiveCommentsEnabled={isLiveCommentsEnabled}
2502
+ isEpgEnabled={isEpgEnabled}
2503
+ diagnosticsEnabled={diagnosticsOverlayEnabled}
2504
+ title={title}
2505
+ streamId={mediaId}
2506
+ popupMode
2507
+ integrations={integrations}
2508
+ colors={colors}
2509
+ />
2510
+ ) : null;
2511
+ return (
2512
+ <InlinePreviewFrame
2513
+ hostRef={handlePlayerHostRef}
2514
+ isValidPreviewRect={isValidPreviewRect}
2515
+ positionStyle={inlinePreviewPositionStyle}
2516
+ videoPlayer={videoPlayer}
2517
+ title={title}
2518
+ onPromotePreview={onPromotePreview}
2519
+ controls={controls}
2520
+ colors={colors}
2521
+ muted={muted}
2522
+ videoOnlyMode={videoOnlyMode}
2523
+ handleMuteAction={handleMuteAction}
2524
+ isPlaying={isPlaying}
2525
+ handlePlayPauseAction={handlePlayPauseAction}
2526
+ showInlineChatButton={showInlineChatButton}
2527
+ showLiveChat={showLiveChat}
2528
+ handlePanelAction={handlePanelAction}
2529
+ isFullscreen={isFullscreen}
2530
+ handleFullscreenAction={handleFullscreenAction}
2531
+ invokeAction={invokeAction}
2532
+ mediaId={mediaId}
2533
+ isLoading={isLoading}
2534
+ errorMessage={errorMessage}
2535
+ >
2536
+ {liveChatDrawer}
2537
+ </InlinePreviewFrame>
2538
+ );
2539
+ }
2540
+
2541
+ const fullscreenControlsProps = {
2542
+ insets,
2543
+ scale,
2544
+ title,
2545
+ episodeLabel,
2546
+ isLive,
2547
+ isScreenRecorderEnabled,
2548
+ recStatus,
2549
+ isLoading,
2550
+ showLiveChat,
2551
+ drawerTab,
2552
+ isLiveCommentsEnabled,
2553
+ isEpgEnabled,
2554
+ diagnosticsOverlayEnabled,
2555
+ muted,
2556
+ controls,
2557
+ onClose: handleBackAction,
2558
+ handleRecordingAction,
2559
+ handleStartRecording,
2560
+ handleResumeRecording,
2561
+ handlePauseRecording,
2562
+ handleStopRecording,
2563
+ handlePanelAction,
2564
+ handleRestartAction,
2565
+ handleMuteAction,
2566
+ handleLockAction,
2567
+ handleMinimizeAction,
2568
+ isAudioOnly,
2569
+ isPlaying,
2570
+ handleSeekByAction,
2571
+ handlePlayPauseAction,
2572
+ isSeeking,
2573
+ sliderPos,
2574
+ currentTime,
2575
+ duration,
2576
+ isAudioOnlyFeatureEnabled,
2577
+ videoOnlyMode,
2578
+ showAspectPicker,
2579
+ aspectRatio,
2580
+ showSpeedPicker,
2581
+ playbackRate,
2582
+ showAudioPicker,
2583
+ audioTracks,
2584
+ selectedAudioTrack,
2585
+ isFullscreen,
2586
+ onSliderValueChange: (value) => {
2587
+ isSeeking.current = true;
2588
+ setSliderPos(value);
2589
+ },
2590
+ onSliderSlidingStart: () => { isSeeking.current = true; },
2591
+ onSliderSlidingComplete: (value) => {
2592
+ isSeeking.current = false;
2593
+ handleSeekAction(value);
2594
+ },
2595
+ onToggleAudioOnly: () => {
2596
+ setShowAspectPicker(false);
2597
+ setShowSpeedPicker(false);
2598
+ setShowAudioPicker(false);
2599
+ handleAudioOnlyAction();
2600
+ },
2601
+ onToggleAspectPicker: () => {
2602
+ setShowSpeedPicker(false);
2603
+ setShowAudioPicker(false);
2604
+ setShowAspectPicker((previous) => !previous);
2605
+ },
2606
+ onSelectAspectRatio: (value) => {
2607
+ handleAspectRatioAction(value);
2608
+ setShowAspectPicker(false);
2609
+ scheduleHide();
2610
+ },
2611
+ onToggleSpeedPicker: () => {
2612
+ setShowAspectPicker(false);
2613
+ setShowAudioPicker(false);
2614
+ setShowSpeedPicker((previous) => !previous);
2615
+ },
2616
+ handleSpeedSelect,
2617
+ onToggleAudioPicker: () => {
2618
+ setShowAspectPicker(false);
2619
+ setShowSpeedPicker(false);
2620
+ setShowAudioPicker((previous) => !previous);
2621
+ },
2622
+ handleAudioTrackAction,
2623
+ handleFullscreenAction,
2624
+ handleVideoOnlyAction,
2625
+ };
2626
+
2627
+ const fullscreenContent = (
2628
+ <View
2629
+ ref={handlePlayerHostRef}
2630
+ collapsable={false}
2631
+ style={[styles.fullscreenPlayerContainer, style]}
2632
+ >
2633
+ <StatusBar hidden={!showControls} translucent backgroundColor="transparent" barStyle="light-content" />
2634
+
2635
+ <FullscreenVideoLayer videoPlayer={videoPlayer} zoomScale={zoomScale} isAudioOnly={isAudioOnly} />
2636
+ <FullscreenVisualFeedback
2637
+ isAudioOnly={isAudioOnly}
2638
+ isWebPlatform={isWeb()}
2639
+ brightness={brightness}
2640
+ zoomBadgeText={zoomBadgeText}
2641
+ seekRipple={seekRipple}
2642
+ />
2643
+ <FullscreenGestureLayer
2644
+ isAudioOnly={isAudioOnly}
2645
+ panResponder={panResponder}
2646
+ handlers={{
2647
+ onPointerDown: handleWebPointerDown,
2648
+ onPointerMove: handleWebPointerMove,
2649
+ onPointerUp: handleWebPointerUp,
2650
+ onMouseDown: handleWebMouseDown,
2651
+ onTouchStart: handleWebTouchStart,
2652
+ onTouchMove: handleWebTouchMove,
2653
+ onTouchEnd: handleWebTouchEnd,
2654
+ }}
2655
+ />
2656
+
2657
+ {/* Controls Layer */}
2658
+ <View style={[StyleSheet.absoluteFill, { zIndex: 60, elevation: 60 }]} pointerEvents="box-none">
2659
+ <FullscreenControlsLayer showControls={showControls} isLocked={isLocked} isAudioOnly={isAudioOnly}>
2660
+ <FullscreenControlsPanel {...fullscreenControlsProps} />
2661
+ </FullscreenControlsLayer>
2662
+ </View>
2663
+
2664
+ <FullscreenStatusLayer
2665
+ isAudioOnly={isAudioOnly}
2666
+ audioOnlyProps={{ posterUrl, title, episodeLabel, isPlaying, windowWidth, windowHeight, usesAudioProxy: audioOnlyUsesProxy, onToggleAudioOnly: toggleAudioOnly }}
2667
+ isLoading={isLoading}
2668
+ errorMessage={errorMessage}
2669
+ scale={scale}
2670
+ handleClose={handleClose}
2671
+ />
2672
+ <FullscreenChatLayer
2673
+ visible={showLiveChat}
2674
+ isAudioOnly={isAudioOnly}
2675
+ mediaId={mediaId}
2676
+ title={title}
2677
+ currentUser={currentUser}
2678
+ onClose={() => setShowLiveChat(false)}
2679
+ isLandscape={windowWidth >= windowHeight}
2680
+ drawerTab={drawerTab}
2681
+ playbackUrl={playbackUrl}
2682
+ streamUrl={streamUrl}
2683
+ isLive={isLive}
2684
+ isLiveCommentsEnabled={isLiveCommentsEnabled}
2685
+ isEpgEnabled={isEpgEnabled}
2686
+ diagnosticsEnabled={diagnosticsOverlayEnabled}
2687
+ integrations={integrations}
2688
+ colors={colors}
2689
+ />
2690
+ <FullscreenRecordingLayer
2691
+ isAudioOnly={isAudioOnly}
2692
+ isLive={isLive}
2693
+ isScreenRecorderEnabled={isScreenRecorderEnabled}
2694
+ recStatus={recStatus}
2695
+ recElapsedMs={recElapsedMs}
2696
+ colors={colors}
2697
+ insets={insets}
2698
+ handlePauseRecording={handlePauseRecording}
2699
+ handleResumeRecording={handleResumeRecording}
2700
+ handleStopRecording={handleStopRecording}
2701
+ recNotice={recNotice}
2702
+ onDismissNotice={() => setRecNotice(null)}
2703
+ />
2704
+ <FullscreenSessionLayer
2705
+ locked={isLocked}
2706
+ controlsVisible={showControls}
2707
+ isAudioOnly={isAudioOnly}
2708
+ insets={insets}
2709
+ scale={scale}
2710
+ onToggleLock={toggleLock}
2711
+ />
2712
+ </View>
2713
+ );
2714
+
2715
+ if (!isWeb()) {
2716
+ return (
2717
+ <Modal
2718
+ visible={visible}
2719
+ animationType="none"
2720
+ transparent={false}
2721
+ statusBarTranslucent={true}
2722
+ hardwareAccelerated={true}
2723
+ onRequestClose={handleBackAction}
2724
+ supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
2725
+ >
2726
+ {fullscreenContent}
2727
+ </Modal>
2728
+ );
2729
+ }
2730
+
2731
+ return (
2732
+ <View
2733
+ ref={handlePlayerHostRef}
2734
+ collapsable={false}
2735
+ style={[
2736
+ styles.playerHost,
2737
+ styles.playerHostFullscreen,
2738
+ styles.playerHostWeb,
2739
+ style,
2740
+ ]}
2741
+ >
2742
+ {fullscreenContent}
2743
+ </View>
2744
+ );
2745
+ };
2746
+
2747
+ const styles = StyleSheet.create({
2748
+ container: {
2749
+ flex: 1,
2750
+ backgroundColor: '#000',
2751
+ },
2752
+ playerHost: {
2753
+ position: 'absolute',
2754
+ top: 0,
2755
+ left: 0,
2756
+ zIndex: 99999,
2757
+ elevation: 99999,
2758
+ backgroundColor: 'transparent',
2759
+ overflow: 'hidden',
2760
+ },
2761
+ playerHostFullscreen: {
2762
+ ...StyleSheet.absoluteFillObject,
2763
+ width: '100%',
2764
+ height: '100%',
2765
+ backgroundColor: '#000',
2766
+ zIndex: 99999,
2767
+ elevation: 99999,
2768
+ },
2769
+ fullscreenPlayerContainer: {
2770
+ flex: 1,
2771
+ width: '100%',
2772
+ height: '100%',
2773
+ backgroundColor: '#000',
2774
+ position: 'relative',
2775
+ overflow: 'hidden',
2776
+ },
2777
+ inlineVideoStage: {
2778
+ width: '100%',
2779
+ height: '100%',
2780
+ overflow: 'hidden',
2781
+ borderRadius: 14,
2782
+ position: 'relative',
2783
+ },
2784
+ playerHostInline: {
2785
+ position: 'absolute',
2786
+ top: 0,
2787
+ left: 0,
2788
+ right: null,
2789
+ bottom: null,
2790
+ backgroundColor: '#000',
2791
+ borderRadius: 14,
2792
+ overflow: 'hidden',
2793
+ zIndex: 120,
2794
+ elevation: 0,
2795
+ },
2796
+ playerHostWeb: {
2797
+ position: 'fixed',
2798
+ top: 0,
2799
+ left: 0,
2800
+ right: 0,
2801
+ bottom: 0,
2802
+ width: '100%',
2803
+ height: '100%',
2804
+ zIndex: 100000,
2805
+ overflow: 'hidden',
2806
+ },
2807
+ videoContainer: {
2808
+ flex: 1,
2809
+ backgroundColor: '#000',
2810
+ justifyContent: 'center',
2811
+ alignItems: 'center',
2812
+ },
2813
+ inlinePreviewChrome: {
2814
+ ...StyleSheet.absoluteFillObject,
2815
+ justifyContent: 'space-between',
2816
+ padding: 10,
2817
+ zIndex: 6,
2818
+ },
2819
+ inlinePreviewTopRow: {
2820
+ flexDirection: 'row',
2821
+ alignItems: 'flex-start',
2822
+ gap: 8,
2823
+ zIndex: 4,
2824
+ },
2825
+ inlinePreviewCenterControls: {
2826
+ ...StyleSheet.absoluteFillObject,
2827
+ alignItems: 'center',
2828
+ justifyContent: 'center',
2829
+ zIndex: 4,
2830
+ },
2831
+ inlinePreviewCenterButton: {
2832
+ width: 58,
2833
+ height: 58,
2834
+ borderRadius: 29,
2835
+ alignItems: 'center',
2836
+ justifyContent: 'center',
2837
+ paddingLeft: 2,
2838
+ backgroundColor: 'rgba(12, 18, 28, 0.76)',
2839
+ borderWidth: 1.5,
2840
+ borderColor: 'rgba(255,255,255,0.82)',
2841
+ },
2842
+ inlineLiveBadge: {
2843
+ flexDirection: 'row',
2844
+ alignItems: 'center',
2845
+ gap: 6,
2846
+ backgroundColor: 'rgba(183, 40, 47, 0.92)',
2847
+ paddingHorizontal: 9,
2848
+ paddingVertical: 5,
2849
+ borderRadius: 7,
2850
+ },
2851
+ inlineLiveDot: {
2852
+ width: 7,
2853
+ height: 7,
2854
+ borderRadius: 4,
2855
+ backgroundColor: '#FFF',
2856
+ },
2857
+ inlineLiveText: {
2858
+ color: '#FFF',
2859
+ fontSize: 11,
2860
+ fontWeight: '800',
2861
+ letterSpacing: 0.5,
2862
+ },
2863
+ inlinePreviewIconButton: {
2864
+ width: 36,
2865
+ height: 36,
2866
+ alignItems: 'center',
2867
+ justifyContent: 'center',
2868
+ borderRadius: 18,
2869
+ backgroundColor: 'rgba(12, 18, 28, 0.72)',
2870
+ borderWidth: 1,
2871
+ borderColor: 'rgba(255,255,255,0.2)',
2872
+ },
2873
+ inlinePreviewBottomRow: {
2874
+ flexDirection: 'row',
2875
+ alignItems: 'center',
2876
+ justifyContent: 'space-between',
2877
+ gap: 8,
2878
+ zIndex: 4,
2879
+ },
2880
+ inlinePreviewActions: {
2881
+ flexDirection: 'row',
2882
+ alignItems: 'center',
2883
+ gap: 8,
2884
+ },
2885
+ inlinePreviewTitle: {
2886
+ flex: 1,
2887
+ color: '#FFF',
2888
+ fontSize: 13,
2889
+ fontWeight: '700',
2890
+ },
2891
+ inlineChatButton: {
2892
+ flexDirection: 'row',
2893
+ alignItems: 'center',
2894
+ gap: 6,
2895
+ backgroundColor: 'rgba(12, 18, 28, 0.78)',
2896
+ borderWidth: 1,
2897
+ borderColor: 'rgba(255,255,255,0.24)',
2898
+ paddingHorizontal: 10,
2899
+ paddingVertical: 7,
2900
+ borderRadius: 16,
2901
+ },
2902
+ inlineChatButtonText: {
2903
+ color: '#FFF',
2904
+ fontSize: 12,
2905
+ fontWeight: '700',
2906
+ },
2907
+ inlinePreviewLoading: {
2908
+ ...StyleSheet.absoluteFillObject,
2909
+ alignItems: 'center',
2910
+ justifyContent: 'center',
2911
+ zIndex: 2,
2912
+ },
2913
+ inlinePreviewLoadingPill: {
2914
+ flexDirection: 'row',
2915
+ alignItems: 'center',
2916
+ gap: 9,
2917
+ paddingHorizontal: 13,
2918
+ paddingVertical: 9,
2919
+ borderRadius: 24,
2920
+ backgroundColor: 'rgba(4, 10, 18, 0.78)',
2921
+ borderWidth: 1,
2922
+ borderColor: 'rgba(255,255,255,0.24)',
2923
+ },
2924
+ inlinePreviewLoadingText: {
2925
+ color: '#FFF',
2926
+ fontSize: 12,
2927
+ fontWeight: '700',
2928
+ },
2929
+ inlinePreviewError: {
2930
+ ...StyleSheet.absoluteFillObject,
2931
+ alignItems: 'center',
2932
+ justifyContent: 'center',
2933
+ gap: 6,
2934
+ backgroundColor: 'rgba(0,0,0,0.55)',
2935
+ zIndex: 8,
2936
+ },
2937
+ inlinePreviewErrorText: {
2938
+ color: '#FFF',
2939
+ fontSize: 12,
2940
+ fontWeight: '700',
2941
+ },
2942
+ videoWrapFullscreen: {
2943
+ width: '100%',
2944
+ height: '100%',
2945
+ flex: 1,
2946
+ },
2947
+ video: {
2948
+ width: '100%',
2949
+ height: '100%',
2950
+ },
2951
+ controlsShell: {
2952
+ flex: 1,
2953
+ backgroundColor: 'transparent',
2954
+ zIndex: 65,
2955
+ elevation: 65,
2956
+ },
2957
+ brightnessDimOverlay: {
2958
+ position: 'absolute',
2959
+ top: 0,
2960
+ left: 0,
2961
+ right: 0,
2962
+ bottom: 0,
2963
+ width: '100%',
2964
+ height: '100%',
2965
+ backgroundColor: '#000000',
2966
+ zIndex: 15,
2967
+ elevation: 15,
2968
+ },
2969
+ zoomBadge: {
2970
+ position: 'absolute',
2971
+ top: 30,
2972
+ alignSelf: 'center',
2973
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
2974
+ paddingHorizontal: 16,
2975
+ paddingVertical: 8,
2976
+ borderRadius: 20,
2977
+ borderWidth: 1,
2978
+ borderColor: 'rgba(255, 255, 255, 0.2)',
2979
+ zIndex: 9999,
2980
+ },
2981
+ zoomBadgeText: {
2982
+ color: '#FFFFFF',
2983
+ fontSize: 14,
2984
+ fontWeight: 'bold',
2985
+ },
2986
+ seekRippleOverlay: {
2987
+ position: 'absolute',
2988
+ top: 0,
2989
+ bottom: 0,
2990
+ width: '40%',
2991
+ justifyContent: 'center',
2992
+ alignItems: 'center',
2993
+ zIndex: 9998,
2994
+ },
2995
+ seekRippleLeft: {
2996
+ left: 0,
2997
+ borderTopRightRadius: 200,
2998
+ borderBottomRightRadius: 200,
2999
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
3000
+ },
3001
+ seekRippleRight: {
3002
+ right: 0,
3003
+ borderTopLeftRadius: 200,
3004
+ borderBottomLeftRadius: 200,
3005
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
3006
+ },
3007
+ seekRippleCircle: {
3008
+ alignItems: 'center',
3009
+ justifyContent: 'center',
3010
+ backgroundColor: 'rgba(0, 0, 0, 0.65)',
3011
+ paddingHorizontal: 22,
3012
+ paddingVertical: 14,
3013
+ borderRadius: 28,
3014
+ },
3015
+ seekRippleText: {
3016
+ color: '#FFFFFF',
3017
+ fontWeight: 'bold',
3018
+ fontSize: 14,
3019
+ marginTop: 4,
3020
+ },
3021
+ gestureCatcher: {
3022
+ backgroundColor: 'rgba(0, 0, 0, 0.01)',
3023
+ zIndex: 50,
3024
+ elevation: 50,
3025
+ ...(isWeb()
3026
+ ? {
3027
+ touchAction: 'none',
3028
+ userSelect: 'none',
3029
+ }
3030
+ : {}),
3031
+ },
3032
+ loadingLayer: {
3033
+ position: 'absolute',
3034
+ top: 0,
3035
+ left: 0,
3036
+ right: 0,
3037
+ bottom: 0,
3038
+ width: '100%',
3039
+ height: '100%',
3040
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
3041
+ alignItems: 'center',
3042
+ justifyContent: 'center',
3043
+ gap: 14,
3044
+ zIndex: 999,
3045
+ elevation: 999,
3046
+ },
3047
+ loadingText: {
3048
+ color: '#FFFFFF',
3049
+ letterSpacing: 0.3,
3050
+ },
3051
+ centeredOverlay: {
3052
+ ...StyleSheet.absoluteFillObject,
3053
+ alignItems: 'center',
3054
+ justifyContent: 'center',
3055
+ backgroundColor: 'rgba(0, 0, 0, 0.85)',
3056
+ gap: 12,
3057
+ zIndex: 100,
3058
+ },
3059
+ errorTitle: {
3060
+ color: '#FFF',
3061
+ },
3062
+ errorMsg: {
3063
+ color: '#CCC',
3064
+ textAlign: 'center',
3065
+ },
3066
+ errorBtn: {
3067
+ marginTop: 8,
3068
+ backgroundColor: '#E53935',
3069
+ borderRadius: 20,
3070
+ paddingHorizontal: 24,
3071
+ paddingVertical: 10,
3072
+ },
3073
+ errorBtnText: {
3074
+ color: '#FFF',
3075
+ },
3076
+ floatingLockBtn: {
3077
+ position: 'absolute',
3078
+ flexDirection: 'row',
3079
+ alignItems: 'center',
3080
+ gap: 6,
3081
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
3082
+ borderColor: '#FF5252',
3083
+ borderWidth: 1.5,
3084
+ paddingHorizontal: 16,
3085
+ paddingVertical: 8,
3086
+ borderRadius: 20,
3087
+ zIndex: 400,
3088
+ elevation: 400,
3089
+ },
3090
+ floatingLockText: {
3091
+ color: '#FFF',
3092
+ },
3093
+ });