@cinecrew/cinecrew-player 0.1.6 → 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.
package/src/web/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useWebVideoAspectRatio } from '../native/media/web/useWebVideoAspectRatio';
3
3
  import { useWebMpegTsPlayback } from '../native/media/web/useWebMpegTsPlayback.web';
4
+ import { useWebFlvPlayback } from '../native/media/web/useWebFlvPlayback.web';
4
5
  import { useWebHlsPlayback } from '../native/media/web/useWebHlsPlayback.web';
6
+ import { useWebDashPlayback } from '../native/media/web/useWebDashPlayback.web';
7
+ import { useWebOgvPlayback } from '../native/media/web/useWebOgvPlayback.web';
5
8
  import { useWebAc3AudioPlayback } from '../native/media/web/useWebAc3AudioPlayback.web';
6
9
  import { getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
7
10
  import { EMOJI_GROUPS, searchEmojis } from '../data/emoji';
8
11
  import { createRecordingDownloadLink, createVideoRecordingStream, createScreenRecordingStream, downloadRecording, getRecordingMimeType } from '../utils/webRecording';
9
12
  import { invokePlayerAction } from '../utils/invokePlayerAction.js';
10
13
  import { emitProgressBarTime } from '../utils/progressBarTime.js';
14
+ import { getPlayerErrorMessage } from '../utils/playerError.js';
11
15
  import './styles.css';
12
16
 
13
17
  const h = React.createElement;
@@ -311,19 +315,10 @@ function WebBottomControls(props) {
311
315
 
312
316
  function getRecordingOverlayContent({ status, elapsed, error, downloadLink, onPause, onResume, onStop, onDownload, onDismiss }) {
313
317
  if (status === 'complete') {
314
- const children = [];
315
- if (error) children.push(h('span', null, error));
316
- children.push(h('strong', { className: 'cinecrew-player__recording-ready' }, 'Recording ready'));
317
- if (downloadLink) {
318
- children.push(h('a', {
319
- className: 'cinecrew-player__recording-download',
320
- href: downloadLink.url,
321
- download: downloadLink.filename,
322
- onClick: onDownload,
323
- }, 'Download recording'));
324
- }
325
- children.push(h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording controls' }, 'Done'));
326
- return h(React.Fragment, null, ...children);
318
+ return h(React.Fragment, null,
319
+ error ? h('span', null, error) : null,
320
+ h('strong', { className: 'cinecrew-player__recording-ready' }, 'Recording ready'),
321
+ h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording controls' }, 'Done'));
327
322
  }
328
323
  if (status === 'finalizing') {
329
324
  return h('strong', { className: 'cinecrew-player__recording-ready' }, 'Preparing recording…');
@@ -394,6 +389,51 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
394
389
  bottomControls);
395
390
  }
396
391
 
392
+ function getDrawerResizedVideoStyle(drawerResize, videoStyle) {
393
+ if (!drawerResize) return {};
394
+ if (videoStyle.width === 'auto') {
395
+ return {
396
+ left: 'calc(var(--cinecrew-media-width, 64%) / 2)',
397
+ top: '50%',
398
+ right: 'auto',
399
+ bottom: 'auto',
400
+ maxWidth: 'var(--cinecrew-media-width, 64%)',
401
+ transform: 'translate(-50%, -50%)',
402
+ };
403
+ }
404
+ return {
405
+ width: 'var(--cinecrew-media-width, 64%)',
406
+ height: '100%',
407
+ left: 0,
408
+ top: 0,
409
+ right: 'auto',
410
+ bottom: 0,
411
+ transform: 'none',
412
+ };
413
+ }
414
+
415
+ function getVideoSurfaceStyle(videoStyle, resizedVideoStyle, audioOnly) {
416
+ return {
417
+ ...videoStyle,
418
+ ...resizedVideoStyle,
419
+ opacity: audioOnly ? 0 : 1,
420
+ };
421
+ }
422
+
423
+ function getOgvStageStyle(drawerResize, audioOnly) {
424
+ return {
425
+ position: 'absolute',
426
+ top: 0,
427
+ left: 0,
428
+ right: drawerResize ? 'auto' : 0,
429
+ bottom: 0,
430
+ width: drawerResize ? 'var(--cinecrew-media-width, 64%)' : '100%',
431
+ height: '100%',
432
+ backgroundColor: '#000',
433
+ opacity: audioOnly ? 0 : 1,
434
+ };
435
+ }
436
+
397
437
  function WebPlayerSurface({
398
438
  title,
399
439
  streamUrl,
@@ -419,31 +459,40 @@ function WebPlayerSurface({
419
459
  onError,
420
460
  handleError,
421
461
  corsMode,
462
+ useOgv,
463
+ mediaType,
464
+ ogvResourceBase,
465
+ pausedRef,
466
+ onErrorRef,
422
467
  }) {
468
+ const ogvContainerRef = useRef(null);
469
+ useWebOgvPlayback({
470
+ activeUrl: streamUrl,
471
+ type: mediaType,
472
+ videoRef,
473
+ playerContainerRef: ogvContainerRef,
474
+ playerStyle: videoStyle,
475
+ pausedRef,
476
+ onErrorRef,
477
+ onBufferingRef: bufferingRef,
478
+ resourceBase: ogvResourceBase,
479
+ });
423
480
  if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
424
- let resizedVideoStyle = {};
425
- if (drawerResize && videoStyle.width === 'auto') {
426
- resizedVideoStyle = {
427
- left: 'calc(var(--cinecrew-media-width, 64%) / 2)',
428
- top: '50%',
429
- right: 'auto',
430
- bottom: 'auto',
431
- maxWidth: 'var(--cinecrew-media-width, 64%)',
432
- transform: 'translate(-50%, -50%)',
433
- };
434
- } else if (drawerResize) {
435
- resizedVideoStyle = {
436
- width: 'var(--cinecrew-media-width, 64%)',
437
- height: '100%',
438
- left: 0,
439
- top: 0,
440
- right: 'auto',
441
- bottom: 0,
442
- transform: 'none',
443
- };
481
+ const resizedVideoStyle = getDrawerResizedVideoStyle(drawerResize, videoStyle);
482
+ const surfaceStyle = getVideoSurfaceStyle(videoStyle, resizedVideoStyle, audioOnly);
483
+ if (useOgv) {
484
+ return h('div', {
485
+ ref: ogvContainerRef,
486
+ className: 'cinecrew-player__video',
487
+ style: getOgvStageStyle(drawerResize, audioOnly),
488
+ onClick: inlinePreview ? onPromotePreview : undefined,
489
+ 'data-stream-mode': 'ogv',
490
+ });
444
491
  }
445
492
  return h('video', {
446
- key: `${directVideoSource || ''}:${corsMode || 'nocors'}`,
493
+ // MSE-backed engines (HLS, DASH, MPEG-TS, FLV) own this element. Changing
494
+ // the CORS mode must not replace it after an engine has attached to it.
495
+ key: directVideoSource ? `${directVideoSource}:${corsMode || 'nocors'}` : 'managed-media',
447
496
  ref: videoRef,
448
497
  className: 'cinecrew-player__video',
449
498
  src: directVideoSource,
@@ -453,11 +502,7 @@ function WebPlayerSurface({
453
502
  playsInline: true,
454
503
  preload: 'auto',
455
504
  crossOrigin: corsMode,
456
- style: {
457
- ...videoStyle,
458
- ...resizedVideoStyle,
459
- opacity: audioOnly ? 0 : 1,
460
- },
505
+ style: surfaceStyle,
461
506
  onClick: inlinePreview ? onPromotePreview : undefined,
462
507
  onError: (event) => {
463
508
  if (!directVideoSource) return;
@@ -467,7 +512,8 @@ function WebPlayerSurface({
467
512
  });
468
513
  }
469
514
 
470
- function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
515
+ function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo, hasVideo, isPaused }) {
516
+ const bars = [1, 2, 3, 4, 5, 6, 7];
471
517
  return h('div', {
472
518
  className: 'cinecrew-player__audio-card',
473
519
  style: { background: '#07090E', color: theme.controlColor, borderColor: theme.accentColor },
@@ -476,13 +522,18 @@ function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
476
522
  ? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
477
523
  : h('div', { className: 'cinecrew-player__audio-placeholder', role: 'img', 'aria-label': 'Audio artwork placeholder' },
478
524
  h(Icon, { name: 'audioOnly', icons, color: theme.accentColor })),
479
- h('span', { className: 'cinecrew-player__audio-wave', style: { color: theme.accentColor }, 'aria-hidden': true }, '•••••••'),
525
+ h('div', {
526
+ className: `cinecrew-player__audio-wave${!isPaused ? ' is-playing' : ''}`,
527
+ 'aria-hidden': true,
528
+ }, bars.map((b) => h('span', { key: b, style: { background: theme.accentColor } }))),
480
529
  h('strong', null, title || 'Audio only'),
481
- h('button', {
482
- type: 'button',
483
- onClick: onSwitchToVideo,
484
- style: { color: theme.accentColor, borderColor: theme.accentColor },
485
- }, 'Switch to video'));
530
+ hasVideo && onSwitchToVideo
531
+ ? h('button', {
532
+ type: 'button',
533
+ onClick: onSwitchToVideo,
534
+ style: { color: theme.accentColor, borderColor: theme.accentColor },
535
+ }, 'Switch to video')
536
+ : null);
486
537
  }
487
538
 
488
539
  function WebPlayerError({ error, theme, renderBackButton }) {
@@ -493,8 +544,8 @@ function WebPlayerError({ error, theme, renderBackButton }) {
493
544
  renderBackButton());
494
545
  }
495
546
 
496
- function getDirectVideoSource({ mpegTs, useHls, sourceType, activeUrl }) {
497
- if (mpegTs || useHls || /mpegurl|mpeg-ts/.test(sourceType)) return undefined;
547
+ function getDirectVideoSource({ mpegTs, useHls, useDash, useOgv, sourceType, activeUrl }) {
548
+ if (mpegTs || useHls || useDash || useOgv || /mpegurl|mpeg-ts|dash|flv|video\/ogg|ogv/.test(sourceType)) return undefined;
498
549
  return activeUrl;
499
550
  }
500
551
 
@@ -523,6 +574,9 @@ function stopBrowserRecorder(recorderRef, completionRef, finalizerRef) {
523
574
  return;
524
575
  }
525
576
  recorder.addEventListener('error', (event) => reject(event.error || new Error('Recording failed.')), { once: true });
577
+ try {
578
+ if (recorder.state === 'recording') recorder.requestData();
579
+ } catch {}
526
580
  recorder.stop();
527
581
  if (completion) completion.then(resolve, reject);
528
582
  else resolve();
@@ -544,16 +598,21 @@ async function performRecordingStop({ stop, action, recordingElapsed, streamUrl,
544
598
  let stopCore = () => stopBrowserRecorder(recorderRef, completionRef, finalizerRef);
545
599
  if (stop) stopCore = () => stop();
546
600
  await action('onRecordingStop', stopCore, { elapsedMs: recordingElapsed, source: streamUrl, title });
547
- if (!stop) await waitForRecordingFinalization(completionRef.current);
601
+ if (!stop) return await waitForRecordingFinalization(completionRef.current);
602
+ return null;
548
603
  }
549
604
 
550
- function updateRecordingStopState({ clock, recordingBlobRef, stop, setRecordingElapsed, setRecordingStatus, setRecordingError }) {
605
+ function updateRecordingStopState({ clock, completedBlob, stop, setRecordingElapsed, setRecordingStatus, setRecordingError }) {
551
606
  if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
552
607
  setRecordingElapsed(clock.accumulatedMs);
553
- const hasRecording = Boolean(recordingBlobRef.current);
554
- setRecordingStatus(hasRecording ? 'complete' : 'idle');
555
- if (!hasRecording && !stop) {
608
+ if (completedBlob) {
609
+ setRecordingStatus('idle');
610
+ setRecordingError('');
611
+ } else if (!stop) {
612
+ setRecordingStatus('idle');
556
613
  setRecordingError((current) => current || 'The browser returned no recorded media data for this source. Try a local or CORS-enabled video while it is playing.');
614
+ } else {
615
+ setRecordingStatus('idle');
557
616
  }
558
617
  }
559
618
 
@@ -615,7 +674,7 @@ function getPlaybackStatus(error, buffering, paused) {
615
674
 
616
675
  function WebPlayerLayout(props) {
617
676
  let controlLayer = null;
618
- if (!props.error && !props.audioOnly) {
677
+ if (!props.error) {
619
678
  controlLayer = h(WebPlayerControls, {
620
679
  locked: props.locked,
621
680
  buffering: props.buffering,
@@ -634,11 +693,19 @@ function WebPlayerLayout(props) {
634
693
  let loadingNotice = null;
635
694
  if (props.buffering && !props.error) {
636
695
  loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
637
- h('span', { className: 'cinecrew-player__spinner', style: { borderTopColor: props.theme.accentColor } }), 'Loading stream…')
696
+ h('span', { className: 'cinecrew-player__spinner', style: { borderTopColor: props.theme.accentColor } }), 'Loading stream…');
638
697
  }
639
698
  let audioCard = null;
640
699
  if (props.audioOnly) {
641
- audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, icons: props.icons, onSwitchToVideo: props.onSwitchToVideo });
700
+ audioCard = h(WebAudioOnlyCard, {
701
+ poster: props.poster,
702
+ title: props.title,
703
+ theme: props.theme,
704
+ icons: props.icons,
705
+ onSwitchToVideo: props.onSwitchToVideo,
706
+ hasVideo: props.hasVideo,
707
+ isPaused: props.isPaused,
708
+ });
642
709
  }
643
710
  let panelNode = null;
644
711
  if (props.activePanel && props.webPanel) {
@@ -650,23 +717,26 @@ function WebPlayerLayout(props) {
650
717
  }
651
718
  return h('div', {
652
719
  ref: props.playerRef,
653
- className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''}${props.drawerMode === 'resize' && props.activePanel ? ' cinecrew-player--drawer-resize' : ''} ${props.className}`.trim(),
720
+ className: `cinecrew-player${props.audioOnly ? ' cinecrew-player--audio-mode' : ''}${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''}${props.drawerMode === 'resize' && props.activePanel ? ' cinecrew-player--drawer-resize' : ''} ${props.className}`.trim(),
654
721
  style: { ...props.rootStyle, ...props.style, background: props.theme.backgroundColor, borderRadius: props.theme.borderRadius, '--cinecrew-accent': props.theme.accentColor, '--cinecrew-text': props.theme.controlColor, '--cinecrew-surface': props.theme.surfaceColor, '--cinecrew-media-width': '64%' },
655
722
  onWheel: props.onWheel,
656
723
  'data-stream-mode': props.streamMode,
657
724
  },
658
725
  props.mediaSurface,
726
+ audioCard,
659
727
  h('div', { className: 'cinecrew-player__shade', style: { background: 'linear-gradient(180deg, rgba(0,0,0,.48), transparent 28%, transparent 68%, rgba(0,0,0,.64))' } }),
660
728
  loadingNotice,
661
729
  h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.locked ? () => null : props.renderBackButton }),
662
730
  controlLayer,
663
- audioCard,
664
731
  panelNode);
665
732
  }
666
733
 
667
- function getStreamMode(mpegTs, useHls) {
734
+ function getStreamMode({ mpegTs, isFlv, useHls, useDash, useOgv }) {
735
+ if (isFlv) return 'flv';
668
736
  if (mpegTs) return 'mpegts';
669
737
  if (useHls) return 'hls';
738
+ if (useDash) return 'dash';
739
+ if (useOgv) return 'ogv';
670
740
  return 'native';
671
741
  }
672
742
 
@@ -1232,7 +1302,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1232
1302
 
1233
1303
  const handleError = useCallback((detail) => {
1234
1304
  const message = typeof detail === 'string' ? detail : detail?.message || 'Unable to play this media source.';
1235
- // If the video failed while crossOrigin="anonymous" was set, retry without
1305
+ // If playback failed while crossOrigin="anonymous" was set, retry without
1236
1306
  // CORS so playback still works (recording will fall back to screen capture).
1237
1307
  if (detail?.isCorsCandidate && corsModeRef.current === 'anonymous') {
1238
1308
  setCorsMode(undefined);
@@ -1241,7 +1311,16 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1241
1311
  }
1242
1312
  setError(message);
1243
1313
  setBuffering(false);
1244
- errorRef.current?.(detail instanceof Error ? detail : { ...detail, message });
1314
+ if (detail instanceof Error) {
1315
+ errorRef.current?.(detail);
1316
+ return;
1317
+ }
1318
+ const actualMessage = getPlayerErrorMessage(detail);
1319
+ errorRef.current?.({
1320
+ ...detail,
1321
+ message,
1322
+ ...(actualMessage ? { actualMessage } : {}),
1323
+ });
1245
1324
  }, []);
1246
1325
  const onErrorRef = useRef(handleError);
1247
1326
  onErrorRef.current = handleError;
@@ -1313,6 +1392,19 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1313
1392
  onErrorRef,
1314
1393
  onBufferingRef,
1315
1394
  });
1395
+ const useFlv = useWebFlvPlayback({
1396
+ activeUrl,
1397
+ type: media.type || media.mimeType,
1398
+ isLive,
1399
+ videoRef,
1400
+ pausedRef: pausedStateRef,
1401
+ onErrorRef,
1402
+ onBufferingRef,
1403
+ onAutoplayBlocked: () => {
1404
+ pausedStateRef.current = true;
1405
+ setIsPaused(true);
1406
+ },
1407
+ });
1316
1408
  const useHls = useWebHlsPlayback({
1317
1409
  activeUrl,
1318
1410
  isLive,
@@ -1320,7 +1412,22 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1320
1412
  pausedRef: pausedStateRef,
1321
1413
  onErrorRef,
1322
1414
  });
1323
- const streamMode = getStreamMode(mpegTs.useMpegTs, useHls);
1415
+ const useDash = useWebDashPlayback({
1416
+ activeUrl,
1417
+ type: media.type || media.mimeType,
1418
+ videoRef,
1419
+ pausedRef: pausedStateRef,
1420
+ onErrorRef,
1421
+ });
1422
+ const useOgv = Boolean(/\.ogv(?:$|[?#])/i.test(activeUrl)
1423
+ || /(?:video\/ogg|\bogv\b)/i.test(String(media.type || media.mimeType || '')));
1424
+ const streamMode = getStreamMode({
1425
+ mpegTs: mpegTs.useMpegTs,
1426
+ isFlv: mpegTs.isFlv,
1427
+ useHls,
1428
+ useDash,
1429
+ useOgv,
1430
+ });
1324
1431
  const ac3AudioPlayback = useWebAc3AudioPlayback({
1325
1432
  active: mpegTs.useAc3Fallback,
1326
1433
  streamUrl,
@@ -1329,17 +1436,39 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1329
1436
  volume: volume * 100,
1330
1437
  videoRef,
1331
1438
  });
1439
+ const isManagedPlayback = mpegTs.useMpegTs || useFlv || useHls || useDash || useOgv;
1332
1440
 
1333
1441
  useEffect(() => {
1334
1442
  const video = videoRef.current;
1335
1443
  if (!video) return;
1336
- if (isPaused) video.pause();
1337
- else video.play().catch((playError) => {
1338
- if (playError?.name !== 'NotAllowedError' && playError?.name !== 'AbortError') {
1339
- handleError(playError);
1444
+ if (isPaused) {
1445
+ video.pause();
1446
+ } else if (isManagedPlayback && video.readyState < 2) {
1447
+ // Let the streaming engine start playback once its MediaSource has
1448
+ // produced a decodable frame. Calling play() before then can reject and
1449
+ // incorrectly trigger the native-source CORS retry path.
1450
+ return;
1451
+ } else {
1452
+ const playPromise = video.play();
1453
+ if (playPromise !== undefined) {
1454
+ playPromise.catch((playError) => {
1455
+ if (playError?.name === 'NotAllowedError') {
1456
+ if (useFlv) {
1457
+ video.pause();
1458
+ pausedStateRef.current = true;
1459
+ setIsPaused(true);
1460
+ } else {
1461
+ video.muted = true;
1462
+ setMuted(true);
1463
+ video.play().catch(() => {});
1464
+ }
1465
+ } else if (playError?.name !== 'AbortError') {
1466
+ handleError({ message: playError?.message || 'Unable to start playback.', err: playError });
1467
+ }
1468
+ });
1340
1469
  }
1341
- });
1342
- }, [isPaused, streamUrl, handleError]);
1470
+ }
1471
+ }, [isPaused, streamUrl, corsMode, handleError, isManagedPlayback, useFlv]);
1343
1472
 
1344
1473
  useEffect(() => {
1345
1474
  const video = videoRef.current;
@@ -1376,14 +1505,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1376
1505
 
1377
1506
  const startBuiltinRecording = useCallback(async () => {
1378
1507
  const video = videoRef.current;
1379
- const mimeType = getRecordingMimeType();
1380
- if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
1381
1508
  if (!video) throw new Error('The media element is not ready to record.');
1382
1509
  let capture = createVideoRecordingStream(video, playerRef.current, () => aspectRatioRef.current, ac3AudioPlayback.getRecordingAudioStream());
1383
1510
  // Fallback: if direct capture failed (CORS / tainted), use screen capture.
1384
1511
  if (!capture) {
1385
1512
  capture = await createScreenRecordingStream();
1386
1513
  }
1514
+ const hasAudio = Boolean(capture.stream?.getAudioTracks?.().length);
1515
+ const mimeType = getRecordingMimeType(hasAudio);
1516
+ if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
1387
1517
  const recorder = new MediaRecorder(capture.stream, { mimeType });
1388
1518
  recordingCaptureRef.current = capture;
1389
1519
  recordingChunksRef.current = [];
@@ -1405,17 +1535,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1405
1535
  if (!downloadLink) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
1406
1536
  recordingDownloadLinkRef.current = downloadLink;
1407
1537
  setRecordingDownloadLink(downloadLink);
1408
- setRecordingStatus('complete');
1409
1538
  try {
1410
- if (!downloadRecording(blob, title)) throw new Error('The browser did not start the recording download. Use Download recording to retry.');
1539
+ if (!downloadRecording(blob, title)) throw new Error('The browser did not start the recording download. Try a local or CORS-enabled source.');
1411
1540
  // Auto-dismiss back to idle after a successful download.
1412
1541
  setRecordingStatus('idle');
1542
+ setRecordingError('');
1413
1543
  recordingBlobRef.current = null;
1414
1544
  if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
1415
1545
  recordingDownloadLinkRef.current = null;
1416
1546
  setRecordingDownloadLink(null);
1417
1547
  } catch (downloadError) {
1418
- setRecordingError(downloadError?.message || 'The recording could not be downloaded. Use Download recording to retry.');
1548
+ setRecordingError(downloadError?.message || 'The recording could not be downloaded.');
1549
+ setRecordingStatus('idle');
1419
1550
  }
1420
1551
  } catch (error_) {
1421
1552
  setRecordingError(error_?.message || 'Could not finish the recording.');
@@ -1498,7 +1629,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1498
1629
  const stopRecording = useCallback(async () => {
1499
1630
  try {
1500
1631
  const stop = integrations.recording?.stop;
1501
- await performRecordingStop({
1632
+ const completedBlob = await performRecordingStop({
1502
1633
  stop,
1503
1634
  action,
1504
1635
  recordingElapsed,
@@ -1511,7 +1642,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1511
1642
  });
1512
1643
  updateRecordingStopState({
1513
1644
  clock: recordingClockRef.current,
1514
- recordingBlobRef,
1645
+ completedBlob,
1515
1646
  stop,
1516
1647
  setRecordingElapsed,
1517
1648
  setRecordingStatus,
@@ -1623,8 +1754,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1623
1754
  const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
1624
1755
  const sourceType = String(media.type || media.mimeType || '').toLowerCase();
1625
1756
  const directVideoSource = getDirectVideoSource({
1626
- mpegTs: mpegTs.useMpegTs,
1757
+ mpegTs: mpegTs.useMpegTs || useFlv,
1627
1758
  useHls,
1759
+ useDash,
1760
+ useOgv,
1628
1761
  sourceType,
1629
1762
  activeUrl,
1630
1763
  });
@@ -1765,6 +1898,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1765
1898
  onError: handleError,
1766
1899
  handleError,
1767
1900
  corsMode,
1901
+ useOgv,
1902
+ mediaType: media.type || media.mimeType,
1903
+ ogvResourceBase: props.ogvResourceBase,
1904
+ pausedRef: pausedStateRef,
1905
+ onErrorRef,
1768
1906
  });
1769
1907
  const bottomControlProps = {
1770
1908
  isLive,
@@ -1819,6 +1957,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1819
1957
  error,
1820
1958
  buffering,
1821
1959
  audioOnly,
1960
+ hasVideo: !audioOnly,
1822
1961
  activePanel,
1823
1962
  webPanel,
1824
1963
  renderBackButton: () => control('back', 'Close player', () => action('onBack', undefined, { title, source: media }), { icon: 'close' }),
@@ -273,13 +273,13 @@
273
273
  .cinecrew-player__audio-card {
274
274
  position: absolute;
275
275
  inset: 0;
276
- z-index: 5;
276
+ z-index: 2;
277
277
  box-sizing: border-box;
278
278
  display: flex;
279
279
  flex-direction: column;
280
280
  align-items: center;
281
281
  justify-content: center;
282
- gap: 18px;
282
+ gap: 16px;
283
283
  padding: 24px;
284
284
  border: 1px solid;
285
285
  border-radius: inherit;
@@ -287,6 +287,10 @@
287
287
  box-shadow: 0 18px 60px rgba(0, 0, 0, .32);
288
288
  }
289
289
 
290
+ .cinecrew-player--audio-mode .cinecrew-player__controls {
291
+ opacity: 1;
292
+ }
293
+
290
294
  .cinecrew-player__audio-poster {
291
295
  width: min(30vw, 160px);
292
296
  aspect-ratio: 1;
@@ -312,8 +316,37 @@
312
316
  }
313
317
 
314
318
  .cinecrew-player__audio-wave {
315
- letter-spacing: .28em;
316
- font-size: 1.25rem;
319
+ display: flex;
320
+ align-items: center;
321
+ justify-content: center;
322
+ gap: 5px;
323
+ height: 24px;
324
+ }
325
+
326
+ .cinecrew-player__audio-wave span {
327
+ display: inline-block;
328
+ width: 4px;
329
+ height: 8px;
330
+ border-radius: 2px;
331
+ background: var(--cinecrew-accent, #00E5FF);
332
+ transition: height 0.2s ease, opacity 0.2s ease;
333
+ }
334
+
335
+ .cinecrew-player__audio-wave.is-playing span {
336
+ animation: cinecrew-audio-bar 0.9s ease-in-out infinite alternate;
337
+ }
338
+
339
+ .cinecrew-player__audio-wave.is-playing span:nth-child(1) { height: 14px; animation-delay: 0.1s; }
340
+ .cinecrew-player__audio-wave.is-playing span:nth-child(2) { height: 22px; animation-delay: 0.3s; }
341
+ .cinecrew-player__audio-wave.is-playing span:nth-child(3) { height: 10px; animation-delay: 0.5s; }
342
+ .cinecrew-player__audio-wave.is-playing span:nth-child(4) { height: 24px; animation-delay: 0.2s; }
343
+ .cinecrew-player__audio-wave.is-playing span:nth-child(5) { height: 16px; animation-delay: 0.4s; }
344
+ .cinecrew-player__audio-wave.is-playing span:nth-child(6) { height: 20px; animation-delay: 0.6s; }
345
+ .cinecrew-player__audio-wave.is-playing span:nth-child(7) { height: 12px; animation-delay: 0.15s; }
346
+
347
+ @keyframes cinecrew-audio-bar {
348
+ 0% { transform: scaleY(0.35); opacity: 0.45; }
349
+ 100% { transform: scaleY(1.25); opacity: 1; }
317
350
  }
318
351
 
319
352
  .cinecrew-player__audio-card strong {
package/types/index.d.ts CHANGED
@@ -80,6 +80,20 @@ export interface PlayerActionContext {
80
80
  video?: unknown | null;
81
81
  }
82
82
 
83
+ /** Structured playback failure. `message` is suitable for the player UI; `actualMessage` and `cause`/`err` preserve the underlying engine diagnostic when available. */
84
+ export interface PlayerErrorDetails {
85
+ message: string;
86
+ actualMessage?: string;
87
+ cause?: unknown;
88
+ err?: unknown;
89
+ errorType?: string;
90
+ errorDetail?: string;
91
+ httpStatus?: number | null;
92
+ [key: string]: unknown;
93
+ }
94
+
95
+ export type PlayerError = Error | PlayerErrorDetails;
96
+
83
97
  /** App callback for a player action. Back is app-owned and has no built-in behavior. */
84
98
  export type PlayerAction = (payload?: Record<string, unknown>, context?: PlayerActionContext) => unknown;
85
99
 
@@ -234,6 +248,8 @@ export interface CineCrewPlayerProps {
234
248
  videoOnly?: boolean;
235
249
  /** Optional resolver for share pages or other non-media links. */
236
250
  resolveSource?: (source: PlayerSource, context: { platform: 'web' | 'native' | 'electron' }) => PlayerSource | string | Promise<PlayerSource | string>;
251
+ /** Optional base URL for ogv.js worker/WASM assets; defaults to the versioned jsDelivr distribution. */
252
+ ogvResourceBase?: string;
237
253
  audioTracks?: AudioTrack[];
238
254
  selectedAudioTrack?: string | number;
239
255
  resumePosition?: number;
@@ -265,7 +281,8 @@ export interface CineCrewPlayerProps {
265
281
  onProgressBarChange?: (time: string) => void;
266
282
  onPlaying?: (event: unknown) => void;
267
283
  onBuffering?: (buffering: boolean) => void;
268
- onError?: (error: Error | Record<string, unknown>) => void;
284
+ /** Receives the player-facing error plus the underlying engine diagnostic (`actualMessage`, `cause`, `err`) when available. */
285
+ onError?: (error: PlayerError) => void;
269
286
  onEnded?: () => void;
270
287
  onPlaybackRoute?: (url: string) => void;
271
288
  onNextEpisode?: (episode: Record<string, unknown>) => void;
@@ -292,7 +309,8 @@ export interface InlineLivePlayerProps {
292
309
  icons?: PlayerIcons;
293
310
  style?: unknown;
294
311
  initialMuted?: boolean;
295
- onError?: (error: Error | Record<string, unknown>) => void;
312
+ /** Receives the player-facing error plus the underlying engine diagnostic (`actualMessage`, `cause`, `err`) when available. */
313
+ onError?: (error: PlayerError) => void;
296
314
  onPlaying?: (event: unknown) => void;
297
315
  }
298
316