@cinecrew/cinecrew-player 0.1.3 → 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 +56 -46
- package/package.json +1 -2
- package/src/native/InlineLivePlayer.js +95 -33
- package/src/native/MediaPlayerView.js +75 -73
- package/src/native/index.js +4 -5
- package/src/native/media/LiveChatDrawer.js +16 -25
- package/src/native/media/WebVideoPlayer.js +13 -0
- package/src/native/media/player/AudioOnlyView.js +3 -40
- package/src/native/media/player/PlayerBottomBar.js +4 -12
- package/src/native/media/player/playerConstants.js +3 -3
- package/src/native/media/web/useWebAc3AudioPlayback.web.js +9 -3
- package/src/native/media/web/useWebHlsPlayback.web.js +1 -0
- package/src/native/media/web/useWebVideoAspectRatio.js +7 -2
- package/src/utils/invokePlayerAction.js +24 -0
- package/src/utils/progressBarTime.js +46 -0
- package/src/utils/sourceUtils.js +0 -25
- package/src/utils/webRecording.js +194 -0
- package/src/web/index.js +523 -156
- package/src/web/styles.css +142 -23
- package/types/index.d.ts +21 -2
- package/src/native/media/YouTubeVideoPlayer.native.js +0 -120
- package/src/native/media/YouTubeVideoPlayer.web.js +0 -223
- package/src/utils/youtubeHtml.js +0 -34
package/src/web/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { useWebVideoAspectRatio } from '../native/media/web/useWebVideoAspectRatio';
|
|
3
3
|
import { useWebMpegTsPlayback } from '../native/media/web/useWebMpegTsPlayback.web';
|
|
4
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';
|
|
8
|
+
import { createRecordingDownloadLink, createVideoRecordingStream, createScreenRecordingStream, downloadRecording, getRecordingMimeType } from '../utils/webRecording';
|
|
9
|
+
import { invokePlayerAction } from '../utils/invokePlayerAction.js';
|
|
10
|
+
import { emitProgressBarTime } from '../utils/progressBarTime.js';
|
|
9
11
|
import './styles.css';
|
|
10
12
|
|
|
11
13
|
const h = React.createElement;
|
|
@@ -25,6 +27,19 @@ const DEFAULT_ICONS = {
|
|
|
25
27
|
liveChat: 'comment-text-multiple-outline', epg: 'television-classic', diagnostics: 'logs',
|
|
26
28
|
fullscreen: 'fullscreen', close: 'close',
|
|
27
29
|
};
|
|
30
|
+
const DEFAULT_ASPECT_RATIOS = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '21:9', '1:1'];
|
|
31
|
+
|
|
32
|
+
function normalizeAspectRatios(aspectRatios) {
|
|
33
|
+
const values = Array.isArray(aspectRatios) && aspectRatios.length ? aspectRatios : DEFAULT_ASPECT_RATIOS;
|
|
34
|
+
const unique = new Map();
|
|
35
|
+
values.forEach((item) => {
|
|
36
|
+
const value = typeof item === 'string' ? item : item?.value;
|
|
37
|
+
if (typeof value !== 'string' || !value.trim()) return;
|
|
38
|
+
const label = typeof item === 'string' ? item : item.label || value;
|
|
39
|
+
unique.set(value, { value, label });
|
|
40
|
+
});
|
|
41
|
+
return unique.size ? [...unique.values()] : DEFAULT_ASPECT_RATIOS.map((value) => ({ value, label: value }));
|
|
42
|
+
}
|
|
28
43
|
|
|
29
44
|
// Inline SVG keeps the React DOM/Electron entry independent of React Native,
|
|
30
45
|
// Expo, and native icon-font packages. The native entry uses Expo vector icons.
|
|
@@ -185,23 +200,24 @@ function WebSeekControl({ currentTime, duration, theme, onSeek }) {
|
|
|
185
200
|
h('span', null, `−${formatTime(remainingTime)}`));
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
|
|
189
|
-
const ratios = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '1:1'];
|
|
203
|
+
function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect, ratios, selectedRatio }) {
|
|
190
204
|
let menu = null;
|
|
191
205
|
if (open) {
|
|
192
206
|
menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
|
|
193
207
|
ratios.map((ratio) => h('button', {
|
|
194
|
-
key: ratio,
|
|
208
|
+
key: ratio.value,
|
|
195
209
|
type: 'button',
|
|
210
|
+
className: `cinecrew-player__menu-option${selectedRatio === ratio.value ? ' is-selected' : ''}`,
|
|
211
|
+
'aria-pressed': selectedRatio === ratio.value,
|
|
196
212
|
onClick: () => onSelect(ratio),
|
|
197
|
-
}, ratio)));
|
|
213
|
+
}, ratio.label)));
|
|
198
214
|
}
|
|
199
215
|
return h('div', { className: 'cinecrew-player__menu-wrap cinecrew-player__menu-wrap--aspect', key: 'aspectRatio' },
|
|
200
216
|
h(PlayerButton, { name: 'aspectRatio', label: 'Aspect ratio', icons, theme, onClick: onToggle, active: open }),
|
|
201
217
|
menu);
|
|
202
218
|
}
|
|
203
219
|
|
|
204
|
-
function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
|
|
220
|
+
function WebAudioTrackMenu({ open, tracks, selectedTrackId, theme, icons, onToggle, onSelect }) {
|
|
205
221
|
let menu = null;
|
|
206
222
|
if (open) {
|
|
207
223
|
menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
|
|
@@ -209,6 +225,8 @@ function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
|
|
|
209
225
|
? tracks.map((track, index) => h('button', {
|
|
210
226
|
key: track.id ?? index,
|
|
211
227
|
type: 'button',
|
|
228
|
+
className: String(track.id) === String(selectedTrackId) ? 'cinecrew-player__menu-option is-selected' : 'cinecrew-player__menu-option',
|
|
229
|
+
'aria-pressed': String(track.id) === String(selectedTrackId),
|
|
212
230
|
onClick: () => onSelect(track.id),
|
|
213
231
|
}, track.name || track.language || `Track ${index + 1}`))
|
|
214
232
|
: h('div', { className: 'cinecrew-player__menu-empty', role: 'status' }, 'No audio tracks available'));
|
|
@@ -231,13 +249,14 @@ function WebPlaybackRateControl({ value, onChange }) {
|
|
|
231
249
|
function WebBottomControls(props) {
|
|
232
250
|
const {
|
|
233
251
|
isLive, overrides, theme, icons, currentTime, duration, seekTo,
|
|
234
|
-
|
|
235
|
-
|
|
252
|
+
progressBarVisible, inlinePreview, title,
|
|
253
|
+
showAspectMenu, setShowAspectMenu, selectAspect, aspectRatios, selectedAspectRatio,
|
|
254
|
+
audioOnly, setAudioOnlyMode, availableTracks, selectedAudioTrack,
|
|
236
255
|
showAudioMenu, setShowAudioMenu, selectAudio, playbackRate,
|
|
237
256
|
setPlaybackRateAction, fullscreen, toggleFullscreen,
|
|
238
257
|
} = props;
|
|
239
258
|
let seek = null;
|
|
240
|
-
if (duration > 0 && isControlEnabled(overrides, 'seek', true)) {
|
|
259
|
+
if (duration > 0 && progressBarVisible && isControlEnabled(overrides, 'seek', true)) {
|
|
241
260
|
seek = h(WebSeekControl, { currentTime, duration, theme, onSeek: seekTo });
|
|
242
261
|
}
|
|
243
262
|
let aspect = null;
|
|
@@ -248,9 +267,11 @@ function WebBottomControls(props) {
|
|
|
248
267
|
icons,
|
|
249
268
|
onToggle: () => setShowAspectMenu((value) => !value),
|
|
250
269
|
onSelect: (ratio) => {
|
|
251
|
-
selectAspect(ratio);
|
|
270
|
+
selectAspect(ratio.value);
|
|
252
271
|
setShowAspectMenu(false);
|
|
253
272
|
},
|
|
273
|
+
ratios: aspectRatios,
|
|
274
|
+
selectedRatio: selectedAspectRatio,
|
|
254
275
|
});
|
|
255
276
|
}
|
|
256
277
|
let audioTracks = null;
|
|
@@ -258,6 +279,7 @@ function WebBottomControls(props) {
|
|
|
258
279
|
audioTracks = h(WebAudioTrackMenu, {
|
|
259
280
|
open: showAudioMenu,
|
|
260
281
|
tracks: availableTracks,
|
|
282
|
+
selectedTrackId: selectedAudioTrack,
|
|
261
283
|
theme,
|
|
262
284
|
icons,
|
|
263
285
|
onToggle: () => setShowAudioMenu((value) => !value),
|
|
@@ -271,7 +293,6 @@ function WebBottomControls(props) {
|
|
|
271
293
|
if (isControlEnabled(overrides, 'playbackRate', true) && !isLive) {
|
|
272
294
|
playbackRateControl = h(WebPlaybackRateControl, { value: playbackRate, onChange: setPlaybackRateAction });
|
|
273
295
|
}
|
|
274
|
-
const videoOnlyLabel = 'Video only';
|
|
275
296
|
const audioOnlyLabel = 'Audio only';
|
|
276
297
|
const fullscreenLabel = fullscreen ? 'Exit full screen' : 'Full screen';
|
|
277
298
|
|
|
@@ -279,8 +300,8 @@ function WebBottomControls(props) {
|
|
|
279
300
|
seek,
|
|
280
301
|
h('div', { className: 'cinecrew-player__bottom-actions' },
|
|
281
302
|
h('div', { className: 'cinecrew-player__bottom-left-actions' },
|
|
303
|
+
inlinePreview && title ? h('span', { className: 'cinecrew-player__inline-title', title }, title) : null,
|
|
282
304
|
renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { active: audioOnly }, overrides, icons, theme }),
|
|
283
|
-
renderControlButton({ name: 'videoOnly', label: videoOnlyLabel, callback: () => setVideoOnlyMode(!videoOnly), options: { active: videoOnly, defaultVisible: false }, overrides, icons, theme }),
|
|
284
305
|
aspect),
|
|
285
306
|
h('div', { className: 'cinecrew-player__bottom-right-actions' },
|
|
286
307
|
audioTracks,
|
|
@@ -288,7 +309,50 @@ function WebBottomControls(props) {
|
|
|
288
309
|
renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
|
|
289
310
|
}
|
|
290
311
|
|
|
291
|
-
function
|
|
312
|
+
function getRecordingOverlayContent({ status, elapsed, error, downloadLink, onPause, onResume, onStop, onDownload, onDismiss }) {
|
|
313
|
+
if (status === 'complete') {
|
|
314
|
+
const children = [];
|
|
315
|
+
if (error) children.push(h('span', null, error));
|
|
316
|
+
children.push(h('strong', { className: 'cinecrew-player__recording-ready' }, 'Recording ready'));
|
|
317
|
+
if (downloadLink) {
|
|
318
|
+
children.push(h('a', {
|
|
319
|
+
className: 'cinecrew-player__recording-download',
|
|
320
|
+
href: downloadLink.url,
|
|
321
|
+
download: downloadLink.filename,
|
|
322
|
+
onClick: onDownload,
|
|
323
|
+
}, 'Download recording'));
|
|
324
|
+
}
|
|
325
|
+
children.push(h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording controls' }, 'Done'));
|
|
326
|
+
return h(React.Fragment, null, ...children);
|
|
327
|
+
}
|
|
328
|
+
if (status === 'finalizing') {
|
|
329
|
+
return h('strong', { className: 'cinecrew-player__recording-ready' }, 'Preparing recording…');
|
|
330
|
+
}
|
|
331
|
+
if (error) return h('span', null, error);
|
|
332
|
+
|
|
333
|
+
const isPaused = status === 'paused';
|
|
334
|
+
return h(React.Fragment, null,
|
|
335
|
+
h('span', { className: 'cinecrew-player__recording-indicator' }, 'REC'),
|
|
336
|
+
h('strong', { className: 'cinecrew-player__recording-timer' }, formatTime(elapsed / 1000)),
|
|
337
|
+
h('div', { className: 'cinecrew-player__recording-actions' },
|
|
338
|
+
h('button', { type: 'button', onClick: isPaused ? onResume : onPause }, isPaused ? 'Resume' : 'Pause'),
|
|
339
|
+
h('button', { type: 'button', onClick: onStop }, 'Stop & download')));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function WebRecordingOverlay(props) {
|
|
343
|
+
const { status, error, theme, onDismiss } = props;
|
|
344
|
+
if (status === 'idle' && !error) return null;
|
|
345
|
+
const dismissButton = error
|
|
346
|
+
? h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording message' }, '×')
|
|
347
|
+
: null;
|
|
348
|
+
return h('div', {
|
|
349
|
+
className: `cinecrew-player__recording-overlay${error ? ' is-error' : ''}`,
|
|
350
|
+
style: { color: theme.controlColor },
|
|
351
|
+
role: error ? 'alert' : 'status',
|
|
352
|
+
}, getRecordingOverlayContent(props), dismissButton);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, title, inlinePreview, togglePlay, bottomProps }) {
|
|
292
356
|
let leftControls = locked ? null : unlockedControls.left;
|
|
293
357
|
let rightControls = unlockedControls.right;
|
|
294
358
|
if (locked) {
|
|
@@ -300,7 +364,7 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
|
|
|
300
364
|
let centerControls = null;
|
|
301
365
|
let bottomControls = null;
|
|
302
366
|
if (!locked) {
|
|
303
|
-
if (!buffering) {
|
|
367
|
+
if (!buffering && bottomProps.recordingStatus === 'idle') {
|
|
304
368
|
const playLabel = paused ? 'Play' : 'Pause';
|
|
305
369
|
const playIcon = paused ? 'play' : 'pause';
|
|
306
370
|
centerControls = h('div', { className: 'cinecrew-player__center-controls' },
|
|
@@ -308,19 +372,32 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
|
|
|
308
372
|
}
|
|
309
373
|
bottomControls = h(WebBottomControls, bottomProps);
|
|
310
374
|
}
|
|
311
|
-
|
|
375
|
+
const recordingUiVisible = bottomProps.recordingStatus !== 'idle' || Boolean(bottomProps.recordingError);
|
|
376
|
+
return h('div', { className: `cinecrew-player__controls${recordingUiVisible ? ' is-recording' : ''}`, style: { color: theme.controlColor } },
|
|
312
377
|
h('div', { className: 'cinecrew-player__top-controls' },
|
|
313
378
|
h('div', { className: 'cinecrew-player__top-left-actions' }, leftControls),
|
|
379
|
+
!locked && !inlinePreview && paused && title ? h('div', { className: 'cinecrew-player__title', style: { color: theme.controlColor }, title }, title) : null,
|
|
314
380
|
h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
|
|
315
381
|
centerControls,
|
|
382
|
+
h(WebRecordingOverlay, {
|
|
383
|
+
status: bottomProps.recordingStatus,
|
|
384
|
+
elapsed: bottomProps.recordingElapsed,
|
|
385
|
+
error: bottomProps.recordingError,
|
|
386
|
+
theme,
|
|
387
|
+
downloadLink: bottomProps.recordingDownloadLink,
|
|
388
|
+
onPause: bottomProps.pauseRecording,
|
|
389
|
+
onResume: bottomProps.resumeRecording,
|
|
390
|
+
onStop: bottomProps.stopRecording,
|
|
391
|
+
onDownload: bottomProps.downloadRecording,
|
|
392
|
+
onDismiss: bottomProps.clearRecordingError,
|
|
393
|
+
}),
|
|
316
394
|
bottomControls);
|
|
317
395
|
}
|
|
318
396
|
|
|
319
397
|
function WebPlayerSurface({
|
|
320
|
-
|
|
398
|
+
title,
|
|
321
399
|
streamUrl,
|
|
322
400
|
videoRef,
|
|
323
|
-
youtubeRef,
|
|
324
401
|
directVideoSource,
|
|
325
402
|
poster,
|
|
326
403
|
autoPlay,
|
|
@@ -339,28 +416,10 @@ function WebPlayerSurface({
|
|
|
339
416
|
onProgress,
|
|
340
417
|
onPlaying,
|
|
341
418
|
onEnded,
|
|
342
|
-
onStateChange,
|
|
343
419
|
onError,
|
|
344
420
|
handleError,
|
|
421
|
+
corsMode,
|
|
345
422
|
}) {
|
|
346
|
-
if (youtubeVideoId) {
|
|
347
|
-
return h(YouTubeVideoPlayer, {
|
|
348
|
-
ref: youtubeRef,
|
|
349
|
-
videoId: youtubeVideoId,
|
|
350
|
-
paused,
|
|
351
|
-
muted: muted || videoOnly,
|
|
352
|
-
volume,
|
|
353
|
-
playbackRate,
|
|
354
|
-
onReady,
|
|
355
|
-
onProgress,
|
|
356
|
-
onPlaying,
|
|
357
|
-
onBuffering: bufferingRef.current,
|
|
358
|
-
onStateChange,
|
|
359
|
-
onError,
|
|
360
|
-
onEnded,
|
|
361
|
-
style: drawerResize ? { width: 'var(--cinecrew-media-width, 64%)', height: '100%', inset: '0 auto 0 0' } : undefined,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
423
|
if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
|
|
365
424
|
let resizedVideoStyle = {};
|
|
366
425
|
if (drawerResize && videoStyle.width === 'auto') {
|
|
@@ -392,6 +451,7 @@ function WebPlayerSurface({
|
|
|
392
451
|
muted: muted || videoOnly,
|
|
393
452
|
playsInline: true,
|
|
394
453
|
preload: 'auto',
|
|
454
|
+
crossOrigin: corsMode,
|
|
395
455
|
style: {
|
|
396
456
|
...videoStyle,
|
|
397
457
|
...resizedVideoStyle,
|
|
@@ -401,7 +461,7 @@ function WebPlayerSurface({
|
|
|
401
461
|
onError: (event) => {
|
|
402
462
|
if (!directVideoSource) return;
|
|
403
463
|
const mediaError = event.currentTarget?.error;
|
|
404
|
-
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' });
|
|
405
465
|
},
|
|
406
466
|
});
|
|
407
467
|
}
|
|
@@ -409,7 +469,7 @@ function WebPlayerSurface({
|
|
|
409
469
|
function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
|
|
410
470
|
return h('div', {
|
|
411
471
|
className: 'cinecrew-player__audio-card',
|
|
412
|
-
style: { background:
|
|
472
|
+
style: { background: '#07090E', color: theme.controlColor, borderColor: theme.accentColor },
|
|
413
473
|
},
|
|
414
474
|
poster
|
|
415
475
|
? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
|
|
@@ -447,32 +507,64 @@ function getPanelIntegration(activePanel, integrations) {
|
|
|
447
507
|
return null;
|
|
448
508
|
}
|
|
449
509
|
|
|
450
|
-
function getActiveMediaUrl(youtubeVideoId, streamUrl) {
|
|
451
|
-
return youtubeVideoId ? '' : streamUrl;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
510
|
function getInlinePlayerStyle(inlinePreview, rect) {
|
|
455
511
|
if (!inlinePreview || !rect) return {};
|
|
456
512
|
return { position: 'absolute', left: rect.x, top: rect.y, width: rect.width, height: rect.height };
|
|
457
513
|
}
|
|
458
514
|
|
|
515
|
+
function stopBrowserRecorder(recorderRef, completionRef, finalizerRef) {
|
|
516
|
+
return new Promise((resolve, reject) => {
|
|
517
|
+
const recorder = recorderRef.current;
|
|
518
|
+
const completion = completionRef.current;
|
|
519
|
+
if (!recorder || recorder.state === 'inactive') {
|
|
520
|
+
finalizerRef.current?.();
|
|
521
|
+
Promise.resolve(completion).then(resolve, reject);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
recorder.addEventListener('error', (event) => reject(event.error || new Error('Recording failed.')), { once: true });
|
|
525
|
+
recorder.stop();
|
|
526
|
+
if (completion) completion.then(resolve, reject);
|
|
527
|
+
else resolve();
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function waitForRecordingFinalization(completion) {
|
|
532
|
+
let timeoutId;
|
|
533
|
+
return Promise.race([
|
|
534
|
+
completion,
|
|
535
|
+
new Promise((_, reject) => {
|
|
536
|
+
timeoutId = window.setTimeout(() => reject(new Error('The browser did not finish finalizing the recording. Try again with a local or CORS-enabled source.')), 10000);
|
|
537
|
+
}),
|
|
538
|
+
]).finally(() => window.clearTimeout(timeoutId));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function performRecordingStop({ stop, action, recordingElapsed, streamUrl, title, recorderRef, completionRef, finalizerRef, setRecordingStatus }) {
|
|
542
|
+
if (!stop) setRecordingStatus('finalizing');
|
|
543
|
+
let stopCore = () => stopBrowserRecorder(recorderRef, completionRef, finalizerRef);
|
|
544
|
+
if (stop) stopCore = () => stop();
|
|
545
|
+
await action('onRecordingStop', stopCore, { elapsedMs: recordingElapsed, source: streamUrl, title });
|
|
546
|
+
if (!stop) await waitForRecordingFinalization(completionRef.current);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function updateRecordingStopState({ clock, recordingBlobRef, stop, setRecordingElapsed, setRecordingStatus, setRecordingError }) {
|
|
550
|
+
if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
|
|
551
|
+
setRecordingElapsed(clock.accumulatedMs);
|
|
552
|
+
const hasRecording = Boolean(recordingBlobRef.current);
|
|
553
|
+
setRecordingStatus(hasRecording ? 'complete' : 'idle');
|
|
554
|
+
if (!hasRecording && !stop) {
|
|
555
|
+
setRecordingError((current) => current || 'The browser returned no recorded media data for this source. Try a local or CORS-enabled video while it is playing.');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
459
559
|
function buildUnlockedControls({
|
|
460
560
|
control, onBack, isLive, restart, toggleLock, toggleMute, muted, hasRecording,
|
|
461
|
-
|
|
462
|
-
hasChat, hasEpg, hasDiagnostics, handleOpenPanel,
|
|
561
|
+
recordingStatus, startRecording, resumeRecording, stopRecording, activePanel,
|
|
562
|
+
hasChat, hasEpg, hasDiagnostics, handleOpenPanel, backVisible,
|
|
463
563
|
}) {
|
|
464
564
|
const leftControls = [control('back', 'Back', onBack, { defaultVisible: backVisible })];
|
|
465
565
|
let recordingControl = null;
|
|
466
566
|
if (hasRecording) {
|
|
467
|
-
|
|
468
|
-
const recordingAction = recording ? 'onRecordingStop' : 'onRecordingStart';
|
|
469
|
-
const handleRecordingToggle = async () => {
|
|
470
|
-
const handler = recording ? integrations.recording?.stop : integrations.recording?.start;
|
|
471
|
-
const fallback = handler ? () => handler({ getVideoElement: () => videoRef.current, streamUrl, title }) : undefined;
|
|
472
|
-
await action(recordingAction, fallback, { source: streamUrl, title });
|
|
473
|
-
setRecording((value) => !value);
|
|
474
|
-
};
|
|
475
|
-
recordingControl = control('recording', recordingLabel, handleRecordingToggle, { active: recording });
|
|
567
|
+
recordingControl = buildRecordingControl(control, recordingStatus, startRecording, resumeRecording, stopRecording);
|
|
476
568
|
}
|
|
477
569
|
const chatLabel = activePanel === 'chat' ? 'Close live chat' : 'Live chat';
|
|
478
570
|
const epgLabel = activePanel === 'epg' ? 'Close programme guide' : 'Programme guide';
|
|
@@ -491,6 +583,23 @@ function buildUnlockedControls({
|
|
|
491
583
|
return { left: leftControls, right: rightControls };
|
|
492
584
|
}
|
|
493
585
|
|
|
586
|
+
function buildRecordingControl(control, status, startRecording, resumeRecording, stopRecording) {
|
|
587
|
+
let label = 'Start recording';
|
|
588
|
+
let callback = startRecording;
|
|
589
|
+
if (status === 'recording') {
|
|
590
|
+
label = 'Stop recording';
|
|
591
|
+
callback = stopRecording;
|
|
592
|
+
} else if (status === 'paused') {
|
|
593
|
+
label = 'Resume recording';
|
|
594
|
+
callback = resumeRecording;
|
|
595
|
+
} else if (status === 'finalizing') {
|
|
596
|
+
label = 'Preparing recording';
|
|
597
|
+
callback = () => {};
|
|
598
|
+
}
|
|
599
|
+
const isFinalizing = status === 'finalizing';
|
|
600
|
+
return control('recording', label, callback, { active: status !== 'idle', disabled: isFinalizing });
|
|
601
|
+
}
|
|
602
|
+
|
|
494
603
|
function getDrawerLabel(activePanel) {
|
|
495
604
|
if (activePanel === 'chat') return 'Live chat drawer';
|
|
496
605
|
if (activePanel === 'epg') return 'Programme guide drawer';
|
|
@@ -515,14 +624,12 @@ function WebPlayerLayout(props) {
|
|
|
515
624
|
unlockedControls: props.unlockedControls,
|
|
516
625
|
toggleLock: props.toggleLock,
|
|
517
626
|
paused: props.isPaused,
|
|
627
|
+
title: props.title,
|
|
628
|
+
inlinePreview: props.inlinePreview,
|
|
518
629
|
togglePlay: props.togglePlay,
|
|
519
630
|
bottomProps: props.bottomControlProps,
|
|
520
631
|
});
|
|
521
632
|
}
|
|
522
|
-
let playerTitle = null;
|
|
523
|
-
if (props.title && !props.audioOnly) {
|
|
524
|
-
playerTitle = h('div', { className: 'cinecrew-player__title', style: { color: props.theme.controlColor } }, props.title);
|
|
525
|
-
}
|
|
526
633
|
let loadingNotice = null;
|
|
527
634
|
if (props.buffering && !props.error) {
|
|
528
635
|
loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
|
|
@@ -549,7 +656,6 @@ function WebPlayerLayout(props) {
|
|
|
549
656
|
},
|
|
550
657
|
props.mediaSurface,
|
|
551
658
|
h('div', { className: 'cinecrew-player__shade', style: { background: 'linear-gradient(180deg, rgba(0,0,0,.48), transparent 28%, transparent 68%, rgba(0,0,0,.64))' } }),
|
|
552
|
-
playerTitle,
|
|
553
659
|
loadingNotice,
|
|
554
660
|
h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.locked ? () => null : props.renderBackButton }),
|
|
555
661
|
controlLayer,
|
|
@@ -557,8 +663,7 @@ function WebPlayerLayout(props) {
|
|
|
557
663
|
panelNode);
|
|
558
664
|
}
|
|
559
665
|
|
|
560
|
-
function getStreamMode(
|
|
561
|
-
if (youtubeVideoId) return 'youtube';
|
|
666
|
+
function getStreamMode(mpegTs, useHls) {
|
|
562
667
|
if (mpegTs) return 'mpegts';
|
|
563
668
|
if (useHls) return 'hls';
|
|
564
669
|
return 'native';
|
|
@@ -652,14 +757,14 @@ function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status,
|
|
|
652
757
|
const buffered = getBufferedAhead(video, currentTime);
|
|
653
758
|
const frames = getFrameQuality(video);
|
|
654
759
|
const cards = [
|
|
655
|
-
{ icon: 'display', label: 'Video resolution', value: resolution },
|
|
656
|
-
{ icon: 'network', label: 'Connection rate', value: Number.isFinite(connection?.downlink) ? `${connection.downlink} Mbps` : 'Not reported' },
|
|
657
|
-
{ icon: 'clock', label: 'Network RTT estimate', value: Number.isFinite(connection?.rtt) ? `${connection.rtt} ms` : 'Not reported' },
|
|
658
|
-
{ icon: 'buffer', label: 'Buffered ahead', value: buffered === null ? 'Not reported' : `${buffered.toFixed(1)} sec` },
|
|
659
|
-
{ icon: 'frames', label: 'Dropped / total frames', value: frames && Number.isFinite(frames.dropped) ? `${frames.dropped} / ${frames.total ?? '—'}` : 'Not reported' },
|
|
660
|
-
{ icon: 'speedometer', label: 'Playback position', value: `${formatTime(currentTime)} / ${formatTime(duration)}` },
|
|
661
|
-
{ icon: 'network', label: 'Connection type', value: connection?.effectiveType || 'Not reported' },
|
|
662
|
-
{ icon: 'video', label: 'Stream format', value: streamMode || 'Unknown' },
|
|
760
|
+
{ icon: 'display', color: '#48c9ff', label: 'Video resolution', value: resolution },
|
|
761
|
+
{ icon: 'network', color: '#43d6a0', label: 'Connection rate', value: Number.isFinite(connection?.downlink) ? `${connection.downlink} Mbps` : 'Not reported' },
|
|
762
|
+
{ icon: 'clock', color: '#ffbd59', label: 'Network RTT estimate', value: Number.isFinite(connection?.rtt) ? `${connection.rtt} ms` : 'Not reported' },
|
|
763
|
+
{ icon: 'buffer', color: '#b18cff', label: 'Buffered ahead', value: buffered === null ? 'Not reported' : `${buffered.toFixed(1)} sec` },
|
|
764
|
+
{ icon: 'frames', color: '#ff6f91', label: 'Dropped / total frames', value: frames && Number.isFinite(frames.dropped) ? `${frames.dropped} / ${frames.total ?? '—'}` : 'Not reported' },
|
|
765
|
+
{ icon: 'speedometer', color: '#47d0cf', label: 'Playback position', value: `${formatTime(currentTime)} / ${formatTime(duration)}` },
|
|
766
|
+
{ icon: 'network', color: '#ff9666', label: 'Connection type', value: connection?.effectiveType || 'Not reported' },
|
|
767
|
+
{ icon: 'video', color: '#93aaff', label: 'Stream format', value: streamMode || 'Unknown' },
|
|
663
768
|
];
|
|
664
769
|
return h('section', {
|
|
665
770
|
className: 'cinecrew-player__diagnostics',
|
|
@@ -682,8 +787,8 @@ function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status,
|
|
|
682
787
|
h('span', null, status || 'Unknown'),
|
|
683
788
|
h('span', null, streamMode || 'Unknown format')),
|
|
684
789
|
h('div', { className: 'cinecrew-player__diagnostic-grid' },
|
|
685
|
-
cards.map(({ icon, label, value }) => h('article', { className: 'cinecrew-player__diagnostic-card', key: label },
|
|
686
|
-
h('span', { className: 'cinecrew-player__diagnostic-icon' }, h(Icon, { name: icon, icons, color
|
|
790
|
+
cards.map(({ icon, color, label, value }) => h('article', { className: 'cinecrew-player__diagnostic-card', key: label, style: { '--diagnostic-accent': color } },
|
|
791
|
+
h('span', { className: 'cinecrew-player__diagnostic-icon' }, h(Icon, { name: icon, icons, color })),
|
|
687
792
|
h('span', { className: 'cinecrew-player__diagnostic-label' }, label),
|
|
688
793
|
h('strong', null, value)))),
|
|
689
794
|
h('small', { className: 'cinecrew-player__diagnostics-note' }, 'Network rate and RTT are browser-reported estimates when supported; no extra ping or stream requests are sent.'));
|
|
@@ -779,6 +884,24 @@ function WebEmojiPicker({ onSelect }) {
|
|
|
779
884
|
function WebChatComposer({ sending, canSend, onSend }) {
|
|
780
885
|
const [message, setMessage] = useState('');
|
|
781
886
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
887
|
+
const emojiAnchorRef = useRef(null);
|
|
888
|
+
useEffect(() => {
|
|
889
|
+
if (!emojiOpen) return undefined;
|
|
890
|
+
const closeOnOutsidePointer = (event) => {
|
|
891
|
+
if (!emojiAnchorRef.current?.contains(event.target)) setEmojiOpen(false);
|
|
892
|
+
};
|
|
893
|
+
const closeOnEscape = (event) => {
|
|
894
|
+
if (event.key === 'Escape') setEmojiOpen(false);
|
|
895
|
+
};
|
|
896
|
+
document.addEventListener('pointerdown', closeOnOutsidePointer, true);
|
|
897
|
+
document.addEventListener('click', closeOnOutsidePointer, true);
|
|
898
|
+
document.addEventListener('keydown', closeOnEscape);
|
|
899
|
+
return () => {
|
|
900
|
+
document.removeEventListener('pointerdown', closeOnOutsidePointer, true);
|
|
901
|
+
document.removeEventListener('click', closeOnOutsidePointer, true);
|
|
902
|
+
document.removeEventListener('keydown', closeOnEscape);
|
|
903
|
+
};
|
|
904
|
+
}, [emojiOpen]);
|
|
782
905
|
const handleSubmit = async (event) => {
|
|
783
906
|
event.preventDefault();
|
|
784
907
|
const comment = message.trim();
|
|
@@ -790,12 +913,14 @@ function WebChatComposer({ sending, canSend, onSend }) {
|
|
|
790
913
|
|
|
791
914
|
return h('form', { className: 'cinecrew-player__chat-form', onSubmit: handleSubmit },
|
|
792
915
|
h('div', { className: 'cinecrew-player__chat-composer' },
|
|
793
|
-
h('
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
916
|
+
h('span', { className: 'cinecrew-player__emoji-anchor', ref: emojiAnchorRef },
|
|
917
|
+
h('button', {
|
|
918
|
+
type: 'button',
|
|
919
|
+
className: 'cinecrew-player__emoji-toggle',
|
|
920
|
+
onClick: () => setEmojiOpen((open) => !open),
|
|
921
|
+
'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
|
|
922
|
+
}, '☺'),
|
|
923
|
+
emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null),
|
|
799
924
|
h('input', {
|
|
800
925
|
value: message,
|
|
801
926
|
onChange: (event) => setMessage(event.target.value),
|
|
@@ -808,8 +933,7 @@ function WebChatComposer({ sending, canSend, onSend }) {
|
|
|
808
933
|
disabled: sending || !message.trim() || !canSend,
|
|
809
934
|
'aria-label': sending ? 'Sending message' : 'Send message',
|
|
810
935
|
title: sending ? 'Sending…' : 'Send message',
|
|
811
|
-
}, h(Icon, { name: 'send', color: '#07111e' })))
|
|
812
|
-
emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null);
|
|
936
|
+
}, h(Icon, { name: 'send', color: '#07111e' }))));
|
|
813
937
|
}
|
|
814
938
|
|
|
815
939
|
function WebPanelRow({ row, index, isChat }) {
|
|
@@ -829,15 +953,6 @@ function WebPanelRow({ row, index, isChat }) {
|
|
|
829
953
|
row.description ? h('span', null, row.description) : null);
|
|
830
954
|
}
|
|
831
955
|
|
|
832
|
-
function WebLoadMoreMessages({ loading, onClick }) {
|
|
833
|
-
return h('button', {
|
|
834
|
-
type: 'button',
|
|
835
|
-
className: 'cinecrew-player__load-more',
|
|
836
|
-
onClick,
|
|
837
|
-
disabled: loading,
|
|
838
|
-
}, loading ? 'Loading more…' : 'See more messages');
|
|
839
|
-
}
|
|
840
|
-
|
|
841
956
|
function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose, messagePageSize = 50 }) {
|
|
842
957
|
const [rows, setRows] = useState([]);
|
|
843
958
|
const [user, setUser] = useState(integrations.user || null);
|
|
@@ -848,6 +963,9 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
848
963
|
const [sending, setSending] = useState(false);
|
|
849
964
|
const [error, setError] = useState('');
|
|
850
965
|
const rowsRef = useRef([]);
|
|
966
|
+
const panelListRef = useRef(null);
|
|
967
|
+
const olderScrollRef = useRef(null);
|
|
968
|
+
const shouldScrollBottomRef = useRef(false);
|
|
851
969
|
const channelId = String(source.streamId || source.mediaId || source.id || '');
|
|
852
970
|
const isChat = kind === 'chat';
|
|
853
971
|
const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
|
|
@@ -875,6 +993,7 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
875
993
|
const mergedRows = mergeChatMessages(rowsRef.current, nextRows);
|
|
876
994
|
updateRows(mergedRows);
|
|
877
995
|
const addedCount = Math.max(0, mergedRows.length - previousCount);
|
|
996
|
+
if (addedCount > 0) shouldScrollBottomRef.current = true;
|
|
878
997
|
setMessageOffset((offset) => previousCount === 0
|
|
879
998
|
? nextRows.length
|
|
880
999
|
: Math.max(offset, nextRows.length) + addedCount);
|
|
@@ -916,6 +1035,8 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
916
1035
|
|
|
917
1036
|
const loadOlderMessages = async () => {
|
|
918
1037
|
if (!isChat || !hasMoreMessages || loadingOlder) return;
|
|
1038
|
+
const panelList = panelListRef.current;
|
|
1039
|
+
if (panelList) olderScrollRef.current = { height: panelList.scrollHeight, top: panelList.scrollTop };
|
|
919
1040
|
setLoadingOlder(true);
|
|
920
1041
|
try {
|
|
921
1042
|
setError('');
|
|
@@ -932,6 +1053,25 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
932
1053
|
}
|
|
933
1054
|
};
|
|
934
1055
|
|
|
1056
|
+
useLayoutEffect(() => {
|
|
1057
|
+
const panelList = panelListRef.current;
|
|
1058
|
+
if (!panelList) return;
|
|
1059
|
+
if (olderScrollRef.current) {
|
|
1060
|
+
const previous = olderScrollRef.current;
|
|
1061
|
+
panelList.scrollTop = previous.top + panelList.scrollHeight - previous.height;
|
|
1062
|
+
olderScrollRef.current = null;
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (shouldScrollBottomRef.current) {
|
|
1066
|
+
panelList.scrollTop = panelList.scrollHeight;
|
|
1067
|
+
shouldScrollBottomRef.current = false;
|
|
1068
|
+
}
|
|
1069
|
+
}, [rows]);
|
|
1070
|
+
|
|
1071
|
+
useEffect(() => {
|
|
1072
|
+
if (isChat && hasMoreMessages && !loadingOlder && panelListRef.current?.scrollTop <= 24) loadOlderMessages();
|
|
1073
|
+
}, [rows.length, hasMoreMessages, loadingOlder, isChat]);
|
|
1074
|
+
|
|
935
1075
|
const send = async (comment) => {
|
|
936
1076
|
if (!comment || sending || !integration.sendMessage) return false;
|
|
937
1077
|
setSending(true);
|
|
@@ -963,8 +1103,15 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
963
1103
|
loading ? h('div', { className: 'cinecrew-player__panel-state' }, 'Loading…') : null,
|
|
964
1104
|
error ? h('div', { className: 'cinecrew-player__panel-state is-error', role: 'status' }, error) : null,
|
|
965
1105
|
!loading && !error && rows.length === 0 ? h('div', { className: 'cinecrew-player__panel-state' }, emptyStateMessage) : null,
|
|
966
|
-
h('div', {
|
|
967
|
-
|
|
1106
|
+
h('div', {
|
|
1107
|
+
className: 'cinecrew-player__panel-list',
|
|
1108
|
+
ref: panelListRef,
|
|
1109
|
+
onScroll: (event) => {
|
|
1110
|
+
if (isChat && event.currentTarget.scrollTop <= 24) loadOlderMessages();
|
|
1111
|
+
},
|
|
1112
|
+
'aria-live': isChat ? 'polite' : undefined,
|
|
1113
|
+
},
|
|
1114
|
+
isChat && loadingOlder ? h('div', { className: 'cinecrew-player__older-loading', role: 'status' }, 'Loading older messages…') : null,
|
|
968
1115
|
rows.map((row, index) => h(WebPanelRow, { key: getChatMessageKey(row, index), row, index, isChat }))),
|
|
969
1116
|
isChat ? h(WebChatComposer, {
|
|
970
1117
|
sending,
|
|
@@ -989,6 +1136,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
989
1136
|
volume: volumeProp = 1,
|
|
990
1137
|
playbackRate: playbackRateProp = 1,
|
|
991
1138
|
paused: pausedProp,
|
|
1139
|
+
showProgressBar = true,
|
|
992
1140
|
controls: controlOverrides = {},
|
|
993
1141
|
features = {},
|
|
994
1142
|
drawerMode = 'overlay',
|
|
@@ -1019,6 +1167,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1019
1167
|
onFullscreen,
|
|
1020
1168
|
onReady,
|
|
1021
1169
|
onProgress,
|
|
1170
|
+
onProgressBarChange,
|
|
1022
1171
|
onPlaying,
|
|
1023
1172
|
onBuffering,
|
|
1024
1173
|
onError,
|
|
@@ -1028,11 +1177,13 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1028
1177
|
const resolution = useResolvedPlayerSource(source, url, resolveSource, getWebRuntimePlatform());
|
|
1029
1178
|
const media = resolution.source || {};
|
|
1030
1179
|
const streamUrl = String(media.uri || media.url || '');
|
|
1031
|
-
const youtubeVideoId = getYouTubeVideoId(media);
|
|
1032
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;
|
|
1033
1185
|
const theme = { ...DEFAULT_THEME, ...themeProp };
|
|
1034
1186
|
const videoRef = useRef(null);
|
|
1035
|
-
const youtubeRef = useRef(null);
|
|
1036
1187
|
const playerRef = useRef(null);
|
|
1037
1188
|
const publicPlayerRef = useRef(null);
|
|
1038
1189
|
const pausedRef = useRef(pausedProp ?? !autoPlay);
|
|
@@ -1042,26 +1193,51 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1042
1193
|
const [volume, setVolume] = useState(Math.max(0, Math.min(1, Number(volumeProp) || 0)));
|
|
1043
1194
|
const [videoOnly, setVideoOnly] = useState(!!videoOnlyProp);
|
|
1044
1195
|
const [audioOnly, setAudioOnly] = useState(!!audioOnlyProp);
|
|
1045
|
-
const [aspectRatio, setAspectRatio] = useState('FIT');
|
|
1196
|
+
const [aspectRatio, setAspectRatio] = useState(props.defaultAspectRatio || 'FIT');
|
|
1046
1197
|
const [locked, setLocked] = useState(false);
|
|
1047
1198
|
const [buffering, setBuffering] = useState(Boolean(streamUrl) || resolution.loading);
|
|
1199
|
+
const [corsMode, setCorsMode] = useState('anonymous');
|
|
1200
|
+
const corsModeRef = useRef('anonymous');
|
|
1201
|
+
corsModeRef.current = corsMode;
|
|
1048
1202
|
const [error, setError] = useState('');
|
|
1049
1203
|
const [currentTime, setCurrentTime] = useState(0);
|
|
1050
1204
|
const [duration, setDuration] = useState(0);
|
|
1051
1205
|
const [showAspectMenu, setShowAspectMenu] = useState(false);
|
|
1052
1206
|
const [showAudioMenu, setShowAudioMenu] = useState(false);
|
|
1053
1207
|
const [activePanel, setActivePanel] = useState(null);
|
|
1054
|
-
const [
|
|
1208
|
+
const [recordingStatus, setRecordingStatus] = useState('idle');
|
|
1209
|
+
const [recordingElapsed, setRecordingElapsed] = useState(0);
|
|
1210
|
+
const [recordingError, setRecordingError] = useState('');
|
|
1211
|
+
const [recordingDownloadLink, setRecordingDownloadLink] = useState(null);
|
|
1055
1212
|
const [availableTracks, setAvailableTracks] = useState([]);
|
|
1056
1213
|
const [fullscreen, setFullscreen] = useState(false);
|
|
1057
1214
|
const [playbackRate, setPlaybackRate] = useState(Number(playbackRateProp) || 1);
|
|
1215
|
+
const aspectRatiosNormalized = useMemo(() => normalizeAspectRatios(props.aspectRatios), [props.aspectRatios]);
|
|
1058
1216
|
const { videoStyle } = useWebVideoAspectRatio(aspectRatio, false);
|
|
1217
|
+
const aspectRatioRef = useRef(aspectRatio);
|
|
1218
|
+
const recorderRef = useRef(null);
|
|
1219
|
+
const recordingCaptureRef = useRef(null);
|
|
1220
|
+
const recordingChunksRef = useRef([]);
|
|
1221
|
+
const recordingBlobRef = useRef(null);
|
|
1222
|
+
const recordingDownloadLinkRef = useRef(null);
|
|
1223
|
+
const recordingFinalizerRef = useRef(null);
|
|
1224
|
+
const recordingCompletionRef = useRef(null);
|
|
1225
|
+
const lastProgressBarSecondRef = useRef(null);
|
|
1226
|
+
const recordingClockRef = useRef({ startedAt: 0, accumulatedMs: 0 });
|
|
1227
|
+
aspectRatioRef.current = aspectRatio;
|
|
1059
1228
|
const pausedStateRef = pausedRef;
|
|
1060
1229
|
pausedRef.current = isPaused;
|
|
1061
1230
|
errorRef.current = onError;
|
|
1062
1231
|
|
|
1063
1232
|
const handleError = useCallback((detail) => {
|
|
1064
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
|
+
}
|
|
1065
1241
|
setError(message);
|
|
1066
1242
|
setBuffering(false);
|
|
1067
1243
|
errorRef.current?.(detail instanceof Error ? detail : { ...detail, message });
|
|
@@ -1082,7 +1258,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1082
1258
|
setBuffering(Boolean(streamUrl) || resolution.loading);
|
|
1083
1259
|
setCurrentTime(0);
|
|
1084
1260
|
setDuration(0);
|
|
1261
|
+
lastProgressBarSecondRef.current = null;
|
|
1085
1262
|
setAvailableTracks([]);
|
|
1263
|
+
setCorsMode('anonymous');
|
|
1264
|
+
corsModeRef.current = 'anonymous';
|
|
1086
1265
|
if (resolution.error) {
|
|
1087
1266
|
handleError(resolution.error);
|
|
1088
1267
|
return;
|
|
@@ -1097,6 +1276,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1097
1276
|
}, [pausedProp]);
|
|
1098
1277
|
|
|
1099
1278
|
useEffect(() => setAudioOnly(!!audioOnlyProp), [audioOnlyProp]);
|
|
1279
|
+
useEffect(() => {
|
|
1280
|
+
setAspectRatio(props.defaultAspectRatio || 'FIT');
|
|
1281
|
+
}, [streamUrl, props.defaultAspectRatio]);
|
|
1100
1282
|
|
|
1101
1283
|
useEffect(() => {
|
|
1102
1284
|
setMuted(!!mutedProp);
|
|
@@ -1118,7 +1300,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1118
1300
|
if (liveChatNonce) setActivePanel('chat');
|
|
1119
1301
|
}, [liveChatNonce]);
|
|
1120
1302
|
|
|
1121
|
-
const activeUrl =
|
|
1303
|
+
const activeUrl = streamUrl;
|
|
1122
1304
|
|
|
1123
1305
|
const mpegTs = useWebMpegTsPlayback({
|
|
1124
1306
|
streamUrl,
|
|
@@ -1137,8 +1319,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1137
1319
|
pausedRef: pausedStateRef,
|
|
1138
1320
|
onErrorRef,
|
|
1139
1321
|
});
|
|
1140
|
-
const streamMode = getStreamMode(
|
|
1141
|
-
useWebAc3AudioPlayback({
|
|
1322
|
+
const streamMode = getStreamMode(mpegTs.useMpegTs, useHls);
|
|
1323
|
+
const ac3AudioPlayback = useWebAc3AudioPlayback({
|
|
1142
1324
|
active: mpegTs.useAc3Fallback,
|
|
1143
1325
|
streamUrl,
|
|
1144
1326
|
enabled: isAudioPlaybackEnabled(muted, videoOnly),
|
|
@@ -1156,7 +1338,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1156
1338
|
handleError(playError);
|
|
1157
1339
|
}
|
|
1158
1340
|
});
|
|
1159
|
-
}, [isPaused, streamUrl,
|
|
1341
|
+
}, [isPaused, streamUrl, handleError]);
|
|
1160
1342
|
|
|
1161
1343
|
useEffect(() => {
|
|
1162
1344
|
const video = videoRef.current;
|
|
@@ -1164,7 +1346,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1164
1346
|
video.muted = muted || videoOnly;
|
|
1165
1347
|
video.volume = volume;
|
|
1166
1348
|
video.playbackRate = Number(playbackRate) || 1;
|
|
1167
|
-
}, [muted, volume, playbackRate, videoOnly, streamUrl
|
|
1349
|
+
}, [muted, volume, playbackRate, videoOnly, streamUrl]);
|
|
1168
1350
|
|
|
1169
1351
|
useEffect(() => {
|
|
1170
1352
|
if (!streamUrl) return undefined;
|
|
@@ -1179,18 +1361,204 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1179
1361
|
}, []);
|
|
1180
1362
|
|
|
1181
1363
|
const action = useCallback((name, fallback, payload) => {
|
|
1182
|
-
|
|
1183
|
-
if (
|
|
1184
|
-
|
|
1185
|
-
|
|
1364
|
+
let callback = actions?.[name];
|
|
1365
|
+
if (name === 'onAspectRatioChange') callback = callback || props.onAspectRatioChange;
|
|
1366
|
+
else if (name === 'onFullscreen') callback = callback || onFullscreen;
|
|
1367
|
+
else if (name === 'onBack') callback = callback || onBack || props.onClose;
|
|
1368
|
+
return invokePlayerAction(
|
|
1369
|
+
fallback,
|
|
1370
|
+
callback,
|
|
1371
|
+
payload,
|
|
1372
|
+
{ video: videoRef.current, player: publicPlayerRef.current },
|
|
1373
|
+
);
|
|
1374
|
+
}, [actions, props.onAspectRatioChange, onFullscreen, onBack, props.onClose]);
|
|
1375
|
+
|
|
1376
|
+
const startBuiltinRecording = useCallback(async () => {
|
|
1377
|
+
const video = videoRef.current;
|
|
1378
|
+
const mimeType = getRecordingMimeType();
|
|
1379
|
+
if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
|
|
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
|
+
}
|
|
1386
|
+
const recorder = new MediaRecorder(capture.stream, { mimeType });
|
|
1387
|
+
recordingCaptureRef.current = capture;
|
|
1388
|
+
recordingChunksRef.current = [];
|
|
1389
|
+
recorderRef.current = recorder;
|
|
1390
|
+
let finalized = false;
|
|
1391
|
+
let resolveCompletion;
|
|
1392
|
+
recordingCompletionRef.current = new Promise((resolve) => { resolveCompletion = resolve; });
|
|
1393
|
+
const finalizeRecording = () => {
|
|
1394
|
+
if (finalized) return;
|
|
1395
|
+
finalized = true;
|
|
1396
|
+
let completedBlob = null;
|
|
1397
|
+
try {
|
|
1398
|
+
const blob = new Blob(recordingChunksRef.current, { type: recorder.mimeType || mimeType });
|
|
1399
|
+
if (!blob.size) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
|
|
1400
|
+
recordingBlobRef.current = blob;
|
|
1401
|
+
completedBlob = blob;
|
|
1402
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1403
|
+
const downloadLink = createRecordingDownloadLink(blob, title);
|
|
1404
|
+
if (!downloadLink) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
|
|
1405
|
+
recordingDownloadLinkRef.current = downloadLink;
|
|
1406
|
+
setRecordingDownloadLink(downloadLink);
|
|
1407
|
+
setRecordingStatus('complete');
|
|
1408
|
+
try {
|
|
1409
|
+
if (!downloadRecording(blob, title)) throw new Error('The browser did not start the recording download. Use Download recording to retry.');
|
|
1410
|
+
} catch (downloadError) {
|
|
1411
|
+
setRecordingError(downloadError?.message || 'The recording could not be downloaded. Use Download recording to retry.');
|
|
1412
|
+
}
|
|
1413
|
+
} catch (error_) {
|
|
1414
|
+
setRecordingError(error_?.message || 'Could not finish the recording.');
|
|
1415
|
+
setRecordingStatus('idle');
|
|
1416
|
+
} finally {
|
|
1417
|
+
recordingCaptureRef.current?.cleanup?.();
|
|
1418
|
+
recordingCaptureRef.current = null;
|
|
1419
|
+
recorderRef.current = null;
|
|
1420
|
+
recordingChunksRef.current = [];
|
|
1421
|
+
recordingFinalizerRef.current = null;
|
|
1422
|
+
resolveCompletion?.(completedBlob);
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
recordingFinalizerRef.current = finalizeRecording;
|
|
1426
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
1427
|
+
if (event.data?.size) recordingChunksRef.current.push(event.data);
|
|
1428
|
+
});
|
|
1429
|
+
recorder.addEventListener('stop', finalizeRecording, { once: true });
|
|
1430
|
+
recorder.addEventListener('error', (event) => {
|
|
1431
|
+
setRecordingError(event.error?.message || 'The browser could not record this stream.');
|
|
1432
|
+
}, { once: true });
|
|
1433
|
+
recorder.start(1000);
|
|
1434
|
+
}, [ac3AudioPlayback, title]);
|
|
1435
|
+
|
|
1436
|
+
const startRecording = useCallback(async () => {
|
|
1437
|
+
setRecordingError('');
|
|
1438
|
+
recordingBlobRef.current = null;
|
|
1439
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1440
|
+
recordingDownloadLinkRef.current = null;
|
|
1441
|
+
setRecordingDownloadLink(null);
|
|
1442
|
+
try {
|
|
1443
|
+
const adapterStart = integrations.recording?.start;
|
|
1444
|
+
await action('onRecordingStart', adapterStart
|
|
1445
|
+
? () => adapterStart({ getVideoElement: () => videoRef.current, streamUrl, title })
|
|
1446
|
+
: startBuiltinRecording, { source: streamUrl, title });
|
|
1447
|
+
recordingClockRef.current = { startedAt: Date.now(), accumulatedMs: 0 };
|
|
1448
|
+
setRecordingElapsed(0);
|
|
1449
|
+
setRecordingStatus('recording');
|
|
1450
|
+
} catch (startError) {
|
|
1451
|
+
setRecordingStatus('idle');
|
|
1452
|
+
setRecordingError(startError?.message || 'Could not start recording.');
|
|
1453
|
+
}
|
|
1454
|
+
}, [action, integrations.recording, startBuiltinRecording, streamUrl, title]);
|
|
1455
|
+
|
|
1456
|
+
const pauseRecording = useCallback(async () => {
|
|
1457
|
+
try {
|
|
1458
|
+
const pause = integrations.recording?.pause;
|
|
1459
|
+
await action('onRecordingPause', pause
|
|
1460
|
+
? () => pause()
|
|
1461
|
+
: () => {
|
|
1462
|
+
if (recorderRef.current?.state !== 'recording') throw new Error('Recording cannot be paused right now.');
|
|
1463
|
+
recorderRef.current.pause();
|
|
1464
|
+
}, { elapsedMs: recordingElapsed });
|
|
1465
|
+
const clock = recordingClockRef.current;
|
|
1466
|
+
if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
|
|
1467
|
+
clock.startedAt = 0;
|
|
1468
|
+
setRecordingElapsed(clock.accumulatedMs);
|
|
1469
|
+
setRecordingStatus('paused');
|
|
1470
|
+
} catch (pauseError) {
|
|
1471
|
+
setRecordingError(pauseError?.message || 'Could not pause recording.');
|
|
1472
|
+
}
|
|
1473
|
+
}, [action, integrations.recording, recordingElapsed]);
|
|
1474
|
+
|
|
1475
|
+
const resumeRecording = useCallback(async () => {
|
|
1476
|
+
try {
|
|
1477
|
+
const resume = integrations.recording?.resume;
|
|
1478
|
+
await action('onRecordingResume', resume
|
|
1479
|
+
? () => resume()
|
|
1480
|
+
: () => {
|
|
1481
|
+
if (recorderRef.current?.state !== 'paused') throw new Error('Recording cannot resume right now.');
|
|
1482
|
+
recorderRef.current.resume();
|
|
1483
|
+
}, { elapsedMs: recordingElapsed });
|
|
1484
|
+
recordingClockRef.current.startedAt = Date.now();
|
|
1485
|
+
setRecordingStatus('recording');
|
|
1486
|
+
} catch (resumeError) {
|
|
1487
|
+
setRecordingError(resumeError?.message || 'Could not resume recording.');
|
|
1488
|
+
}
|
|
1489
|
+
}, [action, integrations.recording, recordingElapsed]);
|
|
1490
|
+
|
|
1491
|
+
const stopRecording = useCallback(async () => {
|
|
1492
|
+
try {
|
|
1493
|
+
const stop = integrations.recording?.stop;
|
|
1494
|
+
await performRecordingStop({
|
|
1495
|
+
stop,
|
|
1496
|
+
action,
|
|
1497
|
+
recordingElapsed,
|
|
1498
|
+
streamUrl,
|
|
1499
|
+
title,
|
|
1500
|
+
recorderRef,
|
|
1501
|
+
completionRef: recordingCompletionRef,
|
|
1502
|
+
finalizerRef: recordingFinalizerRef,
|
|
1503
|
+
setRecordingStatus,
|
|
1504
|
+
});
|
|
1505
|
+
updateRecordingStopState({
|
|
1506
|
+
clock: recordingClockRef.current,
|
|
1507
|
+
recordingBlobRef,
|
|
1508
|
+
stop,
|
|
1509
|
+
setRecordingElapsed,
|
|
1510
|
+
setRecordingStatus,
|
|
1511
|
+
setRecordingError,
|
|
1512
|
+
});
|
|
1513
|
+
} catch (stopError) {
|
|
1514
|
+
setRecordingError(stopError?.message || 'Could not finish recording.');
|
|
1515
|
+
}
|
|
1516
|
+
}, [action, integrations.recording, recordingElapsed, streamUrl, title]);
|
|
1517
|
+
|
|
1518
|
+
const downloadCompletedRecording = useCallback(() => {
|
|
1519
|
+
try {
|
|
1520
|
+
if (!recordingDownloadLinkRef.current?.url) throw new Error('No recording is ready to download.');
|
|
1521
|
+
setRecordingError('');
|
|
1522
|
+
} catch (downloadError) {
|
|
1523
|
+
setRecordingError(downloadError?.message || 'The recording could not be downloaded.');
|
|
1524
|
+
}
|
|
1525
|
+
}, []);
|
|
1526
|
+
|
|
1527
|
+
const dismissRecording = useCallback(() => {
|
|
1528
|
+
setRecordingStatus('idle');
|
|
1529
|
+
setRecordingError('');
|
|
1530
|
+
recordingBlobRef.current = null;
|
|
1531
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1532
|
+
recordingDownloadLinkRef.current = null;
|
|
1533
|
+
setRecordingDownloadLink(null);
|
|
1534
|
+
recordingCompletionRef.current = null;
|
|
1535
|
+
}, []);
|
|
1536
|
+
|
|
1537
|
+
useEffect(() => {
|
|
1538
|
+
if (recordingStatus !== 'recording') return undefined;
|
|
1539
|
+
const timer = setInterval(() => {
|
|
1540
|
+
const clock = recordingClockRef.current;
|
|
1541
|
+
setRecordingElapsed(clock.accumulatedMs + (clock.startedAt ? Date.now() - clock.startedAt : 0));
|
|
1542
|
+
}, 250);
|
|
1543
|
+
return () => clearInterval(timer);
|
|
1544
|
+
}, [recordingStatus]);
|
|
1545
|
+
|
|
1546
|
+
useEffect(() => () => {
|
|
1547
|
+
const recorder = recorderRef.current;
|
|
1548
|
+
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
|
1549
|
+
recordingCaptureRef.current?.cleanup?.();
|
|
1550
|
+
}, [streamUrl]);
|
|
1551
|
+
useEffect(() => () => {
|
|
1552
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1553
|
+
}, []);
|
|
1186
1554
|
|
|
1187
|
-
const togglePlay = useCallback(() => action('onPlayPause', () => setPaused(), { isPlaying:
|
|
1555
|
+
const togglePlay = useCallback(() => action('onPlayPause', () => setPaused(), { isPlaying: pausedRef.current }), [action, setPaused]);
|
|
1188
1556
|
const restart = useCallback(() => action('onRestart', () => {
|
|
1189
1557
|
const video = videoRef.current;
|
|
1190
1558
|
if (video) video.currentTime = 0;
|
|
1191
|
-
else youtubeRef.current?.seekTo?.(0);
|
|
1192
1559
|
setPaused(false);
|
|
1193
|
-
|
|
1560
|
+
emitProgressBarTime(0, progressBarCallback, lastProgressBarSecondRef, { force: true });
|
|
1561
|
+
}, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, progressBarCallback, setPaused]);
|
|
1194
1562
|
const toggleMute = useCallback(() => action('onMute', () => setMuted((value) => !value), { muted: !muted }), [action, muted]);
|
|
1195
1563
|
const toggleLock = useCallback(() => action('onLock', () => setLocked((value) => !value), { locked: !locked }), [action, locked]);
|
|
1196
1564
|
const selectAspect = useCallback((next) => action('onAspectRatioChange', () => setAspectRatio(next), { aspectRatio: next }), [action]);
|
|
@@ -1204,10 +1572,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1204
1572
|
const setAudioOnlyMode = useCallback((next) => action('onAudioOnlyChange', () => setAudioOnly(next), { enabled: next }), [action]);
|
|
1205
1573
|
const setPlaybackRateAction = useCallback((next) => action('onPlaybackRateChange', () => setPlaybackRate(next), { playbackRate: next }), [action]);
|
|
1206
1574
|
const seekTo = useCallback((seconds) => action('onSeek', () => {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1575
|
+
const targetSeconds = Math.max(0, Number(seconds) || 0);
|
|
1576
|
+
if (videoRef.current) videoRef.current.currentTime = targetSeconds;
|
|
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]);
|
|
1211
1580
|
useImperativeHandle(ref, () => {
|
|
1212
1581
|
const api = {
|
|
1213
1582
|
play: () => setPaused(false),
|
|
@@ -1222,12 +1591,14 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1222
1591
|
setAudioTrack: (id) => {
|
|
1223
1592
|
for (const item of availableTracks) if (item.nativeTrack) item.nativeTrack.enabled = String(item.id) === String(id);
|
|
1224
1593
|
},
|
|
1225
|
-
setAudioOnly
|
|
1594
|
+
setAudioOnly,
|
|
1226
1595
|
setVideoOnly: setVideoOnlyMode,
|
|
1227
|
-
setPlaybackRate
|
|
1596
|
+
setPlaybackRate,
|
|
1228
1597
|
seekTo,
|
|
1229
1598
|
seekBy: (delta) => seekTo((Number(videoRef.current?.currentTime) || currentTime) + (Number(delta) || 0)),
|
|
1230
1599
|
back: handleBack,
|
|
1600
|
+
setPanel: (panel) => setActivePanel(panel || null),
|
|
1601
|
+
closePanel: () => setActivePanel(null),
|
|
1231
1602
|
getVideoElement: () => videoRef.current,
|
|
1232
1603
|
enterFullscreen: () => playerRef.current?.requestFullscreen?.(),
|
|
1233
1604
|
exitFullscreen: () => typeof document !== 'undefined' ? document.exitFullscreen?.() : undefined,
|
|
@@ -1235,11 +1606,13 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1235
1606
|
};
|
|
1236
1607
|
publicPlayerRef.current = api;
|
|
1237
1608
|
return api;
|
|
1238
|
-
}, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio,
|
|
1609
|
+
}, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnly, setVideoOnlyMode, setPlaybackRate, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
|
|
1239
1610
|
|
|
1240
1611
|
const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
|
|
1241
1612
|
const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
|
|
1242
|
-
const
|
|
1613
|
+
const canRecordFromBrowser = typeof MediaRecorder !== 'undefined';
|
|
1614
|
+
const hasRecording = controlOverrides.recording !== false
|
|
1615
|
+
&& (!!integrations.recording || typeof actions.onRecordingStart === 'function' || canRecordFromBrowser);
|
|
1243
1616
|
const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
|
|
1244
1617
|
const sourceType = String(media.type || media.mimeType || '').toLowerCase();
|
|
1245
1618
|
const directVideoSource = getDirectVideoSource({
|
|
@@ -1249,11 +1622,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1249
1622
|
activeUrl,
|
|
1250
1623
|
});
|
|
1251
1624
|
|
|
1252
|
-
const handleYouTubeStateChange = useCallback((state) => {
|
|
1253
|
-
if (state === 'playing') setIsPaused(false);
|
|
1254
|
-
if (state === 'paused' || state === 'ended') setIsPaused(true);
|
|
1255
|
-
}, []);
|
|
1256
|
-
|
|
1257
1625
|
useEffect(() => {
|
|
1258
1626
|
const video = videoRef.current;
|
|
1259
1627
|
if (!video) return undefined;
|
|
@@ -1261,6 +1629,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1261
1629
|
const updateTime = () => {
|
|
1262
1630
|
setCurrentTime(Number(video.currentTime) || 0);
|
|
1263
1631
|
if (Number.isFinite(video.duration)) setDuration(video.duration);
|
|
1632
|
+
emitProgressBarTime(Number(video.currentTime) || 0, progressBarCallback, lastProgressBarSecondRef);
|
|
1264
1633
|
onProgress?.({ currentTime: (Number(video.currentTime) || 0) * 1000, duration: (Number(video.duration) || 0) * 1000, target: video.currentTime });
|
|
1265
1634
|
};
|
|
1266
1635
|
const onReadyEvent = () => {
|
|
@@ -1288,7 +1657,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1288
1657
|
video.removeEventListener('timeupdate', updateTime);
|
|
1289
1658
|
video.removeEventListener('ended', endedHandler);
|
|
1290
1659
|
};
|
|
1291
|
-
}, [onReady, onPlaying, onProgress, onEnded, tracksProp]);
|
|
1660
|
+
}, [onReady, onPlaying, onProgress, progressBarCallback, onEnded, tracksProp]);
|
|
1292
1661
|
|
|
1293
1662
|
useEffect(() => {
|
|
1294
1663
|
if (selectedAudioTrack === undefined || selectedAudioTrack === null) return;
|
|
@@ -1304,18 +1673,17 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1304
1673
|
}, []);
|
|
1305
1674
|
|
|
1306
1675
|
const toggleFullscreen = () => action('onFullscreen', () => {
|
|
1307
|
-
if (
|
|
1308
|
-
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
1676
|
+
if (document.fullscreenElement === playerRef.current) document.exitFullscreen?.();
|
|
1309
1677
|
else playerRef.current?.requestFullscreen?.();
|
|
1310
1678
|
}, { isFullscreen: !fullscreen });
|
|
1311
|
-
const openPanel = (panel) =>
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1679
|
+
const openPanel = (panel) => {
|
|
1680
|
+
const isOpen = activePanel !== panel;
|
|
1681
|
+
return action(
|
|
1682
|
+
getPanelActionName(panel),
|
|
1683
|
+
() => setActivePanel((current) => current === panel ? null : panel),
|
|
1684
|
+
{ tab: panel, isOpen, close: () => setActivePanel(null) },
|
|
1685
|
+
);
|
|
1686
|
+
};
|
|
1319
1687
|
const control = (name, label, callback, options = {}) => renderControlButton({
|
|
1320
1688
|
name, label, callback, options, overrides: controlOverrides, icons, theme,
|
|
1321
1689
|
});
|
|
@@ -1335,18 +1703,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1335
1703
|
toggleMute,
|
|
1336
1704
|
muted,
|
|
1337
1705
|
hasRecording,
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
streamUrl,
|
|
1343
|
-
title,
|
|
1706
|
+
recordingStatus,
|
|
1707
|
+
startRecording,
|
|
1708
|
+
resumeRecording,
|
|
1709
|
+
stopRecording,
|
|
1344
1710
|
activePanel,
|
|
1345
1711
|
hasChat,
|
|
1346
1712
|
hasEpg,
|
|
1347
1713
|
hasDiagnostics,
|
|
1348
1714
|
handleOpenPanel: openPanel,
|
|
1349
|
-
setRecording,
|
|
1350
1715
|
});
|
|
1351
1716
|
const webPanel = createWebPanelNode({
|
|
1352
1717
|
renderer: panelRenderer,
|
|
@@ -1368,16 +1733,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1368
1733
|
messagePageSize,
|
|
1369
1734
|
});
|
|
1370
1735
|
|
|
1371
|
-
const handleYouTubeProgress = (event) => {
|
|
1372
|
-
setCurrentTime(Number(event.currentTime) / 1000 || 0);
|
|
1373
|
-
setDuration(Number(event.duration) / 1000 || 0);
|
|
1374
|
-
onProgress?.(event);
|
|
1375
|
-
};
|
|
1376
1736
|
const mediaSurface = h(WebPlayerSurface, {
|
|
1377
|
-
|
|
1737
|
+
title,
|
|
1378
1738
|
streamUrl,
|
|
1379
1739
|
videoRef,
|
|
1380
|
-
youtubeRef,
|
|
1381
1740
|
directVideoSource,
|
|
1382
1741
|
poster,
|
|
1383
1742
|
autoPlay,
|
|
@@ -1393,15 +1752,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1393
1752
|
onPromotePreview,
|
|
1394
1753
|
bufferingRef: onBufferingRef,
|
|
1395
1754
|
onReady,
|
|
1396
|
-
onProgress
|
|
1755
|
+
onProgress,
|
|
1397
1756
|
onPlaying,
|
|
1398
1757
|
onEnded,
|
|
1399
|
-
onStateChange: handleYouTubeStateChange,
|
|
1400
1758
|
onError: handleError,
|
|
1401
1759
|
handleError,
|
|
1760
|
+
corsMode,
|
|
1402
1761
|
});
|
|
1403
1762
|
const bottomControlProps = {
|
|
1404
1763
|
isLive,
|
|
1764
|
+
progressBarVisible,
|
|
1765
|
+
inlinePreview,
|
|
1766
|
+
title,
|
|
1405
1767
|
overrides: controlOverrides,
|
|
1406
1768
|
theme,
|
|
1407
1769
|
icons,
|
|
@@ -1413,11 +1775,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1413
1775
|
showAspectMenu,
|
|
1414
1776
|
setShowAspectMenu,
|
|
1415
1777
|
selectAspect,
|
|
1416
|
-
|
|
1417
|
-
|
|
1778
|
+
aspectRatios: aspectRatiosNormalized,
|
|
1779
|
+
selectedAspectRatio: aspectRatio,
|
|
1418
1780
|
audioOnly,
|
|
1419
1781
|
setAudioOnlyMode,
|
|
1420
1782
|
availableTracks,
|
|
1783
|
+
selectedAudioTrack,
|
|
1421
1784
|
showAudioMenu,
|
|
1422
1785
|
setShowAudioMenu,
|
|
1423
1786
|
selectAudio,
|
|
@@ -1425,6 +1788,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1425
1788
|
setPlaybackRateAction,
|
|
1426
1789
|
fullscreen,
|
|
1427
1790
|
toggleFullscreen,
|
|
1791
|
+
recordingStatus,
|
|
1792
|
+
recordingElapsed,
|
|
1793
|
+
recordingError,
|
|
1794
|
+
recordingDownloadLink,
|
|
1795
|
+
pauseRecording,
|
|
1796
|
+
resumeRecording,
|
|
1797
|
+
stopRecording,
|
|
1798
|
+
downloadRecording: downloadCompletedRecording,
|
|
1799
|
+
clearRecordingError: dismissRecording,
|
|
1428
1800
|
};
|
|
1429
1801
|
return h(WebPlayerLayout, {
|
|
1430
1802
|
playerRef,
|
|
@@ -1442,7 +1814,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1442
1814
|
audioOnly,
|
|
1443
1815
|
activePanel,
|
|
1444
1816
|
webPanel,
|
|
1445
|
-
renderBackButton: () => control('back', 'Close player', () => action('onBack',
|
|
1817
|
+
renderBackButton: () => control('back', 'Close player', () => action('onBack', undefined, { title, source: media }), { icon: 'close' }),
|
|
1446
1818
|
onSwitchToVideo: () => setAudioOnlyMode(false),
|
|
1447
1819
|
locked,
|
|
1448
1820
|
controlOverrides,
|
|
@@ -1469,7 +1841,6 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
|
|
|
1469
1841
|
paused = false,
|
|
1470
1842
|
isActive = true,
|
|
1471
1843
|
onActivate,
|
|
1472
|
-
onFullscreen,
|
|
1473
1844
|
controls = {},
|
|
1474
1845
|
actions = {},
|
|
1475
1846
|
theme,
|
|
@@ -1506,16 +1877,12 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
|
|
|
1506
1877
|
playbackRate: false, playPause: true, mute: true, fullscreen: true,
|
|
1507
1878
|
...controls,
|
|
1508
1879
|
},
|
|
1509
|
-
actions
|
|
1510
|
-
...actions,
|
|
1511
|
-
...(onFullscreen && !actions.onFullscreen ? { onFullscreen: () => onFullscreen() } : {}),
|
|
1512
|
-
},
|
|
1880
|
+
actions,
|
|
1513
1881
|
theme,
|
|
1514
1882
|
icons,
|
|
1515
1883
|
onError,
|
|
1516
1884
|
onPlaying,
|
|
1517
1885
|
inlinePreview: true,
|
|
1518
|
-
onPromotePreview: onFullscreen,
|
|
1519
1886
|
style: { width: '100%', height, aspectRatio: '16 / 9', ...style },
|
|
1520
1887
|
});
|
|
1521
1888
|
});
|