@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,120 @@
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { WebView } from 'react-native-webview';
4
+ import { buildYouTubePlayerHtml } from '../../utils/youtubeHtml';
5
+
6
+ const stateName = (value) => ({ 0: 'ended', 1: 'playing', 2: 'paused', 3: 'buffering', 5: 'cued' })[value] || 'unstarted';
7
+
8
+ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
9
+ videoId,
10
+ paused = false,
11
+ muted = false,
12
+ volume = 1,
13
+ playbackRate = 1,
14
+ onReady,
15
+ onProgress,
16
+ onPlaying,
17
+ onBuffering,
18
+ onStateChange,
19
+ onError,
20
+ onEnded,
21
+ }, ref) {
22
+ const webViewRef = useRef(null);
23
+ const handlersRef = useRef({});
24
+ const latestPropsRef = useRef({});
25
+ const readyRef = useRef(false);
26
+ handlersRef.current = { onReady, onProgress, onPlaying, onBuffering, onStateChange, onError, onEnded };
27
+ latestPropsRef.current = { paused, muted, volume, playbackRate };
28
+
29
+ const sendCommand = useCallback((name, value) => {
30
+ const serialized = JSON.stringify(value ?? null);
31
+ webViewRef.current?.injectJavaScript(
32
+ `window.cinecrewPlayerCommand && window.cinecrewPlayerCommand(${JSON.stringify(name)}, ${serialized}); true;`
33
+ );
34
+ }, []);
35
+
36
+ const handleMessage = useCallback((event) => {
37
+ let message;
38
+ try { message = JSON.parse(event.nativeEvent.data); } catch { return; }
39
+ const handlers = handlersRef.current;
40
+ if (message.type === 'ready') {
41
+ readyRef.current = true;
42
+ handlers.onBuffering?.(false);
43
+ handlers.onReady?.({ youtube: true });
44
+ const latest = latestPropsRef.current;
45
+ sendCommand(latest.paused ? 'pause' : 'play');
46
+ sendCommand(latest.muted ? 'mute' : 'unmute');
47
+ sendCommand('volume', Math.round(Math.max(0, Math.min(1, latest.volume)) * 100));
48
+ sendCommand('rate', latest.playbackRate);
49
+ return;
50
+ }
51
+ if (message.type === 'state') {
52
+ const state = stateName(Number(message.data?.state));
53
+ handlers.onStateChange?.(state);
54
+ handlers.onBuffering?.(state === 'buffering');
55
+ if (state === 'playing') handlers.onPlaying?.({ state, youtube: true });
56
+ if (state === 'ended') handlers.onEnded?.();
57
+ return;
58
+ }
59
+ if (message.type === 'progress') {
60
+ const currentTime = Number(message.data?.currentTime) || 0;
61
+ const duration = Number(message.data?.duration) || 0;
62
+ handlers.onProgress?.({ currentTime: currentTime * 1000, duration: duration * 1000, target: currentTime });
63
+ return;
64
+ }
65
+ if (message.type === 'error') {
66
+ handlers.onError?.({ code: message.data?.code, message: `YouTube playback failed (${message.data?.code}).` });
67
+ }
68
+ }, [sendCommand]);
69
+
70
+ useEffect(() => { if (readyRef.current) sendCommand(paused ? 'pause' : 'play'); }, [paused, sendCommand]);
71
+ useEffect(() => {
72
+ if (!readyRef.current) return;
73
+ sendCommand(muted ? 'mute' : 'unmute');
74
+ sendCommand('volume', Math.round(Math.max(0, Math.min(1, volume)) * 100));
75
+ }, [muted, volume, sendCommand]);
76
+ useEffect(() => { if (readyRef.current) sendCommand('rate', playbackRate); }, [playbackRate, sendCommand]);
77
+
78
+ const validVideoId = /^[\w-]{11}$/.test(String(videoId || ''));
79
+ const html = useMemo(
80
+ () => validVideoId ? buildYouTubePlayerHtml(videoId, !paused) : '<!doctype html><html><body style="background:#000"></body></html>',
81
+ [validVideoId, videoId],
82
+ );
83
+ const webViewSource = useMemo(() => ({ html, baseUrl: 'https://www.youtube.com' }), [html]);
84
+
85
+ useEffect(() => {
86
+ if (!validVideoId) handlersRef.current.onError?.(new Error('A valid YouTube video ID is required.'));
87
+ }, [validVideoId]);
88
+
89
+ useImperativeHandle(ref, () => ({
90
+ play: () => sendCommand('play'),
91
+ resume: () => sendCommand('play'),
92
+ pause: () => sendCommand('pause'),
93
+ seek: (ratio) => sendCommand('seekRatio', Math.max(0, Math.min(1, Number(ratio) || 0))),
94
+ seekTo: (seconds) => sendCommand('seek', Math.max(0, Number(seconds) || 0)),
95
+ getVideoElement: () => null,
96
+ }), [sendCommand]);
97
+
98
+ return React.createElement(View, { style: styles.container }, React.createElement(WebView, {
99
+ ref: webViewRef,
100
+ source: webViewSource,
101
+ originWhitelist: ['*'],
102
+ javaScriptEnabled: true,
103
+ domStorageEnabled: true,
104
+ allowsInlineMediaPlayback: true,
105
+ mediaPlaybackRequiresUserAction: false,
106
+ allowsFullscreenVideo: false,
107
+ scrollEnabled: false,
108
+ bounces: false,
109
+ onMessage: handleMessage,
110
+ onError: (event) => handlersRef.current.onError?.(event.nativeEvent || event),
111
+ style: styles.webView,
112
+ }));
113
+ });
114
+
115
+ const styles = StyleSheet.create({
116
+ container: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
117
+ webView: { flex: 1, backgroundColor: '#000' },
118
+ });
119
+
120
+ export default YouTubeVideoPlayer;
@@ -0,0 +1,169 @@
1
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
2
+
3
+ let youtubeApiPromise;
4
+ function loadYouTubeApi() {
5
+ if (typeof window === 'undefined') return Promise.reject(new Error('YouTube playback requires a browser.'));
6
+ if (window.YT?.Player) return Promise.resolve(window.YT);
7
+ if (!youtubeApiPromise) {
8
+ youtubeApiPromise = new Promise((resolve, reject) => {
9
+ const existing = document.querySelector('script[data-cinecrew-youtube-api]');
10
+ const previousReady = window.onYouTubeIframeAPIReady;
11
+ window.onYouTubeIframeAPIReady = () => {
12
+ try { previousReady?.(); } catch {}
13
+ resolve(window.YT);
14
+ };
15
+ if (!existing) {
16
+ const script = document.createElement('script');
17
+ script.src = 'https://www.youtube.com/iframe_api';
18
+ script.async = true;
19
+ script.dataset.cinecrewYoutubeApi = 'true';
20
+ script.onerror = () => reject(new Error('Could not load the YouTube player API.'));
21
+ document.head.appendChild(script);
22
+ }
23
+ });
24
+ }
25
+ return youtubeApiPromise;
26
+ }
27
+
28
+ const stateName = (value) => ({ 0: 'ended', 1: 'playing', 2: 'paused', 3: 'buffering', 5: 'cued' })[value] || 'unstarted';
29
+
30
+ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
31
+ videoId,
32
+ paused = false,
33
+ muted = false,
34
+ volume = 1,
35
+ playbackRate = 1,
36
+ onReady,
37
+ onProgress,
38
+ onPlaying,
39
+ onBuffering,
40
+ onStateChange,
41
+ onError,
42
+ onEnded,
43
+ }, ref) {
44
+ const hostRef = useRef(null);
45
+ const playerRef = useRef(null);
46
+ const latestPropsRef = useRef({});
47
+ const onReadyRef = useRef(onReady);
48
+ const onProgressRef = useRef(onProgress);
49
+ const onPlayingRef = useRef(onPlaying);
50
+ const onBufferingRef = useRef(onBuffering);
51
+ const onStateChangeRef = useRef(onStateChange);
52
+ const onErrorRef = useRef(onError);
53
+ const onEndedRef = useRef(onEnded);
54
+ const readyRef = useRef(false);
55
+ latestPropsRef.current = { paused, muted, volume, playbackRate };
56
+ const stateRef = useRef('unstarted');
57
+ onReadyRef.current = onReady;
58
+ onProgressRef.current = onProgress;
59
+ onPlayingRef.current = onPlaying;
60
+ onBufferingRef.current = onBuffering;
61
+ onStateChangeRef.current = onStateChange;
62
+ onErrorRef.current = onError;
63
+ onEndedRef.current = onEnded;
64
+
65
+ useEffect(() => {
66
+ let cancelled = false;
67
+ let progressTimer;
68
+ let player;
69
+ loadYouTubeApi().then((YT) => {
70
+ if (cancelled || !hostRef.current) return;
71
+ player = new YT.Player(hostRef.current, {
72
+ width: '100%',
73
+ height: '100%',
74
+ videoId,
75
+ playerVars: {
76
+ autoplay: paused ? 0 : 1,
77
+ controls: 0,
78
+ playsinline: 1,
79
+ enablejsapi: 1,
80
+ rel: 0,
81
+ modestbranding: 1,
82
+ fs: 0,
83
+ origin: window.location.origin,
84
+ },
85
+ events: {
86
+ onReady: (event) => {
87
+ if (cancelled) return;
88
+ playerRef.current = event.target;
89
+ readyRef.current = true;
90
+ const latest = latestPropsRef.current;
91
+ event.target.setVolume(Math.round(Math.max(0, Math.min(1, latest.volume)) * 100));
92
+ if (latest.muted) event.target.mute();
93
+ if (latest.paused) event.target.pauseVideo();
94
+ else event.target.playVideo();
95
+ event.target.setPlaybackRate?.(Number(latest.playbackRate) || 1);
96
+ onBufferingRef.current?.(false);
97
+ onReadyRef.current?.(event.target);
98
+ progressTimer = setInterval(() => {
99
+ const target = playerRef.current;
100
+ if (!target || stateRef.current !== 'playing') return;
101
+ const currentTime = Number(target.getCurrentTime?.()) || 0;
102
+ const duration = Number(target.getDuration?.()) || 0;
103
+ onProgressRef.current?.({ currentTime: currentTime * 1000, duration: duration * 1000, target: currentTime });
104
+ }, 500);
105
+ },
106
+ onStateChange: (event) => {
107
+ const state = stateName(event.data);
108
+ stateRef.current = state;
109
+ onStateChangeRef.current?.(state);
110
+ onBufferingRef.current?.(state === 'buffering');
111
+ if (state === 'playing') onPlayingRef.current?.(event);
112
+ if (state === 'ended') onEndedRef.current?.();
113
+ },
114
+ onError: (event) => onErrorRef.current?.({ code: event.data, message: `YouTube playback failed (${event.data}).` }),
115
+ },
116
+ });
117
+ playerRef.current = player;
118
+ }).catch((error) => {
119
+ if (!cancelled) onErrorRef.current?.(error);
120
+ });
121
+ return () => {
122
+ cancelled = true;
123
+ clearInterval(progressTimer);
124
+ readyRef.current = false;
125
+ try { player?.destroy?.(); } catch {}
126
+ playerRef.current = null;
127
+ };
128
+ }, [videoId]);
129
+
130
+ useEffect(() => {
131
+ const player = playerRef.current;
132
+ if (!player || !readyRef.current) return;
133
+ if (paused) player.pauseVideo?.();
134
+ else player.playVideo?.();
135
+ }, [paused, videoId]);
136
+
137
+ useEffect(() => {
138
+ const player = playerRef.current;
139
+ if (!player || !readyRef.current) return;
140
+ if (muted) player.mute?.();
141
+ else player.unMute?.();
142
+ player.setVolume?.(Math.round(Math.max(0, Math.min(1, volume)) * 100));
143
+ }, [muted, volume, videoId]);
144
+
145
+ useEffect(() => {
146
+ if (readyRef.current) playerRef.current?.setPlaybackRate?.(Number(playbackRate) || 1);
147
+ }, [playbackRate, videoId]);
148
+
149
+ useImperativeHandle(ref, () => ({
150
+ play: () => playerRef.current?.playVideo?.(),
151
+ resume: () => playerRef.current?.playVideo?.(),
152
+ pause: () => playerRef.current?.pauseVideo?.(),
153
+ seek: (ratio) => {
154
+ const player = playerRef.current;
155
+ const duration = Number(player?.getDuration?.()) || 0;
156
+ if (duration > 0) player?.seekTo?.(duration * Math.max(0, Math.min(1, Number(ratio) || 0)), true);
157
+ },
158
+ seekTo: (seconds) => playerRef.current?.seekTo?.(Math.max(0, Number(seconds) || 0), true),
159
+ getVideoElement: () => null,
160
+ }), []);
161
+
162
+ return React.createElement('div', {
163
+ ref: hostRef,
164
+ className: 'cinecrew-player__youtube',
165
+ style: { position: 'absolute', inset: 0, width: '100%', height: '100%', background: '#000' },
166
+ });
167
+ });
168
+
169
+ export default YouTubeVideoPlayer;
@@ -0,0 +1,337 @@
1
+ import { useMemo } from 'react';
2
+ import { StyleSheet, View, Text, TouchableOpacity, Image } from 'react-native';
3
+ import { isAndroid, isIOS, isWeb } from '../../../utils/runtimePlatform';
4
+ import { cleanPlayerTitle } from '../../../utils/mediaUtils';
5
+ import { PlayerIcon, usePlayerColors } from '../../customization';
6
+
7
+ function shouldDisplayEpisodeSubtitle(isMobile, episodeLabel, displayTitle) {
8
+ if (isMobile || !episodeLabel) return false;
9
+ const episode = episodeLabel.trim().toLowerCase();
10
+ const title = displayTitle.toLowerCase();
11
+ return Boolean(episode && title !== episode && !title.includes(episode));
12
+ }
13
+
14
+ function AudioArtwork({ posterUrl, isLandscape, palette }) {
15
+ if (posterUrl) {
16
+ return (
17
+ <View style={[styles.audioOnlyPosterWrap, isLandscape && styles.audioOnlyPosterWrapLandscape]}>
18
+ <Image source={{ uri: posterUrl }} style={styles.audioOnlyPoster} resizeMode="cover" />
19
+ </View>
20
+ );
21
+ }
22
+ return (
23
+ <View style={[styles.audioOnlyIconWrap, isLandscape && styles.audioOnlyIconWrapLandscape]}>
24
+ <PlayerIcon name="headphones" size={isLandscape ? 36 : 54} color={palette.accentColor} />
25
+ </View>
26
+ );
27
+ }
28
+
29
+ function SoundWave({ isPlaying, isLandscape, accentColor }) {
30
+ const animationBars = [
31
+ ['short-left', styles.soundWaveBarAnim1],
32
+ ['medium-left', styles.soundWaveBarAnim2],
33
+ ['tall-left', styles.soundWaveBarAnim3],
34
+ ['peak', styles.soundWaveBarAnim4],
35
+ ['tall-right', styles.soundWaveBarAnim5],
36
+ ['medium-right', styles.soundWaveBarAnim2],
37
+ ['short-right', styles.soundWaveBarAnim4],
38
+ ];
39
+ return (
40
+ <View style={[styles.soundWaveRow, isLandscape && styles.soundWaveRowLandscape]}>
41
+ {animationBars.map(([key, animationStyle]) => (
42
+ <View
43
+ key={key}
44
+ style={[styles.soundWaveBar, { backgroundColor: accentColor }, isPlaying ? animationStyle : styles.soundWaveBarStatic]}
45
+ />
46
+ ))}
47
+ </View>
48
+ );
49
+ }
50
+
51
+ function AudioOnlyBackdrop({ posterUrl }) {
52
+ return (
53
+ <>
54
+ {posterUrl ? (
55
+ <Image
56
+ source={{ uri: posterUrl }}
57
+ style={[StyleSheet.absoluteFill, styles.audioOnlyBackdropImage]}
58
+ resizeMode="cover"
59
+ blurRadius={isWeb() ? 40 : 25}
60
+ pointerEvents="none"
61
+ />
62
+ ) : null}
63
+ <View style={styles.audioOnlyBackdropDim} pointerEvents="none" />
64
+ </>
65
+ );
66
+ }
67
+
68
+ function AudioOnlyBadge({ usesAudioProxy, palette, isLandscape }) {
69
+ const message = usesAudioProxy
70
+ ? 'Battery Saver Audio Mode • Screen can be locked'
71
+ : 'Audio Mode • Video hidden • Screen can be locked';
72
+ return (
73
+ <View style={[styles.audioOnlyBadge, isLandscape && styles.audioOnlyBadgeLandscape, { backgroundColor: palette.backgroundColor, borderColor: palette.borderColor }]}>
74
+ <PlayerIcon name="lightning-bolt" size={14} color={palette.accentColor} />
75
+ <Text style={[styles.audioOnlyBadgeText, { color: palette.accentColor }]}>{message}</Text>
76
+ </View>
77
+ );
78
+ }
79
+
80
+ export const AudioOnlyView = ({
81
+ posterUrl,
82
+ title,
83
+ episodeLabel,
84
+ isPlaying,
85
+ windowWidth,
86
+ windowHeight,
87
+ usesAudioProxy = false,
88
+ onToggleAudioOnly,
89
+ }) => {
90
+ const palette = usePlayerColors();
91
+ const isLandscape = windowWidth >= windowHeight;
92
+ const isMobile = isAndroid() || isIOS() || Math.min(windowWidth, windowHeight) < 600;
93
+
94
+ const displayTitle = useMemo(() => {
95
+ return cleanPlayerTitle(title, episodeLabel, isMobile);
96
+ }, [title, episodeLabel, isMobile]);
97
+
98
+ const showEpisodeSubtitle = useMemo(
99
+ () => shouldDisplayEpisodeSubtitle(isMobile, episodeLabel, displayTitle),
100
+ [isMobile, episodeLabel, displayTitle],
101
+ );
102
+
103
+ return (
104
+ <View style={[styles.audioOnlyContainer, { backgroundColor: palette.backgroundColor }]} pointerEvents="auto">
105
+ <AudioOnlyBackdrop posterUrl={posterUrl} />
106
+
107
+ <View style={[styles.audioOnlyCard, isLandscape && styles.audioOnlyCardLandscape, { backgroundColor: palette.surfaceColor, borderColor: palette.accentColor }]} pointerEvents="auto">
108
+ <AudioArtwork posterUrl={posterUrl} isLandscape={isLandscape} palette={palette} />
109
+
110
+ <SoundWave isPlaying={isPlaying} isLandscape={isLandscape} accentColor={palette.accentColor} />
111
+
112
+ <Text style={[styles.audioOnlyTitle, isLandscape && styles.audioOnlyTitleLandscape, { color: palette.controlColor }]} numberOfLines={2}>
113
+ {displayTitle}
114
+ </Text>
115
+
116
+ {showEpisodeSubtitle ? (
117
+ <Text style={[styles.audioOnlyEpisodeText, { color: palette.mutedColor }]} numberOfLines={1}>
118
+ {episodeLabel}
119
+ </Text>
120
+ ) : null}
121
+
122
+ <AudioOnlyBadge usesAudioProxy={usesAudioProxy} palette={palette} isLandscape={isLandscape} />
123
+
124
+ <TouchableOpacity
125
+ style={[styles.audioOnlyReturnBtn, isLandscape && styles.audioOnlyReturnBtnLandscape, { backgroundColor: palette.backgroundColor, borderColor: palette.accentColor }]}
126
+ onPress={(e) => {
127
+ e.stopPropagation();
128
+ onToggleAudioOnly();
129
+ }}
130
+ hitSlop={8}
131
+ activeOpacity={0.8}
132
+ >
133
+ <PlayerIcon name="video-outline" size={18} color={palette.accentColor} />
134
+ <Text style={[styles.audioOnlyReturnText, { color: palette.accentColor }]}>Switch to Video</Text>
135
+ </TouchableOpacity>
136
+ </View>
137
+ </View>
138
+ );
139
+ };
140
+
141
+ const styles = StyleSheet.create({
142
+ audioOnlyContainer: {
143
+ position: 'absolute',
144
+ top: 0,
145
+ left: 0,
146
+ right: 0,
147
+ bottom: 0,
148
+ width: '100%',
149
+ height: '100%',
150
+ backgroundColor: '#07090E',
151
+ alignItems: 'center',
152
+ justifyContent: 'center',
153
+ zIndex: 50,
154
+ elevation: 50,
155
+ },
156
+ audioOnlyBackdropImage: {
157
+ position: 'absolute',
158
+ top: 0,
159
+ left: 0,
160
+ right: 0,
161
+ bottom: 0,
162
+ width: '100%',
163
+ height: '100%',
164
+ },
165
+ audioOnlyBackdropDim: {
166
+ position: 'absolute',
167
+ top: 0,
168
+ left: 0,
169
+ right: 0,
170
+ bottom: 0,
171
+ width: '100%',
172
+ height: '100%',
173
+ backgroundColor: 'rgba(7, 9, 14, 0.88)',
174
+ },
175
+ audioOnlyCard: {
176
+ alignItems: 'center',
177
+ justifyContent: 'center',
178
+ paddingHorizontal: 24,
179
+ paddingVertical: 18,
180
+ maxWidth: 440,
181
+ maxHeight: '92%',
182
+ borderRadius: 20,
183
+ backgroundColor: 'rgba(15, 23, 42, 0.85)',
184
+ borderWidth: 1,
185
+ borderColor: 'rgba(0, 229, 255, 0.25)',
186
+ shadowColor: '#000',
187
+ shadowOffset: { width: 0, height: 8 },
188
+ shadowOpacity: 0.6,
189
+ shadowRadius: 16,
190
+ elevation: 10,
191
+ ...(isWeb() ? { boxShadow: '0px 8px 16px rgba(0, 0, 0, 0.6)' } : null),
192
+ zIndex: 12,
193
+ },
194
+ audioOnlyCardLandscape: {
195
+ paddingVertical: 12,
196
+ paddingHorizontal: 20,
197
+ },
198
+ audioOnlyPosterWrap: {
199
+ width: 100,
200
+ height: 100,
201
+ borderRadius: 50,
202
+ overflow: 'hidden',
203
+ borderWidth: 2,
204
+ borderColor: '#00E5FF',
205
+ marginBottom: 14,
206
+ shadowColor: '#00E5FF',
207
+ shadowOffset: { width: 0, height: 4 },
208
+ shadowOpacity: 0.5,
209
+ shadowRadius: 10,
210
+ elevation: 8,
211
+ ...(isWeb() ? { boxShadow: '0px 4px 10px rgba(0, 229, 255, 0.5)' } : null),
212
+ },
213
+ audioOnlyPosterWrapLandscape: {
214
+ width: 68,
215
+ height: 68,
216
+ borderRadius: 34,
217
+ marginBottom: 8,
218
+ },
219
+ audioOnlyPoster: {
220
+ width: '100%',
221
+ height: '100%',
222
+ },
223
+ audioOnlyIconWrap: {
224
+ width: 90,
225
+ height: 90,
226
+ borderRadius: 45,
227
+ backgroundColor: 'rgba(0, 229, 255, 0.15)',
228
+ borderWidth: 1.5,
229
+ borderColor: '#00E5FF',
230
+ alignItems: 'center',
231
+ justifyContent: 'center',
232
+ marginBottom: 14,
233
+ },
234
+ audioOnlyIconWrapLandscape: {
235
+ width: 60,
236
+ height: 60,
237
+ borderRadius: 30,
238
+ marginBottom: 8,
239
+ },
240
+ soundWaveRow: {
241
+ flexDirection: 'row',
242
+ alignItems: 'center',
243
+ justifyContent: 'center',
244
+ gap: 5,
245
+ height: 28,
246
+ marginBottom: 12,
247
+ },
248
+ soundWaveRowLandscape: {
249
+ height: 18,
250
+ marginBottom: 6,
251
+ },
252
+ soundWaveBar: {
253
+ width: 4,
254
+ borderRadius: 2,
255
+ backgroundColor: '#00E5FF',
256
+ },
257
+ soundWaveBarStatic: {
258
+ height: 8,
259
+ opacity: 0.4,
260
+ },
261
+ soundWaveBarAnim1: {
262
+ height: 22,
263
+ },
264
+ soundWaveBarAnim2: {
265
+ height: 14,
266
+ },
267
+ soundWaveBarAnim3: {
268
+ height: 26,
269
+ },
270
+ soundWaveBarAnim4: {
271
+ height: 18,
272
+ },
273
+ soundWaveBarAnim5: {
274
+ height: 10,
275
+ },
276
+ audioOnlyTitle: {
277
+ color: '#FFF',
278
+ fontSize: 16,
279
+ fontWeight: '700',
280
+ textAlign: 'center',
281
+ marginBottom: 4,
282
+ },
283
+ audioOnlyTitleLandscape: {
284
+ fontSize: 14.5,
285
+ marginBottom: 2,
286
+ },
287
+ audioOnlyEpisodeText: {
288
+ color: 'rgba(255, 255, 255, 0.7)',
289
+ fontSize: 13,
290
+ textAlign: 'center',
291
+ marginBottom: 8,
292
+ },
293
+ audioOnlyBadge: {
294
+ flexDirection: 'row',
295
+ alignItems: 'center',
296
+ gap: 5,
297
+ backgroundColor: 'rgba(255, 214, 10, 0.15)',
298
+ borderColor: 'rgba(255, 214, 10, 0.4)',
299
+ borderWidth: 1,
300
+ paddingHorizontal: 10,
301
+ paddingVertical: 4,
302
+ borderRadius: 12,
303
+ marginTop: 6,
304
+ marginBottom: 14,
305
+ },
306
+ audioOnlyBadgeLandscape: {
307
+ marginTop: 3,
308
+ marginBottom: 8,
309
+ paddingVertical: 3,
310
+ paddingHorizontal: 8,
311
+ },
312
+ audioOnlyBadgeText: {
313
+ color: '#FFD60A',
314
+ fontSize: 11,
315
+ fontWeight: '600',
316
+ },
317
+ audioOnlyReturnBtn: {
318
+ flexDirection: 'row',
319
+ alignItems: 'center',
320
+ gap: 6,
321
+ backgroundColor: 'rgba(0, 229, 255, 0.15)',
322
+ borderColor: '#00E5FF',
323
+ borderWidth: 1,
324
+ paddingHorizontal: 16,
325
+ paddingVertical: 7,
326
+ borderRadius: 20,
327
+ },
328
+ audioOnlyReturnBtnLandscape: {
329
+ paddingHorizontal: 14,
330
+ paddingVertical: 5,
331
+ },
332
+ audioOnlyReturnText: {
333
+ color: '#00E5FF',
334
+ fontSize: 12.5,
335
+ fontWeight: '700',
336
+ },
337
+ });