@cinecrew/cinecrew-player 0.1.4 → 0.1.5
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 +13 -0
- 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 +61 -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') {
|
|
@@ -469,6 +451,7 @@ function WebPlayerSurface({
|
|
|
469
451
|
muted: muted || videoOnly,
|
|
470
452
|
playsInline: true,
|
|
471
453
|
preload: 'auto',
|
|
454
|
+
crossOrigin: corsMode,
|
|
472
455
|
style: {
|
|
473
456
|
...videoStyle,
|
|
474
457
|
...resizedVideoStyle,
|
|
@@ -478,7 +461,7 @@ function WebPlayerSurface({
|
|
|
478
461
|
onError: (event) => {
|
|
479
462
|
if (!directVideoSource) return;
|
|
480
463
|
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 });
|
|
464
|
+
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
465
|
},
|
|
483
466
|
});
|
|
484
467
|
}
|
|
@@ -486,7 +469,7 @@ function WebPlayerSurface({
|
|
|
486
469
|
function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
|
|
487
470
|
return h('div', {
|
|
488
471
|
className: 'cinecrew-player__audio-card',
|
|
489
|
-
style: { background:
|
|
472
|
+
style: { background: '#07090E', color: theme.controlColor, borderColor: theme.accentColor },
|
|
490
473
|
},
|
|
491
474
|
poster
|
|
492
475
|
? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
|
|
@@ -524,10 +507,6 @@ function getPanelIntegration(activePanel, integrations) {
|
|
|
524
507
|
return null;
|
|
525
508
|
}
|
|
526
509
|
|
|
527
|
-
function getActiveMediaUrl(youtubeVideoId, streamUrl) {
|
|
528
|
-
return youtubeVideoId ? '' : streamUrl;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
510
|
function getInlinePlayerStyle(inlinePreview, rect) {
|
|
532
511
|
if (!inlinePreview || !rect) return {};
|
|
533
512
|
return { position: 'absolute', left: rect.x, top: rect.y, width: rect.width, height: rect.height };
|
|
@@ -646,6 +625,7 @@ function WebPlayerLayout(props) {
|
|
|
646
625
|
toggleLock: props.toggleLock,
|
|
647
626
|
paused: props.isPaused,
|
|
648
627
|
title: props.title,
|
|
628
|
+
inlinePreview: props.inlinePreview,
|
|
649
629
|
togglePlay: props.togglePlay,
|
|
650
630
|
bottomProps: props.bottomControlProps,
|
|
651
631
|
});
|
|
@@ -683,8 +663,7 @@ function WebPlayerLayout(props) {
|
|
|
683
663
|
panelNode);
|
|
684
664
|
}
|
|
685
665
|
|
|
686
|
-
function getStreamMode(
|
|
687
|
-
if (youtubeVideoId) return 'youtube';
|
|
666
|
+
function getStreamMode(mpegTs, useHls) {
|
|
688
667
|
if (mpegTs) return 'mpegts';
|
|
689
668
|
if (useHls) return 'hls';
|
|
690
669
|
return 'native';
|
|
@@ -1157,6 +1136,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1157
1136
|
volume: volumeProp = 1,
|
|
1158
1137
|
playbackRate: playbackRateProp = 1,
|
|
1159
1138
|
paused: pausedProp,
|
|
1139
|
+
showProgressBar = true,
|
|
1160
1140
|
controls: controlOverrides = {},
|
|
1161
1141
|
features = {},
|
|
1162
1142
|
drawerMode = 'overlay',
|
|
@@ -1197,11 +1177,13 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1197
1177
|
const resolution = useResolvedPlayerSource(source, url, resolveSource, getWebRuntimePlatform());
|
|
1198
1178
|
const media = resolution.source || {};
|
|
1199
1179
|
const streamUrl = String(media.uri || media.url || '');
|
|
1200
|
-
const youtubeVideoId = getYouTubeVideoId(media);
|
|
1201
1180
|
const isLive = liveProp ?? media.isLive ?? false;
|
|
1181
|
+
const progressBarVisible = showProgressBar !== false
|
|
1182
|
+
&& isControlEnabled(controlOverrides, 'seek', true)
|
|
1183
|
+
&& !isLive;
|
|
1184
|
+
const progressBarCallback = progressBarVisible ? onProgressBarChange : undefined;
|
|
1202
1185
|
const theme = { ...DEFAULT_THEME, ...themeProp };
|
|
1203
1186
|
const videoRef = useRef(null);
|
|
1204
|
-
const youtubeRef = useRef(null);
|
|
1205
1187
|
const playerRef = useRef(null);
|
|
1206
1188
|
const publicPlayerRef = useRef(null);
|
|
1207
1189
|
const pausedRef = useRef(pausedProp ?? !autoPlay);
|
|
@@ -1214,6 +1196,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1214
1196
|
const [aspectRatio, setAspectRatio] = useState(props.defaultAspectRatio || 'FIT');
|
|
1215
1197
|
const [locked, setLocked] = useState(false);
|
|
1216
1198
|
const [buffering, setBuffering] = useState(Boolean(streamUrl) || resolution.loading);
|
|
1199
|
+
const [corsMode, setCorsMode] = useState('anonymous');
|
|
1200
|
+
const corsModeRef = useRef('anonymous');
|
|
1201
|
+
corsModeRef.current = corsMode;
|
|
1217
1202
|
const [error, setError] = useState('');
|
|
1218
1203
|
const [currentTime, setCurrentTime] = useState(0);
|
|
1219
1204
|
const [duration, setDuration] = useState(0);
|
|
@@ -1246,6 +1231,13 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1246
1231
|
|
|
1247
1232
|
const handleError = useCallback((detail) => {
|
|
1248
1233
|
const message = typeof detail === 'string' ? detail : detail?.message || 'Unable to play this media source.';
|
|
1234
|
+
// If the video failed while crossOrigin="anonymous" was set, retry without
|
|
1235
|
+
// CORS so playback still works (recording will fall back to screen capture).
|
|
1236
|
+
if (detail?.isCorsCandidate && corsModeRef.current === 'anonymous') {
|
|
1237
|
+
setCorsMode(undefined);
|
|
1238
|
+
corsModeRef.current = undefined;
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1249
1241
|
setError(message);
|
|
1250
1242
|
setBuffering(false);
|
|
1251
1243
|
errorRef.current?.(detail instanceof Error ? detail : { ...detail, message });
|
|
@@ -1268,6 +1260,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1268
1260
|
setDuration(0);
|
|
1269
1261
|
lastProgressBarSecondRef.current = null;
|
|
1270
1262
|
setAvailableTracks([]);
|
|
1263
|
+
setCorsMode('anonymous');
|
|
1264
|
+
corsModeRef.current = 'anonymous';
|
|
1271
1265
|
if (resolution.error) {
|
|
1272
1266
|
handleError(resolution.error);
|
|
1273
1267
|
return;
|
|
@@ -1306,7 +1300,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1306
1300
|
if (liveChatNonce) setActivePanel('chat');
|
|
1307
1301
|
}, [liveChatNonce]);
|
|
1308
1302
|
|
|
1309
|
-
const activeUrl =
|
|
1303
|
+
const activeUrl = streamUrl;
|
|
1310
1304
|
|
|
1311
1305
|
const mpegTs = useWebMpegTsPlayback({
|
|
1312
1306
|
streamUrl,
|
|
@@ -1325,7 +1319,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1325
1319
|
pausedRef: pausedStateRef,
|
|
1326
1320
|
onErrorRef,
|
|
1327
1321
|
});
|
|
1328
|
-
const streamMode = getStreamMode(
|
|
1322
|
+
const streamMode = getStreamMode(mpegTs.useMpegTs, useHls);
|
|
1329
1323
|
const ac3AudioPlayback = useWebAc3AudioPlayback({
|
|
1330
1324
|
active: mpegTs.useAc3Fallback,
|
|
1331
1325
|
streamUrl,
|
|
@@ -1344,7 +1338,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1344
1338
|
handleError(playError);
|
|
1345
1339
|
}
|
|
1346
1340
|
});
|
|
1347
|
-
}, [isPaused, streamUrl,
|
|
1341
|
+
}, [isPaused, streamUrl, handleError]);
|
|
1348
1342
|
|
|
1349
1343
|
useEffect(() => {
|
|
1350
1344
|
const video = videoRef.current;
|
|
@@ -1352,7 +1346,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1352
1346
|
video.muted = muted || videoOnly;
|
|
1353
1347
|
video.volume = volume;
|
|
1354
1348
|
video.playbackRate = Number(playbackRate) || 1;
|
|
1355
|
-
}, [muted, volume, playbackRate, videoOnly, streamUrl
|
|
1349
|
+
}, [muted, volume, playbackRate, videoOnly, streamUrl]);
|
|
1356
1350
|
|
|
1357
1351
|
useEffect(() => {
|
|
1358
1352
|
if (!streamUrl) return undefined;
|
|
@@ -1370,20 +1364,25 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1370
1364
|
let callback = actions?.[name];
|
|
1371
1365
|
if (name === 'onAspectRatioChange') callback = callback || props.onAspectRatioChange;
|
|
1372
1366
|
else if (name === 'onFullscreen') callback = callback || onFullscreen;
|
|
1367
|
+
else if (name === 'onBack') callback = callback || onBack || props.onClose;
|
|
1373
1368
|
return invokePlayerAction(
|
|
1374
1369
|
fallback,
|
|
1375
1370
|
callback,
|
|
1376
1371
|
payload,
|
|
1377
1372
|
{ video: videoRef.current, player: publicPlayerRef.current },
|
|
1378
1373
|
);
|
|
1379
|
-
}, [actions, props.onAspectRatioChange, onFullscreen]);
|
|
1374
|
+
}, [actions, props.onAspectRatioChange, onFullscreen, onBack, props.onClose]);
|
|
1380
1375
|
|
|
1381
1376
|
const startBuiltinRecording = useCallback(async () => {
|
|
1382
1377
|
const video = videoRef.current;
|
|
1383
|
-
if (!video || youtubeVideoId) throw new Error('This source cannot be recorded from the browser player.');
|
|
1384
1378
|
const mimeType = getRecordingMimeType();
|
|
1385
1379
|
if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
|
|
1386
|
-
|
|
1380
|
+
if (!video) throw new Error('The media element is not ready to record.');
|
|
1381
|
+
let capture = createVideoRecordingStream(video, playerRef.current, () => aspectRatioRef.current, ac3AudioPlayback.getRecordingAudioStream());
|
|
1382
|
+
// Fallback: if direct capture failed (CORS / tainted), use screen capture.
|
|
1383
|
+
if (!capture) {
|
|
1384
|
+
capture = await createScreenRecordingStream();
|
|
1385
|
+
}
|
|
1387
1386
|
const recorder = new MediaRecorder(capture.stream, { mimeType });
|
|
1388
1387
|
recordingCaptureRef.current = capture;
|
|
1389
1388
|
recordingChunksRef.current = [];
|
|
@@ -1432,7 +1431,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1432
1431
|
setRecordingError(event.error?.message || 'The browser could not record this stream.');
|
|
1433
1432
|
}, { once: true });
|
|
1434
1433
|
recorder.start(1000);
|
|
1435
|
-
}, [ac3AudioPlayback, title
|
|
1434
|
+
}, [ac3AudioPlayback, title]);
|
|
1436
1435
|
|
|
1437
1436
|
const startRecording = useCallback(async () => {
|
|
1438
1437
|
setRecordingError('');
|
|
@@ -1557,10 +1556,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1557
1556
|
const restart = useCallback(() => action('onRestart', () => {
|
|
1558
1557
|
const video = videoRef.current;
|
|
1559
1558
|
if (video) video.currentTime = 0;
|
|
1560
|
-
else youtubeRef.current?.seekTo?.(0);
|
|
1561
1559
|
setPaused(false);
|
|
1562
|
-
emitProgressBarTime(0,
|
|
1563
|
-
}, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action,
|
|
1560
|
+
emitProgressBarTime(0, progressBarCallback, lastProgressBarSecondRef, { force: true });
|
|
1561
|
+
}, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, progressBarCallback, setPaused]);
|
|
1564
1562
|
const toggleMute = useCallback(() => action('onMute', () => setMuted((value) => !value), { muted: !muted }), [action, muted]);
|
|
1565
1563
|
const toggleLock = useCallback(() => action('onLock', () => setLocked((value) => !value), { locked: !locked }), [action, locked]);
|
|
1566
1564
|
const selectAspect = useCallback((next) => action('onAspectRatioChange', () => setAspectRatio(next), { aspectRatio: next }), [action]);
|
|
@@ -1576,10 +1574,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1576
1574
|
const seekTo = useCallback((seconds) => action('onSeek', () => {
|
|
1577
1575
|
const targetSeconds = Math.max(0, Number(seconds) || 0);
|
|
1578
1576
|
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]);
|
|
1577
|
+
emitProgressBarTime(targetSeconds, progressBarCallback, lastProgressBarSecondRef, { force: true });
|
|
1578
|
+
}, { seconds: Number(seconds) || 0 }), [action, progressBarCallback]);
|
|
1579
|
+
const handleBack = useCallback(() => action('onBack', undefined, { title, source: media }), [action, title, media]);
|
|
1583
1580
|
useImperativeHandle(ref, () => {
|
|
1584
1581
|
const api = {
|
|
1585
1582
|
play: () => setPaused(false),
|
|
@@ -1613,8 +1610,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1613
1610
|
|
|
1614
1611
|
const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
|
|
1615
1612
|
const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
|
|
1613
|
+
const canRecordFromBrowser = typeof MediaRecorder !== 'undefined';
|
|
1616
1614
|
const hasRecording = controlOverrides.recording !== false
|
|
1617
|
-
&& (!!integrations.recording || typeof actions.onRecordingStart === 'function' ||
|
|
1615
|
+
&& (!!integrations.recording || typeof actions.onRecordingStart === 'function' || canRecordFromBrowser);
|
|
1618
1616
|
const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
|
|
1619
1617
|
const sourceType = String(media.type || media.mimeType || '').toLowerCase();
|
|
1620
1618
|
const directVideoSource = getDirectVideoSource({
|
|
@@ -1624,11 +1622,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1624
1622
|
activeUrl,
|
|
1625
1623
|
});
|
|
1626
1624
|
|
|
1627
|
-
const handleYouTubeStateChange = useCallback((state) => {
|
|
1628
|
-
if (state === 'playing') setIsPaused(false);
|
|
1629
|
-
if (state === 'paused' || state === 'ended') setIsPaused(true);
|
|
1630
|
-
}, []);
|
|
1631
|
-
|
|
1632
1625
|
useEffect(() => {
|
|
1633
1626
|
const video = videoRef.current;
|
|
1634
1627
|
if (!video) return undefined;
|
|
@@ -1636,7 +1629,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1636
1629
|
const updateTime = () => {
|
|
1637
1630
|
setCurrentTime(Number(video.currentTime) || 0);
|
|
1638
1631
|
if (Number.isFinite(video.duration)) setDuration(video.duration);
|
|
1639
|
-
emitProgressBarTime(Number(video.currentTime) || 0,
|
|
1632
|
+
emitProgressBarTime(Number(video.currentTime) || 0, progressBarCallback, lastProgressBarSecondRef);
|
|
1640
1633
|
onProgress?.({ currentTime: (Number(video.currentTime) || 0) * 1000, duration: (Number(video.duration) || 0) * 1000, target: video.currentTime });
|
|
1641
1634
|
};
|
|
1642
1635
|
const onReadyEvent = () => {
|
|
@@ -1664,7 +1657,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1664
1657
|
video.removeEventListener('timeupdate', updateTime);
|
|
1665
1658
|
video.removeEventListener('ended', endedHandler);
|
|
1666
1659
|
};
|
|
1667
|
-
}, [onReady, onPlaying, onProgress,
|
|
1660
|
+
}, [onReady, onPlaying, onProgress, progressBarCallback, onEnded, tracksProp]);
|
|
1668
1661
|
|
|
1669
1662
|
useEffect(() => {
|
|
1670
1663
|
if (selectedAudioTrack === undefined || selectedAudioTrack === null) return;
|
|
@@ -1680,8 +1673,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1680
1673
|
}, []);
|
|
1681
1674
|
|
|
1682
1675
|
const toggleFullscreen = () => action('onFullscreen', () => {
|
|
1683
|
-
if (
|
|
1684
|
-
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
1676
|
+
if (document.fullscreenElement === playerRef.current) document.exitFullscreen?.();
|
|
1685
1677
|
else playerRef.current?.requestFullscreen?.();
|
|
1686
1678
|
}, { isFullscreen: !fullscreen });
|
|
1687
1679
|
const openPanel = (panel) => {
|
|
@@ -1741,18 +1733,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1741
1733
|
messagePageSize,
|
|
1742
1734
|
});
|
|
1743
1735
|
|
|
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
1736
|
const mediaSurface = h(WebPlayerSurface, {
|
|
1752
|
-
|
|
1737
|
+
title,
|
|
1753
1738
|
streamUrl,
|
|
1754
1739
|
videoRef,
|
|
1755
|
-
youtubeRef,
|
|
1756
1740
|
directVideoSource,
|
|
1757
1741
|
poster,
|
|
1758
1742
|
autoPlay,
|
|
@@ -1768,15 +1752,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1768
1752
|
onPromotePreview,
|
|
1769
1753
|
bufferingRef: onBufferingRef,
|
|
1770
1754
|
onReady,
|
|
1771
|
-
onProgress
|
|
1755
|
+
onProgress,
|
|
1772
1756
|
onPlaying,
|
|
1773
1757
|
onEnded,
|
|
1774
|
-
onStateChange: handleYouTubeStateChange,
|
|
1775
1758
|
onError: handleError,
|
|
1776
1759
|
handleError,
|
|
1760
|
+
corsMode,
|
|
1777
1761
|
});
|
|
1778
1762
|
const bottomControlProps = {
|
|
1779
1763
|
isLive,
|
|
1764
|
+
progressBarVisible,
|
|
1765
|
+
inlinePreview,
|
|
1766
|
+
title,
|
|
1780
1767
|
overrides: controlOverrides,
|
|
1781
1768
|
theme,
|
|
1782
1769
|
icons,
|
|
@@ -1827,7 +1814,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1827
1814
|
audioOnly,
|
|
1828
1815
|
activePanel,
|
|
1829
1816
|
webPanel,
|
|
1830
|
-
renderBackButton: () => control('back', 'Close player', () => action('onBack',
|
|
1817
|
+
renderBackButton: () => control('back', 'Close player', () => action('onBack', undefined, { title, source: media }), { icon: 'close' }),
|
|
1831
1818
|
onSwitchToVideo: () => setAudioOnlyMode(false),
|
|
1832
1819
|
locked,
|
|
1833
1820
|
controlOverrides,
|
|
@@ -1854,7 +1841,6 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
|
|
|
1854
1841
|
paused = false,
|
|
1855
1842
|
isActive = true,
|
|
1856
1843
|
onActivate,
|
|
1857
|
-
onFullscreen,
|
|
1858
1844
|
controls = {},
|
|
1859
1845
|
actions = {},
|
|
1860
1846
|
theme,
|
|
@@ -1897,7 +1883,6 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
|
|
|
1897
1883
|
onError,
|
|
1898
1884
|
onPlaying,
|
|
1899
1885
|
inlinePreview: true,
|
|
1900
|
-
onPromotePreview: onFullscreen,
|
|
1901
1886
|
style: { width: '100%', height, aspectRatio: '16 / 9', ...style },
|
|
1902
1887
|
});
|
|
1903
1888
|
});
|
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;
|