@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/README.md +30 -31
- package/package.json +1 -2
- package/src/native/InlineLivePlayer.js +88 -27
- package/src/native/MediaPlayerView.js +27 -58
- package/src/native/index.js +4 -5
- package/src/native/media/WebVideoPlayer.js +14 -1
- package/src/native/media/player/AudioOnlyView.js +3 -40
- package/src/native/media/web/useWebHlsPlayback.web.js +1 -0
- package/src/utils/invokePlayerAction.js +1 -1
- package/src/utils/sourceUtils.js +0 -25
- package/src/utils/webRecording.js +50 -3
- package/src/web/index.js +68 -76
- package/src/web/styles.css +13 -2
- package/types/index.d.ts +8 -2
- package/src/native/media/YouTubeVideoPlayer.native.js +0 -120
- package/src/native/media/YouTubeVideoPlayer.web.js +0 -225
- package/src/utils/youtubeHtml.js +0 -34
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 {
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
1563
|
-
}, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action,
|
|
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
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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' ||
|
|
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,
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
});
|
package/src/web/styles.css
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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;
|