@cinecrew/cinecrew-player 0.1.4 → 0.1.6

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
@@ -3,10 +3,9 @@ import { useWebVideoAspectRatio } from '../native/media/web/useWebVideoAspectRat
3
3
  import { useWebMpegTsPlayback } from '../native/media/web/useWebMpegTsPlayback.web';
4
4
  import { useWebHlsPlayback } from '../native/media/web/useWebHlsPlayback.web';
5
5
  import { useWebAc3AudioPlayback } from '../native/media/web/useWebAc3AudioPlayback.web';
6
- import { YouTubeVideoPlayer } from '../native/media/YouTubeVideoPlayer.web.js';
7
- import { getYouTubeVideoId, getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
6
+ import { getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
8
7
  import { EMOJI_GROUPS, searchEmojis } from '../data/emoji';
9
- import { createRecordingDownloadLink, createVideoRecordingStream, downloadRecording, getRecordingMimeType } from '../utils/webRecording';
8
+ import { createRecordingDownloadLink, createVideoRecordingStream, createScreenRecordingStream, downloadRecording, getRecordingMimeType } from '../utils/webRecording';
10
9
  import { invokePlayerAction } from '../utils/invokePlayerAction.js';
11
10
  import { emitProgressBarTime } from '../utils/progressBarTime.js';
12
11
  import './styles.css';
@@ -250,13 +249,14 @@ function WebPlaybackRateControl({ value, onChange }) {
250
249
  function WebBottomControls(props) {
251
250
  const {
252
251
  isLive, overrides, theme, icons, currentTime, duration, seekTo,
252
+ progressBarVisible, inlinePreview, title,
253
253
  showAspectMenu, setShowAspectMenu, selectAspect, aspectRatios, selectedAspectRatio,
254
254
  audioOnly, setAudioOnlyMode, availableTracks, selectedAudioTrack,
255
255
  showAudioMenu, setShowAudioMenu, selectAudio, playbackRate,
256
256
  setPlaybackRateAction, fullscreen, toggleFullscreen,
257
257
  } = props;
258
258
  let seek = null;
259
- if (duration > 0 && isControlEnabled(overrides, 'seek', true)) {
259
+ if (duration > 0 && progressBarVisible && isControlEnabled(overrides, 'seek', true)) {
260
260
  seek = h(WebSeekControl, { currentTime, duration, theme, onSeek: seekTo });
261
261
  }
262
262
  let aspect = null;
@@ -300,6 +300,7 @@ function WebBottomControls(props) {
300
300
  seek,
301
301
  h('div', { className: 'cinecrew-player__bottom-actions' },
302
302
  h('div', { className: 'cinecrew-player__bottom-left-actions' },
303
+ inlinePreview && title ? h('span', { className: 'cinecrew-player__inline-title', title }, title) : null,
303
304
  renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { active: audioOnly }, overrides, icons, theme }),
304
305
  aspect),
305
306
  h('div', { className: 'cinecrew-player__bottom-right-actions' },
@@ -351,7 +352,7 @@ function WebRecordingOverlay(props) {
351
352
  }, getRecordingOverlayContent(props), dismissButton);
352
353
  }
353
354
 
354
- function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, title, togglePlay, bottomProps }) {
355
+ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, title, inlinePreview, togglePlay, bottomProps }) {
355
356
  let leftControls = locked ? null : unlockedControls.left;
356
357
  let rightControls = unlockedControls.right;
357
358
  if (locked) {
@@ -375,7 +376,7 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
375
376
  return h('div', { className: `cinecrew-player__controls${recordingUiVisible ? ' is-recording' : ''}`, style: { color: theme.controlColor } },
376
377
  h('div', { className: 'cinecrew-player__top-controls' },
377
378
  h('div', { className: 'cinecrew-player__top-left-actions' }, leftControls),
378
- !locked && paused && title ? h('div', { className: 'cinecrew-player__title', style: { color: theme.controlColor }, title }, title) : null,
379
+ !locked && !inlinePreview && paused && title ? h('div', { className: 'cinecrew-player__title', style: { color: theme.controlColor }, title }, title) : null,
379
380
  h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
380
381
  centerControls,
381
382
  h(WebRecordingOverlay, {
@@ -394,10 +395,9 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
394
395
  }
395
396
 
396
397
  function WebPlayerSurface({
397
- youtubeVideoId,
398
+ title,
398
399
  streamUrl,
399
400
  videoRef,
400
- youtubeRef,
401
401
  directVideoSource,
402
402
  poster,
403
403
  autoPlay,
@@ -416,28 +416,10 @@ function WebPlayerSurface({
416
416
  onProgress,
417
417
  onPlaying,
418
418
  onEnded,
419
- onStateChange,
420
419
  onError,
421
420
  handleError,
421
+ corsMode,
422
422
  }) {
423
- if (youtubeVideoId) {
424
- return h(YouTubeVideoPlayer, {
425
- ref: youtubeRef,
426
- videoId: youtubeVideoId,
427
- paused,
428
- muted: muted || videoOnly,
429
- volume,
430
- playbackRate,
431
- onReady,
432
- onProgress,
433
- onPlaying,
434
- onBuffering: bufferingRef.current,
435
- onStateChange,
436
- onError,
437
- onEnded,
438
- style: drawerResize ? { width: 'var(--cinecrew-media-width, 64%)', height: '100%', inset: '0 auto 0 0' } : undefined,
439
- });
440
- }
441
423
  if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
442
424
  let resizedVideoStyle = {};
443
425
  if (drawerResize && videoStyle.width === 'auto') {
@@ -461,6 +443,7 @@ function WebPlayerSurface({
461
443
  };
462
444
  }
463
445
  return h('video', {
446
+ key: `${directVideoSource || ''}:${corsMode || 'nocors'}`,
464
447
  ref: videoRef,
465
448
  className: 'cinecrew-player__video',
466
449
  src: directVideoSource,
@@ -469,6 +452,7 @@ function WebPlayerSurface({
469
452
  muted: muted || videoOnly,
470
453
  playsInline: true,
471
454
  preload: 'auto',
455
+ crossOrigin: corsMode,
472
456
  style: {
473
457
  ...videoStyle,
474
458
  ...resizedVideoStyle,
@@ -478,7 +462,7 @@ function WebPlayerSurface({
478
462
  onError: (event) => {
479
463
  if (!directVideoSource) return;
480
464
  const mediaError = event.currentTarget?.error;
481
- handleError({ message: mediaError?.message || 'The browser could not load this stream. Check URL, codec and CORS support.', code: mediaError?.code, cause: mediaError });
465
+ handleError({ message: mediaError?.message || 'The browser could not load this stream. Check URL, codec and CORS support.', code: mediaError?.code, cause: mediaError, isCorsCandidate: corsMode === 'anonymous' });
482
466
  },
483
467
  });
484
468
  }
@@ -486,7 +470,7 @@ function WebPlayerSurface({
486
470
  function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
487
471
  return h('div', {
488
472
  className: 'cinecrew-player__audio-card',
489
- style: { background: theme.surfaceColor, color: theme.controlColor, borderColor: theme.accentColor },
473
+ style: { background: '#07090E', color: theme.controlColor, borderColor: theme.accentColor },
490
474
  },
491
475
  poster
492
476
  ? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
@@ -524,10 +508,6 @@ function getPanelIntegration(activePanel, integrations) {
524
508
  return null;
525
509
  }
526
510
 
527
- function getActiveMediaUrl(youtubeVideoId, streamUrl) {
528
- return youtubeVideoId ? '' : streamUrl;
529
- }
530
-
531
511
  function getInlinePlayerStyle(inlinePreview, rect) {
532
512
  if (!inlinePreview || !rect) return {};
533
513
  return { position: 'absolute', left: rect.x, top: rect.y, width: rect.width, height: rect.height };
@@ -646,6 +626,7 @@ function WebPlayerLayout(props) {
646
626
  toggleLock: props.toggleLock,
647
627
  paused: props.isPaused,
648
628
  title: props.title,
629
+ inlinePreview: props.inlinePreview,
649
630
  togglePlay: props.togglePlay,
650
631
  bottomProps: props.bottomControlProps,
651
632
  });
@@ -683,8 +664,7 @@ function WebPlayerLayout(props) {
683
664
  panelNode);
684
665
  }
685
666
 
686
- function getStreamMode(youtubeVideoId, mpegTs, useHls) {
687
- if (youtubeVideoId) return 'youtube';
667
+ function getStreamMode(mpegTs, useHls) {
688
668
  if (mpegTs) return 'mpegts';
689
669
  if (useHls) return 'hls';
690
670
  return 'native';
@@ -1157,6 +1137,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1157
1137
  volume: volumeProp = 1,
1158
1138
  playbackRate: playbackRateProp = 1,
1159
1139
  paused: pausedProp,
1140
+ showProgressBar = true,
1160
1141
  controls: controlOverrides = {},
1161
1142
  features = {},
1162
1143
  drawerMode = 'overlay',
@@ -1197,11 +1178,13 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1197
1178
  const resolution = useResolvedPlayerSource(source, url, resolveSource, getWebRuntimePlatform());
1198
1179
  const media = resolution.source || {};
1199
1180
  const streamUrl = String(media.uri || media.url || '');
1200
- const youtubeVideoId = getYouTubeVideoId(media);
1201
1181
  const isLive = liveProp ?? media.isLive ?? false;
1182
+ const progressBarVisible = showProgressBar !== false
1183
+ && isControlEnabled(controlOverrides, 'seek', true)
1184
+ && !isLive;
1185
+ const progressBarCallback = progressBarVisible ? onProgressBarChange : undefined;
1202
1186
  const theme = { ...DEFAULT_THEME, ...themeProp };
1203
1187
  const videoRef = useRef(null);
1204
- const youtubeRef = useRef(null);
1205
1188
  const playerRef = useRef(null);
1206
1189
  const publicPlayerRef = useRef(null);
1207
1190
  const pausedRef = useRef(pausedProp ?? !autoPlay);
@@ -1214,6 +1197,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1214
1197
  const [aspectRatio, setAspectRatio] = useState(props.defaultAspectRatio || 'FIT');
1215
1198
  const [locked, setLocked] = useState(false);
1216
1199
  const [buffering, setBuffering] = useState(Boolean(streamUrl) || resolution.loading);
1200
+ const [corsMode, setCorsMode] = useState('anonymous');
1201
+ const corsModeRef = useRef('anonymous');
1202
+ corsModeRef.current = corsMode;
1217
1203
  const [error, setError] = useState('');
1218
1204
  const [currentTime, setCurrentTime] = useState(0);
1219
1205
  const [duration, setDuration] = useState(0);
@@ -1246,6 +1232,13 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1246
1232
 
1247
1233
  const handleError = useCallback((detail) => {
1248
1234
  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
1236
+ // CORS so playback still works (recording will fall back to screen capture).
1237
+ if (detail?.isCorsCandidate && corsModeRef.current === 'anonymous') {
1238
+ setCorsMode(undefined);
1239
+ corsModeRef.current = undefined;
1240
+ return;
1241
+ }
1249
1242
  setError(message);
1250
1243
  setBuffering(false);
1251
1244
  errorRef.current?.(detail instanceof Error ? detail : { ...detail, message });
@@ -1268,6 +1261,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1268
1261
  setDuration(0);
1269
1262
  lastProgressBarSecondRef.current = null;
1270
1263
  setAvailableTracks([]);
1264
+ setCorsMode('anonymous');
1265
+ corsModeRef.current = 'anonymous';
1271
1266
  if (resolution.error) {
1272
1267
  handleError(resolution.error);
1273
1268
  return;
@@ -1306,7 +1301,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1306
1301
  if (liveChatNonce) setActivePanel('chat');
1307
1302
  }, [liveChatNonce]);
1308
1303
 
1309
- const activeUrl = getActiveMediaUrl(youtubeVideoId, streamUrl);
1304
+ const activeUrl = streamUrl;
1310
1305
 
1311
1306
  const mpegTs = useWebMpegTsPlayback({
1312
1307
  streamUrl,
@@ -1325,7 +1320,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1325
1320
  pausedRef: pausedStateRef,
1326
1321
  onErrorRef,
1327
1322
  });
1328
- const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
1323
+ const streamMode = getStreamMode(mpegTs.useMpegTs, useHls);
1329
1324
  const ac3AudioPlayback = useWebAc3AudioPlayback({
1330
1325
  active: mpegTs.useAc3Fallback,
1331
1326
  streamUrl,
@@ -1344,7 +1339,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1344
1339
  handleError(playError);
1345
1340
  }
1346
1341
  });
1347
- }, [isPaused, streamUrl, youtubeVideoId, handleError]);
1342
+ }, [isPaused, streamUrl, handleError]);
1348
1343
 
1349
1344
  useEffect(() => {
1350
1345
  const video = videoRef.current;
@@ -1352,7 +1347,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1352
1347
  video.muted = muted || videoOnly;
1353
1348
  video.volume = volume;
1354
1349
  video.playbackRate = Number(playbackRate) || 1;
1355
- }, [muted, volume, playbackRate, videoOnly, streamUrl, youtubeVideoId]);
1350
+ }, [muted, volume, playbackRate, videoOnly, streamUrl]);
1356
1351
 
1357
1352
  useEffect(() => {
1358
1353
  if (!streamUrl) return undefined;
@@ -1370,20 +1365,25 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1370
1365
  let callback = actions?.[name];
1371
1366
  if (name === 'onAspectRatioChange') callback = callback || props.onAspectRatioChange;
1372
1367
  else if (name === 'onFullscreen') callback = callback || onFullscreen;
1368
+ else if (name === 'onBack') callback = callback || onBack || props.onClose;
1373
1369
  return invokePlayerAction(
1374
1370
  fallback,
1375
1371
  callback,
1376
1372
  payload,
1377
1373
  { video: videoRef.current, player: publicPlayerRef.current },
1378
1374
  );
1379
- }, [actions, props.onAspectRatioChange, onFullscreen]);
1375
+ }, [actions, props.onAspectRatioChange, onFullscreen, onBack, props.onClose]);
1380
1376
 
1381
1377
  const startBuiltinRecording = useCallback(async () => {
1382
1378
  const video = videoRef.current;
1383
- if (!video || youtubeVideoId) throw new Error('This source cannot be recorded from the browser player.');
1384
1379
  const mimeType = getRecordingMimeType();
1385
1380
  if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
1386
- const capture = createVideoRecordingStream(video, playerRef.current, () => aspectRatioRef.current, ac3AudioPlayback.getRecordingAudioStream());
1381
+ if (!video) throw new Error('The media element is not ready to record.');
1382
+ let capture = createVideoRecordingStream(video, playerRef.current, () => aspectRatioRef.current, ac3AudioPlayback.getRecordingAudioStream());
1383
+ // Fallback: if direct capture failed (CORS / tainted), use screen capture.
1384
+ if (!capture) {
1385
+ capture = await createScreenRecordingStream();
1386
+ }
1387
1387
  const recorder = new MediaRecorder(capture.stream, { mimeType });
1388
1388
  recordingCaptureRef.current = capture;
1389
1389
  recordingChunksRef.current = [];
@@ -1408,6 +1408,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1408
1408
  setRecordingStatus('complete');
1409
1409
  try {
1410
1410
  if (!downloadRecording(blob, title)) throw new Error('The browser did not start the recording download. Use Download recording to retry.');
1411
+ // Auto-dismiss back to idle after a successful download.
1412
+ setRecordingStatus('idle');
1413
+ recordingBlobRef.current = null;
1414
+ if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
1415
+ recordingDownloadLinkRef.current = null;
1416
+ setRecordingDownloadLink(null);
1411
1417
  } catch (downloadError) {
1412
1418
  setRecordingError(downloadError?.message || 'The recording could not be downloaded. Use Download recording to retry.');
1413
1419
  }
@@ -1432,7 +1438,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1432
1438
  setRecordingError(event.error?.message || 'The browser could not record this stream.');
1433
1439
  }, { once: true });
1434
1440
  recorder.start(1000);
1435
- }, [ac3AudioPlayback, title, youtubeVideoId]);
1441
+ }, [ac3AudioPlayback, title]);
1436
1442
 
1437
1443
  const startRecording = useCallback(async () => {
1438
1444
  setRecordingError('');
@@ -1557,10 +1563,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1557
1563
  const restart = useCallback(() => action('onRestart', () => {
1558
1564
  const video = videoRef.current;
1559
1565
  if (video) video.currentTime = 0;
1560
- else youtubeRef.current?.seekTo?.(0);
1561
1566
  setPaused(false);
1562
- emitProgressBarTime(0, onProgressBarChange, lastProgressBarSecondRef, { force: true });
1563
- }, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, onProgressBarChange, setPaused]);
1567
+ emitProgressBarTime(0, progressBarCallback, lastProgressBarSecondRef, { force: true });
1568
+ }, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, progressBarCallback, setPaused]);
1564
1569
  const toggleMute = useCallback(() => action('onMute', () => setMuted((value) => !value), { muted: !muted }), [action, muted]);
1565
1570
  const toggleLock = useCallback(() => action('onLock', () => setLocked((value) => !value), { locked: !locked }), [action, locked]);
1566
1571
  const selectAspect = useCallback((next) => action('onAspectRatioChange', () => setAspectRatio(next), { aspectRatio: next }), [action]);
@@ -1576,10 +1581,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1576
1581
  const seekTo = useCallback((seconds) => action('onSeek', () => {
1577
1582
  const targetSeconds = Math.max(0, Number(seconds) || 0);
1578
1583
  if (videoRef.current) videoRef.current.currentTime = targetSeconds;
1579
- else youtubeRef.current?.seekTo?.(targetSeconds);
1580
- emitProgressBarTime(targetSeconds, onProgressBarChange, lastProgressBarSecondRef, { force: true });
1581
- }, { seconds: Number(seconds) || 0 }), [action, onProgressBarChange]);
1582
- const handleBack = useCallback(() => action('onBack', onBack || props.onClose, { title, source: media }), [action, onBack, props.onClose, title, media]);
1584
+ emitProgressBarTime(targetSeconds, progressBarCallback, lastProgressBarSecondRef, { force: true });
1585
+ }, { seconds: Number(seconds) || 0 }), [action, progressBarCallback]);
1586
+ const handleBack = useCallback(() => action('onBack', undefined, { title, source: media }), [action, title, media]);
1583
1587
  useImperativeHandle(ref, () => {
1584
1588
  const api = {
1585
1589
  play: () => setPaused(false),
@@ -1613,8 +1617,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1613
1617
 
1614
1618
  const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
1615
1619
  const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
1620
+ const canRecordFromBrowser = typeof MediaRecorder !== 'undefined';
1616
1621
  const hasRecording = controlOverrides.recording !== false
1617
- && (!!integrations.recording || typeof actions.onRecordingStart === 'function' || (!youtubeVideoId && typeof MediaRecorder !== 'undefined'));
1622
+ && (!!integrations.recording || typeof actions.onRecordingStart === 'function' || canRecordFromBrowser);
1618
1623
  const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
1619
1624
  const sourceType = String(media.type || media.mimeType || '').toLowerCase();
1620
1625
  const directVideoSource = getDirectVideoSource({
@@ -1624,11 +1629,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1624
1629
  activeUrl,
1625
1630
  });
1626
1631
 
1627
- const handleYouTubeStateChange = useCallback((state) => {
1628
- if (state === 'playing') setIsPaused(false);
1629
- if (state === 'paused' || state === 'ended') setIsPaused(true);
1630
- }, []);
1631
-
1632
1632
  useEffect(() => {
1633
1633
  const video = videoRef.current;
1634
1634
  if (!video) return undefined;
@@ -1636,7 +1636,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1636
1636
  const updateTime = () => {
1637
1637
  setCurrentTime(Number(video.currentTime) || 0);
1638
1638
  if (Number.isFinite(video.duration)) setDuration(video.duration);
1639
- emitProgressBarTime(Number(video.currentTime) || 0, onProgressBarChange, lastProgressBarSecondRef);
1639
+ emitProgressBarTime(Number(video.currentTime) || 0, progressBarCallback, lastProgressBarSecondRef);
1640
1640
  onProgress?.({ currentTime: (Number(video.currentTime) || 0) * 1000, duration: (Number(video.duration) || 0) * 1000, target: video.currentTime });
1641
1641
  };
1642
1642
  const onReadyEvent = () => {
@@ -1664,7 +1664,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1664
1664
  video.removeEventListener('timeupdate', updateTime);
1665
1665
  video.removeEventListener('ended', endedHandler);
1666
1666
  };
1667
- }, [onReady, onPlaying, onProgress, onProgressBarChange, onEnded, tracksProp]);
1667
+ }, [onReady, onPlaying, onProgress, progressBarCallback, onEnded, tracksProp]);
1668
1668
 
1669
1669
  useEffect(() => {
1670
1670
  if (selectedAudioTrack === undefined || selectedAudioTrack === null) return;
@@ -1680,8 +1680,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1680
1680
  }, []);
1681
1681
 
1682
1682
  const toggleFullscreen = () => action('onFullscreen', () => {
1683
- if (inlinePreview && typeof onPromotePreview === 'function') return onPromotePreview({ title, source: media });
1684
- if (document.fullscreenElement) document.exitFullscreen?.();
1683
+ if (document.fullscreenElement === playerRef.current) document.exitFullscreen?.();
1685
1684
  else playerRef.current?.requestFullscreen?.();
1686
1685
  }, { isFullscreen: !fullscreen });
1687
1686
  const openPanel = (panel) => {
@@ -1741,18 +1740,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1741
1740
  messagePageSize,
1742
1741
  });
1743
1742
 
1744
- const handleYouTubeProgress = (event) => {
1745
- const seconds = Number(event.currentTime) / 1000 || 0;
1746
- setCurrentTime(seconds);
1747
- setDuration(Number(event.duration) / 1000 || 0);
1748
- emitProgressBarTime(seconds, onProgressBarChange, lastProgressBarSecondRef);
1749
- onProgress?.(event);
1750
- };
1751
1743
  const mediaSurface = h(WebPlayerSurface, {
1752
- youtubeVideoId,
1744
+ title,
1753
1745
  streamUrl,
1754
1746
  videoRef,
1755
- youtubeRef,
1756
1747
  directVideoSource,
1757
1748
  poster,
1758
1749
  autoPlay,
@@ -1768,15 +1759,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1768
1759
  onPromotePreview,
1769
1760
  bufferingRef: onBufferingRef,
1770
1761
  onReady,
1771
- onProgress: handleYouTubeProgress,
1762
+ onProgress,
1772
1763
  onPlaying,
1773
1764
  onEnded,
1774
- onStateChange: handleYouTubeStateChange,
1775
1765
  onError: handleError,
1776
1766
  handleError,
1767
+ corsMode,
1777
1768
  });
1778
1769
  const bottomControlProps = {
1779
1770
  isLive,
1771
+ progressBarVisible,
1772
+ inlinePreview,
1773
+ title,
1780
1774
  overrides: controlOverrides,
1781
1775
  theme,
1782
1776
  icons,
@@ -1827,7 +1821,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1827
1821
  audioOnly,
1828
1822
  activePanel,
1829
1823
  webPanel,
1830
- renderBackButton: () => control('back', 'Close player', () => action('onBack', props.onBack || props.onClose, { title, source: media }), { icon: 'close' }),
1824
+ renderBackButton: () => control('back', 'Close player', () => action('onBack', undefined, { title, source: media }), { icon: 'close' }),
1831
1825
  onSwitchToVideo: () => setAudioOnlyMode(false),
1832
1826
  locked,
1833
1827
  controlOverrides,
@@ -1854,7 +1848,6 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
1854
1848
  paused = false,
1855
1849
  isActive = true,
1856
1850
  onActivate,
1857
- onFullscreen,
1858
1851
  controls = {},
1859
1852
  actions = {},
1860
1853
  theme,
@@ -1897,7 +1890,6 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
1897
1890
  onError,
1898
1891
  onPlaying,
1899
1892
  inlinePreview: true,
1900
- onPromotePreview: onFullscreen,
1901
1893
  style: { width: '100%', height, aspectRatio: '16 / 9', ...style },
1902
1894
  });
1903
1895
  });
@@ -28,8 +28,7 @@
28
28
  background: #000;
29
29
  }
30
30
 
31
- .cinecrew-player__video,
32
- .cinecrew-player__youtube {
31
+ .cinecrew-player__video {
33
32
  transition: width 180ms ease, height 180ms ease, top 180ms ease, left 180ms ease, transform 180ms ease;
34
33
  }
35
34
 
@@ -51,6 +50,18 @@
51
50
  text-shadow: 0 1px 4px #000;
52
51
  }
53
52
 
53
+ .cinecrew-player__inline-title {
54
+ flex: 0 1 auto;
55
+ min-width: 0;
56
+ max-width: min(52vw, 420px);
57
+ overflow: hidden;
58
+ color: var(--cinecrew-text, #fff);
59
+ font-weight: 700;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ text-shadow: 0 1px 4px #000;
63
+ }
64
+
54
65
  .cinecrew-player__controls {
55
66
  position: absolute;
56
67
  inset: 0;
package/types/index.d.ts CHANGED
@@ -80,10 +80,11 @@ export interface PlayerActionContext {
80
80
  video?: unknown | null;
81
81
  }
82
82
 
83
- /** Notification invoked after the player's built-in action has executed. */
83
+ /** App callback for a player action. Back is app-owned and has no built-in behavior. */
84
84
  export type PlayerAction = (payload?: Record<string, unknown>, context?: PlayerActionContext) => unknown;
85
85
 
86
86
  export interface PlayerActions {
87
+ /** User-owned navigation event. The player does not close or navigate on its own. */
87
88
  onBack?: PlayerAction;
88
89
  onPlayPause?: PlayerAction;
89
90
  onSeek?: PlayerAction;
@@ -177,6 +178,7 @@ export interface PlayerIntegrations {
177
178
  render?: (context: { title: string; source: PlayerSource; onClose: () => void }) => React.ReactNode;
178
179
  };
179
180
  recording?: {
181
+ /** Starts host-managed recording for the active direct media source. */
180
182
  start?: (args: { getVideoElement: () => unknown | null; streamUrl: string; title?: string }) => Promise<unknown>;
181
183
  pause?: () => Promise<unknown>;
182
184
  resume?: () => Promise<unknown>;
@@ -209,6 +211,8 @@ export interface CineCrewPlayerProps {
209
211
  volume?: number;
210
212
  playbackRate?: number;
211
213
  controls?: PlayerControls;
214
+ /** Show the playback progress/seek bar. When false, onProgressBarChange is not called. Defaults to true. */
215
+ showProgressBar?: boolean;
212
216
  /** Web/Electron chat and EPG drawer behavior. Overlay keeps the video full-size; resize shrinks it to make room. */
213
217
  drawerMode?: PlayerDrawerMode;
214
218
  /** Web CSS or React Native view-style overrides for the chat, EPG, and diagnostics drawer. */
@@ -243,7 +247,8 @@ export interface CineCrewPlayerProps {
243
247
  playlist?: Array<Record<string, unknown>>;
244
248
  shuffle?: boolean;
245
249
  onClose?: () => void;
246
- onBack?: () => void;
250
+ /** User-owned back event. The player does not close or navigate on its own. */
251
+ onBack?: PlayerAction;
247
252
  onFullscreen?: (state: { isFullscreen: boolean }) => void;
248
253
  /** Invoked after the player applies an aspect ratio selection; actions.onAspectRatioChange takes precedence when both are supplied. */
249
254
  onAspectRatioChange?: PlayerAction;
@@ -279,6 +284,7 @@ export interface InlineLivePlayerProps {
279
284
  paused?: boolean;
280
285
  isActive?: boolean;
281
286
  onActivate?: () => void;
287
+ /** @deprecated Inline fullscreen is self-contained. Use actions.onFullscreen to observe its state. */
282
288
  onFullscreen?: () => void;
283
289
  controls?: Pick<PlayerControls, 'playPause' | 'mute' | 'fullscreen'>;
284
290
  actions?: Pick<PlayerActions, 'onPlayPause' | 'onMute' | 'onFullscreen'>;
@@ -1,120 +0,0 @@
1
- import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
2
- import { StyleSheet, View } from 'react-native';
3
- import { WebView } from 'react-native-webview';
4
- import { buildYouTubePlayerHtml } from '../../utils/youtubeHtml';
5
-
6
- const stateName = (value) => ({ 0: 'ended', 1: 'playing', 2: 'paused', 3: 'buffering', 5: 'cued' })[value] || 'unstarted';
7
-
8
- export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
9
- videoId,
10
- paused = false,
11
- muted = false,
12
- volume = 1,
13
- playbackRate = 1,
14
- onReady,
15
- onProgress,
16
- onPlaying,
17
- onBuffering,
18
- onStateChange,
19
- onError,
20
- onEnded,
21
- }, ref) {
22
- const webViewRef = useRef(null);
23
- const handlersRef = useRef({});
24
- const latestPropsRef = useRef({});
25
- const readyRef = useRef(false);
26
- handlersRef.current = { onReady, onProgress, onPlaying, onBuffering, onStateChange, onError, onEnded };
27
- latestPropsRef.current = { paused, muted, volume, playbackRate };
28
-
29
- const sendCommand = useCallback((name, value) => {
30
- const serialized = JSON.stringify(value ?? null);
31
- webViewRef.current?.injectJavaScript(
32
- `window.cinecrewPlayerCommand && window.cinecrewPlayerCommand(${JSON.stringify(name)}, ${serialized}); true;`
33
- );
34
- }, []);
35
-
36
- const handleMessage = useCallback((event) => {
37
- let message;
38
- try { message = JSON.parse(event.nativeEvent.data); } catch { return; }
39
- const handlers = handlersRef.current;
40
- if (message.type === 'ready') {
41
- readyRef.current = true;
42
- handlers.onBuffering?.(false);
43
- handlers.onReady?.({ youtube: true });
44
- const latest = latestPropsRef.current;
45
- sendCommand(latest.paused ? 'pause' : 'play');
46
- sendCommand(latest.muted ? 'mute' : 'unmute');
47
- sendCommand('volume', Math.round(Math.max(0, Math.min(1, latest.volume)) * 100));
48
- sendCommand('rate', latest.playbackRate);
49
- return;
50
- }
51
- if (message.type === 'state') {
52
- const state = stateName(Number(message.data?.state));
53
- handlers.onStateChange?.(state);
54
- handlers.onBuffering?.(state === 'buffering');
55
- if (state === 'playing') handlers.onPlaying?.({ state, youtube: true });
56
- if (state === 'ended') handlers.onEnded?.();
57
- return;
58
- }
59
- if (message.type === 'progress') {
60
- const currentTime = Number(message.data?.currentTime) || 0;
61
- const duration = Number(message.data?.duration) || 0;
62
- handlers.onProgress?.({ currentTime: currentTime * 1000, duration: duration * 1000, target: currentTime });
63
- return;
64
- }
65
- if (message.type === 'error') {
66
- handlers.onError?.({ code: message.data?.code, message: `YouTube playback failed (${message.data?.code}).` });
67
- }
68
- }, [sendCommand]);
69
-
70
- useEffect(() => { if (readyRef.current) sendCommand(paused ? 'pause' : 'play'); }, [paused, sendCommand]);
71
- useEffect(() => {
72
- if (!readyRef.current) return;
73
- sendCommand(muted ? 'mute' : 'unmute');
74
- sendCommand('volume', Math.round(Math.max(0, Math.min(1, volume)) * 100));
75
- }, [muted, volume, sendCommand]);
76
- useEffect(() => { if (readyRef.current) sendCommand('rate', playbackRate); }, [playbackRate, sendCommand]);
77
-
78
- const validVideoId = /^[\w-]{11}$/.test(String(videoId || ''));
79
- const html = useMemo(
80
- () => validVideoId ? buildYouTubePlayerHtml(videoId, !paused) : '<!doctype html><html><body style="background:#000"></body></html>',
81
- [validVideoId, videoId],
82
- );
83
- const webViewSource = useMemo(() => ({ html, baseUrl: 'https://www.youtube.com' }), [html]);
84
-
85
- useEffect(() => {
86
- if (!validVideoId) handlersRef.current.onError?.(new Error('A valid YouTube video ID is required.'));
87
- }, [validVideoId]);
88
-
89
- useImperativeHandle(ref, () => ({
90
- play: () => sendCommand('play'),
91
- resume: () => sendCommand('play'),
92
- pause: () => sendCommand('pause'),
93
- seek: (ratio) => sendCommand('seekRatio', Math.max(0, Math.min(1, Number(ratio) || 0))),
94
- seekTo: (seconds) => sendCommand('seek', Math.max(0, Number(seconds) || 0)),
95
- getVideoElement: () => null,
96
- }), [sendCommand]);
97
-
98
- return React.createElement(View, { style: styles.container }, React.createElement(WebView, {
99
- ref: webViewRef,
100
- source: webViewSource,
101
- originWhitelist: ['*'],
102
- javaScriptEnabled: true,
103
- domStorageEnabled: true,
104
- allowsInlineMediaPlayback: true,
105
- mediaPlaybackRequiresUserAction: false,
106
- allowsFullscreenVideo: false,
107
- scrollEnabled: false,
108
- bounces: false,
109
- onMessage: handleMessage,
110
- onError: (event) => handlersRef.current.onError?.(event.nativeEvent || event),
111
- style: styles.webView,
112
- }));
113
- });
114
-
115
- const styles = StyleSheet.create({
116
- container: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
117
- webView: { flex: 1, backgroundColor: '#000' },
118
- });
119
-
120
- export default YouTubeVideoPlayer;