@cinecrew/cinecrew-player 0.1.5 → 0.1.7

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.
@@ -1,12 +1,25 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import mpegts from '../../../../vendor/mpegts.js/mpegts.js';
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
- if (mpegts.LoggingControl) {
9
- mpegts.LoggingControl.enableAll = false;
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 useMpegTs = isLive
91
- // The resolver may return an opaque temporary URL whose pathname no
92
- // longer ends in .ts or /live/. Keep the source format from the original
93
- // Xtream URL so those redirects still go through the TS demuxer. If the
94
- // resolver explicitly returned HLS, let the HLS player own that source.
95
- && (isRawLiveTransportStream(streamUrl) || isRawLiveTransportStream(activeUrl))
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
- if (!mpegts.isSupported()) {
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: true,
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
- // Match the standalone Sony test: allow a deeper forward buffer to
255
- // absorb provider jitter instead of constantly chasing the live edge.
256
- stashInitialSize: useVideoOnly ? 2048 : 512 * 1024,
257
- liveBufferLatencyChasing: !useVideoOnly,
281
+ stashInitialSize: getStashInitialSize(useVideoOnly, isLive),
282
+ liveBufferLatencyChasing: Boolean(isLive && !useVideoOnly),
258
283
  liveBufferLatencyMaxLatency: 8,
259
284
  liveBufferLatencyMinRemain: 3,
260
- autoCleanupSourceBuffer: true,
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
- video.dataset.mpegtsCodecGate = 'pending';
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,3 @@
1
+ export function useWebOgvPlayback() {
2
+ return false;
3
+ }
@@ -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: sourceStream,
111
+ stream,
104
112
  aspectRatioApplied: false,
105
- cleanup: () => sourceStream.getTracks().forEach((track) => track.stop()),
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 = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
169
- return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || '';
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') {