@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,319 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
3
|
+
import { isElectron, isWeb } from '../../utils/runtimePlatform';
|
|
4
|
+
import { PlayerIcon } from '../customization';
|
|
5
|
+
|
|
6
|
+
const REC_RED = '#FF5252';
|
|
7
|
+
const DEFAULT_DARK_PALETTE = {
|
|
8
|
+
mode: 'dark', primary: '#00E5FF', outline: 'rgba(255,255,255,0.14)',
|
|
9
|
+
surface: '#12161C', surfaceHighlight: '#262B33',
|
|
10
|
+
onSurfacePrimary: '#FFFFFF', onSurfaceSecondary: 'rgba(255,255,255,0.68)',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function formatRecElapsed(elapsedMs = 0) {
|
|
14
|
+
const totalSeconds = Math.max(0, Math.floor(Number(elapsedMs) / 1000));
|
|
15
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
16
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
17
|
+
const seconds = totalSeconds % 60;
|
|
18
|
+
return hours > 0
|
|
19
|
+
? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
|
20
|
+
: `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveTheme(colors) {
|
|
24
|
+
const palette = colors?.onSurfacePrimary ? colors : DEFAULT_DARK_PALETTE;
|
|
25
|
+
const isLight = palette.mode === 'light';
|
|
26
|
+
return {
|
|
27
|
+
isLight,
|
|
28
|
+
barBg: isLight ? 'rgba(255,255,255,0.94)' : 'rgba(18,22,28,0.92)',
|
|
29
|
+
border: isLight ? palette.outline : 'rgba(255,82,82,0.45)',
|
|
30
|
+
text: palette.onSurfacePrimary,
|
|
31
|
+
muted: palette.onSurfaceSecondary,
|
|
32
|
+
btnBg: isLight ? palette.surfaceHighlight : 'rgba(255,255,255,0.08)',
|
|
33
|
+
stopBg: isLight ? 'rgba(255,82,82,0.12)' : 'rgba(255,82,82,0.22)',
|
|
34
|
+
noticeBg: isLight ? palette.surface : 'rgba(18,22,28,0.94)',
|
|
35
|
+
noticeBorder: palette.outline,
|
|
36
|
+
primary: palette.primary,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function RecDot({ paused }) {
|
|
41
|
+
const pulse = useRef(new Animated.Value(1)).current;
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (paused) {
|
|
45
|
+
pulse.stopAnimation();
|
|
46
|
+
pulse.setValue(1);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const loop = Animated.loop(
|
|
50
|
+
Animated.sequence([
|
|
51
|
+
Animated.timing(pulse, {
|
|
52
|
+
toValue: 0.25,
|
|
53
|
+
duration: 650,
|
|
54
|
+
easing: Easing.inOut(Easing.quad),
|
|
55
|
+
useNativeDriver: true,
|
|
56
|
+
}),
|
|
57
|
+
Animated.timing(pulse, {
|
|
58
|
+
toValue: 1,
|
|
59
|
+
duration: 650,
|
|
60
|
+
easing: Easing.inOut(Easing.quad),
|
|
61
|
+
useNativeDriver: true,
|
|
62
|
+
}),
|
|
63
|
+
]),
|
|
64
|
+
);
|
|
65
|
+
loop.start();
|
|
66
|
+
return () => loop.stop();
|
|
67
|
+
}, [paused, pulse]);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Animated.View
|
|
71
|
+
style={[
|
|
72
|
+
styles.dot,
|
|
73
|
+
{ opacity: pulse, transform: [{ scale: pulse.interpolate({ inputRange: [0.25, 1], outputRange: [0.75, 1] }) }] },
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function LiveRecordingOverlay({
|
|
80
|
+
status,
|
|
81
|
+
elapsedMs,
|
|
82
|
+
colors,
|
|
83
|
+
topInset = 12,
|
|
84
|
+
showTransport = true,
|
|
85
|
+
onPause,
|
|
86
|
+
onResume,
|
|
87
|
+
onStop,
|
|
88
|
+
}) {
|
|
89
|
+
if (status !== 'recording' && status !== 'paused') return null;
|
|
90
|
+
|
|
91
|
+
const theme = resolveTheme(colors);
|
|
92
|
+
const paused = status === 'paused';
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<View
|
|
96
|
+
pointerEvents="box-none"
|
|
97
|
+
style={[styles.wrap, { top: Math.max(topInset, 10) }]}
|
|
98
|
+
>
|
|
99
|
+
<View
|
|
100
|
+
style={[
|
|
101
|
+
styles.bar,
|
|
102
|
+
{
|
|
103
|
+
backgroundColor: theme.barBg,
|
|
104
|
+
borderColor: paused ? theme.border : REC_RED,
|
|
105
|
+
},
|
|
106
|
+
]}
|
|
107
|
+
>
|
|
108
|
+
<View style={styles.recLabel}>
|
|
109
|
+
<RecDot paused={paused} />
|
|
110
|
+
<Text style={[styles.recText, { color: REC_RED }]}>
|
|
111
|
+
{paused ? 'PAUSED' : 'REC'}
|
|
112
|
+
</Text>
|
|
113
|
+
<Text style={[styles.timeText, { color: theme.text }]}>
|
|
114
|
+
{formatRecElapsed(elapsedMs)}
|
|
115
|
+
</Text>
|
|
116
|
+
</View>
|
|
117
|
+
|
|
118
|
+
{showTransport ? (
|
|
119
|
+
<View style={styles.transport}>
|
|
120
|
+
<TouchableOpacity
|
|
121
|
+
style={[styles.roundBtn, { backgroundColor: theme.btnBg }]}
|
|
122
|
+
onPress={(e) => {
|
|
123
|
+
e?.stopPropagation?.();
|
|
124
|
+
if (paused) onResume?.();
|
|
125
|
+
else onPause?.();
|
|
126
|
+
}}
|
|
127
|
+
hitSlop={10}
|
|
128
|
+
accessibilityLabel={paused ? 'Resume recording' : 'Pause recording'}
|
|
129
|
+
>
|
|
130
|
+
<PlayerIcon
|
|
131
|
+
name={paused ? 'play' : 'pause'}
|
|
132
|
+
size={16}
|
|
133
|
+
color={theme.text}
|
|
134
|
+
/>
|
|
135
|
+
</TouchableOpacity>
|
|
136
|
+
<TouchableOpacity
|
|
137
|
+
style={[styles.roundBtn, { backgroundColor: theme.stopBg }]}
|
|
138
|
+
onPress={(e) => {
|
|
139
|
+
e?.stopPropagation?.();
|
|
140
|
+
onStop?.();
|
|
141
|
+
}}
|
|
142
|
+
hitSlop={10}
|
|
143
|
+
accessibilityLabel="Stop recording"
|
|
144
|
+
>
|
|
145
|
+
<PlayerIcon name="stop" size={16} color={REC_RED} />
|
|
146
|
+
</TouchableOpacity>
|
|
147
|
+
</View>
|
|
148
|
+
) : null}
|
|
149
|
+
</View>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function LiveRecordingNotice({ notice, colors, onDismiss }) {
|
|
155
|
+
if (!notice) return null;
|
|
156
|
+
const theme = resolveTheme(colors);
|
|
157
|
+
const isError = notice.type === 'error';
|
|
158
|
+
let iconName = 'check-circle-outline';
|
|
159
|
+
if (isError) iconName = 'alert-circle-outline';
|
|
160
|
+
else if (notice.type === 'info') iconName = 'information-outline';
|
|
161
|
+
const accent = isError ? REC_RED : theme.primary;
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<TouchableOpacity
|
|
165
|
+
activeOpacity={0.9}
|
|
166
|
+
onPress={onDismiss}
|
|
167
|
+
style={[
|
|
168
|
+
styles.notice,
|
|
169
|
+
{
|
|
170
|
+
backgroundColor: theme.noticeBg,
|
|
171
|
+
borderColor: isError ? REC_RED : theme.primary,
|
|
172
|
+
},
|
|
173
|
+
]}
|
|
174
|
+
>
|
|
175
|
+
<PlayerIcon
|
|
176
|
+
name={iconName}
|
|
177
|
+
size={18}
|
|
178
|
+
color={accent}
|
|
179
|
+
/>
|
|
180
|
+
<Text style={[styles.noticeText, { color: theme.text }]} numberOfLines={2}>
|
|
181
|
+
{notice.message}
|
|
182
|
+
</Text>
|
|
183
|
+
<PlayerIcon name="close" size={16} color={theme.muted} />
|
|
184
|
+
</TouchableOpacity>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function PipRecordingBadge({ status, elapsedMs }) {
|
|
189
|
+
if (status !== 'recording' && status !== 'paused') return null;
|
|
190
|
+
return (
|
|
191
|
+
<View style={[styles.pipRec, status === 'paused' && styles.pipRecPaused]}>
|
|
192
|
+
<View style={styles.pipDot} />
|
|
193
|
+
<Text style={styles.pipRecText}>
|
|
194
|
+
{status === 'paused' ? 'PAUSED' : `REC ${formatRecElapsed(elapsedMs)}`}
|
|
195
|
+
</Text>
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const styles = StyleSheet.create({
|
|
201
|
+
wrap: {
|
|
202
|
+
position: 'absolute',
|
|
203
|
+
left: 0,
|
|
204
|
+
right: 0,
|
|
205
|
+
zIndex: 80,
|
|
206
|
+
alignItems: 'center',
|
|
207
|
+
pointerEvents: 'box-none',
|
|
208
|
+
},
|
|
209
|
+
bar: {
|
|
210
|
+
flexDirection: 'row',
|
|
211
|
+
alignItems: 'center',
|
|
212
|
+
gap: 10,
|
|
213
|
+
paddingLeft: 12,
|
|
214
|
+
paddingRight: 8,
|
|
215
|
+
paddingVertical: 6,
|
|
216
|
+
borderRadius: 22,
|
|
217
|
+
borderWidth: 1,
|
|
218
|
+
...(isWeb() || isElectron()
|
|
219
|
+
? {
|
|
220
|
+
boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
shadowColor: '#000',
|
|
224
|
+
shadowOffset: { width: 0, height: 4 },
|
|
225
|
+
shadowOpacity: 0.28,
|
|
226
|
+
shadowRadius: 10,
|
|
227
|
+
elevation: 8,
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
recLabel: {
|
|
231
|
+
flexDirection: 'row',
|
|
232
|
+
alignItems: 'center',
|
|
233
|
+
gap: 8,
|
|
234
|
+
},
|
|
235
|
+
dot: {
|
|
236
|
+
width: 9,
|
|
237
|
+
height: 9,
|
|
238
|
+
borderRadius: 5,
|
|
239
|
+
backgroundColor: REC_RED,
|
|
240
|
+
},
|
|
241
|
+
recText: {
|
|
242
|
+
fontSize: 11,
|
|
243
|
+
fontWeight: '800',
|
|
244
|
+
letterSpacing: 1.2,
|
|
245
|
+
},
|
|
246
|
+
timeText: {
|
|
247
|
+
fontSize: 13,
|
|
248
|
+
fontWeight: '700',
|
|
249
|
+
fontVariant: ['tabular-nums'],
|
|
250
|
+
minWidth: 42,
|
|
251
|
+
},
|
|
252
|
+
transport: {
|
|
253
|
+
flexDirection: 'row',
|
|
254
|
+
alignItems: 'center',
|
|
255
|
+
gap: 6,
|
|
256
|
+
},
|
|
257
|
+
roundBtn: {
|
|
258
|
+
width: 28,
|
|
259
|
+
height: 28,
|
|
260
|
+
borderRadius: 14,
|
|
261
|
+
alignItems: 'center',
|
|
262
|
+
justifyContent: 'center',
|
|
263
|
+
},
|
|
264
|
+
notice: {
|
|
265
|
+
position: 'absolute',
|
|
266
|
+
left: 16,
|
|
267
|
+
right: 16,
|
|
268
|
+
bottom: 28,
|
|
269
|
+
zIndex: 90,
|
|
270
|
+
flexDirection: 'row',
|
|
271
|
+
alignItems: 'center',
|
|
272
|
+
gap: 10,
|
|
273
|
+
paddingHorizontal: 14,
|
|
274
|
+
paddingVertical: 12,
|
|
275
|
+
borderRadius: 14,
|
|
276
|
+
borderWidth: 1,
|
|
277
|
+
...(isWeb() || isElectron()
|
|
278
|
+
? {
|
|
279
|
+
boxShadow: '0 8px 22px rgba(0,0,0,0.28)',
|
|
280
|
+
}
|
|
281
|
+
: {
|
|
282
|
+
shadowColor: '#000',
|
|
283
|
+
shadowOffset: { width: 0, height: 4 },
|
|
284
|
+
shadowOpacity: 0.25,
|
|
285
|
+
shadowRadius: 10,
|
|
286
|
+
elevation: 8,
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
noticeText: {
|
|
290
|
+
flex: 1,
|
|
291
|
+
fontSize: 13,
|
|
292
|
+
fontWeight: '600',
|
|
293
|
+
lineHeight: 18,
|
|
294
|
+
},
|
|
295
|
+
pipRec: {
|
|
296
|
+
flexDirection: 'row',
|
|
297
|
+
alignItems: 'center',
|
|
298
|
+
backgroundColor: REC_RED,
|
|
299
|
+
paddingHorizontal: 5,
|
|
300
|
+
paddingVertical: 1.5,
|
|
301
|
+
borderRadius: 4,
|
|
302
|
+
gap: 3,
|
|
303
|
+
},
|
|
304
|
+
pipRecPaused: {
|
|
305
|
+
backgroundColor: '#C77700',
|
|
306
|
+
},
|
|
307
|
+
pipDot: {
|
|
308
|
+
width: 4,
|
|
309
|
+
height: 4,
|
|
310
|
+
borderRadius: 2,
|
|
311
|
+
backgroundColor: '#FFF',
|
|
312
|
+
},
|
|
313
|
+
pipRecText: {
|
|
314
|
+
color: '#FFF',
|
|
315
|
+
fontSize: 8,
|
|
316
|
+
fontWeight: '800',
|
|
317
|
+
letterSpacing: 0.3,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useRef, useEffect, useCallback, useState } from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { useWebMediaSession } from './web/useWebMediaSession';
|
|
4
|
+
import { useWebVideoAspectRatio } from './web/useWebVideoAspectRatio';
|
|
5
|
+
import { useWebMpegTsPlayback } from './web/useWebMpegTsPlayback';
|
|
6
|
+
import { useWebHlsPlayback } from './web/useWebHlsPlayback';
|
|
7
|
+
import { useWebAc3AudioPlayback } from './web/useWebAc3AudioPlayback';
|
|
8
|
+
|
|
9
|
+
export const WebVideoPlayer = forwardRef(({
|
|
10
|
+
streamUrl,
|
|
11
|
+
paused,
|
|
12
|
+
muted,
|
|
13
|
+
volume = 100,
|
|
14
|
+
playbackRate = 1,
|
|
15
|
+
videoAspectRatio,
|
|
16
|
+
title,
|
|
17
|
+
posterUrl,
|
|
18
|
+
isLive,
|
|
19
|
+
audioOnly = false,
|
|
20
|
+
videoOnly = false,
|
|
21
|
+
captions = [],
|
|
22
|
+
onProgress,
|
|
23
|
+
onPlaying,
|
|
24
|
+
onBuffering,
|
|
25
|
+
onError,
|
|
26
|
+
onEnded,
|
|
27
|
+
onTogglePlayPause,
|
|
28
|
+
onSeekBy,
|
|
29
|
+
onPlaybackRoute,
|
|
30
|
+
}, ref) => {
|
|
31
|
+
const videoRef = useRef(null);
|
|
32
|
+
const pausedRef = useRef(paused);
|
|
33
|
+
const playbackRateRef = useRef(playbackRate);
|
|
34
|
+
const lastKnownPosRef = useRef(0);
|
|
35
|
+
const onPlaybackRouteRef = useRef(onPlaybackRoute);
|
|
36
|
+
const onPlayingRef = useRef(onPlaying);
|
|
37
|
+
const onBufferingRef = useRef(onBuffering);
|
|
38
|
+
const onErrorRef = useRef(onError);
|
|
39
|
+
const playbackErrorRef = useRef(onError);
|
|
40
|
+
const onEndedRef = useRef(onEnded);
|
|
41
|
+
const [activeUrl, setActiveUrl] = useState('');
|
|
42
|
+
pausedRef.current = paused;
|
|
43
|
+
playbackRateRef.current = playbackRate;
|
|
44
|
+
onPlaybackRouteRef.current = onPlaybackRoute;
|
|
45
|
+
onPlayingRef.current = onPlaying;
|
|
46
|
+
onBufferingRef.current = onBuffering;
|
|
47
|
+
onErrorRef.current = onError;
|
|
48
|
+
onEndedRef.current = onEnded;
|
|
49
|
+
|
|
50
|
+
const handlePlaybackError = useCallback((error) => {
|
|
51
|
+
onErrorRef.current?.(error);
|
|
52
|
+
}, []);
|
|
53
|
+
playbackErrorRef.current = handlePlaybackError;
|
|
54
|
+
|
|
55
|
+
const mpegTsPlayback = useWebMpegTsPlayback({
|
|
56
|
+
streamUrl,
|
|
57
|
+
activeUrl,
|
|
58
|
+
isLive,
|
|
59
|
+
videoOnly,
|
|
60
|
+
videoRef,
|
|
61
|
+
pausedRef,
|
|
62
|
+
onErrorRef: playbackErrorRef,
|
|
63
|
+
onBufferingRef,
|
|
64
|
+
});
|
|
65
|
+
const useMpegTsSource = mpegTsPlayback.useMpegTs;
|
|
66
|
+
const useAc3Fallback = mpegTsPlayback.useAc3Fallback;
|
|
67
|
+
const useHlsSource = useWebHlsPlayback({
|
|
68
|
+
activeUrl,
|
|
69
|
+
isLive,
|
|
70
|
+
videoRef,
|
|
71
|
+
pausedRef,
|
|
72
|
+
onErrorRef: playbackErrorRef,
|
|
73
|
+
});
|
|
74
|
+
const ac3Audio = useWebAc3AudioPlayback({
|
|
75
|
+
active: useAc3Fallback,
|
|
76
|
+
streamUrl: activeUrl,
|
|
77
|
+
enabled: !muted,
|
|
78
|
+
paused,
|
|
79
|
+
volume,
|
|
80
|
+
videoRef,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 1. Aspect Ratio Styling
|
|
84
|
+
const { videoStyle, applyAspectRatio } = useWebVideoAspectRatio(videoAspectRatio, audioOnly);
|
|
85
|
+
const videoSource = useMpegTsSource || useHlsSource ? undefined : activeUrl;
|
|
86
|
+
let streamMode = useHlsSource ? 'hls' : 'native';
|
|
87
|
+
if (useMpegTsSource) streamMode = 'mpegts';
|
|
88
|
+
const resolvedScheme = new URL(activeUrl || 'http://localhost', 'http://localhost').protocol.replace(':', '');
|
|
89
|
+
|
|
90
|
+
// Report active streaming route
|
|
91
|
+
const reportPlaybackRoute = useCallback((video, attachedUrl) => {
|
|
92
|
+
const emit = onPlaybackRouteRef.current;
|
|
93
|
+
if (typeof emit !== 'function') return;
|
|
94
|
+
const current = video?.currentSrc || video?.src || '';
|
|
95
|
+
const httpCurrent = /^https?:/i.test(current) && !current.startsWith('blob:') ? current : '';
|
|
96
|
+
const raw = httpCurrent || attachedUrl || '';
|
|
97
|
+
if (raw) emit(raw);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
// Progress emitter
|
|
101
|
+
const emitProgress = useCallback(() => {
|
|
102
|
+
const video = videoRef.current;
|
|
103
|
+
if (!video) return;
|
|
104
|
+
|
|
105
|
+
const curTime = video.currentTime || 0;
|
|
106
|
+
const duration = video.duration || 0;
|
|
107
|
+
|
|
108
|
+
if (Number.isFinite(curTime)) {
|
|
109
|
+
lastKnownPosRef.current = curTime;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof onProgress === 'function') {
|
|
113
|
+
onProgress({
|
|
114
|
+
currentTime: curTime * 1000,
|
|
115
|
+
duration: (Number.isFinite(duration) && duration > 0 ? duration : 0) * 1000,
|
|
116
|
+
target: curTime,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}, [onProgress]);
|
|
120
|
+
|
|
121
|
+
// The host supplies the media URL. Scheme and source resolution are left to
|
|
122
|
+
// the consumer and the playback engine on each platform.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!streamUrl) {
|
|
125
|
+
setActiveUrl('');
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
setActiveUrl(streamUrl);
|
|
129
|
+
return undefined;
|
|
130
|
+
}, [streamUrl]);
|
|
131
|
+
|
|
132
|
+
// MediaSession API
|
|
133
|
+
useWebMediaSession({
|
|
134
|
+
title,
|
|
135
|
+
posterUrl,
|
|
136
|
+
isLive,
|
|
137
|
+
paused,
|
|
138
|
+
videoRef,
|
|
139
|
+
pausedRef,
|
|
140
|
+
lastKnownPosRef,
|
|
141
|
+
onTogglePlayPause,
|
|
142
|
+
onSeekBy,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Sync play/pause prop changes
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const video = videoRef.current;
|
|
148
|
+
if (!video) return;
|
|
149
|
+
|
|
150
|
+
if (paused) {
|
|
151
|
+
video.pause();
|
|
152
|
+
} else if (
|
|
153
|
+
video.dataset.mpegtsCodecGate !== 'pending'
|
|
154
|
+
&& video.paused
|
|
155
|
+
&& (video.src || video.currentSrc)
|
|
156
|
+
) {
|
|
157
|
+
video.play().catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
}, [paused, activeUrl]);
|
|
160
|
+
|
|
161
|
+
// Gain and volume
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const video = videoRef.current;
|
|
164
|
+
if (!video) return;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
video.muted = muted;
|
|
168
|
+
video.volume = Math.max(0, Math.min(1, (volume ?? 100) / 100));
|
|
169
|
+
} catch {}
|
|
170
|
+
|
|
171
|
+
}, [muted, volume, activeUrl]);
|
|
172
|
+
|
|
173
|
+
// Playback rate
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const video = videoRef.current;
|
|
176
|
+
if (!video) return;
|
|
177
|
+
if (playbackRate) {
|
|
178
|
+
try {
|
|
179
|
+
video.playbackRate = playbackRate;
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
}, [playbackRate, activeUrl]);
|
|
183
|
+
|
|
184
|
+
// Imperative handle for parent component control
|
|
185
|
+
useImperativeHandle(ref, () => ({
|
|
186
|
+
reload() {
|
|
187
|
+
const video = videoRef.current;
|
|
188
|
+
if (video && activeUrl) {
|
|
189
|
+
video.currentTime = 0;
|
|
190
|
+
video.play().catch(() => {});
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
seek(ratio) {
|
|
194
|
+
const video = videoRef.current;
|
|
195
|
+
if (!video) return;
|
|
196
|
+
const duration = video.duration || 0;
|
|
197
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
198
|
+
video.currentTime = Math.max(0, Math.min(1, ratio)) * duration;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
seekTo(seconds) {
|
|
202
|
+
const video = videoRef.current;
|
|
203
|
+
if (!video || !Number.isFinite(seconds)) return;
|
|
204
|
+
video.currentTime = seconds;
|
|
205
|
+
},
|
|
206
|
+
changeVideoAspectRatio(aspect) {
|
|
207
|
+
applyAspectRatio(aspect);
|
|
208
|
+
},
|
|
209
|
+
async requestPictureInPicture() {
|
|
210
|
+
const video = videoRef.current;
|
|
211
|
+
if (video && typeof video.requestPictureInPicture === 'function' && typeof document !== 'undefined' && document.pictureInPictureEnabled) {
|
|
212
|
+
return await video.requestPictureInPicture();
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
},
|
|
216
|
+
async exitPictureInPicture() {
|
|
217
|
+
if (typeof document !== 'undefined' && document.pictureInPictureElement && typeof document.exitPictureInPicture === 'function') {
|
|
218
|
+
return await document.exitPictureInPicture();
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
},
|
|
222
|
+
getVideoElement() {
|
|
223
|
+
return videoRef.current;
|
|
224
|
+
},
|
|
225
|
+
activateAudio(nextVolume) {
|
|
226
|
+
return ac3Audio.activateAudio(nextVolume);
|
|
227
|
+
},
|
|
228
|
+
deactivateAudio() {
|
|
229
|
+
ac3Audio.deactivateAudio();
|
|
230
|
+
},
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<View style={styles.container} collapsable={false}>
|
|
235
|
+
{activeUrl ? (
|
|
236
|
+
<video
|
|
237
|
+
key={activeUrl}
|
|
238
|
+
ref={videoRef}
|
|
239
|
+
data-resolved-scheme={resolvedScheme}
|
|
240
|
+
data-stream-mode={streamMode}
|
|
241
|
+
src={videoSource}
|
|
242
|
+
autoPlay={!paused}
|
|
243
|
+
muted={muted}
|
|
244
|
+
playsInline
|
|
245
|
+
controls={false}
|
|
246
|
+
style={videoStyle}
|
|
247
|
+
onPlaying={() => {
|
|
248
|
+
const video = videoRef.current;
|
|
249
|
+
reportPlaybackRoute(video, activeUrl);
|
|
250
|
+
onPlayingRef.current?.();
|
|
251
|
+
onBufferingRef.current?.(false);
|
|
252
|
+
emitProgress();
|
|
253
|
+
}}
|
|
254
|
+
onWaiting={() => {
|
|
255
|
+
onBufferingRef.current?.(true);
|
|
256
|
+
}}
|
|
257
|
+
onPause={() => {
|
|
258
|
+
onBufferingRef.current?.(false);
|
|
259
|
+
}}
|
|
260
|
+
onTimeUpdate={emitProgress}
|
|
261
|
+
onLoadedMetadata={() => {
|
|
262
|
+
emitProgress();
|
|
263
|
+
reportPlaybackRoute(videoRef.current, activeUrl);
|
|
264
|
+
}}
|
|
265
|
+
onEnded={() => {
|
|
266
|
+
onEndedRef.current?.();
|
|
267
|
+
}}
|
|
268
|
+
onCanPlay={() => {
|
|
269
|
+
const video = videoRef.current;
|
|
270
|
+
if (video && video.dataset.mpegtsCodecGate !== 'pending' && !pausedRef.current && video.paused) {
|
|
271
|
+
video.play().catch(() => {});
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
onError={(e) => {
|
|
275
|
+
const video = videoRef.current;
|
|
276
|
+
const mediaError = video?.error;
|
|
277
|
+
if (!video?.src && !video?.currentSrc) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (video && video.readyState >= 2) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
playbackErrorRef.current?.({
|
|
284
|
+
message: mediaError?.message || 'Failed to load stream.',
|
|
285
|
+
err: mediaError || e,
|
|
286
|
+
});
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<track
|
|
290
|
+
kind="captions"
|
|
291
|
+
src={captions[0]?.src || 'data:text/vtt,WEBVTT%0A%0A'}
|
|
292
|
+
srcLang={captions[0]?.language || 'und'}
|
|
293
|
+
label={captions[0]?.label || 'Captions'}
|
|
294
|
+
default={!captions.length || Boolean(captions[0]?.default)}
|
|
295
|
+
/>
|
|
296
|
+
{captions.slice(1).map((caption, index) => (
|
|
297
|
+
<track
|
|
298
|
+
key={caption.id || caption.src || index}
|
|
299
|
+
kind="captions"
|
|
300
|
+
src={caption.src}
|
|
301
|
+
srcLang={caption.language || 'und'}
|
|
302
|
+
label={caption.label || caption.language || `Caption ${index + 1}`}
|
|
303
|
+
default={Boolean(caption.default)}
|
|
304
|
+
/>
|
|
305
|
+
))}
|
|
306
|
+
</video>
|
|
307
|
+
) : null}
|
|
308
|
+
</View>
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const styles = StyleSheet.create({
|
|
313
|
+
container: {
|
|
314
|
+
...StyleSheet.absoluteFillObject,
|
|
315
|
+
backgroundColor: '#000',
|
|
316
|
+
justifyContent: 'center',
|
|
317
|
+
alignItems: 'center',
|
|
318
|
+
overflow: 'hidden',
|
|
319
|
+
},
|
|
320
|
+
});
|