@cinecrew/cinecrew-player 0.1.6 → 0.1.8
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/README.md +19 -2
- package/package.json +5 -3
- package/src/native/InlineLivePlayer.js +11 -1
- package/src/native/media/WebVideoPlayer.js +74 -7
- package/src/native/media/web/useWebDashPlayback.native.js +3 -0
- package/src/native/media/web/useWebDashPlayback.web.js +84 -0
- package/src/native/media/web/useWebFlvPlayback.web.js +156 -0
- package/src/native/media/web/useWebHlsPlayback.web.js +25 -2
- package/src/native/media/web/useWebMpegTsPlayback.native.js +1 -1
- package/src/native/media/web/useWebMpegTsPlayback.web.js +72 -22
- package/src/native/media/web/useWebOgvPlayback.native.js +3 -0
- package/src/native/media/web/useWebOgvPlayback.web.js +312 -0
- package/src/utils/playerError.js +34 -0
- package/src/utils/webRecording.js +17 -5
- package/src/web/index.js +215 -76
- package/src/web/styles.css +37 -4
- package/types/index.d.ts +20 -2
- package/vendor/mpegts.js/mpegts.js +3 -2
package/README.md
CHANGED
|
@@ -112,6 +112,7 @@ flowchart LR
|
|
|
112
112
|
| MP4 / browser-native media | ✅ Browser media element | ✅ Native engine | A directly playable URL or local URI |
|
|
113
113
|
| HLS (`.m3u8`) | ✅ hls.js / native HLS where available | ✅ Native engine | Origin access, valid playlist/segments, compatible codecs |
|
|
114
114
|
| MPEG-TS (`.ts`) | ✅ Bundled patched MPEG-TS client, when browser conditions permit | ✅ VLC path | Browser codecs and CORS; native module availability |
|
|
115
|
+
| Ogg/Theora (`.ogv`) | ✅ `ogv.js` WebAssembly decoder | ✅ Native VLC path | Web uses the versioned ogv.js worker/WASM assets; provide `ogvResourceBase` to self-host them |
|
|
115
116
|
| Hosted-video or share pages | ⚙️ Resolve to a direct media URL with `resolveSource` | ⚙️ Resolve to a direct media URL with `resolveSource` | A watch/share page is not a media stream |
|
|
116
117
|
| Local files | ✅ Platform-supported local/blob URI | ✅ Platform-supported file URI | App obtains and passes the platform-readable URI |
|
|
117
118
|
| Share pages / cloud-drive pages | ⚙️ Optional `resolveSource` | ⚙️ Optional `resolveSource` | Your app resolves authentication and obtains a playable media URL |
|
|
@@ -352,6 +353,7 @@ In short: CineCrew’s intended distinction is **one app-facing player package f
|
|
|
352
353
|
| `videoOnly` | `boolean` | `false` | Start muted in video-only mode. |
|
|
353
354
|
| `audioOnly` | `boolean` | `false` | Start in audio-only presentation. Playback continues while the visual card is shown. |
|
|
354
355
|
| `resolveSource` | callback | — | Optional synchronous or asynchronous resolver for share pages and provider-specific links. Receives `{ uri, ...source }` and `{ platform }`; return a playable URL or `PlayerSource`. Without it, the original source is passed through unchanged. |
|
|
356
|
+
| `ogvResourceBase` | `string` | versioned jsDelivr ogv.js assets | Optional base URL for the OGV decoder’s worker and WebAssembly files. Use this to self-host the matching `ogv@1.9.0/dist/` resources. Web OGV playback is selected by `.ogv` URLs or the `video/ogg` type. |
|
|
355
357
|
| `audioTracks` | `AudioTrack[]` | detected | Optional supplied track list (web). Native tracks are read from the native player. |
|
|
356
358
|
| `selectedAudioTrack` | `string \| number` | first/default track | Initial or preferred audio track. |
|
|
357
359
|
| `resumePosition` | `number` | `0` | Resume position in seconds for on-demand playback. |
|
|
@@ -362,7 +364,7 @@ In short: CineCrew’s intended distinction is **one app-facing player package f
|
|
|
362
364
|
| `onClose`, `onBack` | callbacks | — | App-owned navigation callbacks; Back does not close the player unless your callback does so. |
|
|
363
365
|
| `onAspectRatioChange` | `PlayerAction` | — | Top-level callback invoked after the player applies the selected aspect ratio; `actions.onAspectRatioChange` takes precedence if both are supplied. |
|
|
364
366
|
| `onProgressBarChange` | `(time: string) => void` | — | When the progress bar is shown, reports the played position as zero-padded `HH:MM:SS` once per elapsed playback second, and immediately after a completed seek or restart. Scrubbing reports the committed position, not every intermediate drag update. It is not called when `showProgressBar` is false, `controls.seek` is false, or the live-player UI hides seeking. |
|
|
365
|
-
| `onReady`, `onProgress`, `onPlaying`, `onBuffering`, `onError`, `onEnded`, `onPlaybackRoute` | callbacks | — | Playback lifecycle callbacks.
|
|
367
|
+
| `onReady`, `onProgress`, `onPlaying`, `onBuffering`, `onError`, `onEnded`, `onPlaybackRoute` | callbacks | — | Playback lifecycle callbacks. `onError` receives a player-facing `message` plus `actualMessage` and the original engine error under `cause`/`err` when available; progress payloads are platform-specific native/browser events. |
|
|
366
368
|
| `onNextEpisode`, `onCwRefresh` | callbacks | — | Episode advancement and post-close refresh hooks. |
|
|
367
369
|
| `renderLiveChat`, `renderEpg` | render functions | — | Web custom-panel render slots. On native, use the chat/EPG integration adapters. |
|
|
368
370
|
| `initialShowLiveChat`, `liveChatNonce` | `boolean`, `number` | `false`, `0` | Open or re-open the live-chat panel (when available). |
|
|
@@ -370,6 +372,21 @@ In short: CineCrew’s intended distinction is **one app-facing player package f
|
|
|
370
372
|
|
|
371
373
|
`features={{ diagnostics: true }}` enables the diagnostics button and built-in stream status panel on web; `controls.diagnostics` can hide it. `onFullscreen` receives `{ isFullscreen }` after the player requests the fullscreen change. All lifecycle callbacks in the table are optional; native event objects differ from browser events.
|
|
372
374
|
|
|
375
|
+
Use `onError` to receive the same playback failure shown in the player and inspect the original platform/decoder diagnostic. `message` is the player-facing summary; `actualMessage` prefers the underlying error text when the engine provides one, while `cause` and `err` retain the original engine error/event for logging or app-specific reporting:
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
<CineCrewPlayer
|
|
379
|
+
source={source}
|
|
380
|
+
onError={(error) => {
|
|
381
|
+
const diagnostic = error instanceof Error
|
|
382
|
+
? error.message
|
|
383
|
+
: error.actualMessage || error.message;
|
|
384
|
+
showSnackbar(diagnostic);
|
|
385
|
+
console.error('Playback error details:', error.cause || error.err || error);
|
|
386
|
+
}}
|
|
387
|
+
/>
|
|
388
|
+
```
|
|
389
|
+
|
|
373
390
|
### Player source
|
|
374
391
|
|
|
375
392
|
```ts
|
|
@@ -406,7 +423,7 @@ type PlayerSource = string | {
|
|
|
406
423
|
| `actions` | matching `PlayerActions` subset | built-in | Observe play/pause, mute, or fullscreen actions after their built-in behavior runs. |
|
|
407
424
|
| `initialMuted` | `boolean` | `true` | Initial preview mute state. |
|
|
408
425
|
| `theme`, `icons`, `style` | `PlayerTheme`, `PlayerIcons`, platform style | defaults | Customize preview colors, controls, and layout. |
|
|
409
|
-
| `onError`, `onPlaying` | callbacks | — | Playback lifecycle callbacks. |
|
|
426
|
+
| `onError`, `onPlaying` | callbacks | — | Playback lifecycle callbacks. `onError` includes the underlying `actualMessage` and raw `cause`/`err` when available. |
|
|
410
427
|
|
|
411
428
|
## Control visibility
|
|
412
429
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cinecrew/cinecrew-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Customizable cross-platform live and on-demand video player for React and React Native.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -108,12 +108,14 @@
|
|
|
108
108
|
"@expo/vector-icons": "^15.1.1",
|
|
109
109
|
"@mediabunny/ac3": "^1.59.0",
|
|
110
110
|
"@react-native-community/slider": "^5.2.0",
|
|
111
|
+
"dashjs": "^5.2.1",
|
|
111
112
|
"expo-video": "^57.0.2",
|
|
113
|
+
"flv.js": "^1.6.2",
|
|
112
114
|
"hls.js": "^1.7.3",
|
|
113
115
|
"mediabunny": "^1.59.0",
|
|
116
|
+
"prop-types": "^15.8.1",
|
|
114
117
|
"react-native-safe-area-context": "^5.7.0",
|
|
115
|
-
"react-native-svg": "^15.15.4"
|
|
116
|
-
"prop-types": "^15.8.1"
|
|
118
|
+
"react-native-svg": "^15.15.4"
|
|
117
119
|
},
|
|
118
120
|
"peerDependencies": {
|
|
119
121
|
"react": ">=18",
|
|
@@ -15,6 +15,7 @@ import { ElectronVideoPlayer } from './media/ElectronVideoPlayer';
|
|
|
15
15
|
import { isAndroid, isElectron, isIOS, isWeb } from '../utils/runtimePlatform';
|
|
16
16
|
import { USER_AGENT } from './media/player/playerConstants';
|
|
17
17
|
import { invokePlayerAction } from '../utils/invokePlayerAction.js';
|
|
18
|
+
import { getPlayerErrorMessage } from '../utils/playerError.js';
|
|
18
19
|
|
|
19
20
|
function getArtwork(channel) {
|
|
20
21
|
return channel?.logoUrl || channel?.logo || channel?.stream_icon || channel?.posterUrl || channel?.image || '';
|
|
@@ -256,7 +257,16 @@ function InlineLivePlayerView({
|
|
|
256
257
|
const message = typeof detail === 'string' ? detail : detail?.message || 'Could not play this channel.';
|
|
257
258
|
setError(message);
|
|
258
259
|
setLoading(false);
|
|
259
|
-
|
|
260
|
+
if (detail instanceof Error) {
|
|
261
|
+
onError?.(detail);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const actualMessage = getPlayerErrorMessage(detail);
|
|
265
|
+
onError?.({
|
|
266
|
+
...detail,
|
|
267
|
+
message,
|
|
268
|
+
...(actualMessage ? { actualMessage } : {}),
|
|
269
|
+
});
|
|
260
270
|
}, [onError]);
|
|
261
271
|
|
|
262
272
|
const handlePlaying = useCallback((event) => {
|
|
@@ -3,7 +3,10 @@ import { View, StyleSheet } from 'react-native';
|
|
|
3
3
|
import { useWebMediaSession } from './web/useWebMediaSession';
|
|
4
4
|
import { useWebVideoAspectRatio } from './web/useWebVideoAspectRatio';
|
|
5
5
|
import { useWebMpegTsPlayback } from './web/useWebMpegTsPlayback';
|
|
6
|
+
import { useWebFlvPlayback } from './web/useWebFlvPlayback';
|
|
6
7
|
import { useWebHlsPlayback } from './web/useWebHlsPlayback';
|
|
8
|
+
import { useWebDashPlayback } from './web/useWebDashPlayback';
|
|
9
|
+
import { useWebOgvPlayback } from './web/useWebOgvPlayback';
|
|
7
10
|
import { useWebAc3AudioPlayback } from './web/useWebAc3AudioPlayback';
|
|
8
11
|
|
|
9
12
|
export const WebVideoPlayer = forwardRef(({
|
|
@@ -27,8 +30,10 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
27
30
|
onTogglePlayPause,
|
|
28
31
|
onSeekBy,
|
|
29
32
|
onPlaybackRoute,
|
|
33
|
+
ogvResourceBase,
|
|
30
34
|
}, ref) => {
|
|
31
35
|
const videoRef = useRef(null);
|
|
36
|
+
const ogvContainerRef = useRef(null);
|
|
32
37
|
const pausedRef = useRef(paused);
|
|
33
38
|
const playbackRateRef = useRef(playbackRate);
|
|
34
39
|
const lastKnownPosRef = useRef(0);
|
|
@@ -38,6 +43,8 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
38
43
|
const onErrorRef = useRef(onError);
|
|
39
44
|
const playbackErrorRef = useRef(onError);
|
|
40
45
|
const onEndedRef = useRef(onEnded);
|
|
46
|
+
const onProgressRef = useRef(null);
|
|
47
|
+
const onPlaybackRouteEventRef = useRef(null);
|
|
41
48
|
const [activeUrl, setActiveUrl] = useState('');
|
|
42
49
|
const [corsMode, setCorsMode] = useState('anonymous');
|
|
43
50
|
const corsModeRef = useRef('anonymous');
|
|
@@ -65,6 +72,15 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
65
72
|
onErrorRef: playbackErrorRef,
|
|
66
73
|
onBufferingRef,
|
|
67
74
|
});
|
|
75
|
+
const useFlvSource = useWebFlvPlayback({
|
|
76
|
+
activeUrl,
|
|
77
|
+
type: streamUrl,
|
|
78
|
+
isLive,
|
|
79
|
+
videoRef,
|
|
80
|
+
pausedRef,
|
|
81
|
+
onErrorRef: playbackErrorRef,
|
|
82
|
+
onBufferingRef,
|
|
83
|
+
});
|
|
68
84
|
const useMpegTsSource = mpegTsPlayback.useMpegTs;
|
|
69
85
|
const useAc3Fallback = mpegTsPlayback.useAc3Fallback;
|
|
70
86
|
const useHlsSource = useWebHlsPlayback({
|
|
@@ -74,6 +90,33 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
74
90
|
pausedRef,
|
|
75
91
|
onErrorRef: playbackErrorRef,
|
|
76
92
|
});
|
|
93
|
+
const useDashSource = useWebDashPlayback({
|
|
94
|
+
activeUrl,
|
|
95
|
+
videoRef,
|
|
96
|
+
pausedRef,
|
|
97
|
+
onErrorRef: playbackErrorRef,
|
|
98
|
+
});
|
|
99
|
+
const { videoStyle, applyAspectRatio } = useWebVideoAspectRatio(videoAspectRatio, audioOnly);
|
|
100
|
+
const useOgvSource = useWebOgvPlayback({
|
|
101
|
+
activeUrl,
|
|
102
|
+
type: streamUrl,
|
|
103
|
+
videoRef,
|
|
104
|
+
playerContainerRef: ogvContainerRef,
|
|
105
|
+
playerStyle: videoStyle,
|
|
106
|
+
pausedRef,
|
|
107
|
+
onErrorRef: playbackErrorRef,
|
|
108
|
+
onBufferingRef,
|
|
109
|
+
resourceBase: ogvResourceBase,
|
|
110
|
+
paused,
|
|
111
|
+
muted,
|
|
112
|
+
volume: (volume ?? 100) / 100,
|
|
113
|
+
playbackRate,
|
|
114
|
+
videoOnly,
|
|
115
|
+
onProgressRef,
|
|
116
|
+
onPlayingRef,
|
|
117
|
+
onEndedRef,
|
|
118
|
+
onPlaybackRouteRef: onPlaybackRouteEventRef,
|
|
119
|
+
});
|
|
77
120
|
const ac3Audio = useWebAc3AudioPlayback({
|
|
78
121
|
active: useAc3Fallback,
|
|
79
122
|
streamUrl: activeUrl,
|
|
@@ -84,10 +127,13 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
84
127
|
});
|
|
85
128
|
|
|
86
129
|
// 1. Aspect Ratio Styling
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (useMpegTsSource) streamMode = 'mpegts';
|
|
130
|
+
const videoSource = useMpegTsSource || useFlvSource || useHlsSource || useDashSource || useOgvSource ? undefined : activeUrl;
|
|
131
|
+
let streamMode = 'native';
|
|
132
|
+
if (mpegTsPlayback.isFlv) streamMode = 'flv';
|
|
133
|
+
else if (useMpegTsSource) streamMode = 'mpegts';
|
|
134
|
+
else if (useHlsSource) streamMode = 'hls';
|
|
135
|
+
else if (useDashSource) streamMode = 'dash';
|
|
136
|
+
else if (useOgvSource) streamMode = 'ogv';
|
|
91
137
|
const resolvedScheme = new URL(activeUrl || 'http://localhost', 'http://localhost').protocol.replace(':', '');
|
|
92
138
|
|
|
93
139
|
// Report active streaming route
|
|
@@ -120,6 +166,11 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
120
166
|
});
|
|
121
167
|
}
|
|
122
168
|
}, [onProgress]);
|
|
169
|
+
onProgressRef.current = emitProgress;
|
|
170
|
+
onPlaybackRouteEventRef.current = (attachedUrl) => {
|
|
171
|
+
const emit = onPlaybackRouteRef.current;
|
|
172
|
+
if (typeof emit === 'function' && attachedUrl) emit(attachedUrl);
|
|
173
|
+
};
|
|
123
174
|
|
|
124
175
|
// The host supplies the media URL. Scheme and source resolution are left to
|
|
125
176
|
// the consumer and the playback engine on each platform.
|
|
@@ -161,7 +212,7 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
161
212
|
) {
|
|
162
213
|
video.play().catch(() => {});
|
|
163
214
|
}
|
|
164
|
-
}, [paused, activeUrl]);
|
|
215
|
+
}, [paused, activeUrl, corsMode]);
|
|
165
216
|
|
|
166
217
|
// Gain and volume
|
|
167
218
|
useEffect(() => {
|
|
@@ -237,7 +288,22 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
237
288
|
|
|
238
289
|
return (
|
|
239
290
|
<View style={styles.container} collapsable={false}>
|
|
240
|
-
{activeUrl
|
|
291
|
+
{activeUrl && (
|
|
292
|
+
useOgvSource ? (
|
|
293
|
+
<div
|
|
294
|
+
key={activeUrl}
|
|
295
|
+
ref={ogvContainerRef}
|
|
296
|
+
data-resolved-scheme={resolvedScheme}
|
|
297
|
+
data-stream-mode="ogv"
|
|
298
|
+
className="cinecrew-player__video"
|
|
299
|
+
style={{
|
|
300
|
+
...StyleSheet.absoluteFillObject,
|
|
301
|
+
width: '100%',
|
|
302
|
+
height: '100%',
|
|
303
|
+
backgroundColor: '#000',
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
) : (
|
|
241
307
|
<video
|
|
242
308
|
key={`${activeUrl}:${corsMode || 'nocors'}`}
|
|
243
309
|
ref={videoRef}
|
|
@@ -317,7 +383,8 @@ export const WebVideoPlayer = forwardRef(({
|
|
|
317
383
|
/>
|
|
318
384
|
))}
|
|
319
385
|
</video>
|
|
320
|
-
|
|
386
|
+
)
|
|
387
|
+
)}
|
|
321
388
|
</View>
|
|
322
389
|
);
|
|
323
390
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
const isDashUrl = (url, type) => {
|
|
4
|
+
if (type && /dash/i.test(type)) return true;
|
|
5
|
+
return /\.mpd(?:$|[?#])/i.test(String(url || ''));
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function handleAutoplayFailure(video, error) {
|
|
9
|
+
if (error?.name !== 'NotAllowedError') return;
|
|
10
|
+
video.muted = true;
|
|
11
|
+
video.play().catch(() => {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useWebDashPlayback({
|
|
15
|
+
activeUrl,
|
|
16
|
+
type,
|
|
17
|
+
videoRef,
|
|
18
|
+
pausedRef,
|
|
19
|
+
onErrorRef,
|
|
20
|
+
}) {
|
|
21
|
+
const useDash = Boolean(isDashUrl(activeUrl, type));
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!useDash || !activeUrl || typeof window === 'undefined') return undefined;
|
|
25
|
+
const video = videoRef.current;
|
|
26
|
+
if (!video) return undefined;
|
|
27
|
+
|
|
28
|
+
let player = null;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
|
|
31
|
+
import('dashjs')
|
|
32
|
+
.then((mod) => {
|
|
33
|
+
if (disposed) return;
|
|
34
|
+
const dashjs = mod.default || mod;
|
|
35
|
+
if (!dashjs?.MediaPlayer) {
|
|
36
|
+
onErrorRef.current?.({ message: 'Dash.js is not supported in this browser.' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
player = dashjs.MediaPlayer().create();
|
|
41
|
+
video.crossOrigin = 'anonymous';
|
|
42
|
+
player.initialize(video, activeUrl, !pausedRef.current);
|
|
43
|
+
|
|
44
|
+
player.on(dashjs.MediaPlayer.events.ERROR, (event) => {
|
|
45
|
+
if (disposed) return;
|
|
46
|
+
const status = Number(event?.error?.data?.response?.status || event?.data?.status);
|
|
47
|
+
const httpStatus = Number.isFinite(status) && status >= 100 ? status : null;
|
|
48
|
+
const errorMsg = httpStatus
|
|
49
|
+
? `Stream request failed (HTTP ${httpStatus}).`
|
|
50
|
+
: `MPEG-DASH playback failed (${String(event?.error?.message || event?.message || 'playback error')}).`;
|
|
51
|
+
onErrorRef.current?.({ message: errorMsg, httpStatus, err: event });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const attemptAutoplay = () => {
|
|
55
|
+
if (disposed || pausedRef.current) return;
|
|
56
|
+
const playPromise = video.play();
|
|
57
|
+
playPromise?.catch((err) => handleAutoplayFailure(video, err));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
player.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, attemptAutoplay);
|
|
61
|
+
player.on(dashjs.MediaPlayer.events.CAN_PLAY, attemptAutoplay);
|
|
62
|
+
})
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
if (!disposed) {
|
|
65
|
+
onErrorRef.current?.({ message: 'Failed to initialize MPEG-DASH engine.', err });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
disposed = true;
|
|
71
|
+
if (player) {
|
|
72
|
+
try {
|
|
73
|
+
player.destroy();
|
|
74
|
+
} catch {}
|
|
75
|
+
} else if (video.src === activeUrl) {
|
|
76
|
+
video.pause();
|
|
77
|
+
video.removeAttribute('src');
|
|
78
|
+
video.load();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}, [activeUrl, useDash, videoRef, pausedRef, onErrorRef]);
|
|
82
|
+
|
|
83
|
+
return useDash;
|
|
84
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import flvjs from 'flv.js';
|
|
3
|
+
import { getPlayerErrorMessage } from '../../../utils/playerError.js';
|
|
4
|
+
|
|
5
|
+
const isFlvSource = (url, type) => /flv/i.test(String(type || ''))
|
|
6
|
+
|| /\.flv(?:$|[?#])/i.test(String(url || ''))
|
|
7
|
+
|| /^(?:ws|wss):/i.test(String(url || ''));
|
|
8
|
+
|
|
9
|
+
function attemptPlayback(video, pausedRef, onAutoplayBlockedRef) {
|
|
10
|
+
if (pausedRef.current) return;
|
|
11
|
+
const playResult = video.play();
|
|
12
|
+
playResult?.catch?.((error) => {
|
|
13
|
+
if (error?.name !== 'NotAllowedError') return;
|
|
14
|
+
// Autoplay may be blocked until a user gesture. Keep the requested audio
|
|
15
|
+
// state intact and expose the player's normal Play control for that gesture.
|
|
16
|
+
video.pause();
|
|
17
|
+
pausedRef.current = true;
|
|
18
|
+
onAutoplayBlockedRef.current?.();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useWebFlvPlayback({
|
|
23
|
+
activeUrl,
|
|
24
|
+
type,
|
|
25
|
+
isLive,
|
|
26
|
+
videoRef,
|
|
27
|
+
pausedRef,
|
|
28
|
+
onErrorRef,
|
|
29
|
+
onBufferingRef,
|
|
30
|
+
onAutoplayBlocked,
|
|
31
|
+
}) {
|
|
32
|
+
const useFlv = Boolean(isFlvSource(activeUrl, type));
|
|
33
|
+
const onAutoplayBlockedRef = useRef(onAutoplayBlocked);
|
|
34
|
+
onAutoplayBlockedRef.current = onAutoplayBlocked;
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!useFlv || !activeUrl || typeof window === 'undefined') return undefined;
|
|
38
|
+
const video = videoRef.current;
|
|
39
|
+
if (!video) return undefined;
|
|
40
|
+
|
|
41
|
+
const flv = flvjs?.default || flvjs;
|
|
42
|
+
if (!flv?.isSupported?.()) {
|
|
43
|
+
onErrorRef.current?.({ message: 'FLV playback is not supported in this browser.' });
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let disposed = false;
|
|
48
|
+
let playbackErrorReported = false;
|
|
49
|
+
let specificCodecErrorReported = false;
|
|
50
|
+
let player;
|
|
51
|
+
const handleCanPlay = () => {
|
|
52
|
+
onBufferingRef?.current?.(false);
|
|
53
|
+
attemptPlayback(video, pausedRef, onAutoplayBlockedRef);
|
|
54
|
+
};
|
|
55
|
+
const handleWaiting = () => onBufferingRef?.current?.(true);
|
|
56
|
+
const handleVideoError = () => {
|
|
57
|
+
if (disposed || specificCodecErrorReported || video.readyState >= 2) return;
|
|
58
|
+
const mediaError = video.error;
|
|
59
|
+
onBufferingRef?.current?.(false);
|
|
60
|
+
onErrorRef.current?.({
|
|
61
|
+
message: mediaError?.message || 'The browser could not decode the FLV stream.',
|
|
62
|
+
err: mediaError,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
player = flv.createPlayer({
|
|
68
|
+
type: 'flv',
|
|
69
|
+
url: activeUrl,
|
|
70
|
+
isLive: Boolean(isLive),
|
|
71
|
+
hasVideo: true,
|
|
72
|
+
}, {
|
|
73
|
+
// flv.js's worker bundle is webpack-specific and can fail to initialize
|
|
74
|
+
// under consumer bundlers such as Vite/Metro. Inline transmuxing is
|
|
75
|
+
// reliable for this bounded remux operation and matches the package's
|
|
76
|
+
// default behavior.
|
|
77
|
+
enableWorker: false,
|
|
78
|
+
enableStashBuffer: true,
|
|
79
|
+
stashInitialSize: isLive ? 384 * 1024 : 1024 * 1024,
|
|
80
|
+
lazyLoad: false,
|
|
81
|
+
autoCleanupSourceBuffer: Boolean(isLive),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
player.on(flv.Events.ERROR, (errorType, errorDetail, errorInfo) => {
|
|
85
|
+
if (disposed || specificCodecErrorReported) return;
|
|
86
|
+
const details = [errorType, errorDetail, errorInfo?.msg, errorInfo?.message]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(' ');
|
|
89
|
+
const unsupportedAudio = /unsupported codec in audio frame|unsupported audio codec|audio.*(?:unsupported|not supported)|(?:unsupported|not supported).*audio/i.test(details);
|
|
90
|
+
const unsupportedVideo = /unsupported codec in video frame|unsupported.*video|video.*(?:unsupported|not supported).*codec/i.test(details);
|
|
91
|
+
// flv.js rejects individual audio packets with codecs other than AAC
|
|
92
|
+
// and MP3, but can continue transmuxing the remaining supported audio
|
|
93
|
+
// and video packets. Don't turn a skippable packet into a fatal player
|
|
94
|
+
// error or disable the stream's supported audio track.
|
|
95
|
+
if (unsupportedAudio && !unsupportedVideo) return;
|
|
96
|
+
if (playbackErrorReported && !unsupportedAudio && !unsupportedVideo) return;
|
|
97
|
+
playbackErrorReported = true;
|
|
98
|
+
specificCodecErrorReported = unsupportedAudio || unsupportedVideo;
|
|
99
|
+
const status = Number(errorInfo?.code || errorInfo?.status);
|
|
100
|
+
const httpStatus = Number.isFinite(status) && status >= 100 ? status : null;
|
|
101
|
+
let message;
|
|
102
|
+
if (httpStatus) {
|
|
103
|
+
message = `FLV stream request failed (HTTP ${httpStatus}).`;
|
|
104
|
+
} else if (unsupportedAudio) {
|
|
105
|
+
message = 'This FLV audio codec is not supported by the browser player. Use AAC or MP3 audio in the FLV stream.';
|
|
106
|
+
} else if (unsupportedVideo) {
|
|
107
|
+
message = 'This FLV video codec is not supported by the browser player. Use H.264/AVC video in the FLV stream.';
|
|
108
|
+
} else {
|
|
109
|
+
message = `FLV playback failed (${String(errorDetail || errorType || 'unknown error')}).`;
|
|
110
|
+
}
|
|
111
|
+
const actualMessage = getPlayerErrorMessage(errorInfo)
|
|
112
|
+
|| [errorType, errorDetail].filter(Boolean).map(String).join(': ')
|
|
113
|
+
|| message;
|
|
114
|
+
onBufferingRef?.current?.(false);
|
|
115
|
+
onErrorRef.current?.({
|
|
116
|
+
message,
|
|
117
|
+
actualMessage,
|
|
118
|
+
errorType: String(errorType || ''),
|
|
119
|
+
errorDetail: String(errorDetail || ''),
|
|
120
|
+
httpStatus,
|
|
121
|
+
cause: errorInfo || { errorType, errorDetail },
|
|
122
|
+
// Keep the existing alias for consumers that already inspect it.
|
|
123
|
+
err: errorInfo || { errorType, errorDetail },
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
video.crossOrigin = 'anonymous';
|
|
128
|
+
video.addEventListener('canplay', handleCanPlay);
|
|
129
|
+
video.addEventListener('waiting', handleWaiting);
|
|
130
|
+
video.addEventListener('error', handleVideoError);
|
|
131
|
+
player.attachMediaElement(video);
|
|
132
|
+
player.load();
|
|
133
|
+
attemptPlayback(video, pausedRef, onAutoplayBlockedRef);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
onBufferingRef?.current?.(false);
|
|
136
|
+
onErrorRef.current?.({
|
|
137
|
+
message: error?.message || 'Failed to initialize FLV playback.',
|
|
138
|
+
err: error,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
disposed = true;
|
|
144
|
+
video.removeEventListener('canplay', handleCanPlay);
|
|
145
|
+
video.removeEventListener('waiting', handleWaiting);
|
|
146
|
+
video.removeEventListener('error', handleVideoError);
|
|
147
|
+
if (!player) return;
|
|
148
|
+
try { player.pause(); } catch {}
|
|
149
|
+
try { player.unload(); } catch {}
|
|
150
|
+
try { player.detachMediaElement(); } catch {}
|
|
151
|
+
try { player.destroy(); } catch {}
|
|
152
|
+
};
|
|
153
|
+
}, [activeUrl, isLive, useFlv, videoRef, pausedRef, onErrorRef, onBufferingRef]);
|
|
154
|
+
|
|
155
|
+
return useFlv;
|
|
156
|
+
}
|
|
@@ -4,7 +4,7 @@ import Hls from 'hls.js';
|
|
|
4
4
|
const isHlsUrl = (url) => /\.m3u8(?:$|[?#])/i.test(String(url || ''));
|
|
5
5
|
|
|
6
6
|
export function useWebHlsPlayback({ activeUrl, isLive, videoRef, pausedRef, onErrorRef }) {
|
|
7
|
-
const useHls =
|
|
7
|
+
const useHls = Boolean(isHlsUrl(activeUrl));
|
|
8
8
|
|
|
9
9
|
useEffect(() => {
|
|
10
10
|
if (!useHls || !activeUrl || typeof window === 'undefined') return undefined;
|
|
@@ -43,10 +43,33 @@ export function useWebHlsPlayback({ activeUrl, isLive, videoRef, pausedRef, onEr
|
|
|
43
43
|
hls.loadSource(activeUrl);
|
|
44
44
|
video.crossOrigin = 'anonymous';
|
|
45
45
|
hls.attachMedia(video);
|
|
46
|
+
const attemptPlay = () => {
|
|
47
|
+
if (pausedRef.current) return;
|
|
48
|
+
const playPromise = video.play();
|
|
49
|
+
if (playPromise !== undefined) {
|
|
50
|
+
playPromise.catch((err) => {
|
|
51
|
+
if (err?.name === 'NotAllowedError') {
|
|
52
|
+
video.muted = true;
|
|
53
|
+
video.play().catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
hls.on(Hls.Events.MANIFEST_PARSED, attemptPlay);
|
|
46
59
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
47
60
|
video.src = activeUrl;
|
|
48
61
|
video.load();
|
|
49
|
-
if (!pausedRef.current)
|
|
62
|
+
if (!pausedRef.current) {
|
|
63
|
+
const playPromise = video.play();
|
|
64
|
+
if (playPromise !== undefined) {
|
|
65
|
+
playPromise.catch((err) => {
|
|
66
|
+
if (err?.name === 'NotAllowedError') {
|
|
67
|
+
video.muted = true;
|
|
68
|
+
video.play().catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
50
73
|
} else {
|
|
51
74
|
onErrorRef.current?.({ message: 'This browser does not support HLS playback.' });
|
|
52
75
|
}
|