@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,184 @@
|
|
|
1
|
+
import { StyleSheet, View, TouchableOpacity } from 'react-native';
|
|
2
|
+
import { isWeb } from '../../../utils/runtimePlatform';
|
|
3
|
+
import { getBrightnessIcon, getVolumeIcon } from './playerConstants';
|
|
4
|
+
import { PlayerIcon } from '../../customization';
|
|
5
|
+
|
|
6
|
+
export const BrightnessIndicator = ({
|
|
7
|
+
visible,
|
|
8
|
+
brightness,
|
|
9
|
+
leftInset = 28,
|
|
10
|
+
onPointerDown,
|
|
11
|
+
onNativeGrant,
|
|
12
|
+
onNativeMove,
|
|
13
|
+
}) => {
|
|
14
|
+
if (!visible) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<View
|
|
18
|
+
style={[
|
|
19
|
+
styles.verticalIndicatorContainer,
|
|
20
|
+
styles.verticalIndicatorLeft,
|
|
21
|
+
{ left: leftInset },
|
|
22
|
+
]}
|
|
23
|
+
pointerEvents="box-none"
|
|
24
|
+
>
|
|
25
|
+
<View
|
|
26
|
+
style={styles.verticalIndicatorPill}
|
|
27
|
+
{...(isWeb()
|
|
28
|
+
? {
|
|
29
|
+
onPointerDown,
|
|
30
|
+
onMouseDown: onPointerDown,
|
|
31
|
+
onTouchStart: onPointerDown,
|
|
32
|
+
}
|
|
33
|
+
: {
|
|
34
|
+
onStartShouldSetResponder: () => true,
|
|
35
|
+
onResponderGrant: onNativeGrant,
|
|
36
|
+
onResponderMove: onNativeMove,
|
|
37
|
+
})}
|
|
38
|
+
>
|
|
39
|
+
<View style={styles.verticalIndicatorIconWrap} pointerEvents="none">
|
|
40
|
+
<PlayerIcon
|
|
41
|
+
name={getBrightnessIcon(brightness)}
|
|
42
|
+
size={20}
|
|
43
|
+
color="#FFD60A"
|
|
44
|
+
/>
|
|
45
|
+
</View>
|
|
46
|
+
<View style={styles.verticalIndicatorTrack} pointerEvents="none">
|
|
47
|
+
<View
|
|
48
|
+
style={[
|
|
49
|
+
styles.verticalIndicatorFill,
|
|
50
|
+
{
|
|
51
|
+
height: `${Math.round(brightness * 100)}%`,
|
|
52
|
+
backgroundColor: '#FFD60A',
|
|
53
|
+
},
|
|
54
|
+
]}
|
|
55
|
+
/>
|
|
56
|
+
</View>
|
|
57
|
+
</View>
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const VolumeIndicator = ({
|
|
63
|
+
visible,
|
|
64
|
+
volume,
|
|
65
|
+
muted,
|
|
66
|
+
rightInset = 28,
|
|
67
|
+
onPointerDown,
|
|
68
|
+
onNativeGrant,
|
|
69
|
+
onNativeMove,
|
|
70
|
+
onToggleMute,
|
|
71
|
+
}) => {
|
|
72
|
+
if (!visible) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View
|
|
76
|
+
style={[
|
|
77
|
+
styles.verticalIndicatorContainer,
|
|
78
|
+
styles.verticalIndicatorRight,
|
|
79
|
+
{ right: rightInset },
|
|
80
|
+
]}
|
|
81
|
+
pointerEvents="box-none"
|
|
82
|
+
>
|
|
83
|
+
<View
|
|
84
|
+
style={styles.verticalIndicatorPill}
|
|
85
|
+
{...(isWeb()
|
|
86
|
+
? {
|
|
87
|
+
onPointerDown,
|
|
88
|
+
onMouseDown: onPointerDown,
|
|
89
|
+
onTouchStart: onPointerDown,
|
|
90
|
+
}
|
|
91
|
+
: {
|
|
92
|
+
onStartShouldSetResponder: () => true,
|
|
93
|
+
onResponderGrant: onNativeGrant,
|
|
94
|
+
onResponderMove: onNativeMove,
|
|
95
|
+
})}
|
|
96
|
+
>
|
|
97
|
+
<TouchableOpacity
|
|
98
|
+
style={styles.verticalIndicatorIconWrap}
|
|
99
|
+
onPress={(e) => {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
onToggleMute();
|
|
102
|
+
}}
|
|
103
|
+
activeOpacity={0.7}
|
|
104
|
+
>
|
|
105
|
+
<PlayerIcon
|
|
106
|
+
name={getVolumeIcon(muted ? 0 : volume)}
|
|
107
|
+
size={20}
|
|
108
|
+
color={muted ? '#FF5252' : '#00E5FF'}
|
|
109
|
+
/>
|
|
110
|
+
</TouchableOpacity>
|
|
111
|
+
<View style={styles.verticalIndicatorTrack} pointerEvents="none">
|
|
112
|
+
<View
|
|
113
|
+
style={[
|
|
114
|
+
styles.verticalIndicatorFill,
|
|
115
|
+
{
|
|
116
|
+
height: `${muted ? 0 : volume}%`,
|
|
117
|
+
backgroundColor: muted ? '#FF5252' : '#00E5FF',
|
|
118
|
+
},
|
|
119
|
+
]}
|
|
120
|
+
/>
|
|
121
|
+
</View>
|
|
122
|
+
</View>
|
|
123
|
+
</View>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const styles = StyleSheet.create({
|
|
128
|
+
verticalIndicatorContainer: {
|
|
129
|
+
position: 'absolute',
|
|
130
|
+
top: 0,
|
|
131
|
+
bottom: 32,
|
|
132
|
+
justifyContent: 'center',
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
zIndex: 150,
|
|
135
|
+
},
|
|
136
|
+
verticalIndicatorLeft: {
|
|
137
|
+
left: 20,
|
|
138
|
+
},
|
|
139
|
+
verticalIndicatorRight: {
|
|
140
|
+
right: 20,
|
|
141
|
+
},
|
|
142
|
+
verticalIndicatorPill: {
|
|
143
|
+
width: 48,
|
|
144
|
+
height: 184,
|
|
145
|
+
paddingVertical: 12,
|
|
146
|
+
paddingHorizontal: 6,
|
|
147
|
+
borderRadius: 24,
|
|
148
|
+
backgroundColor: 'rgba(0, 0, 0, 0.48)',
|
|
149
|
+
borderWidth: 1,
|
|
150
|
+
borderColor: 'rgba(255, 255, 255, 0.16)',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
justifyContent: 'center',
|
|
153
|
+
shadowColor: '#000',
|
|
154
|
+
shadowOffset: { width: 0, height: 4 },
|
|
155
|
+
shadowOpacity: 0.35,
|
|
156
|
+
shadowRadius: 8,
|
|
157
|
+
elevation: 6,
|
|
158
|
+
...(isWeb() ? {
|
|
159
|
+
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.35)',
|
|
160
|
+
cursor: 'pointer',
|
|
161
|
+
userSelect: 'none',
|
|
162
|
+
touchAction: 'none',
|
|
163
|
+
} : null),
|
|
164
|
+
},
|
|
165
|
+
verticalIndicatorIconWrap: {
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
justifyContent: 'center',
|
|
168
|
+
height: 26,
|
|
169
|
+
width: 26,
|
|
170
|
+
},
|
|
171
|
+
verticalIndicatorTrack: {
|
|
172
|
+
width: 8,
|
|
173
|
+
flex: 1,
|
|
174
|
+
marginVertical: 8,
|
|
175
|
+
borderRadius: 4,
|
|
176
|
+
backgroundColor: 'rgba(255, 255, 255, 0.18)',
|
|
177
|
+
overflow: 'hidden',
|
|
178
|
+
justifyContent: 'flex-end',
|
|
179
|
+
},
|
|
180
|
+
verticalIndicatorFill: {
|
|
181
|
+
width: '100%',
|
|
182
|
+
borderRadius: 4,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './playerConstants';
|
|
2
|
+
export * from './VLCBoundary';
|
|
3
|
+
export * from './ExoVideoFallback';
|
|
4
|
+
export * from './AudioOnlyView';
|
|
5
|
+
export * from './PipOverlay';
|
|
6
|
+
export * from './CenterControls';
|
|
7
|
+
export * from './PlayerTopBar';
|
|
8
|
+
export * from './PlayerBottomBar';
|
|
9
|
+
export * from './VerticalIndicator';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Easing } from 'react-native';
|
|
2
|
+
import { isWeb } from '../../../utils/runtimePlatform';
|
|
3
|
+
|
|
4
|
+
export const USER_AGENT = 'Lavf/58.29.100';
|
|
5
|
+
export const PLAYBACK_SPEEDS = [0.25, 0.5, 1, 1.25, 1.5, 1.75, 2];
|
|
6
|
+
export const PIP_WIDTH = isWeb() ? 360 : 280;
|
|
7
|
+
export const PIP_HEIGHT = isWeb() ? 202 : 158;
|
|
8
|
+
export const PIP_RIGHT = isWeb() ? 24 : 16;
|
|
9
|
+
export const PIP_BOTTOM = isWeb() ? 24 : 36;
|
|
10
|
+
export const PIP_ANIM_EASING = Easing.bezier(0.22, 1, 0.36, 1);
|
|
11
|
+
export const PIP_CORNERS = ['tl', 'tr', 'bl', 'br'];
|
|
12
|
+
|
|
13
|
+
export function getPipCornerPosition(corner, winW, winH, insets = {}) {
|
|
14
|
+
const padX = PIP_RIGHT;
|
|
15
|
+
const padTop = Math.max(insets.top || 0, isWeb() ? 16 : 8) + 8;
|
|
16
|
+
const padBottom = PIP_BOTTOM;
|
|
17
|
+
const left = corner === 'tl' || corner === 'bl'
|
|
18
|
+
? padX
|
|
19
|
+
: Math.max(padX, winW - PIP_WIDTH - padX);
|
|
20
|
+
const top = corner === 'tl' || corner === 'tr'
|
|
21
|
+
? padTop
|
|
22
|
+
: Math.max(padBottom, winH - PIP_HEIGHT - padBottom);
|
|
23
|
+
return { left, top };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nearestPipCorner(x, y, winW, winH, insets) {
|
|
27
|
+
let best = 'br';
|
|
28
|
+
let bestDist = Infinity;
|
|
29
|
+
for (const corner of PIP_CORNERS) {
|
|
30
|
+
const pos = getPipCornerPosition(corner, winW, winH, insets);
|
|
31
|
+
const dist = (pos.left - x) ** 2 + (pos.top - y) ** 2;
|
|
32
|
+
if (dist < bestDist) {
|
|
33
|
+
bestDist = dist;
|
|
34
|
+
best = corner;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return best;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const ASPECT_OPTIONS = [
|
|
41
|
+
{ label: 'Fit Screen', value: 'FIT' },
|
|
42
|
+
{ label: 'Fill Screen', value: 'FILL_SCREEN' },
|
|
43
|
+
{ label: '16:9', value: '16:9' },
|
|
44
|
+
{ label: '4:3', value: '4:3' },
|
|
45
|
+
{ label: '21:9', value: '21:9' },
|
|
46
|
+
{ label: '1:1', value: '1:1' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function calculateScreenAspectRatio(width, height) {
|
|
50
|
+
if (!width || !height) return '16:9';
|
|
51
|
+
const w = Math.max(width, height);
|
|
52
|
+
const h = Math.min(width, height);
|
|
53
|
+
const ratio = w / h;
|
|
54
|
+
|
|
55
|
+
if (Math.abs(ratio - (16 / 9)) < 0.05) return '16:9';
|
|
56
|
+
if (Math.abs(ratio - (4 / 3)) < 0.05) return '4:3';
|
|
57
|
+
if (Math.abs(ratio - (16 / 10)) < 0.05) return '16:10';
|
|
58
|
+
if (Math.abs(ratio - (18 / 9)) < 0.05) return '18:9';
|
|
59
|
+
if (Math.abs(ratio - (19.5 / 9)) < 0.05) return '39:18';
|
|
60
|
+
if (Math.abs(ratio - (20 / 9)) < 0.05) return '20:9';
|
|
61
|
+
if (Math.abs(ratio - (21 / 9)) < 0.05) return '21:9';
|
|
62
|
+
|
|
63
|
+
const num = Math.round(ratio * 100);
|
|
64
|
+
return `${num}:100`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatTime(seconds) {
|
|
68
|
+
if (!seconds || Number.isNaN(seconds) || seconds <= 0) return '00:00';
|
|
69
|
+
const totalSecs = Math.floor(seconds);
|
|
70
|
+
const mins = Math.floor(totalSecs / 60);
|
|
71
|
+
const secs = totalSecs % 60;
|
|
72
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
73
|
+
if (mins >= 60) {
|
|
74
|
+
const hrs = Math.floor(mins / 60);
|
|
75
|
+
return `${hrs}:${pad(mins % 60)}:${pad(secs)}`;
|
|
76
|
+
}
|
|
77
|
+
return `${pad(mins)}:${pad(secs)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getBrightnessIcon(val) {
|
|
81
|
+
if (val >= 0.7) return 'white-balance-sunny';
|
|
82
|
+
if (val >= 0.4) return 'brightness-6';
|
|
83
|
+
return 'brightness-4';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isSafariOrIOS() {
|
|
87
|
+
if (!isWeb() || typeof navigator === 'undefined') return false;
|
|
88
|
+
const ua = navigator.userAgent || '';
|
|
89
|
+
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
90
|
+
const isSafari = /^((?!chrome|android).)*safari/i.test(ua) || (/AppleWebKit/i.test(ua) && !/Chrome|CriOS|OPR|Edge|Edg/i.test(ua));
|
|
91
|
+
return isIOS || isSafari;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getVolumeIcon(vol) {
|
|
95
|
+
if (!vol || vol <= 0) return 'volume-mute';
|
|
96
|
+
if (vol > 50) return 'volume-high';
|
|
97
|
+
return 'volume-medium';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getWebPoint(e) {
|
|
101
|
+
const ne = e?.nativeEvent ?? e;
|
|
102
|
+
const touch = e?.touches?.[0]
|
|
103
|
+
|| ne?.touches?.[0]
|
|
104
|
+
|| ne?.changedTouches?.[0];
|
|
105
|
+
const x = Number(touch?.clientX ?? e?.clientX ?? ne?.clientX ?? ne?.pageX);
|
|
106
|
+
const y = Number(touch?.clientY ?? e?.clientY ?? ne?.clientY ?? ne?.pageY);
|
|
107
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
108
|
+
return { x, y };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function bindWebVerticalDrag(e, updateFromY) {
|
|
112
|
+
if (bindWebVerticalDrag.active) return;
|
|
113
|
+
if (typeof e?.stopPropagation === 'function') e.stopPropagation();
|
|
114
|
+
const point = getWebPoint(e);
|
|
115
|
+
if (!point) return;
|
|
116
|
+
const target = e.currentTarget;
|
|
117
|
+
const rect = typeof target?.getBoundingClientRect === 'function'
|
|
118
|
+
? target.getBoundingClientRect()
|
|
119
|
+
: null;
|
|
120
|
+
if (!rect) return;
|
|
121
|
+
bindWebVerticalDrag.active = true;
|
|
122
|
+
updateFromY(point.y, rect.top, rect.height);
|
|
123
|
+
const pointerId = e.pointerId ?? e.nativeEvent?.pointerId;
|
|
124
|
+
if (pointerId !== null && typeof target.setPointerCapture === 'function') {
|
|
125
|
+
try { target.setPointerCapture(pointerId); } catch { /* ignore */ }
|
|
126
|
+
}
|
|
127
|
+
const onMove = (moveEvt) => {
|
|
128
|
+
const next = getWebPoint(moveEvt);
|
|
129
|
+
if (!next) return;
|
|
130
|
+
if (typeof moveEvt.preventDefault === 'function') moveEvt.preventDefault();
|
|
131
|
+
updateFromY(next.y, rect.top, rect.height);
|
|
132
|
+
};
|
|
133
|
+
const onUp = () => {
|
|
134
|
+
bindWebVerticalDrag.active = false;
|
|
135
|
+
window.removeEventListener('pointermove', onMove);
|
|
136
|
+
window.removeEventListener('pointerup', onUp);
|
|
137
|
+
window.removeEventListener('pointercancel', onUp);
|
|
138
|
+
window.removeEventListener('touchmove', onMove);
|
|
139
|
+
window.removeEventListener('touchend', onUp);
|
|
140
|
+
window.removeEventListener('mousemove', onMove);
|
|
141
|
+
window.removeEventListener('mouseup', onUp);
|
|
142
|
+
};
|
|
143
|
+
window.addEventListener('pointermove', onMove, { passive: false });
|
|
144
|
+
window.addEventListener('pointerup', onUp);
|
|
145
|
+
window.addEventListener('pointercancel', onUp);
|
|
146
|
+
window.addEventListener('touchmove', onMove, { passive: false });
|
|
147
|
+
window.addEventListener('touchend', onUp);
|
|
148
|
+
window.addEventListener('mousemove', onMove);
|
|
149
|
+
window.addEventListener('mouseup', onUp);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function applyTrackDefaults(selected, setSelected, setTracks, tracksProp) {
|
|
153
|
+
if (!Array.isArray(tracksProp)) return;
|
|
154
|
+
setTracks(tracksProp);
|
|
155
|
+
if (selected !== null) return;
|
|
156
|
+
const active = tracksProp.find((t) => t.selected || t.active) || tracksProp[0];
|
|
157
|
+
if (active) setSelected(active.id);
|
|
158
|
+
}
|
|
159
|
+
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
function createVideoClockSynchronizer({ audio, videoRef, isCancelled }) {
|
|
4
|
+
let anchorTimestamp = null;
|
|
5
|
+
let previousTimestamp = null;
|
|
6
|
+
let anchorVideoTime = null;
|
|
7
|
+
|
|
8
|
+
return async (timestamp) => {
|
|
9
|
+
const context = audio.context;
|
|
10
|
+
const video = videoRef.current;
|
|
11
|
+
if (!video) return null;
|
|
12
|
+
if (anchorTimestamp === null) {
|
|
13
|
+
anchorTimestamp = timestamp;
|
|
14
|
+
anchorVideoTime = Number(video.currentTime) || 0;
|
|
15
|
+
}
|
|
16
|
+
if (previousTimestamp !== null && timestamp < previousTimestamp - 1) {
|
|
17
|
+
anchorTimestamp = timestamp;
|
|
18
|
+
anchorVideoTime = Number(video.currentTime) || anchorVideoTime;
|
|
19
|
+
}
|
|
20
|
+
previousTimestamp = timestamp;
|
|
21
|
+
|
|
22
|
+
const getVideoDelta = () => anchorVideoTime + (timestamp - anchorTimestamp)
|
|
23
|
+
- (Number(video.currentTime) || 0);
|
|
24
|
+
let videoDelta = getVideoDelta();
|
|
25
|
+
if (videoDelta < -0.25) return null;
|
|
26
|
+
|
|
27
|
+
while (!isCancelled() && (context.state !== 'running' || videoDelta > 0.75)) {
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
29
|
+
if (isCancelled()) return null;
|
|
30
|
+
videoDelta = getVideoDelta();
|
|
31
|
+
}
|
|
32
|
+
if (videoDelta < -0.25) return null;
|
|
33
|
+
return { video, context, videoDelta };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function scheduleAc3Samples({ iterator, audio, videoRef, sources, isCancelled, setState }) {
|
|
38
|
+
const synchronize = createVideoClockSynchronizer({ audio, videoRef, isCancelled });
|
|
39
|
+
let decodedSampleCount = 0;
|
|
40
|
+
for await (const sample of iterator) {
|
|
41
|
+
if (isCancelled()) {
|
|
42
|
+
sample.close();
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const buffer = sample.toAudioBuffer();
|
|
46
|
+
const timestamp = sample.timestamp;
|
|
47
|
+
sample.close();
|
|
48
|
+
if (isCancelled() || !buffer.numberOfChannels || !buffer.length) continue;
|
|
49
|
+
|
|
50
|
+
const timing = await synchronize(timestamp);
|
|
51
|
+
if (!timing || isCancelled()) continue;
|
|
52
|
+
const source = timing.context.createBufferSource();
|
|
53
|
+
source.buffer = buffer;
|
|
54
|
+
source.connect(audio.gain);
|
|
55
|
+
source.onended = () => {
|
|
56
|
+
sources.delete(source);
|
|
57
|
+
source.disconnect();
|
|
58
|
+
};
|
|
59
|
+
sources.add(source);
|
|
60
|
+
const playbackRate = Math.max(0.25, Number(timing.video.playbackRate) || 1);
|
|
61
|
+
source.start(timing.context.currentTime + Math.max(0.005, timing.videoDelta / playbackRate));
|
|
62
|
+
decodedSampleCount += 1;
|
|
63
|
+
if (decodedSampleCount === 1 || decodedSampleCount % 64 === 0) {
|
|
64
|
+
setState(`playing:${decodedSampleCount}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function decodeAc3Stream({ streamUrl, audio, videoRef, resources, sources, isCancelled, setState }) {
|
|
70
|
+
try {
|
|
71
|
+
const [{ AudioSampleSink, Input, MPEG_TS, UrlSource }, { registerAc3Decoder }] = await Promise.all([
|
|
72
|
+
import('mediabunny'),
|
|
73
|
+
import('@mediabunny/ac3'),
|
|
74
|
+
]);
|
|
75
|
+
if (isCancelled()) return;
|
|
76
|
+
registerAc3Decoder();
|
|
77
|
+
|
|
78
|
+
resources.input = new Input({ source: new UrlSource(streamUrl), formats: [MPEG_TS] });
|
|
79
|
+
const audioTrack = await resources.input.getPrimaryAudioTrack();
|
|
80
|
+
if (isCancelled()) return;
|
|
81
|
+
if (!audioTrack) {
|
|
82
|
+
setState('no-audio-track');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
resources.iterator = new AudioSampleSink(audioTrack).samples();
|
|
87
|
+
setState('decoding');
|
|
88
|
+
await scheduleAc3Samples({
|
|
89
|
+
iterator: resources.iterator,
|
|
90
|
+
audio,
|
|
91
|
+
videoRef,
|
|
92
|
+
sources,
|
|
93
|
+
isCancelled,
|
|
94
|
+
setState,
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!isCancelled()) setState(`error:${error?.name || 'decode'}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useWebAc3AudioPlayback({
|
|
102
|
+
active,
|
|
103
|
+
streamUrl,
|
|
104
|
+
enabled,
|
|
105
|
+
paused,
|
|
106
|
+
volume,
|
|
107
|
+
videoRef,
|
|
108
|
+
}) {
|
|
109
|
+
const audioRef = useRef({ context: null, gain: null });
|
|
110
|
+
const enabledRef = useRef(enabled);
|
|
111
|
+
const volumeRef = useRef(volume);
|
|
112
|
+
enabledRef.current = enabled;
|
|
113
|
+
volumeRef.current = volume;
|
|
114
|
+
|
|
115
|
+
const ensureAudioContext = useCallback(() => {
|
|
116
|
+
if (typeof window === 'undefined') return null;
|
|
117
|
+
if (audioRef.current.context) return audioRef.current;
|
|
118
|
+
|
|
119
|
+
const AudioContextConstructor = window.AudioContext || window.webkitAudioContext;
|
|
120
|
+
if (!AudioContextConstructor) return null;
|
|
121
|
+
|
|
122
|
+
const context = new AudioContextConstructor({ latencyHint: 'interactive' });
|
|
123
|
+
const gain = context.createGain();
|
|
124
|
+
gain.gain.value = enabledRef.current
|
|
125
|
+
? Math.max(0, Math.min(1, Number(volumeRef.current) / 100))
|
|
126
|
+
: 0;
|
|
127
|
+
gain.connect(context.destination);
|
|
128
|
+
audioRef.current = { context, gain };
|
|
129
|
+
return audioRef.current;
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const activateAudio = useCallback((nextVolume) => {
|
|
133
|
+
const audio = ensureAudioContext();
|
|
134
|
+
if (!audio) return false;
|
|
135
|
+
|
|
136
|
+
const target = Math.max(0, Math.min(1, Number(nextVolume ?? volumeRef.current) / 100));
|
|
137
|
+
audio.gain.gain.setTargetAtTime(target, audio.context.currentTime, 0.025);
|
|
138
|
+
if (audio.context.state === 'suspended') {
|
|
139
|
+
audio.context.resume().catch(() => {});
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}, [ensureAudioContext]);
|
|
143
|
+
|
|
144
|
+
const deactivateAudio = useCallback(() => {
|
|
145
|
+
const audio = audioRef.current;
|
|
146
|
+
if (!audio.context || !audio.gain) return;
|
|
147
|
+
audio.gain.gain.setTargetAtTime(0, audio.context.currentTime, 0.02);
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const { context, gain } = audioRef.current;
|
|
152
|
+
if (!context || !gain) return;
|
|
153
|
+
const target = enabled ? Math.max(0, Math.min(1, Number(volume) / 100)) : 0;
|
|
154
|
+
gain.gain.setTargetAtTime(target, context.currentTime, 0.025);
|
|
155
|
+
}, [enabled, volume]);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!active) return undefined;
|
|
159
|
+
ensureAudioContext();
|
|
160
|
+
return undefined;
|
|
161
|
+
}, [active, ensureAudioContext]);
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const video = videoRef.current;
|
|
165
|
+
const setState = (state) => {
|
|
166
|
+
if (video) video.dataset.ac3AudioState = state;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (!active || !streamUrl) {
|
|
170
|
+
if (video) delete video.dataset.ac3AudioState;
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
if (!enabled || paused) {
|
|
174
|
+
deactivateAudio();
|
|
175
|
+
setState(enabled ? 'paused' : 'muted');
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let cancelled = false;
|
|
180
|
+
const resources = { input: null, iterator: null };
|
|
181
|
+
const sources = new Set();
|
|
182
|
+
const audio = ensureAudioContext();
|
|
183
|
+
|
|
184
|
+
if (!audio) {
|
|
185
|
+
setState('unsupported');
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
audio.gain.gain.setTargetAtTime(Math.max(0, Math.min(1, Number(volume) / 100)), audio.context.currentTime, 0.025);
|
|
190
|
+
setState('loading');
|
|
191
|
+
decodeAc3Stream({
|
|
192
|
+
streamUrl,
|
|
193
|
+
audio,
|
|
194
|
+
videoRef,
|
|
195
|
+
resources,
|
|
196
|
+
sources,
|
|
197
|
+
isCancelled: () => cancelled,
|
|
198
|
+
setState,
|
|
199
|
+
});
|
|
200
|
+
return () => {
|
|
201
|
+
cancelled = true;
|
|
202
|
+
try { resources.iterator?.return?.(); } catch {}
|
|
203
|
+
try { resources.input?.dispose(); } catch {}
|
|
204
|
+
for (const source of sources) {
|
|
205
|
+
try { source.stop(); } catch {}
|
|
206
|
+
try { source.disconnect(); } catch {}
|
|
207
|
+
}
|
|
208
|
+
sources.clear();
|
|
209
|
+
};
|
|
210
|
+
}, [active, streamUrl, enabled, paused, videoRef, ensureAudioContext, deactivateAudio]);
|
|
211
|
+
|
|
212
|
+
useEffect(() => () => {
|
|
213
|
+
const { context } = audioRef.current;
|
|
214
|
+
if (context && context.state !== 'closed') context.close().catch(() => {});
|
|
215
|
+
audioRef.current = { context: null, gain: null };
|
|
216
|
+
}, []);
|
|
217
|
+
|
|
218
|
+
return { activateAudio, deactivateAudio };
|
|
219
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import Hls from 'hls.js';
|
|
3
|
+
|
|
4
|
+
const isHlsUrl = (url) => /\.m3u8(?:$|[?#])/i.test(String(url || ''));
|
|
5
|
+
|
|
6
|
+
export function useWebHlsPlayback({ activeUrl, isLive, videoRef, pausedRef, onErrorRef }) {
|
|
7
|
+
const useHls = isLive && isHlsUrl(activeUrl);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!useHls || !activeUrl || typeof window === 'undefined') return undefined;
|
|
11
|
+
const video = videoRef.current;
|
|
12
|
+
if (!video) return undefined;
|
|
13
|
+
|
|
14
|
+
let hls;
|
|
15
|
+
let disposed = false;
|
|
16
|
+
if (Hls.isSupported()) {
|
|
17
|
+
hls = new Hls({
|
|
18
|
+
enableWorker: true,
|
|
19
|
+
lowLatencyMode: false,
|
|
20
|
+
backBufferLength: 30,
|
|
21
|
+
manifestLoadingTimeOut: 8000,
|
|
22
|
+
manifestLoadingMaxRetry: 3,
|
|
23
|
+
manifestLoadingRetryDelay: 1000,
|
|
24
|
+
manifestLoadingMaxRetryTimeout: 8000,
|
|
25
|
+
levelLoadingTimeOut: 8000,
|
|
26
|
+
levelLoadingMaxRetry: 2,
|
|
27
|
+
levelLoadingRetryDelay: 1000,
|
|
28
|
+
levelLoadingMaxRetryTimeout: 8000,
|
|
29
|
+
fragLoadingTimeOut: 10000,
|
|
30
|
+
fragLoadingMaxRetry: 4,
|
|
31
|
+
fragLoadingRetryDelay: 1000,
|
|
32
|
+
fragLoadingMaxRetryTimeout: 15000,
|
|
33
|
+
});
|
|
34
|
+
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
35
|
+
if (disposed || !data?.fatal) return;
|
|
36
|
+
const status = Number(data?.response?.code || data?.response?.status);
|
|
37
|
+
const httpStatus = Number.isFinite(status) && status >= 100 ? status : null;
|
|
38
|
+
const message = httpStatus
|
|
39
|
+
? `Stream request failed (HTTP ${httpStatus}).`
|
|
40
|
+
: `HLS playback failed (${String(data?.details || data?.type || 'unknown error')}).`;
|
|
41
|
+
onErrorRef.current?.({ message, httpStatus, err: data });
|
|
42
|
+
});
|
|
43
|
+
hls.loadSource(activeUrl);
|
|
44
|
+
hls.attachMedia(video);
|
|
45
|
+
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
46
|
+
video.src = activeUrl;
|
|
47
|
+
video.load();
|
|
48
|
+
if (!pausedRef.current) video.play().catch(() => {});
|
|
49
|
+
} else {
|
|
50
|
+
onErrorRef.current?.({ message: 'This browser does not support HLS playback.' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
disposed = true;
|
|
55
|
+
if (hls) {
|
|
56
|
+
hls.destroy();
|
|
57
|
+
} else if (video.src === activeUrl) {
|
|
58
|
+
video.pause();
|
|
59
|
+
video.removeAttribute('src');
|
|
60
|
+
video.load();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}, [activeUrl, useHls, videoRef, pausedRef, onErrorRef]);
|
|
64
|
+
|
|
65
|
+
return useHls;
|
|
66
|
+
}
|