@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.
- package/CineCrewPlayer.podspec +22 -0
- package/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +574 -0
- package/android/build.gradle +48 -0
- package/android/proguard-rules.pro +29 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/yuanzhou/vlc/ReactVlcPlayerPackage.java +28 -0
- package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/ReactVlcPlayerView.java +988 -0
- package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/ReactVlcPlayerViewManager.java +208 -0
- package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/VideoEventEmitter.java +136 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.cjs +1 -0
- package/assets/cinecrew-player-logo.svg +44 -0
- package/package.json +132 -0
- package/packages/react-native-vlc-media-player/LICENSE +21 -0
- package/packages/react-native-vlc-media-player/VLCPlayer.js +207 -0
- package/packages/react-native-vlc-media-player/expo/android/withGradleTasks.cjs +49 -0
- package/packages/react-native-vlc-media-player/expo/ios/withMobileVlcKit.cjs +28 -0
- package/packages/react-native-vlc-media-player/expo/withVlcMediaPlayer.cjs +10 -0
- package/packages/react-native-vlc-media-player/index.d.ts +354 -0
- package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayer.h +36 -0
- package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayer.m +635 -0
- package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayerManager.h +5 -0
- package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayerManager.m +100 -0
- package/src/native/InlineLivePlayer.js +321 -0
- package/src/native/MediaPlayerView.js +3093 -0
- package/src/native/customization.js +44 -0
- package/src/native/index.js +107 -0
- package/src/native/media/ElectronVideoPlayer.js +288 -0
- package/src/native/media/EmojiPickerModal.js +30 -0
- package/src/native/media/LiveChatDrawer.js +1177 -0
- package/src/native/media/LiveRecordingOverlay.js +319 -0
- package/src/native/media/WebVideoPlayer.js +320 -0
- package/src/native/media/YouTubeVideoPlayer.native.js +120 -0
- package/src/native/media/YouTubeVideoPlayer.web.js +169 -0
- package/src/native/media/player/AudioOnlyView.js +337 -0
- package/src/native/media/player/CenterControls.js +94 -0
- package/src/native/media/player/ExoVideoFallback.js +329 -0
- package/src/native/media/player/PipOverlay.js +1 -0
- package/src/native/media/player/PlayerBottomBar.js +295 -0
- package/src/native/media/player/PlayerTopBar.js +255 -0
- package/src/native/media/player/VLCBoundary.js +41 -0
- package/src/native/media/player/VerticalIndicator.js +184 -0
- package/src/native/media/player/index.js +9 -0
- package/src/native/media/player/playerConstants.js +159 -0
- package/src/native/media/web/useWebAc3AudioPlayback.native.js +6 -0
- package/src/native/media/web/useWebAc3AudioPlayback.web.js +219 -0
- package/src/native/media/web/useWebHlsPlayback.native.js +3 -0
- package/src/native/media/web/useWebHlsPlayback.web.js +66 -0
- package/src/native/media/web/useWebMediaSession.js +90 -0
- package/src/native/media/web/useWebMpegTsPlayback.native.js +3 -0
- package/src/native/media/web/useWebMpegTsPlayback.web.js +356 -0
- package/src/native/media/web/useWebVideoAspectRatio.js +52 -0
- package/src/native/media/web/webPlaybackErrors.js +4 -0
- package/src/utils/layoutUtils.js +12 -0
- package/src/utils/mediaUtils.js +7 -0
- package/src/utils/recording.js +8 -0
- package/src/utils/runtimePlatform.js +19 -0
- package/src/utils/sourceUtils.js +99 -0
- package/src/utils/youtubeHtml.js +34 -0
- package/src/web/index.js +1129 -0
- package/src/web/styles.css +378 -0
- package/types/index.d.ts +268 -0
- package/vendor/mpegts.js/LICENSE +202 -0
- package/vendor/mpegts.js/mpegts.js +3 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { isWeb } from '../../../utils/runtimePlatform';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook to integrate the browser MediaSession API for background audio,
|
|
6
|
+
* OS lock-screen controls, notification media controls, and hardware media keys.
|
|
7
|
+
*/
|
|
8
|
+
export function useWebMediaSession({
|
|
9
|
+
title,
|
|
10
|
+
posterUrl,
|
|
11
|
+
isLive,
|
|
12
|
+
paused,
|
|
13
|
+
videoRef,
|
|
14
|
+
pausedRef,
|
|
15
|
+
lastKnownPosRef,
|
|
16
|
+
onTogglePlayPause,
|
|
17
|
+
onSeekBy,
|
|
18
|
+
}) {
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isWeb() || typeof navigator === 'undefined' || !('mediaSession' in navigator)) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (typeof window !== 'undefined' && window.MediaMetadata) {
|
|
26
|
+
navigator.mediaSession.metadata = new window.MediaMetadata({
|
|
27
|
+
title: title || 'Now Playing',
|
|
28
|
+
artist: 'Xtream API Media Player',
|
|
29
|
+
album: isLive ? 'Live TV' : (title || 'Media Stream'),
|
|
30
|
+
artwork: posterUrl
|
|
31
|
+
? [{ src: posterUrl, sizes: '512x512', type: 'image/jpeg' }]
|
|
32
|
+
: [],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
navigator.mediaSession.playbackState = paused ? 'paused' : 'playing';
|
|
37
|
+
|
|
38
|
+
const playHandler = () => {
|
|
39
|
+
if (pausedRef) pausedRef.current = false;
|
|
40
|
+
const video = videoRef?.current;
|
|
41
|
+
if (video) video.play().catch(() => {});
|
|
42
|
+
if (typeof onTogglePlayPause === 'function') onTogglePlayPause(true);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const pauseHandler = () => {
|
|
46
|
+
if (pausedRef) pausedRef.current = true;
|
|
47
|
+
const video = videoRef?.current;
|
|
48
|
+
if (video) video.pause();
|
|
49
|
+
if (typeof onTogglePlayPause === 'function') onTogglePlayPause(false);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const seekBackwardHandler = (details) => {
|
|
53
|
+
const skip = details?.seekOffset || 10;
|
|
54
|
+
const video = videoRef?.current;
|
|
55
|
+
if (video && Number.isFinite(video.currentTime)) {
|
|
56
|
+
video.currentTime = Math.max(0, video.currentTime - skip);
|
|
57
|
+
if (lastKnownPosRef) lastKnownPosRef.current = video.currentTime;
|
|
58
|
+
}
|
|
59
|
+
if (typeof onSeekBy === 'function') onSeekBy(-skip);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const seekForwardHandler = (details) => {
|
|
63
|
+
const skip = details?.seekOffset || 10;
|
|
64
|
+
const video = videoRef?.current;
|
|
65
|
+
if (video && Number.isFinite(video.currentTime)) {
|
|
66
|
+
video.currentTime = Math.min(video.duration || 999999, video.currentTime + skip);
|
|
67
|
+
if (lastKnownPosRef) lastKnownPosRef.current = video.currentTime;
|
|
68
|
+
}
|
|
69
|
+
if (typeof onSeekBy === 'function') onSeekBy(skip);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
navigator.mediaSession.setActionHandler('play', playHandler);
|
|
73
|
+
navigator.mediaSession.setActionHandler('pause', pauseHandler);
|
|
74
|
+
navigator.mediaSession.setActionHandler('seekbackward', seekBackwardHandler);
|
|
75
|
+
navigator.mediaSession.setActionHandler('seekforward', seekForwardHandler);
|
|
76
|
+
navigator.mediaSession.setActionHandler('stop', pauseHandler);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
navigator.mediaSession.setActionHandler('play', null);
|
|
80
|
+
navigator.mediaSession.setActionHandler('pause', null);
|
|
81
|
+
navigator.mediaSession.setActionHandler('seekbackward', null);
|
|
82
|
+
navigator.mediaSession.setActionHandler('seekforward', null);
|
|
83
|
+
navigator.mediaSession.setActionHandler('stop', null);
|
|
84
|
+
};
|
|
85
|
+
} catch (e) {
|
|
86
|
+
String(e);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}, [title, posterUrl, isLive, paused, videoRef, pausedRef, lastKnownPosRef, onTogglePlayPause, onSeekBy]);
|
|
90
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import mpegts from '../../../../vendor/mpegts.js/mpegts.js';
|
|
3
|
+
import {
|
|
4
|
+
WEB_AC3_UNSUPPORTED_CODE,
|
|
5
|
+
WEB_AC3_UNSUPPORTED_MESSAGE,
|
|
6
|
+
} from './webPlaybackErrors';
|
|
7
|
+
|
|
8
|
+
if (mpegts.LoggingControl) {
|
|
9
|
+
mpegts.LoggingControl.enableAll = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const isRawLiveTransportStream = (url) => {
|
|
13
|
+
if (typeof url !== 'string') return false;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(url, 'http://localhost');
|
|
16
|
+
return /\.ts$/i.test(parsed.pathname)
|
|
17
|
+
|| (/\/live\//i.test(parsed.pathname) && !/\.m3u8$/i.test(parsed.pathname));
|
|
18
|
+
} catch {
|
|
19
|
+
return /\/live\/.*\.ts(?:\?|$)/i.test(url);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isHlsUrl = (url) => /\.m3u8(?:$|[?#])/i.test(String(url || ''));
|
|
24
|
+
|
|
25
|
+
function containsAc3Codec(value) {
|
|
26
|
+
const normalized = String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
27
|
+
return normalized.includes('ac3') || normalized.includes('dolby');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildRetryFailureMessage(message) {
|
|
31
|
+
const detail = message ? ` (${message})` : '';
|
|
32
|
+
return 'The live stream connection failed after 3 reconnect attempts' + detail + '.';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function redactStreamDetails(value) {
|
|
36
|
+
return String(value || '')
|
|
37
|
+
.replace(/https?:\/\/\S+/gi, '[stream endpoint]')
|
|
38
|
+
.replace(/\/live\/[^?#\s]*/gi, '/live/[redacted]');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isTransientStreamFailure(detailCode, status) {
|
|
42
|
+
return detailCode === 'Exception'
|
|
43
|
+
|| /NetworkError|Timeout/i.test(detailCode)
|
|
44
|
+
|| [408, 425, 429].includes(status)
|
|
45
|
+
|| status >= 500;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatStreamFailure({ detailCode, status, isTransient, recoveryAttempts, message }) {
|
|
49
|
+
if (detailCode === 'HttpStatusCodeInvalid') {
|
|
50
|
+
if (Number.isFinite(status) && status > 0) return `Stream request failed (HTTP ${status}).`;
|
|
51
|
+
return 'Stream request failed with an unreadable HTTP response.';
|
|
52
|
+
}
|
|
53
|
+
if (isTransient && recoveryAttempts >= 3) return buildRetryFailureMessage(message);
|
|
54
|
+
const prefix = detailCode || 'Failed to load live stream.';
|
|
55
|
+
return message ? `${prefix}: ${message}` : prefix;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createErrorContext(type, detail, info, player) {
|
|
59
|
+
const detailCode = String(detail || type || '');
|
|
60
|
+
const mediaSourceMessage = String(info?.msg || info?.message || '');
|
|
61
|
+
const codecText = [
|
|
62
|
+
detailCode,
|
|
63
|
+
mediaSourceMessage,
|
|
64
|
+
player?.mediaInfo?.audioCodec,
|
|
65
|
+
player?.mediaInfo?.mimeType,
|
|
66
|
+
].filter(Boolean).join(' ');
|
|
67
|
+
const status = Number(info?.code);
|
|
68
|
+
return {
|
|
69
|
+
detailCode,
|
|
70
|
+
mediaSourceMessage,
|
|
71
|
+
isAc3: containsAc3Codec(codecText),
|
|
72
|
+
status,
|
|
73
|
+
isTransient: isTransientStreamFailure(detailCode, status),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useWebMpegTsPlayback({
|
|
78
|
+
streamUrl,
|
|
79
|
+
activeUrl,
|
|
80
|
+
isLive,
|
|
81
|
+
videoOnly = false,
|
|
82
|
+
videoRef,
|
|
83
|
+
pausedRef,
|
|
84
|
+
onErrorRef,
|
|
85
|
+
onBufferingRef,
|
|
86
|
+
}) {
|
|
87
|
+
const [unavailableForUrl, setUnavailableForUrl] = useState('');
|
|
88
|
+
const [ac3FallbackForUrl, setAc3FallbackForUrl] = useState('');
|
|
89
|
+
const ac3FallbackForUrlRef = useRef('');
|
|
90
|
+
const useMpegTs = isLive
|
|
91
|
+
// The resolver may return an opaque temporary URL whose pathname no
|
|
92
|
+
// longer ends in .ts or /live/. Keep the source format from the original
|
|
93
|
+
// Xtream URL so those redirects still go through the TS demuxer. If the
|
|
94
|
+
// resolver explicitly returned HLS, let the HLS player own that source.
|
|
95
|
+
&& (isRawLiveTransportStream(streamUrl) || isRawLiveTransportStream(activeUrl))
|
|
96
|
+
&& !isHlsUrl(activeUrl)
|
|
97
|
+
&& unavailableForUrl !== streamUrl;
|
|
98
|
+
const useVideoOnly = useMpegTs && videoOnly;
|
|
99
|
+
const useAc3Fallback = useMpegTs && !useVideoOnly && ac3FallbackForUrl === streamUrl;
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
setUnavailableForUrl('');
|
|
103
|
+
ac3FallbackForUrlRef.current = '';
|
|
104
|
+
setAc3FallbackForUrl('');
|
|
105
|
+
}, [streamUrl]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!activeUrl || !useMpegTs || typeof window === 'undefined') return undefined;
|
|
109
|
+
const video = videoRef.current;
|
|
110
|
+
if (!video) return undefined;
|
|
111
|
+
|
|
112
|
+
let disposed = false;
|
|
113
|
+
let blockedByAudioCodec = false;
|
|
114
|
+
let player;
|
|
115
|
+
const previousVideoVisibility = video.style.visibility;
|
|
116
|
+
let recoveryTimer = null;
|
|
117
|
+
let recoveryAttempts = 0;
|
|
118
|
+
let recoveryPending = false;
|
|
119
|
+
let stallWatchdog = null;
|
|
120
|
+
let stablePlaybackTimer = null;
|
|
121
|
+
let lastPlaybackProgressAt = Date.now();
|
|
122
|
+
let lastPlaybackTime = Number(video.currentTime) || 0;
|
|
123
|
+
|
|
124
|
+
const clearStablePlaybackTimer = () => {
|
|
125
|
+
if (stablePlaybackTimer) {
|
|
126
|
+
clearTimeout(stablePlaybackTimer);
|
|
127
|
+
stablePlaybackTimer = null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const notePlaybackProgress = () => {
|
|
132
|
+
const currentTime = Number(video.currentTime) || 0;
|
|
133
|
+
if (currentTime < lastPlaybackTime - 1) {
|
|
134
|
+
lastPlaybackTime = currentTime;
|
|
135
|
+
lastPlaybackProgressAt = Date.now();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (currentTime <= lastPlaybackTime + 0.05) return;
|
|
139
|
+
lastPlaybackTime = currentTime;
|
|
140
|
+
lastPlaybackProgressAt = Date.now();
|
|
141
|
+
if (stablePlaybackTimer || recoveryAttempts === 0) return;
|
|
142
|
+
stablePlaybackTimer = setTimeout(() => {
|
|
143
|
+
stablePlaybackTimer = null;
|
|
144
|
+
if (!disposed && !pausedRef.current && video.readyState >= 2
|
|
145
|
+
&& Date.now() - lastPlaybackProgressAt < 2500) {
|
|
146
|
+
recoveryAttempts = 0;
|
|
147
|
+
}
|
|
148
|
+
}, 15000);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const scheduleRecovery = (message, httpStatus = null, error = null) => {
|
|
152
|
+
if (disposed || recoveryPending || pausedRef.current || !player) return;
|
|
153
|
+
if (recoveryAttempts >= 3) {
|
|
154
|
+
onErrorRef.current?.({
|
|
155
|
+
message: buildRetryFailureMessage(message),
|
|
156
|
+
httpStatus,
|
|
157
|
+
err: error,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
clearStablePlaybackTimer();
|
|
163
|
+
const delayMs = [500, 1200, 2500][recoveryAttempts];
|
|
164
|
+
recoveryAttempts += 1;
|
|
165
|
+
recoveryPending = true;
|
|
166
|
+
lastPlaybackProgressAt = Date.now();
|
|
167
|
+
onBufferingRef.current?.(true);
|
|
168
|
+
recoveryTimer = setTimeout(() => {
|
|
169
|
+
recoveryTimer = null;
|
|
170
|
+
recoveryPending = false;
|
|
171
|
+
if (disposed || !player || pausedRef.current) return;
|
|
172
|
+
try {
|
|
173
|
+
player.unload();
|
|
174
|
+
player.load();
|
|
175
|
+
lastPlaybackTime = Number(video.currentTime) || 0;
|
|
176
|
+
lastPlaybackProgressAt = Date.now();
|
|
177
|
+
const playResult = player.play();
|
|
178
|
+
playResult?.catch?.(() => {});
|
|
179
|
+
} catch (recoveryError) {
|
|
180
|
+
onErrorRef.current?.({
|
|
181
|
+
message: recoveryError?.message || 'Could not reconnect to the live stream.',
|
|
182
|
+
err: recoveryError,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}, delayMs);
|
|
186
|
+
};
|
|
187
|
+
const handlePlaying = () => {
|
|
188
|
+
lastPlaybackProgressAt = Date.now();
|
|
189
|
+
if (recoveryAttempts > 0 && !stablePlaybackTimer) {
|
|
190
|
+
stablePlaybackTimer = setTimeout(() => {
|
|
191
|
+
stablePlaybackTimer = null;
|
|
192
|
+
if (!disposed && !pausedRef.current && video.readyState >= 2
|
|
193
|
+
&& Date.now() - lastPlaybackProgressAt < 2500) {
|
|
194
|
+
recoveryAttempts = 0;
|
|
195
|
+
}
|
|
196
|
+
}, 15000);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const handleWaiting = () => onBufferingRef.current?.(true);
|
|
200
|
+
|
|
201
|
+
const blockAc3Playback = () => {
|
|
202
|
+
if (disposed || blockedByAudioCodec) return;
|
|
203
|
+
blockedByAudioCodec = true;
|
|
204
|
+
video.dataset.mpegtsCodecGate = 'blocked';
|
|
205
|
+
video.style.visibility = 'hidden';
|
|
206
|
+
video.pause();
|
|
207
|
+
onBufferingRef.current?.(false);
|
|
208
|
+
try {
|
|
209
|
+
player?.pause();
|
|
210
|
+
} catch {
|
|
211
|
+
// The media engine may already have been disposed.
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
player?.unload();
|
|
215
|
+
} catch {
|
|
216
|
+
// The media engine may already have been disposed.
|
|
217
|
+
}
|
|
218
|
+
onErrorRef.current?.({
|
|
219
|
+
code: WEB_AC3_UNSUPPORTED_CODE,
|
|
220
|
+
blockPlayback: true,
|
|
221
|
+
message: WEB_AC3_UNSUPPORTED_MESSAGE,
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleMediaInfo = (info) => {
|
|
226
|
+
const codecSummary = [info?.audioCodec, info?.mimeType].filter(Boolean).join(' ');
|
|
227
|
+
if (containsAc3Codec(codecSummary)) {
|
|
228
|
+
blockAc3Playback();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
delete video.dataset.mpegtsCodecGate;
|
|
232
|
+
if (!pausedRef.current && player) {
|
|
233
|
+
const playResult = player.play();
|
|
234
|
+
playResult?.catch?.(() => {});
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (!mpegts.isSupported()) {
|
|
240
|
+
setUnavailableForUrl(streamUrl);
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
player = mpegts.createPlayer({
|
|
245
|
+
type: 'mpegts',
|
|
246
|
+
url: activeUrl,
|
|
247
|
+
isLive: true,
|
|
248
|
+
hasAudio: !useAc3Fallback && !useVideoOnly,
|
|
249
|
+
hasVideo: true,
|
|
250
|
+
}, {
|
|
251
|
+
enableWorker: !useVideoOnly,
|
|
252
|
+
lazyLoad: false,
|
|
253
|
+
enableStashBuffer: true,
|
|
254
|
+
// Match the standalone Sony test: allow a deeper forward buffer to
|
|
255
|
+
// absorb provider jitter instead of constantly chasing the live edge.
|
|
256
|
+
stashInitialSize: useVideoOnly ? 2048 : 512 * 1024,
|
|
257
|
+
liveBufferLatencyChasing: !useVideoOnly,
|
|
258
|
+
liveBufferLatencyMaxLatency: 8,
|
|
259
|
+
liveBufferLatencyMinRemain: 3,
|
|
260
|
+
autoCleanupSourceBuffer: true,
|
|
261
|
+
autoCleanupMaxBackwardDuration: useVideoOnly ? 120 : 30,
|
|
262
|
+
autoCleanupMinBackwardDuration: useVideoOnly ? 60 : 15,
|
|
263
|
+
disableAudio: useAc3Fallback || useVideoOnly,
|
|
264
|
+
});
|
|
265
|
+
// Wait until the MPEG-TS demuxer reports its codecs so unsupported AC3
|
|
266
|
+
// streams can be stopped before the browser renders their video.
|
|
267
|
+
video.autoplay = false;
|
|
268
|
+
video.dataset.mpegtsCodecGate = 'pending';
|
|
269
|
+
player.on(mpegts.Events.MEDIA_INFO, handleMediaInfo);
|
|
270
|
+
video.addEventListener('timeupdate', notePlaybackProgress);
|
|
271
|
+
video.addEventListener('playing', handlePlaying);
|
|
272
|
+
video.addEventListener('waiting', handleWaiting);
|
|
273
|
+
|
|
274
|
+
player.on(mpegts.Events.ERROR, (type, detail, info) => {
|
|
275
|
+
if (disposed || recoveryPending || blockedByAudioCodec) return;
|
|
276
|
+
const errorContext = createErrorContext(type, detail, info, player);
|
|
277
|
+
const { detailCode, mediaSourceMessage, status, isTransient } = errorContext;
|
|
278
|
+
if (errorContext.isAc3) {
|
|
279
|
+
if (isLive) {
|
|
280
|
+
blockAc3Playback();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (ac3FallbackForUrlRef.current !== streamUrl) {
|
|
284
|
+
ac3FallbackForUrlRef.current = streamUrl;
|
|
285
|
+
setAc3FallbackForUrl(streamUrl);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const safeMediaSourceMessage = redactStreamDetails(mediaSourceMessage);
|
|
290
|
+
if (isTransient && recoveryAttempts < 3) {
|
|
291
|
+
scheduleRecovery(safeMediaSourceMessage || detailCode, status, info || detail || type);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const message = formatStreamFailure({
|
|
295
|
+
detailCode,
|
|
296
|
+
status,
|
|
297
|
+
isTransient,
|
|
298
|
+
recoveryAttempts,
|
|
299
|
+
message: safeMediaSourceMessage,
|
|
300
|
+
});
|
|
301
|
+
onErrorRef.current?.({
|
|
302
|
+
message,
|
|
303
|
+
httpStatus: Number.isFinite(status) && status > 0 ? status : null,
|
|
304
|
+
err: info || detail || type,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
player.attachMediaElement(video);
|
|
308
|
+
player.load();
|
|
309
|
+
stallWatchdog = setInterval(() => {
|
|
310
|
+
if (disposed || pausedRef.current || recoveryPending || video.currentTime <= 0) return;
|
|
311
|
+
if (Date.now() - lastPlaybackProgressAt >= 12000) {
|
|
312
|
+
scheduleRecovery('playback stalled');
|
|
313
|
+
}
|
|
314
|
+
}, 3000);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
setUnavailableForUrl(streamUrl);
|
|
317
|
+
onErrorRef.current?.({ message: error?.message || 'Failed to initialize live playback.', err: error });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return () => {
|
|
321
|
+
disposed = true;
|
|
322
|
+
if (recoveryTimer) clearTimeout(recoveryTimer);
|
|
323
|
+
if (stallWatchdog) clearInterval(stallWatchdog);
|
|
324
|
+
clearStablePlaybackTimer();
|
|
325
|
+
video.removeEventListener('timeupdate', notePlaybackProgress);
|
|
326
|
+
video.removeEventListener('playing', handlePlaying);
|
|
327
|
+
video.removeEventListener('waiting', handleWaiting);
|
|
328
|
+
delete video.dataset.mpegtsCodecGate;
|
|
329
|
+
video.autoplay = !pausedRef.current;
|
|
330
|
+
video.style.visibility = previousVideoVisibility;
|
|
331
|
+
if (!player) return;
|
|
332
|
+
try {
|
|
333
|
+
player.pause();
|
|
334
|
+
} catch {
|
|
335
|
+
// The media engine may already be disposed.
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
player.unload();
|
|
339
|
+
} catch {
|
|
340
|
+
// The media engine may already be disposed.
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
player.detachMediaElement();
|
|
344
|
+
} catch {
|
|
345
|
+
// The media engine may already be disposed.
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
player.destroy();
|
|
349
|
+
} catch {
|
|
350
|
+
// The media engine may already be disposed.
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}, [activeUrl, useMpegTs, useAc3Fallback, useVideoOnly, streamUrl, videoRef, pausedRef, onErrorRef, onBufferingRef]);
|
|
354
|
+
|
|
355
|
+
return { useMpegTs, useAc3Fallback };
|
|
356
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook to manage video aspect ratio scaling styles (FIT, FILL, STRETCH, CENTER, custom aspect ratios).
|
|
5
|
+
*/
|
|
6
|
+
export function useWebVideoAspectRatio(videoAspectRatio, audioOnly) {
|
|
7
|
+
const [aspectFit, setAspectFit] = useState('contain');
|
|
8
|
+
const [aspectRatioVal, setAspectRatioVal] = useState(undefined);
|
|
9
|
+
|
|
10
|
+
const applyAspectRatio = useCallback((aspect) => {
|
|
11
|
+
if (aspect === 'FILL_SCREEN' || aspect === 'FILL') {
|
|
12
|
+
setAspectFit('cover');
|
|
13
|
+
setAspectRatioVal(undefined);
|
|
14
|
+
} else if (aspect === 'STRETCH') {
|
|
15
|
+
setAspectFit('fill');
|
|
16
|
+
setAspectRatioVal(undefined);
|
|
17
|
+
} else if (aspect === 'CENTER') {
|
|
18
|
+
setAspectFit('none');
|
|
19
|
+
setAspectRatioVal(undefined);
|
|
20
|
+
} else if (typeof aspect === 'string' && aspect.includes(':')) {
|
|
21
|
+
const parts = aspect.split(':');
|
|
22
|
+
setAspectFit('contain');
|
|
23
|
+
setAspectRatioVal(`${parts[0]}/${parts[1]}`);
|
|
24
|
+
} else {
|
|
25
|
+
setAspectFit('contain');
|
|
26
|
+
setAspectRatioVal(undefined);
|
|
27
|
+
}
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
applyAspectRatio(videoAspectRatio);
|
|
32
|
+
}, [videoAspectRatio, applyAspectRatio]);
|
|
33
|
+
|
|
34
|
+
const useCustomRatio = Boolean(aspectRatioVal) && !audioOnly;
|
|
35
|
+
const videoStyle = {
|
|
36
|
+
width: useCustomRatio ? 'auto' : '100%',
|
|
37
|
+
height: useCustomRatio ? 'auto' : '100%',
|
|
38
|
+
...(useCustomRatio
|
|
39
|
+
? { maxWidth: '100%', maxHeight: '100%', aspectRatio: aspectRatioVal }
|
|
40
|
+
: {}),
|
|
41
|
+
objectFit: useCustomRatio ? 'contain' : aspectFit,
|
|
42
|
+
backgroundColor: '#000',
|
|
43
|
+
...(audioOnly ? { opacity: 0 } : {}),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
aspectFit,
|
|
48
|
+
aspectRatioVal,
|
|
49
|
+
videoStyle,
|
|
50
|
+
applyAspectRatio,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getFontSize(baseSize, windowWidth = 390, windowHeight = 0) {
|
|
2
|
+
const width = Number(windowWidth) || 390;
|
|
3
|
+
const height = Number(windowHeight) || width;
|
|
4
|
+
const shortSide = Math.min(width, height);
|
|
5
|
+
const scale = Math.max(0.85, Math.min(1.15, shortSide / 390));
|
|
6
|
+
return Math.max(9, Math.round(Number(baseSize) * scale));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getFontWeight(baseWeight) {
|
|
10
|
+
const weights = { normal: 400, bold: 700 };
|
|
11
|
+
return String(weights[baseWeight] || Number(baseWeight) || 400);
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function cleanPlayerTitle(title, episodeLabel, isMobile = false) {
|
|
2
|
+
const clean = String(title || '').trim();
|
|
3
|
+
if (!clean) return 'Now Playing';
|
|
4
|
+
const episode = String(episodeLabel || '').trim();
|
|
5
|
+
if (!isMobile || !episode || clean.toLowerCase().includes(episode.toLowerCase())) return clean;
|
|
6
|
+
return clean;
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function formatRecElapsed(ms) {
|
|
2
|
+
const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
3
|
+
const hours = Math.floor(total / 3600);
|
|
4
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
5
|
+
const seconds = total % 60;
|
|
6
|
+
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
7
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export function isElectron() {
|
|
4
|
+
if (typeof window === 'undefined') return false;
|
|
5
|
+
if (typeof navigator !== 'undefined' && /cursor/i.test(navigator.userAgent)) return false;
|
|
6
|
+
return Boolean(window.process?.versions?.electron);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isWeb() {
|
|
10
|
+
return Platform.OS === 'web' && !isElectron();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAndroid() {
|
|
14
|
+
return Platform.OS === 'android';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isIOS() {
|
|
18
|
+
return Platform.OS === 'ios';
|
|
19
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export function normalizePlayerSource(source, url) {
|
|
4
|
+
const value = source ?? url ?? '';
|
|
5
|
+
if (typeof value === 'string') return { uri: value };
|
|
6
|
+
return value && typeof value === 'object' ? value : { uri: '' };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getYouTubeVideoId(source) {
|
|
10
|
+
const media = typeof source === 'string' ? { uri: source } : source || {};
|
|
11
|
+
const explicitId = media.youtubeVideoId || (String(media.type || '').toLowerCase() === 'youtube' ? media.id : null);
|
|
12
|
+
const isValidId = (value) => /^[\w-]{11}$/.test(String(value || ''));
|
|
13
|
+
if (isValidId(explicitId)) return String(explicitId);
|
|
14
|
+
|
|
15
|
+
const raw = String(media.uri || media.url || '').trim();
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(raw);
|
|
19
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
20
|
+
if (host === 'youtu.be') {
|
|
21
|
+
const id = parsed.pathname.split('/').find(Boolean);
|
|
22
|
+
return isValidId(id) ? id : null;
|
|
23
|
+
}
|
|
24
|
+
if (host !== 'youtube.com' && host !== 'youtube-nocookie.com' && host !== 'm.youtube.com') return null;
|
|
25
|
+
const queryId = parsed.searchParams.get('v');
|
|
26
|
+
if (isValidId(queryId)) return queryId;
|
|
27
|
+
const match = /^\/(?:embed|shorts|live)\/([\w-]{11})/.exec(parsed.pathname);
|
|
28
|
+
return isValidId(match?.[1]) ? match[1] : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getWebRuntimePlatform() {
|
|
35
|
+
if (typeof window === 'undefined') return 'web';
|
|
36
|
+
const electronProcess = Boolean(window.process?.versions?.electron);
|
|
37
|
+
const electronUserAgent = typeof navigator !== 'undefined' && /\bElectron\//i.test(navigator.userAgent || '');
|
|
38
|
+
return electronProcess || electronUserAgent ? 'electron' : 'web';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sourceKey(source, url) {
|
|
42
|
+
const value = source ?? url ?? '';
|
|
43
|
+
if (typeof value === 'string') return value;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return String(value?.uri || value?.url || '');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Apply an optional consumer-owned resolver without rewriting the input URL. */
|
|
52
|
+
export function useResolvedPlayerSource(source, url, resolveSource, platform) {
|
|
53
|
+
const key = sourceKey(source, url);
|
|
54
|
+
const resolverRef = useRef(resolveSource);
|
|
55
|
+
const inputRef = useRef(normalizePlayerSource(source, url));
|
|
56
|
+
const resolverEnabled = typeof resolveSource === 'function';
|
|
57
|
+
resolverRef.current = resolveSource;
|
|
58
|
+
inputRef.current = normalizePlayerSource(source, url);
|
|
59
|
+
const [result, setResult] = useState({ key: null, source: null, loading: false, error: null });
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
const input = inputRef.current;
|
|
64
|
+
const resolver = resolverRef.current;
|
|
65
|
+
if (typeof resolver !== 'function' || !(input.uri || input.url)) {
|
|
66
|
+
setResult({ key, source: input, loading: false, error: null });
|
|
67
|
+
return () => { cancelled = true; };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setResult({ key, source: null, loading: true, error: null });
|
|
71
|
+
Promise.resolve()
|
|
72
|
+
.then(() => resolver(input, { platform }))
|
|
73
|
+
.then((resolved) => {
|
|
74
|
+
if (resolved === null || resolved === undefined || resolved === '') {
|
|
75
|
+
throw new Error('The source resolver did not return a playable media source.');
|
|
76
|
+
}
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
setResult({
|
|
79
|
+
key,
|
|
80
|
+
source: { ...input, ...normalizePlayerSource(resolved) },
|
|
81
|
+
loading: false,
|
|
82
|
+
error: null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch((error) => {
|
|
87
|
+
if (!cancelled) setResult({ key, source: null, loading: false, error });
|
|
88
|
+
});
|
|
89
|
+
return () => { cancelled = true; };
|
|
90
|
+
}, [key, platform, resolverEnabled]);
|
|
91
|
+
|
|
92
|
+
if (result.key === key) return result;
|
|
93
|
+
return {
|
|
94
|
+
key,
|
|
95
|
+
source: resolverEnabled ? null : normalizePlayerSource(source, url),
|
|
96
|
+
loading: resolverEnabled,
|
|
97
|
+
error: null,
|
|
98
|
+
};
|
|
99
|
+
}
|