@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
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import
|
|
2
|
+
import '../../../../vendor/mpegts.js/mpegts.js';
|
|
3
3
|
import {
|
|
4
4
|
WEB_AC3_UNSUPPORTED_CODE,
|
|
5
5
|
WEB_AC3_UNSUPPORTED_MESSAGE,
|
|
6
6
|
} from './webPlaybackErrors';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
mpegts
|
|
8
|
+
const getMpegts = () => {
|
|
9
|
+
if (typeof globalThis !== 'undefined' && globalThis.mpegts) return globalThis.mpegts;
|
|
10
|
+
if (typeof window !== 'undefined' && window.mpegts) return window.mpegts;
|
|
11
|
+
return undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getStashInitialSize = (useVideoOnly, isLive) => {
|
|
15
|
+
if (useVideoOnly) return 2048;
|
|
16
|
+
if (isLive) return 512 * 1024;
|
|
17
|
+
return 1024 * 1024;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const initialMpegts = getMpegts();
|
|
21
|
+
if (initialMpegts?.LoggingControl) {
|
|
22
|
+
initialMpegts.LoggingControl.enableAll = false;
|
|
10
23
|
}
|
|
11
24
|
|
|
12
25
|
const isRawLiveTransportStream = (url) => {
|
|
@@ -20,6 +33,16 @@ const isRawLiveTransportStream = (url) => {
|
|
|
20
33
|
}
|
|
21
34
|
};
|
|
22
35
|
|
|
36
|
+
const isFlvStream = (url) => {
|
|
37
|
+
if (typeof url !== 'string') return false;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = new URL(url, 'http://localhost');
|
|
40
|
+
return /\.flv$/i.test(parsed.pathname) || /^(?:ws|wss):/i.test(url);
|
|
41
|
+
} catch {
|
|
42
|
+
return /\.flv(?:$|[?#])/i.test(url) || /^(?:ws|wss):/i.test(url);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
23
46
|
const isHlsUrl = (url) => /\.m3u8(?:$|[?#])/i.test(String(url || ''));
|
|
24
47
|
|
|
25
48
|
function containsAc3Codec(value) {
|
|
@@ -87,12 +110,9 @@ export function useWebMpegTsPlayback({
|
|
|
87
110
|
const [unavailableForUrl, setUnavailableForUrl] = useState('');
|
|
88
111
|
const [ac3FallbackForUrl, setAc3FallbackForUrl] = useState('');
|
|
89
112
|
const ac3FallbackForUrlRef = useRef('');
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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))
|
|
113
|
+
const isFlv = isFlvStream(streamUrl) || isFlvStream(activeUrl);
|
|
114
|
+
const isRawTs = isRawLiveTransportStream(streamUrl) || isRawLiveTransportStream(activeUrl);
|
|
115
|
+
const useMpegTs = !isFlv && isRawTs
|
|
96
116
|
&& !isHlsUrl(activeUrl)
|
|
97
117
|
&& unavailableForUrl !== streamUrl;
|
|
98
118
|
const useVideoOnly = useMpegTs && videoOnly;
|
|
@@ -120,6 +140,7 @@ export function useWebMpegTsPlayback({
|
|
|
120
140
|
let stablePlaybackTimer = null;
|
|
121
141
|
let lastPlaybackProgressAt = Date.now();
|
|
122
142
|
let lastPlaybackTime = Number(video.currentTime) || 0;
|
|
143
|
+
let handleCanPlay = null;
|
|
123
144
|
|
|
124
145
|
const clearStablePlaybackTimer = () => {
|
|
125
146
|
if (stablePlaybackTimer) {
|
|
@@ -231,42 +252,61 @@ export function useWebMpegTsPlayback({
|
|
|
231
252
|
delete video.dataset.mpegtsCodecGate;
|
|
232
253
|
if (!pausedRef.current && player) {
|
|
233
254
|
const playResult = player.play();
|
|
234
|
-
playResult?.catch?.(() => {
|
|
255
|
+
playResult?.catch?.((err) => {
|
|
256
|
+
if (err?.name === 'NotAllowedError') {
|
|
257
|
+
video.muted = true;
|
|
258
|
+
player.play()?.catch?.(() => {});
|
|
259
|
+
}
|
|
260
|
+
});
|
|
235
261
|
}
|
|
236
262
|
};
|
|
237
263
|
|
|
238
264
|
try {
|
|
239
|
-
|
|
265
|
+
const mpegts = getMpegts();
|
|
266
|
+
if (!mpegts?.isSupported()) {
|
|
240
267
|
setUnavailableForUrl(streamUrl);
|
|
241
268
|
return undefined;
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
player = mpegts.createPlayer({
|
|
245
|
-
type: 'mpegts',
|
|
272
|
+
type: isFlv ? 'flv' : 'mpegts',
|
|
246
273
|
url: activeUrl,
|
|
247
|
-
isLive:
|
|
274
|
+
isLive: Boolean(isLive),
|
|
248
275
|
hasAudio: !useAc3Fallback && !useVideoOnly,
|
|
249
276
|
hasVideo: true,
|
|
250
277
|
}, {
|
|
251
278
|
enableWorker: !useVideoOnly,
|
|
252
279
|
lazyLoad: false,
|
|
253
280
|
enableStashBuffer: true,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
stashInitialSize: useVideoOnly ? 2048 : 512 * 1024,
|
|
257
|
-
liveBufferLatencyChasing: !useVideoOnly,
|
|
281
|
+
stashInitialSize: getStashInitialSize(useVideoOnly, isLive),
|
|
282
|
+
liveBufferLatencyChasing: Boolean(isLive && !useVideoOnly),
|
|
258
283
|
liveBufferLatencyMaxLatency: 8,
|
|
259
284
|
liveBufferLatencyMinRemain: 3,
|
|
260
|
-
autoCleanupSourceBuffer:
|
|
285
|
+
autoCleanupSourceBuffer: Boolean(isLive),
|
|
261
286
|
autoCleanupMaxBackwardDuration: useVideoOnly ? 120 : 30,
|
|
262
287
|
autoCleanupMinBackwardDuration: useVideoOnly ? 60 : 15,
|
|
263
288
|
disableAudio: useAc3Fallback || useVideoOnly,
|
|
264
289
|
});
|
|
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
290
|
video.autoplay = false;
|
|
268
|
-
|
|
291
|
+
if (!isFlv) {
|
|
292
|
+
video.dataset.mpegtsCodecGate = 'pending';
|
|
293
|
+
} else {
|
|
294
|
+
delete video.dataset.mpegtsCodecGate;
|
|
295
|
+
}
|
|
269
296
|
player.on(mpegts.Events.MEDIA_INFO, handleMediaInfo);
|
|
297
|
+
handleCanPlay = () => {
|
|
298
|
+
delete video.dataset.mpegtsCodecGate;
|
|
299
|
+
if (!pausedRef.current && player) {
|
|
300
|
+
const playResult = player.play();
|
|
301
|
+
playResult?.catch?.((err) => {
|
|
302
|
+
if (err?.name === 'NotAllowedError') {
|
|
303
|
+
video.muted = true;
|
|
304
|
+
player.play()?.catch?.(() => {});
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
video.addEventListener('canplay', handleCanPlay);
|
|
270
310
|
video.addEventListener('timeupdate', notePlaybackProgress);
|
|
271
311
|
video.addEventListener('playing', handlePlaying);
|
|
272
312
|
video.addEventListener('waiting', handleWaiting);
|
|
@@ -306,6 +346,15 @@ export function useWebMpegTsPlayback({
|
|
|
306
346
|
});
|
|
307
347
|
player.attachMediaElement(video);
|
|
308
348
|
player.load();
|
|
349
|
+
if (!pausedRef.current) {
|
|
350
|
+
const playResult = player.play();
|
|
351
|
+
playResult?.catch?.((err) => {
|
|
352
|
+
if (err?.name === 'NotAllowedError') {
|
|
353
|
+
video.muted = true;
|
|
354
|
+
player.play()?.catch?.(() => {});
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
309
358
|
stallWatchdog = setInterval(() => {
|
|
310
359
|
if (disposed || pausedRef.current || recoveryPending || video.currentTime <= 0) return;
|
|
311
360
|
if (Date.now() - lastPlaybackProgressAt >= 12000) {
|
|
@@ -322,6 +371,7 @@ export function useWebMpegTsPlayback({
|
|
|
322
371
|
if (recoveryTimer) clearTimeout(recoveryTimer);
|
|
323
372
|
if (stallWatchdog) clearInterval(stallWatchdog);
|
|
324
373
|
clearStablePlaybackTimer();
|
|
374
|
+
if (handleCanPlay) video.removeEventListener('canplay', handleCanPlay);
|
|
325
375
|
video.removeEventListener('timeupdate', notePlaybackProgress);
|
|
326
376
|
video.removeEventListener('playing', handlePlaying);
|
|
327
377
|
video.removeEventListener('waiting', handleWaiting);
|
|
@@ -352,5 +402,5 @@ export function useWebMpegTsPlayback({
|
|
|
352
402
|
};
|
|
353
403
|
}, [activeUrl, useMpegTs, useAc3Fallback, useVideoOnly, streamUrl, videoRef, pausedRef, onErrorRef, onBufferingRef]);
|
|
354
404
|
|
|
355
|
-
return { useMpegTs, useAc3Fallback };
|
|
405
|
+
return { useMpegTs, useAc3Fallback, isFlv };
|
|
356
406
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_OGV_RESOURCE_BASE = 'https://cdn.jsdelivr.net/npm/ogv@1.9.0/dist';
|
|
4
|
+
const ogvRuntimePromises = new Map();
|
|
5
|
+
|
|
6
|
+
function normalizeResourceBase(value) {
|
|
7
|
+
const base = String(value || DEFAULT_OGV_RESOURCE_BASE).trim();
|
|
8
|
+
let lastNonSlash = base.length;
|
|
9
|
+
while (lastNonSlash > 0 && base[lastNonSlash - 1] === '/') {
|
|
10
|
+
lastNonSlash -= 1;
|
|
11
|
+
}
|
|
12
|
+
return base.slice(0, lastNonSlash);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadOgvRuntime(resourceBase) {
|
|
16
|
+
if (window.OGVPlayer && window.OGVLoader) {
|
|
17
|
+
return Promise.resolve({
|
|
18
|
+
OGVPlayer: window.OGVPlayer,
|
|
19
|
+
OGVLoader: window.OGVLoader,
|
|
20
|
+
OGVCompat: window.OGVCompat,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scriptUrl = `${resourceBase}/ogv.js`;
|
|
25
|
+
if (ogvRuntimePromises.has(scriptUrl)) return ogvRuntimePromises.get(scriptUrl);
|
|
26
|
+
|
|
27
|
+
const promise = new Promise((resolve, reject) => {
|
|
28
|
+
const script = document.createElement('script');
|
|
29
|
+
script.src = scriptUrl;
|
|
30
|
+
script.async = true;
|
|
31
|
+
script.dataset.cinecrewOgvRuntime = 'true';
|
|
32
|
+
script.onload = () => {
|
|
33
|
+
if (!window.OGVPlayer || !window.OGVLoader) {
|
|
34
|
+
reject(new Error('The OGV runtime loaded but did not expose OGVPlayer.'));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
resolve({
|
|
38
|
+
OGVPlayer: window.OGVPlayer,
|
|
39
|
+
OGVLoader: window.OGVLoader,
|
|
40
|
+
OGVCompat: window.OGVCompat,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
script.onerror = () => reject(new Error(`Could not load the OGV runtime from ${scriptUrl}.`));
|
|
44
|
+
document.head.appendChild(script);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
ogvRuntimePromises.set(scriptUrl, promise);
|
|
48
|
+
promise.catch(() => ogvRuntimePromises.delete(scriptUrl));
|
|
49
|
+
return promise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isOgvSource = (url, type) => /(?:^|\/)video\/ogg(?:$|;)/i.test(String(type || ''))
|
|
53
|
+
|| /\bogv\b/i.test(String(type || ''))
|
|
54
|
+
|| /\.ogv(?:$|[?#])/i.test(String(url || ''));
|
|
55
|
+
|
|
56
|
+
function attemptPlayback(player, pausedRef) {
|
|
57
|
+
if (pausedRef.current) return;
|
|
58
|
+
const result = player.play();
|
|
59
|
+
result?.catch?.((error) => {
|
|
60
|
+
if (error?.name === 'NotAllowedError') {
|
|
61
|
+
player.muted = true;
|
|
62
|
+
player.play()?.catch?.(() => {});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readCustomRatio(playerStyle) {
|
|
68
|
+
if (playerStyle?.width !== 'auto' || playerStyle?.height !== 'auto') return null;
|
|
69
|
+
const parts = String(playerStyle.aspectRatio || '').split('/').map(Number);
|
|
70
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
71
|
+
const ratio = parts[0] / parts[1];
|
|
72
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function applyPlayerStyle(stage, frame, player, playerStyle) {
|
|
76
|
+
if (!stage?.style || !frame?.style || !player?.style) return;
|
|
77
|
+
|
|
78
|
+
const style = playerStyle || {};
|
|
79
|
+
const ratio = readCustomRatio(style);
|
|
80
|
+
const stageWidth = stage.clientWidth;
|
|
81
|
+
const stageHeight = stage.clientHeight;
|
|
82
|
+
|
|
83
|
+
Object.assign(stage.style, {
|
|
84
|
+
overflow: 'hidden',
|
|
85
|
+
backgroundColor: style.backgroundColor || '#000',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// OGVPlayer has no intrinsic CSS size. Applying width/height:auto directly
|
|
89
|
+
// to it collapses its canvas in browsers. Size a wrapper from the measured
|
|
90
|
+
// stage instead, keeping the decoder element itself at 100% of that frame.
|
|
91
|
+
if (ratio && stageWidth > 0 && stageHeight > 0) {
|
|
92
|
+
const frameWidth = Math.min(stageWidth, stageHeight * ratio);
|
|
93
|
+
const frameHeight = frameWidth / ratio;
|
|
94
|
+
Object.assign(frame.style, {
|
|
95
|
+
position: 'absolute',
|
|
96
|
+
left: '50%',
|
|
97
|
+
top: '50%',
|
|
98
|
+
right: 'auto',
|
|
99
|
+
bottom: 'auto',
|
|
100
|
+
transform: 'translate(-50%, -50%)',
|
|
101
|
+
width: `${frameWidth}px`,
|
|
102
|
+
height: `${frameHeight}px`,
|
|
103
|
+
maxWidth: '100%',
|
|
104
|
+
maxHeight: '100%',
|
|
105
|
+
aspectRatio: style.aspectRatio,
|
|
106
|
+
opacity: style.opacity == null ? '1' : String(style.opacity),
|
|
107
|
+
backgroundColor: style.backgroundColor || '#000',
|
|
108
|
+
overflow: 'hidden',
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
Object.assign(frame.style, {
|
|
112
|
+
position: 'absolute',
|
|
113
|
+
inset: '0',
|
|
114
|
+
left: '0',
|
|
115
|
+
top: '0',
|
|
116
|
+
right: '0',
|
|
117
|
+
bottom: '0',
|
|
118
|
+
transform: 'none',
|
|
119
|
+
width: '100%',
|
|
120
|
+
height: '100%',
|
|
121
|
+
maxWidth: 'none',
|
|
122
|
+
maxHeight: 'none',
|
|
123
|
+
aspectRatio: 'auto',
|
|
124
|
+
opacity: style.opacity == null ? '1' : String(style.opacity),
|
|
125
|
+
backgroundColor: style.backgroundColor || '#000',
|
|
126
|
+
overflow: 'hidden',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only the wrapper owns custom ratio geometry. Reset it from the decoder
|
|
131
|
+
// element so switching between custom ratios and FIT never leaves stale CSS.
|
|
132
|
+
Object.assign(player.style, {
|
|
133
|
+
position: 'relative',
|
|
134
|
+
inset: 'auto',
|
|
135
|
+
left: 'auto',
|
|
136
|
+
top: 'auto',
|
|
137
|
+
right: 'auto',
|
|
138
|
+
bottom: 'auto',
|
|
139
|
+
transform: 'none',
|
|
140
|
+
maxWidth: 'none',
|
|
141
|
+
maxHeight: 'none',
|
|
142
|
+
aspectRatio: 'auto',
|
|
143
|
+
width: '100%',
|
|
144
|
+
height: '100%',
|
|
145
|
+
display: 'block',
|
|
146
|
+
visibility: 'visible',
|
|
147
|
+
objectFit: style.objectFit || 'contain',
|
|
148
|
+
backgroundColor: style.backgroundColor || '#000',
|
|
149
|
+
opacity: '1',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Use ogv.js for Ogg/Theora media on web, while keeping the returned media-like
|
|
155
|
+
* player in videoRef so the shared controls, progress, and recording logic work.
|
|
156
|
+
*/
|
|
157
|
+
export function useWebOgvPlayback({
|
|
158
|
+
activeUrl,
|
|
159
|
+
type,
|
|
160
|
+
videoRef,
|
|
161
|
+
playerContainerRef,
|
|
162
|
+
playerStyle,
|
|
163
|
+
pausedRef,
|
|
164
|
+
onErrorRef,
|
|
165
|
+
onBufferingRef,
|
|
166
|
+
resourceBase,
|
|
167
|
+
paused = true,
|
|
168
|
+
muted = false,
|
|
169
|
+
volume = 1,
|
|
170
|
+
playbackRate = 1,
|
|
171
|
+
videoOnly = false,
|
|
172
|
+
onProgressRef,
|
|
173
|
+
onPlayingRef,
|
|
174
|
+
onEndedRef,
|
|
175
|
+
onPlaybackRouteRef,
|
|
176
|
+
}) {
|
|
177
|
+
const useOgv = Boolean(isOgvSource(activeUrl, type));
|
|
178
|
+
const playerFrameRef = useRef(null);
|
|
179
|
+
const playbackOptionsRef = useRef({ paused, muted, volume, playbackRate, videoOnly });
|
|
180
|
+
playbackOptionsRef.current = { paused, muted, volume, playbackRate, videoOnly };
|
|
181
|
+
const playerStyleRef = useRef(playerStyle);
|
|
182
|
+
playerStyleRef.current = playerStyle;
|
|
183
|
+
const playerStyleKey = JSON.stringify(playerStyle || {});
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!useOgv || !activeUrl || typeof window === 'undefined') return undefined;
|
|
187
|
+
const container = playerContainerRef.current;
|
|
188
|
+
if (!container) return undefined;
|
|
189
|
+
|
|
190
|
+
let disposed = false;
|
|
191
|
+
let player = null;
|
|
192
|
+
let playerFrame = null;
|
|
193
|
+
let resizeObserver = null;
|
|
194
|
+
const reportError = (error) => {
|
|
195
|
+
if (disposed) return;
|
|
196
|
+
onBufferingRef?.current?.(false);
|
|
197
|
+
onErrorRef.current?.({
|
|
198
|
+
message: error?.message || 'OGV playback failed. The browser could not decode this Ogg/Theora stream.',
|
|
199
|
+
err: error,
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
const handleWaiting = () => onBufferingRef?.current?.(true);
|
|
203
|
+
const handleReady = () => {
|
|
204
|
+
onBufferingRef?.current?.(false);
|
|
205
|
+
onProgressRef?.current?.();
|
|
206
|
+
onPlaybackRouteRef?.current?.(activeUrl);
|
|
207
|
+
attemptPlayback(player, pausedRef);
|
|
208
|
+
};
|
|
209
|
+
const handlePlaying = () => {
|
|
210
|
+
onBufferingRef?.current?.(false);
|
|
211
|
+
onPlayingRef?.current?.();
|
|
212
|
+
};
|
|
213
|
+
const handleTimeUpdate = () => onProgressRef?.current?.();
|
|
214
|
+
const handleEnded = () => onEndedRef?.current?.();
|
|
215
|
+
const handleError = (event) => reportError(event?.error || event);
|
|
216
|
+
const handleResize = () => {
|
|
217
|
+
applyPlayerStyle(container, playerFrame, player, playerStyleRef.current);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
onBufferingRef?.current?.(true);
|
|
221
|
+
const resolvedBase = normalizeResourceBase(resourceBase);
|
|
222
|
+
loadOgvRuntime(resolvedBase)
|
|
223
|
+
.then(({ OGVPlayer, OGVLoader, OGVCompat }) => {
|
|
224
|
+
if (disposed) return;
|
|
225
|
+
if (!OGVPlayer || (OGVCompat?.supported && !OGVCompat.supported('OGVPlayer'))) {
|
|
226
|
+
throw new Error('OGV playback is not supported in this browser (WebAssembly and Web Audio are required).');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
OGVLoader.base = resolvedBase;
|
|
230
|
+
player = new OGVPlayer({
|
|
231
|
+
base: resolvedBase,
|
|
232
|
+
worker: true,
|
|
233
|
+
});
|
|
234
|
+
player.className = 'cinecrew-player__video';
|
|
235
|
+
playerFrame = document.createElement('div');
|
|
236
|
+
playerFrame.className = 'cinecrew-player__ogv-frame';
|
|
237
|
+
playerFrameRef.current = playerFrame;
|
|
238
|
+
playerFrame.appendChild(player);
|
|
239
|
+
applyPlayerStyle(container, playerFrame, player, playerStyleRef.current);
|
|
240
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
241
|
+
resizeObserver = new ResizeObserver(handleResize);
|
|
242
|
+
resizeObserver.observe(container);
|
|
243
|
+
} else {
|
|
244
|
+
window.addEventListener('resize', handleResize);
|
|
245
|
+
}
|
|
246
|
+
const options = playbackOptionsRef.current;
|
|
247
|
+
player.muted = Boolean(options.muted || options.videoOnly);
|
|
248
|
+
player.volume = Math.max(0, Math.min(1, Number(options.volume) || 0));
|
|
249
|
+
player.playbackRate = Number(options.playbackRate) || 1;
|
|
250
|
+
player.preload = 'auto';
|
|
251
|
+
|
|
252
|
+
player.addEventListener('waiting', handleWaiting);
|
|
253
|
+
player.addEventListener('loadstart', handleWaiting);
|
|
254
|
+
player.addEventListener('loadedmetadata', handleReady);
|
|
255
|
+
player.addEventListener('canplay', handleReady);
|
|
256
|
+
player.addEventListener('playing', handlePlaying);
|
|
257
|
+
player.addEventListener('timeupdate', handleTimeUpdate);
|
|
258
|
+
player.addEventListener('ended', handleEnded);
|
|
259
|
+
player.addEventListener('error', handleError);
|
|
260
|
+
container.replaceChildren(playerFrame);
|
|
261
|
+
videoRef.current = player;
|
|
262
|
+
player.src = activeUrl;
|
|
263
|
+
if (!options.paused) attemptPlayback(player, pausedRef);
|
|
264
|
+
})
|
|
265
|
+
.catch(reportError);
|
|
266
|
+
|
|
267
|
+
return () => {
|
|
268
|
+
disposed = true;
|
|
269
|
+
onBufferingRef?.current?.(false);
|
|
270
|
+
resizeObserver?.disconnect();
|
|
271
|
+
window.removeEventListener('resize', handleResize);
|
|
272
|
+
if (player) {
|
|
273
|
+
player.removeEventListener('waiting', handleWaiting);
|
|
274
|
+
player.removeEventListener('loadstart', handleWaiting);
|
|
275
|
+
player.removeEventListener('loadedmetadata', handleReady);
|
|
276
|
+
player.removeEventListener('canplay', handleReady);
|
|
277
|
+
player.removeEventListener('playing', handlePlaying);
|
|
278
|
+
player.removeEventListener('timeupdate', handleTimeUpdate);
|
|
279
|
+
player.removeEventListener('ended', handleEnded);
|
|
280
|
+
player.removeEventListener('error', handleError);
|
|
281
|
+
try { player.pause(); } catch {}
|
|
282
|
+
try { player.src = ''; } catch {}
|
|
283
|
+
try { player.remove(); } catch {}
|
|
284
|
+
if (videoRef.current === player) videoRef.current = null;
|
|
285
|
+
}
|
|
286
|
+
if (playerFrameRef.current === playerFrame) playerFrameRef.current = null;
|
|
287
|
+
};
|
|
288
|
+
}, [activeUrl, useOgv, playerContainerRef, videoRef, pausedRef, onErrorRef, onBufferingRef, resourceBase, onProgressRef, onPlayingRef, onEndedRef, onPlaybackRouteRef]);
|
|
289
|
+
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (!useOgv) return;
|
|
292
|
+
const player = videoRef.current;
|
|
293
|
+
if (!player) return;
|
|
294
|
+
player.muted = Boolean(muted || videoOnly);
|
|
295
|
+
player.volume = Math.max(0, Math.min(1, Number(volume) || 0));
|
|
296
|
+
player.playbackRate = Number(playbackRate) || 1;
|
|
297
|
+
if (paused) player.pause();
|
|
298
|
+
else attemptPlayback(player, pausedRef);
|
|
299
|
+
}, [activeUrl, useOgv, videoRef, pausedRef, paused, muted, volume, playbackRate, videoOnly]);
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!useOgv) return;
|
|
303
|
+
applyPlayerStyle(
|
|
304
|
+
playerContainerRef.current,
|
|
305
|
+
playerFrameRef.current,
|
|
306
|
+
videoRef.current,
|
|
307
|
+
playerStyleRef.current,
|
|
308
|
+
);
|
|
309
|
+
}, [useOgv, playerContainerRef, videoRef, playerStyleKey]);
|
|
310
|
+
|
|
311
|
+
return useOgv;
|
|
312
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function messageFrom(value) {
|
|
2
|
+
if (typeof value === 'string') return value.trim();
|
|
3
|
+
if (value instanceof Error) return value.message?.trim() || '';
|
|
4
|
+
if (!value || typeof value !== 'object') return '';
|
|
5
|
+
|
|
6
|
+
// Prefer messages from the underlying media engine over a wrapper's
|
|
7
|
+
// user-facing summary so callers can log or display the real diagnostic.
|
|
8
|
+
const candidates = [
|
|
9
|
+
value.actualMessage,
|
|
10
|
+
value.err?.msg,
|
|
11
|
+
value.err?.message,
|
|
12
|
+
value.err?.error?.msg,
|
|
13
|
+
value.err?.error?.message,
|
|
14
|
+
value.err,
|
|
15
|
+
value.cause?.msg,
|
|
16
|
+
value.cause?.message,
|
|
17
|
+
value.cause,
|
|
18
|
+
value.error?.msg,
|
|
19
|
+
value.error?.message,
|
|
20
|
+
value.error,
|
|
21
|
+
value.msg,
|
|
22
|
+
value.message,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
|
|
27
|
+
}
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Return the most specific available diagnostic from a browser/native player error. */
|
|
32
|
+
export function getPlayerErrorMessage(error) {
|
|
33
|
+
return messageFrom(error);
|
|
34
|
+
}
|
|
@@ -99,10 +99,20 @@ export function createVideoRecordingStream(video, playerElement, getAspectRatio,
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
if (!originClean) {
|
|
102
|
+
sourceStream.getTracks().forEach((track) => track.stop());
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const mode = String(getAspectRatio?.() || 'FIT').toUpperCase();
|
|
107
|
+
const needsCanvas = mode !== 'FIT' && mode !== 'DEFAULT';
|
|
108
|
+
if (!needsCanvas) {
|
|
109
|
+
const stream = new MediaStream([...sourceStream.getVideoTracks(), ...audioTracks]);
|
|
102
110
|
return {
|
|
103
|
-
stream
|
|
111
|
+
stream,
|
|
104
112
|
aspectRatioApplied: false,
|
|
105
|
-
cleanup: () =>
|
|
113
|
+
cleanup: () => {
|
|
114
|
+
sourceStream.getTracks().forEach((track) => track.stop());
|
|
115
|
+
},
|
|
106
116
|
};
|
|
107
117
|
}
|
|
108
118
|
|
|
@@ -163,10 +173,12 @@ export async function createScreenRecordingStream() {
|
|
|
163
173
|
};
|
|
164
174
|
}
|
|
165
175
|
|
|
166
|
-
export function getRecordingMimeType() {
|
|
176
|
+
export function getRecordingMimeType(hasAudio = true) {
|
|
167
177
|
if (typeof MediaRecorder === 'undefined') return '';
|
|
168
|
-
const candidates =
|
|
169
|
-
|
|
178
|
+
const candidates = hasAudio
|
|
179
|
+
? ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']
|
|
180
|
+
: ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
|
|
181
|
+
return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || (MediaRecorder.isTypeSupported?.('video/webm') ? 'video/webm' : '');
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
export function createRecordingDownloadLink(blob, title = 'cinecrew-recording') {
|