@cinecrew/cinecrew-player 0.1.3 → 0.1.4
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 +31 -20
- package/package.json +1 -1
- package/src/native/InlineLivePlayer.js +7 -6
- package/src/native/MediaPlayerView.js +58 -25
- package/src/native/media/LiveChatDrawer.js +16 -25
- package/src/native/media/YouTubeVideoPlayer.web.js +2 -0
- 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/useWebVideoAspectRatio.js +7 -2
- package/src/utils/invokePlayerAction.js +24 -0
- package/src/utils/progressBarTime.js +46 -0
- package/src/utils/webRecording.js +147 -0
- package/src/web/index.js +481 -99
- package/src/web/styles.css +129 -21
- package/types/index.d.ts +14 -1
package/src/web/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
@@ -6,6 +6,9 @@ import { useWebAc3AudioPlayback } from '../native/media/web/useWebAc3AudioPlayba
|
|
|
6
6
|
import { YouTubeVideoPlayer } from '../native/media/YouTubeVideoPlayer.web.js';
|
|
7
7
|
import { getYouTubeVideoId, getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
|
|
8
8
|
import { EMOJI_GROUPS, searchEmojis } from '../data/emoji';
|
|
9
|
+
import { createRecordingDownloadLink, createVideoRecordingStream, downloadRecording, getRecordingMimeType } from '../utils/webRecording';
|
|
10
|
+
import { invokePlayerAction } from '../utils/invokePlayerAction.js';
|
|
11
|
+
import { emitProgressBarTime } from '../utils/progressBarTime.js';
|
|
9
12
|
import './styles.css';
|
|
10
13
|
|
|
11
14
|
const h = React.createElement;
|
|
@@ -25,6 +28,19 @@ const DEFAULT_ICONS = {
|
|
|
25
28
|
liveChat: 'comment-text-multiple-outline', epg: 'television-classic', diagnostics: 'logs',
|
|
26
29
|
fullscreen: 'fullscreen', close: 'close',
|
|
27
30
|
};
|
|
31
|
+
const DEFAULT_ASPECT_RATIOS = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '21:9', '1:1'];
|
|
32
|
+
|
|
33
|
+
function normalizeAspectRatios(aspectRatios) {
|
|
34
|
+
const values = Array.isArray(aspectRatios) && aspectRatios.length ? aspectRatios : DEFAULT_ASPECT_RATIOS;
|
|
35
|
+
const unique = new Map();
|
|
36
|
+
values.forEach((item) => {
|
|
37
|
+
const value = typeof item === 'string' ? item : item?.value;
|
|
38
|
+
if (typeof value !== 'string' || !value.trim()) return;
|
|
39
|
+
const label = typeof item === 'string' ? item : item.label || value;
|
|
40
|
+
unique.set(value, { value, label });
|
|
41
|
+
});
|
|
42
|
+
return unique.size ? [...unique.values()] : DEFAULT_ASPECT_RATIOS.map((value) => ({ value, label: value }));
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
// Inline SVG keeps the React DOM/Electron entry independent of React Native,
|
|
30
46
|
// Expo, and native icon-font packages. The native entry uses Expo vector icons.
|
|
@@ -185,23 +201,24 @@ function WebSeekControl({ currentTime, duration, theme, onSeek }) {
|
|
|
185
201
|
h('span', null, `−${formatTime(remainingTime)}`));
|
|
186
202
|
}
|
|
187
203
|
|
|
188
|
-
function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
|
|
189
|
-
const ratios = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '1:1'];
|
|
204
|
+
function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect, ratios, selectedRatio }) {
|
|
190
205
|
let menu = null;
|
|
191
206
|
if (open) {
|
|
192
207
|
menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
|
|
193
208
|
ratios.map((ratio) => h('button', {
|
|
194
|
-
key: ratio,
|
|
209
|
+
key: ratio.value,
|
|
195
210
|
type: 'button',
|
|
211
|
+
className: `cinecrew-player__menu-option${selectedRatio === ratio.value ? ' is-selected' : ''}`,
|
|
212
|
+
'aria-pressed': selectedRatio === ratio.value,
|
|
196
213
|
onClick: () => onSelect(ratio),
|
|
197
|
-
}, ratio)));
|
|
214
|
+
}, ratio.label)));
|
|
198
215
|
}
|
|
199
216
|
return h('div', { className: 'cinecrew-player__menu-wrap cinecrew-player__menu-wrap--aspect', key: 'aspectRatio' },
|
|
200
217
|
h(PlayerButton, { name: 'aspectRatio', label: 'Aspect ratio', icons, theme, onClick: onToggle, active: open }),
|
|
201
218
|
menu);
|
|
202
219
|
}
|
|
203
220
|
|
|
204
|
-
function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
|
|
221
|
+
function WebAudioTrackMenu({ open, tracks, selectedTrackId, theme, icons, onToggle, onSelect }) {
|
|
205
222
|
let menu = null;
|
|
206
223
|
if (open) {
|
|
207
224
|
menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
|
|
@@ -209,6 +226,8 @@ function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
|
|
|
209
226
|
? tracks.map((track, index) => h('button', {
|
|
210
227
|
key: track.id ?? index,
|
|
211
228
|
type: 'button',
|
|
229
|
+
className: String(track.id) === String(selectedTrackId) ? 'cinecrew-player__menu-option is-selected' : 'cinecrew-player__menu-option',
|
|
230
|
+
'aria-pressed': String(track.id) === String(selectedTrackId),
|
|
212
231
|
onClick: () => onSelect(track.id),
|
|
213
232
|
}, track.name || track.language || `Track ${index + 1}`))
|
|
214
233
|
: h('div', { className: 'cinecrew-player__menu-empty', role: 'status' }, 'No audio tracks available'));
|
|
@@ -231,8 +250,8 @@ function WebPlaybackRateControl({ value, onChange }) {
|
|
|
231
250
|
function WebBottomControls(props) {
|
|
232
251
|
const {
|
|
233
252
|
isLive, overrides, theme, icons, currentTime, duration, seekTo,
|
|
234
|
-
showAspectMenu, setShowAspectMenu, selectAspect,
|
|
235
|
-
|
|
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;
|
|
@@ -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
|
|
|
@@ -280,7 +301,6 @@ function WebBottomControls(props) {
|
|
|
280
301
|
h('div', { className: 'cinecrew-player__bottom-actions' },
|
|
281
302
|
h('div', { className: 'cinecrew-player__bottom-left-actions' },
|
|
282
303
|
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
304
|
aspect),
|
|
285
305
|
h('div', { className: 'cinecrew-player__bottom-right-actions' },
|
|
286
306
|
audioTracks,
|
|
@@ -288,7 +308,50 @@ function WebBottomControls(props) {
|
|
|
288
308
|
renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
|
|
289
309
|
}
|
|
290
310
|
|
|
291
|
-
function
|
|
311
|
+
function getRecordingOverlayContent({ status, elapsed, error, downloadLink, onPause, onResume, onStop, onDownload, onDismiss }) {
|
|
312
|
+
if (status === 'complete') {
|
|
313
|
+
const children = [];
|
|
314
|
+
if (error) children.push(h('span', null, error));
|
|
315
|
+
children.push(h('strong', { className: 'cinecrew-player__recording-ready' }, 'Recording ready'));
|
|
316
|
+
if (downloadLink) {
|
|
317
|
+
children.push(h('a', {
|
|
318
|
+
className: 'cinecrew-player__recording-download',
|
|
319
|
+
href: downloadLink.url,
|
|
320
|
+
download: downloadLink.filename,
|
|
321
|
+
onClick: onDownload,
|
|
322
|
+
}, 'Download recording'));
|
|
323
|
+
}
|
|
324
|
+
children.push(h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording controls' }, 'Done'));
|
|
325
|
+
return h(React.Fragment, null, ...children);
|
|
326
|
+
}
|
|
327
|
+
if (status === 'finalizing') {
|
|
328
|
+
return h('strong', { className: 'cinecrew-player__recording-ready' }, 'Preparing recording…');
|
|
329
|
+
}
|
|
330
|
+
if (error) return h('span', null, error);
|
|
331
|
+
|
|
332
|
+
const isPaused = status === 'paused';
|
|
333
|
+
return h(React.Fragment, null,
|
|
334
|
+
h('span', { className: 'cinecrew-player__recording-indicator' }, 'REC'),
|
|
335
|
+
h('strong', { className: 'cinecrew-player__recording-timer' }, formatTime(elapsed / 1000)),
|
|
336
|
+
h('div', { className: 'cinecrew-player__recording-actions' },
|
|
337
|
+
h('button', { type: 'button', onClick: isPaused ? onResume : onPause }, isPaused ? 'Resume' : 'Pause'),
|
|
338
|
+
h('button', { type: 'button', onClick: onStop }, 'Stop & download')));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function WebRecordingOverlay(props) {
|
|
342
|
+
const { status, error, theme, onDismiss } = props;
|
|
343
|
+
if (status === 'idle' && !error) return null;
|
|
344
|
+
const dismissButton = error
|
|
345
|
+
? h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording message' }, '×')
|
|
346
|
+
: null;
|
|
347
|
+
return h('div', {
|
|
348
|
+
className: `cinecrew-player__recording-overlay${error ? ' is-error' : ''}`,
|
|
349
|
+
style: { color: theme.controlColor },
|
|
350
|
+
role: error ? 'alert' : 'status',
|
|
351
|
+
}, getRecordingOverlayContent(props), dismissButton);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, title, togglePlay, bottomProps }) {
|
|
292
355
|
let leftControls = locked ? null : unlockedControls.left;
|
|
293
356
|
let rightControls = unlockedControls.right;
|
|
294
357
|
if (locked) {
|
|
@@ -300,7 +363,7 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
|
|
|
300
363
|
let centerControls = null;
|
|
301
364
|
let bottomControls = null;
|
|
302
365
|
if (!locked) {
|
|
303
|
-
if (!buffering) {
|
|
366
|
+
if (!buffering && bottomProps.recordingStatus === 'idle') {
|
|
304
367
|
const playLabel = paused ? 'Play' : 'Pause';
|
|
305
368
|
const playIcon = paused ? 'play' : 'pause';
|
|
306
369
|
centerControls = h('div', { className: 'cinecrew-player__center-controls' },
|
|
@@ -308,11 +371,25 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
|
|
|
308
371
|
}
|
|
309
372
|
bottomControls = h(WebBottomControls, bottomProps);
|
|
310
373
|
}
|
|
311
|
-
|
|
374
|
+
const recordingUiVisible = bottomProps.recordingStatus !== 'idle' || Boolean(bottomProps.recordingError);
|
|
375
|
+
return h('div', { className: `cinecrew-player__controls${recordingUiVisible ? ' is-recording' : ''}`, style: { color: theme.controlColor } },
|
|
312
376
|
h('div', { className: 'cinecrew-player__top-controls' },
|
|
313
377
|
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,
|
|
314
379
|
h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
|
|
315
380
|
centerControls,
|
|
381
|
+
h(WebRecordingOverlay, {
|
|
382
|
+
status: bottomProps.recordingStatus,
|
|
383
|
+
elapsed: bottomProps.recordingElapsed,
|
|
384
|
+
error: bottomProps.recordingError,
|
|
385
|
+
theme,
|
|
386
|
+
downloadLink: bottomProps.recordingDownloadLink,
|
|
387
|
+
onPause: bottomProps.pauseRecording,
|
|
388
|
+
onResume: bottomProps.resumeRecording,
|
|
389
|
+
onStop: bottomProps.stopRecording,
|
|
390
|
+
onDownload: bottomProps.downloadRecording,
|
|
391
|
+
onDismiss: bottomProps.clearRecordingError,
|
|
392
|
+
}),
|
|
316
393
|
bottomControls);
|
|
317
394
|
}
|
|
318
395
|
|
|
@@ -456,23 +533,59 @@ function getInlinePlayerStyle(inlinePreview, rect) {
|
|
|
456
533
|
return { position: 'absolute', left: rect.x, top: rect.y, width: rect.width, height: rect.height };
|
|
457
534
|
}
|
|
458
535
|
|
|
536
|
+
function stopBrowserRecorder(recorderRef, completionRef, finalizerRef) {
|
|
537
|
+
return new Promise((resolve, reject) => {
|
|
538
|
+
const recorder = recorderRef.current;
|
|
539
|
+
const completion = completionRef.current;
|
|
540
|
+
if (!recorder || recorder.state === 'inactive') {
|
|
541
|
+
finalizerRef.current?.();
|
|
542
|
+
Promise.resolve(completion).then(resolve, reject);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
recorder.addEventListener('error', (event) => reject(event.error || new Error('Recording failed.')), { once: true });
|
|
546
|
+
recorder.stop();
|
|
547
|
+
if (completion) completion.then(resolve, reject);
|
|
548
|
+
else resolve();
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function waitForRecordingFinalization(completion) {
|
|
553
|
+
let timeoutId;
|
|
554
|
+
return Promise.race([
|
|
555
|
+
completion,
|
|
556
|
+
new Promise((_, reject) => {
|
|
557
|
+
timeoutId = window.setTimeout(() => reject(new Error('The browser did not finish finalizing the recording. Try again with a local or CORS-enabled source.')), 10000);
|
|
558
|
+
}),
|
|
559
|
+
]).finally(() => window.clearTimeout(timeoutId));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function performRecordingStop({ stop, action, recordingElapsed, streamUrl, title, recorderRef, completionRef, finalizerRef, setRecordingStatus }) {
|
|
563
|
+
if (!stop) setRecordingStatus('finalizing');
|
|
564
|
+
let stopCore = () => stopBrowserRecorder(recorderRef, completionRef, finalizerRef);
|
|
565
|
+
if (stop) stopCore = () => stop();
|
|
566
|
+
await action('onRecordingStop', stopCore, { elapsedMs: recordingElapsed, source: streamUrl, title });
|
|
567
|
+
if (!stop) await waitForRecordingFinalization(completionRef.current);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function updateRecordingStopState({ clock, recordingBlobRef, stop, setRecordingElapsed, setRecordingStatus, setRecordingError }) {
|
|
571
|
+
if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
|
|
572
|
+
setRecordingElapsed(clock.accumulatedMs);
|
|
573
|
+
const hasRecording = Boolean(recordingBlobRef.current);
|
|
574
|
+
setRecordingStatus(hasRecording ? 'complete' : 'idle');
|
|
575
|
+
if (!hasRecording && !stop) {
|
|
576
|
+
setRecordingError((current) => current || 'The browser returned no recorded media data for this source. Try a local or CORS-enabled video while it is playing.');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
459
580
|
function buildUnlockedControls({
|
|
460
581
|
control, onBack, isLive, restart, toggleLock, toggleMute, muted, hasRecording,
|
|
461
|
-
|
|
462
|
-
hasChat, hasEpg, hasDiagnostics, handleOpenPanel,
|
|
582
|
+
recordingStatus, startRecording, resumeRecording, stopRecording, activePanel,
|
|
583
|
+
hasChat, hasEpg, hasDiagnostics, handleOpenPanel, backVisible,
|
|
463
584
|
}) {
|
|
464
585
|
const leftControls = [control('back', 'Back', onBack, { defaultVisible: backVisible })];
|
|
465
586
|
let recordingControl = null;
|
|
466
587
|
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 });
|
|
588
|
+
recordingControl = buildRecordingControl(control, recordingStatus, startRecording, resumeRecording, stopRecording);
|
|
476
589
|
}
|
|
477
590
|
const chatLabel = activePanel === 'chat' ? 'Close live chat' : 'Live chat';
|
|
478
591
|
const epgLabel = activePanel === 'epg' ? 'Close programme guide' : 'Programme guide';
|
|
@@ -491,6 +604,23 @@ function buildUnlockedControls({
|
|
|
491
604
|
return { left: leftControls, right: rightControls };
|
|
492
605
|
}
|
|
493
606
|
|
|
607
|
+
function buildRecordingControl(control, status, startRecording, resumeRecording, stopRecording) {
|
|
608
|
+
let label = 'Start recording';
|
|
609
|
+
let callback = startRecording;
|
|
610
|
+
if (status === 'recording') {
|
|
611
|
+
label = 'Stop recording';
|
|
612
|
+
callback = stopRecording;
|
|
613
|
+
} else if (status === 'paused') {
|
|
614
|
+
label = 'Resume recording';
|
|
615
|
+
callback = resumeRecording;
|
|
616
|
+
} else if (status === 'finalizing') {
|
|
617
|
+
label = 'Preparing recording';
|
|
618
|
+
callback = () => {};
|
|
619
|
+
}
|
|
620
|
+
const isFinalizing = status === 'finalizing';
|
|
621
|
+
return control('recording', label, callback, { active: status !== 'idle', disabled: isFinalizing });
|
|
622
|
+
}
|
|
623
|
+
|
|
494
624
|
function getDrawerLabel(activePanel) {
|
|
495
625
|
if (activePanel === 'chat') return 'Live chat drawer';
|
|
496
626
|
if (activePanel === 'epg') return 'Programme guide drawer';
|
|
@@ -515,14 +645,11 @@ function WebPlayerLayout(props) {
|
|
|
515
645
|
unlockedControls: props.unlockedControls,
|
|
516
646
|
toggleLock: props.toggleLock,
|
|
517
647
|
paused: props.isPaused,
|
|
648
|
+
title: props.title,
|
|
518
649
|
togglePlay: props.togglePlay,
|
|
519
650
|
bottomProps: props.bottomControlProps,
|
|
520
651
|
});
|
|
521
652
|
}
|
|
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
653
|
let loadingNotice = null;
|
|
527
654
|
if (props.buffering && !props.error) {
|
|
528
655
|
loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
|
|
@@ -549,7 +676,6 @@ function WebPlayerLayout(props) {
|
|
|
549
676
|
},
|
|
550
677
|
props.mediaSurface,
|
|
551
678
|
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
679
|
loadingNotice,
|
|
554
680
|
h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.locked ? () => null : props.renderBackButton }),
|
|
555
681
|
controlLayer,
|
|
@@ -652,14 +778,14 @@ function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status,
|
|
|
652
778
|
const buffered = getBufferedAhead(video, currentTime);
|
|
653
779
|
const frames = getFrameQuality(video);
|
|
654
780
|
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' },
|
|
781
|
+
{ icon: 'display', color: '#48c9ff', label: 'Video resolution', value: resolution },
|
|
782
|
+
{ icon: 'network', color: '#43d6a0', label: 'Connection rate', value: Number.isFinite(connection?.downlink) ? `${connection.downlink} Mbps` : 'Not reported' },
|
|
783
|
+
{ icon: 'clock', color: '#ffbd59', label: 'Network RTT estimate', value: Number.isFinite(connection?.rtt) ? `${connection.rtt} ms` : 'Not reported' },
|
|
784
|
+
{ icon: 'buffer', color: '#b18cff', label: 'Buffered ahead', value: buffered === null ? 'Not reported' : `${buffered.toFixed(1)} sec` },
|
|
785
|
+
{ icon: 'frames', color: '#ff6f91', label: 'Dropped / total frames', value: frames && Number.isFinite(frames.dropped) ? `${frames.dropped} / ${frames.total ?? '—'}` : 'Not reported' },
|
|
786
|
+
{ icon: 'speedometer', color: '#47d0cf', label: 'Playback position', value: `${formatTime(currentTime)} / ${formatTime(duration)}` },
|
|
787
|
+
{ icon: 'network', color: '#ff9666', label: 'Connection type', value: connection?.effectiveType || 'Not reported' },
|
|
788
|
+
{ icon: 'video', color: '#93aaff', label: 'Stream format', value: streamMode || 'Unknown' },
|
|
663
789
|
];
|
|
664
790
|
return h('section', {
|
|
665
791
|
className: 'cinecrew-player__diagnostics',
|
|
@@ -682,8 +808,8 @@ function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status,
|
|
|
682
808
|
h('span', null, status || 'Unknown'),
|
|
683
809
|
h('span', null, streamMode || 'Unknown format')),
|
|
684
810
|
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
|
|
811
|
+
cards.map(({ icon, color, label, value }) => h('article', { className: 'cinecrew-player__diagnostic-card', key: label, style: { '--diagnostic-accent': color } },
|
|
812
|
+
h('span', { className: 'cinecrew-player__diagnostic-icon' }, h(Icon, { name: icon, icons, color })),
|
|
687
813
|
h('span', { className: 'cinecrew-player__diagnostic-label' }, label),
|
|
688
814
|
h('strong', null, value)))),
|
|
689
815
|
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 +905,24 @@ function WebEmojiPicker({ onSelect }) {
|
|
|
779
905
|
function WebChatComposer({ sending, canSend, onSend }) {
|
|
780
906
|
const [message, setMessage] = useState('');
|
|
781
907
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
908
|
+
const emojiAnchorRef = useRef(null);
|
|
909
|
+
useEffect(() => {
|
|
910
|
+
if (!emojiOpen) return undefined;
|
|
911
|
+
const closeOnOutsidePointer = (event) => {
|
|
912
|
+
if (!emojiAnchorRef.current?.contains(event.target)) setEmojiOpen(false);
|
|
913
|
+
};
|
|
914
|
+
const closeOnEscape = (event) => {
|
|
915
|
+
if (event.key === 'Escape') setEmojiOpen(false);
|
|
916
|
+
};
|
|
917
|
+
document.addEventListener('pointerdown', closeOnOutsidePointer, true);
|
|
918
|
+
document.addEventListener('click', closeOnOutsidePointer, true);
|
|
919
|
+
document.addEventListener('keydown', closeOnEscape);
|
|
920
|
+
return () => {
|
|
921
|
+
document.removeEventListener('pointerdown', closeOnOutsidePointer, true);
|
|
922
|
+
document.removeEventListener('click', closeOnOutsidePointer, true);
|
|
923
|
+
document.removeEventListener('keydown', closeOnEscape);
|
|
924
|
+
};
|
|
925
|
+
}, [emojiOpen]);
|
|
782
926
|
const handleSubmit = async (event) => {
|
|
783
927
|
event.preventDefault();
|
|
784
928
|
const comment = message.trim();
|
|
@@ -790,12 +934,14 @@ function WebChatComposer({ sending, canSend, onSend }) {
|
|
|
790
934
|
|
|
791
935
|
return h('form', { className: 'cinecrew-player__chat-form', onSubmit: handleSubmit },
|
|
792
936
|
h('div', { className: 'cinecrew-player__chat-composer' },
|
|
793
|
-
h('
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
937
|
+
h('span', { className: 'cinecrew-player__emoji-anchor', ref: emojiAnchorRef },
|
|
938
|
+
h('button', {
|
|
939
|
+
type: 'button',
|
|
940
|
+
className: 'cinecrew-player__emoji-toggle',
|
|
941
|
+
onClick: () => setEmojiOpen((open) => !open),
|
|
942
|
+
'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
|
|
943
|
+
}, '☺'),
|
|
944
|
+
emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null),
|
|
799
945
|
h('input', {
|
|
800
946
|
value: message,
|
|
801
947
|
onChange: (event) => setMessage(event.target.value),
|
|
@@ -808,8 +954,7 @@ function WebChatComposer({ sending, canSend, onSend }) {
|
|
|
808
954
|
disabled: sending || !message.trim() || !canSend,
|
|
809
955
|
'aria-label': sending ? 'Sending message' : 'Send message',
|
|
810
956
|
title: sending ? 'Sending…' : 'Send message',
|
|
811
|
-
}, h(Icon, { name: 'send', color: '#07111e' })))
|
|
812
|
-
emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null);
|
|
957
|
+
}, h(Icon, { name: 'send', color: '#07111e' }))));
|
|
813
958
|
}
|
|
814
959
|
|
|
815
960
|
function WebPanelRow({ row, index, isChat }) {
|
|
@@ -829,15 +974,6 @@ function WebPanelRow({ row, index, isChat }) {
|
|
|
829
974
|
row.description ? h('span', null, row.description) : null);
|
|
830
975
|
}
|
|
831
976
|
|
|
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
977
|
function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose, messagePageSize = 50 }) {
|
|
842
978
|
const [rows, setRows] = useState([]);
|
|
843
979
|
const [user, setUser] = useState(integrations.user || null);
|
|
@@ -848,6 +984,9 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
848
984
|
const [sending, setSending] = useState(false);
|
|
849
985
|
const [error, setError] = useState('');
|
|
850
986
|
const rowsRef = useRef([]);
|
|
987
|
+
const panelListRef = useRef(null);
|
|
988
|
+
const olderScrollRef = useRef(null);
|
|
989
|
+
const shouldScrollBottomRef = useRef(false);
|
|
851
990
|
const channelId = String(source.streamId || source.mediaId || source.id || '');
|
|
852
991
|
const isChat = kind === 'chat';
|
|
853
992
|
const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
|
|
@@ -875,6 +1014,7 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
875
1014
|
const mergedRows = mergeChatMessages(rowsRef.current, nextRows);
|
|
876
1015
|
updateRows(mergedRows);
|
|
877
1016
|
const addedCount = Math.max(0, mergedRows.length - previousCount);
|
|
1017
|
+
if (addedCount > 0) shouldScrollBottomRef.current = true;
|
|
878
1018
|
setMessageOffset((offset) => previousCount === 0
|
|
879
1019
|
? nextRows.length
|
|
880
1020
|
: Math.max(offset, nextRows.length) + addedCount);
|
|
@@ -916,6 +1056,8 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
916
1056
|
|
|
917
1057
|
const loadOlderMessages = async () => {
|
|
918
1058
|
if (!isChat || !hasMoreMessages || loadingOlder) return;
|
|
1059
|
+
const panelList = panelListRef.current;
|
|
1060
|
+
if (panelList) olderScrollRef.current = { height: panelList.scrollHeight, top: panelList.scrollTop };
|
|
919
1061
|
setLoadingOlder(true);
|
|
920
1062
|
try {
|
|
921
1063
|
setError('');
|
|
@@ -932,6 +1074,25 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
932
1074
|
}
|
|
933
1075
|
};
|
|
934
1076
|
|
|
1077
|
+
useLayoutEffect(() => {
|
|
1078
|
+
const panelList = panelListRef.current;
|
|
1079
|
+
if (!panelList) return;
|
|
1080
|
+
if (olderScrollRef.current) {
|
|
1081
|
+
const previous = olderScrollRef.current;
|
|
1082
|
+
panelList.scrollTop = previous.top + panelList.scrollHeight - previous.height;
|
|
1083
|
+
olderScrollRef.current = null;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (shouldScrollBottomRef.current) {
|
|
1087
|
+
panelList.scrollTop = panelList.scrollHeight;
|
|
1088
|
+
shouldScrollBottomRef.current = false;
|
|
1089
|
+
}
|
|
1090
|
+
}, [rows]);
|
|
1091
|
+
|
|
1092
|
+
useEffect(() => {
|
|
1093
|
+
if (isChat && hasMoreMessages && !loadingOlder && panelListRef.current?.scrollTop <= 24) loadOlderMessages();
|
|
1094
|
+
}, [rows.length, hasMoreMessages, loadingOlder, isChat]);
|
|
1095
|
+
|
|
935
1096
|
const send = async (comment) => {
|
|
936
1097
|
if (!comment || sending || !integration.sendMessage) return false;
|
|
937
1098
|
setSending(true);
|
|
@@ -963,8 +1124,15 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
963
1124
|
loading ? h('div', { className: 'cinecrew-player__panel-state' }, 'Loading…') : null,
|
|
964
1125
|
error ? h('div', { className: 'cinecrew-player__panel-state is-error', role: 'status' }, error) : null,
|
|
965
1126
|
!loading && !error && rows.length === 0 ? h('div', { className: 'cinecrew-player__panel-state' }, emptyStateMessage) : null,
|
|
966
|
-
h('div', {
|
|
967
|
-
|
|
1127
|
+
h('div', {
|
|
1128
|
+
className: 'cinecrew-player__panel-list',
|
|
1129
|
+
ref: panelListRef,
|
|
1130
|
+
onScroll: (event) => {
|
|
1131
|
+
if (isChat && event.currentTarget.scrollTop <= 24) loadOlderMessages();
|
|
1132
|
+
},
|
|
1133
|
+
'aria-live': isChat ? 'polite' : undefined,
|
|
1134
|
+
},
|
|
1135
|
+
isChat && loadingOlder ? h('div', { className: 'cinecrew-player__older-loading', role: 'status' }, 'Loading older messages…') : null,
|
|
968
1136
|
rows.map((row, index) => h(WebPanelRow, { key: getChatMessageKey(row, index), row, index, isChat }))),
|
|
969
1137
|
isChat ? h(WebChatComposer, {
|
|
970
1138
|
sending,
|
|
@@ -1019,6 +1187,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1019
1187
|
onFullscreen,
|
|
1020
1188
|
onReady,
|
|
1021
1189
|
onProgress,
|
|
1190
|
+
onProgressBarChange,
|
|
1022
1191
|
onPlaying,
|
|
1023
1192
|
onBuffering,
|
|
1024
1193
|
onError,
|
|
@@ -1042,7 +1211,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1042
1211
|
const [volume, setVolume] = useState(Math.max(0, Math.min(1, Number(volumeProp) || 0)));
|
|
1043
1212
|
const [videoOnly, setVideoOnly] = useState(!!videoOnlyProp);
|
|
1044
1213
|
const [audioOnly, setAudioOnly] = useState(!!audioOnlyProp);
|
|
1045
|
-
const [aspectRatio, setAspectRatio] = useState('FIT');
|
|
1214
|
+
const [aspectRatio, setAspectRatio] = useState(props.defaultAspectRatio || 'FIT');
|
|
1046
1215
|
const [locked, setLocked] = useState(false);
|
|
1047
1216
|
const [buffering, setBuffering] = useState(Boolean(streamUrl) || resolution.loading);
|
|
1048
1217
|
const [error, setError] = useState('');
|
|
@@ -1051,11 +1220,26 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1051
1220
|
const [showAspectMenu, setShowAspectMenu] = useState(false);
|
|
1052
1221
|
const [showAudioMenu, setShowAudioMenu] = useState(false);
|
|
1053
1222
|
const [activePanel, setActivePanel] = useState(null);
|
|
1054
|
-
const [
|
|
1223
|
+
const [recordingStatus, setRecordingStatus] = useState('idle');
|
|
1224
|
+
const [recordingElapsed, setRecordingElapsed] = useState(0);
|
|
1225
|
+
const [recordingError, setRecordingError] = useState('');
|
|
1226
|
+
const [recordingDownloadLink, setRecordingDownloadLink] = useState(null);
|
|
1055
1227
|
const [availableTracks, setAvailableTracks] = useState([]);
|
|
1056
1228
|
const [fullscreen, setFullscreen] = useState(false);
|
|
1057
1229
|
const [playbackRate, setPlaybackRate] = useState(Number(playbackRateProp) || 1);
|
|
1230
|
+
const aspectRatiosNormalized = useMemo(() => normalizeAspectRatios(props.aspectRatios), [props.aspectRatios]);
|
|
1058
1231
|
const { videoStyle } = useWebVideoAspectRatio(aspectRatio, false);
|
|
1232
|
+
const aspectRatioRef = useRef(aspectRatio);
|
|
1233
|
+
const recorderRef = useRef(null);
|
|
1234
|
+
const recordingCaptureRef = useRef(null);
|
|
1235
|
+
const recordingChunksRef = useRef([]);
|
|
1236
|
+
const recordingBlobRef = useRef(null);
|
|
1237
|
+
const recordingDownloadLinkRef = useRef(null);
|
|
1238
|
+
const recordingFinalizerRef = useRef(null);
|
|
1239
|
+
const recordingCompletionRef = useRef(null);
|
|
1240
|
+
const lastProgressBarSecondRef = useRef(null);
|
|
1241
|
+
const recordingClockRef = useRef({ startedAt: 0, accumulatedMs: 0 });
|
|
1242
|
+
aspectRatioRef.current = aspectRatio;
|
|
1059
1243
|
const pausedStateRef = pausedRef;
|
|
1060
1244
|
pausedRef.current = isPaused;
|
|
1061
1245
|
errorRef.current = onError;
|
|
@@ -1082,6 +1266,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1082
1266
|
setBuffering(Boolean(streamUrl) || resolution.loading);
|
|
1083
1267
|
setCurrentTime(0);
|
|
1084
1268
|
setDuration(0);
|
|
1269
|
+
lastProgressBarSecondRef.current = null;
|
|
1085
1270
|
setAvailableTracks([]);
|
|
1086
1271
|
if (resolution.error) {
|
|
1087
1272
|
handleError(resolution.error);
|
|
@@ -1097,6 +1282,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1097
1282
|
}, [pausedProp]);
|
|
1098
1283
|
|
|
1099
1284
|
useEffect(() => setAudioOnly(!!audioOnlyProp), [audioOnlyProp]);
|
|
1285
|
+
useEffect(() => {
|
|
1286
|
+
setAspectRatio(props.defaultAspectRatio || 'FIT');
|
|
1287
|
+
}, [streamUrl, props.defaultAspectRatio]);
|
|
1100
1288
|
|
|
1101
1289
|
useEffect(() => {
|
|
1102
1290
|
setMuted(!!mutedProp);
|
|
@@ -1138,7 +1326,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1138
1326
|
onErrorRef,
|
|
1139
1327
|
});
|
|
1140
1328
|
const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
|
|
1141
|
-
useWebAc3AudioPlayback({
|
|
1329
|
+
const ac3AudioPlayback = useWebAc3AudioPlayback({
|
|
1142
1330
|
active: mpegTs.useAc3Fallback,
|
|
1143
1331
|
streamUrl,
|
|
1144
1332
|
enabled: isAudioPlaybackEnabled(muted, videoOnly),
|
|
@@ -1179,18 +1367,200 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1179
1367
|
}, []);
|
|
1180
1368
|
|
|
1181
1369
|
const action = useCallback((name, fallback, payload) => {
|
|
1182
|
-
|
|
1183
|
-
if (
|
|
1184
|
-
|
|
1185
|
-
|
|
1370
|
+
let callback = actions?.[name];
|
|
1371
|
+
if (name === 'onAspectRatioChange') callback = callback || props.onAspectRatioChange;
|
|
1372
|
+
else if (name === 'onFullscreen') callback = callback || onFullscreen;
|
|
1373
|
+
return invokePlayerAction(
|
|
1374
|
+
fallback,
|
|
1375
|
+
callback,
|
|
1376
|
+
payload,
|
|
1377
|
+
{ video: videoRef.current, player: publicPlayerRef.current },
|
|
1378
|
+
);
|
|
1379
|
+
}, [actions, props.onAspectRatioChange, onFullscreen]);
|
|
1380
|
+
|
|
1381
|
+
const startBuiltinRecording = useCallback(async () => {
|
|
1382
|
+
const video = videoRef.current;
|
|
1383
|
+
if (!video || youtubeVideoId) throw new Error('This source cannot be recorded from the browser player.');
|
|
1384
|
+
const mimeType = getRecordingMimeType();
|
|
1385
|
+
if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
|
|
1386
|
+
const capture = createVideoRecordingStream(video, playerRef.current, () => aspectRatioRef.current, ac3AudioPlayback.getRecordingAudioStream());
|
|
1387
|
+
const recorder = new MediaRecorder(capture.stream, { mimeType });
|
|
1388
|
+
recordingCaptureRef.current = capture;
|
|
1389
|
+
recordingChunksRef.current = [];
|
|
1390
|
+
recorderRef.current = recorder;
|
|
1391
|
+
let finalized = false;
|
|
1392
|
+
let resolveCompletion;
|
|
1393
|
+
recordingCompletionRef.current = new Promise((resolve) => { resolveCompletion = resolve; });
|
|
1394
|
+
const finalizeRecording = () => {
|
|
1395
|
+
if (finalized) return;
|
|
1396
|
+
finalized = true;
|
|
1397
|
+
let completedBlob = null;
|
|
1398
|
+
try {
|
|
1399
|
+
const blob = new Blob(recordingChunksRef.current, { type: recorder.mimeType || mimeType });
|
|
1400
|
+
if (!blob.size) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
|
|
1401
|
+
recordingBlobRef.current = blob;
|
|
1402
|
+
completedBlob = blob;
|
|
1403
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1404
|
+
const downloadLink = createRecordingDownloadLink(blob, title);
|
|
1405
|
+
if (!downloadLink) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
|
|
1406
|
+
recordingDownloadLinkRef.current = downloadLink;
|
|
1407
|
+
setRecordingDownloadLink(downloadLink);
|
|
1408
|
+
setRecordingStatus('complete');
|
|
1409
|
+
try {
|
|
1410
|
+
if (!downloadRecording(blob, title)) throw new Error('The browser did not start the recording download. Use Download recording to retry.');
|
|
1411
|
+
} catch (downloadError) {
|
|
1412
|
+
setRecordingError(downloadError?.message || 'The recording could not be downloaded. Use Download recording to retry.');
|
|
1413
|
+
}
|
|
1414
|
+
} catch (error_) {
|
|
1415
|
+
setRecordingError(error_?.message || 'Could not finish the recording.');
|
|
1416
|
+
setRecordingStatus('idle');
|
|
1417
|
+
} finally {
|
|
1418
|
+
recordingCaptureRef.current?.cleanup?.();
|
|
1419
|
+
recordingCaptureRef.current = null;
|
|
1420
|
+
recorderRef.current = null;
|
|
1421
|
+
recordingChunksRef.current = [];
|
|
1422
|
+
recordingFinalizerRef.current = null;
|
|
1423
|
+
resolveCompletion?.(completedBlob);
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
recordingFinalizerRef.current = finalizeRecording;
|
|
1427
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
1428
|
+
if (event.data?.size) recordingChunksRef.current.push(event.data);
|
|
1429
|
+
});
|
|
1430
|
+
recorder.addEventListener('stop', finalizeRecording, { once: true });
|
|
1431
|
+
recorder.addEventListener('error', (event) => {
|
|
1432
|
+
setRecordingError(event.error?.message || 'The browser could not record this stream.');
|
|
1433
|
+
}, { once: true });
|
|
1434
|
+
recorder.start(1000);
|
|
1435
|
+
}, [ac3AudioPlayback, title, youtubeVideoId]);
|
|
1436
|
+
|
|
1437
|
+
const startRecording = useCallback(async () => {
|
|
1438
|
+
setRecordingError('');
|
|
1439
|
+
recordingBlobRef.current = null;
|
|
1440
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1441
|
+
recordingDownloadLinkRef.current = null;
|
|
1442
|
+
setRecordingDownloadLink(null);
|
|
1443
|
+
try {
|
|
1444
|
+
const adapterStart = integrations.recording?.start;
|
|
1445
|
+
await action('onRecordingStart', adapterStart
|
|
1446
|
+
? () => adapterStart({ getVideoElement: () => videoRef.current, streamUrl, title })
|
|
1447
|
+
: startBuiltinRecording, { source: streamUrl, title });
|
|
1448
|
+
recordingClockRef.current = { startedAt: Date.now(), accumulatedMs: 0 };
|
|
1449
|
+
setRecordingElapsed(0);
|
|
1450
|
+
setRecordingStatus('recording');
|
|
1451
|
+
} catch (startError) {
|
|
1452
|
+
setRecordingStatus('idle');
|
|
1453
|
+
setRecordingError(startError?.message || 'Could not start recording.');
|
|
1454
|
+
}
|
|
1455
|
+
}, [action, integrations.recording, startBuiltinRecording, streamUrl, title]);
|
|
1456
|
+
|
|
1457
|
+
const pauseRecording = useCallback(async () => {
|
|
1458
|
+
try {
|
|
1459
|
+
const pause = integrations.recording?.pause;
|
|
1460
|
+
await action('onRecordingPause', pause
|
|
1461
|
+
? () => pause()
|
|
1462
|
+
: () => {
|
|
1463
|
+
if (recorderRef.current?.state !== 'recording') throw new Error('Recording cannot be paused right now.');
|
|
1464
|
+
recorderRef.current.pause();
|
|
1465
|
+
}, { elapsedMs: recordingElapsed });
|
|
1466
|
+
const clock = recordingClockRef.current;
|
|
1467
|
+
if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
|
|
1468
|
+
clock.startedAt = 0;
|
|
1469
|
+
setRecordingElapsed(clock.accumulatedMs);
|
|
1470
|
+
setRecordingStatus('paused');
|
|
1471
|
+
} catch (pauseError) {
|
|
1472
|
+
setRecordingError(pauseError?.message || 'Could not pause recording.');
|
|
1473
|
+
}
|
|
1474
|
+
}, [action, integrations.recording, recordingElapsed]);
|
|
1186
1475
|
|
|
1187
|
-
const
|
|
1476
|
+
const resumeRecording = useCallback(async () => {
|
|
1477
|
+
try {
|
|
1478
|
+
const resume = integrations.recording?.resume;
|
|
1479
|
+
await action('onRecordingResume', resume
|
|
1480
|
+
? () => resume()
|
|
1481
|
+
: () => {
|
|
1482
|
+
if (recorderRef.current?.state !== 'paused') throw new Error('Recording cannot resume right now.');
|
|
1483
|
+
recorderRef.current.resume();
|
|
1484
|
+
}, { elapsedMs: recordingElapsed });
|
|
1485
|
+
recordingClockRef.current.startedAt = Date.now();
|
|
1486
|
+
setRecordingStatus('recording');
|
|
1487
|
+
} catch (resumeError) {
|
|
1488
|
+
setRecordingError(resumeError?.message || 'Could not resume recording.');
|
|
1489
|
+
}
|
|
1490
|
+
}, [action, integrations.recording, recordingElapsed]);
|
|
1491
|
+
|
|
1492
|
+
const stopRecording = useCallback(async () => {
|
|
1493
|
+
try {
|
|
1494
|
+
const stop = integrations.recording?.stop;
|
|
1495
|
+
await performRecordingStop({
|
|
1496
|
+
stop,
|
|
1497
|
+
action,
|
|
1498
|
+
recordingElapsed,
|
|
1499
|
+
streamUrl,
|
|
1500
|
+
title,
|
|
1501
|
+
recorderRef,
|
|
1502
|
+
completionRef: recordingCompletionRef,
|
|
1503
|
+
finalizerRef: recordingFinalizerRef,
|
|
1504
|
+
setRecordingStatus,
|
|
1505
|
+
});
|
|
1506
|
+
updateRecordingStopState({
|
|
1507
|
+
clock: recordingClockRef.current,
|
|
1508
|
+
recordingBlobRef,
|
|
1509
|
+
stop,
|
|
1510
|
+
setRecordingElapsed,
|
|
1511
|
+
setRecordingStatus,
|
|
1512
|
+
setRecordingError,
|
|
1513
|
+
});
|
|
1514
|
+
} catch (stopError) {
|
|
1515
|
+
setRecordingError(stopError?.message || 'Could not finish recording.');
|
|
1516
|
+
}
|
|
1517
|
+
}, [action, integrations.recording, recordingElapsed, streamUrl, title]);
|
|
1518
|
+
|
|
1519
|
+
const downloadCompletedRecording = useCallback(() => {
|
|
1520
|
+
try {
|
|
1521
|
+
if (!recordingDownloadLinkRef.current?.url) throw new Error('No recording is ready to download.');
|
|
1522
|
+
setRecordingError('');
|
|
1523
|
+
} catch (downloadError) {
|
|
1524
|
+
setRecordingError(downloadError?.message || 'The recording could not be downloaded.');
|
|
1525
|
+
}
|
|
1526
|
+
}, []);
|
|
1527
|
+
|
|
1528
|
+
const dismissRecording = useCallback(() => {
|
|
1529
|
+
setRecordingStatus('idle');
|
|
1530
|
+
setRecordingError('');
|
|
1531
|
+
recordingBlobRef.current = null;
|
|
1532
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1533
|
+
recordingDownloadLinkRef.current = null;
|
|
1534
|
+
setRecordingDownloadLink(null);
|
|
1535
|
+
recordingCompletionRef.current = null;
|
|
1536
|
+
}, []);
|
|
1537
|
+
|
|
1538
|
+
useEffect(() => {
|
|
1539
|
+
if (recordingStatus !== 'recording') return undefined;
|
|
1540
|
+
const timer = setInterval(() => {
|
|
1541
|
+
const clock = recordingClockRef.current;
|
|
1542
|
+
setRecordingElapsed(clock.accumulatedMs + (clock.startedAt ? Date.now() - clock.startedAt : 0));
|
|
1543
|
+
}, 250);
|
|
1544
|
+
return () => clearInterval(timer);
|
|
1545
|
+
}, [recordingStatus]);
|
|
1546
|
+
|
|
1547
|
+
useEffect(() => () => {
|
|
1548
|
+
const recorder = recorderRef.current;
|
|
1549
|
+
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
|
1550
|
+
recordingCaptureRef.current?.cleanup?.();
|
|
1551
|
+
}, [streamUrl]);
|
|
1552
|
+
useEffect(() => () => {
|
|
1553
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1554
|
+
}, []);
|
|
1555
|
+
|
|
1556
|
+
const togglePlay = useCallback(() => action('onPlayPause', () => setPaused(), { isPlaying: pausedRef.current }), [action, setPaused]);
|
|
1188
1557
|
const restart = useCallback(() => action('onRestart', () => {
|
|
1189
1558
|
const video = videoRef.current;
|
|
1190
1559
|
if (video) video.currentTime = 0;
|
|
1191
1560
|
else youtubeRef.current?.seekTo?.(0);
|
|
1192
1561
|
setPaused(false);
|
|
1193
|
-
|
|
1562
|
+
emitProgressBarTime(0, onProgressBarChange, lastProgressBarSecondRef, { force: true });
|
|
1563
|
+
}, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, onProgressBarChange, setPaused]);
|
|
1194
1564
|
const toggleMute = useCallback(() => action('onMute', () => setMuted((value) => !value), { muted: !muted }), [action, muted]);
|
|
1195
1565
|
const toggleLock = useCallback(() => action('onLock', () => setLocked((value) => !value), { locked: !locked }), [action, locked]);
|
|
1196
1566
|
const selectAspect = useCallback((next) => action('onAspectRatioChange', () => setAspectRatio(next), { aspectRatio: next }), [action]);
|
|
@@ -1204,9 +1574,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1204
1574
|
const setAudioOnlyMode = useCallback((next) => action('onAudioOnlyChange', () => setAudioOnly(next), { enabled: next }), [action]);
|
|
1205
1575
|
const setPlaybackRateAction = useCallback((next) => action('onPlaybackRateChange', () => setPlaybackRate(next), { playbackRate: next }), [action]);
|
|
1206
1576
|
const seekTo = useCallback((seconds) => action('onSeek', () => {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1577
|
+
const targetSeconds = Math.max(0, Number(seconds) || 0);
|
|
1578
|
+
if (videoRef.current) videoRef.current.currentTime = targetSeconds;
|
|
1579
|
+
else youtubeRef.current?.seekTo?.(targetSeconds);
|
|
1580
|
+
emitProgressBarTime(targetSeconds, onProgressBarChange, lastProgressBarSecondRef, { force: true });
|
|
1581
|
+
}, { seconds: Number(seconds) || 0 }), [action, onProgressBarChange]);
|
|
1210
1582
|
const handleBack = useCallback(() => action('onBack', onBack || props.onClose, { title, source: media }), [action, onBack, props.onClose, title, media]);
|
|
1211
1583
|
useImperativeHandle(ref, () => {
|
|
1212
1584
|
const api = {
|
|
@@ -1222,12 +1594,14 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1222
1594
|
setAudioTrack: (id) => {
|
|
1223
1595
|
for (const item of availableTracks) if (item.nativeTrack) item.nativeTrack.enabled = String(item.id) === String(id);
|
|
1224
1596
|
},
|
|
1225
|
-
setAudioOnly
|
|
1597
|
+
setAudioOnly,
|
|
1226
1598
|
setVideoOnly: setVideoOnlyMode,
|
|
1227
|
-
setPlaybackRate
|
|
1599
|
+
setPlaybackRate,
|
|
1228
1600
|
seekTo,
|
|
1229
1601
|
seekBy: (delta) => seekTo((Number(videoRef.current?.currentTime) || currentTime) + (Number(delta) || 0)),
|
|
1230
1602
|
back: handleBack,
|
|
1603
|
+
setPanel: (panel) => setActivePanel(panel || null),
|
|
1604
|
+
closePanel: () => setActivePanel(null),
|
|
1231
1605
|
getVideoElement: () => videoRef.current,
|
|
1232
1606
|
enterFullscreen: () => playerRef.current?.requestFullscreen?.(),
|
|
1233
1607
|
exitFullscreen: () => typeof document !== 'undefined' ? document.exitFullscreen?.() : undefined,
|
|
@@ -1235,11 +1609,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1235
1609
|
};
|
|
1236
1610
|
publicPlayerRef.current = api;
|
|
1237
1611
|
return api;
|
|
1238
|
-
}, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio,
|
|
1612
|
+
}, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnly, setVideoOnlyMode, setPlaybackRate, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
|
|
1239
1613
|
|
|
1240
1614
|
const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
|
|
1241
1615
|
const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
|
|
1242
|
-
const hasRecording =
|
|
1616
|
+
const hasRecording = controlOverrides.recording !== false
|
|
1617
|
+
&& (!!integrations.recording || typeof actions.onRecordingStart === 'function' || (!youtubeVideoId && typeof MediaRecorder !== 'undefined'));
|
|
1243
1618
|
const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
|
|
1244
1619
|
const sourceType = String(media.type || media.mimeType || '').toLowerCase();
|
|
1245
1620
|
const directVideoSource = getDirectVideoSource({
|
|
@@ -1261,6 +1636,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1261
1636
|
const updateTime = () => {
|
|
1262
1637
|
setCurrentTime(Number(video.currentTime) || 0);
|
|
1263
1638
|
if (Number.isFinite(video.duration)) setDuration(video.duration);
|
|
1639
|
+
emitProgressBarTime(Number(video.currentTime) || 0, onProgressBarChange, lastProgressBarSecondRef);
|
|
1264
1640
|
onProgress?.({ currentTime: (Number(video.currentTime) || 0) * 1000, duration: (Number(video.duration) || 0) * 1000, target: video.currentTime });
|
|
1265
1641
|
};
|
|
1266
1642
|
const onReadyEvent = () => {
|
|
@@ -1288,7 +1664,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1288
1664
|
video.removeEventListener('timeupdate', updateTime);
|
|
1289
1665
|
video.removeEventListener('ended', endedHandler);
|
|
1290
1666
|
};
|
|
1291
|
-
}, [onReady, onPlaying, onProgress, onEnded, tracksProp]);
|
|
1667
|
+
}, [onReady, onPlaying, onProgress, onProgressBarChange, onEnded, tracksProp]);
|
|
1292
1668
|
|
|
1293
1669
|
useEffect(() => {
|
|
1294
1670
|
if (selectedAudioTrack === undefined || selectedAudioTrack === null) return;
|
|
@@ -1304,18 +1680,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1304
1680
|
}, []);
|
|
1305
1681
|
|
|
1306
1682
|
const toggleFullscreen = () => action('onFullscreen', () => {
|
|
1307
|
-
if (typeof
|
|
1683
|
+
if (inlinePreview && typeof onPromotePreview === 'function') return onPromotePreview({ title, source: media });
|
|
1308
1684
|
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
1309
1685
|
else playerRef.current?.requestFullscreen?.();
|
|
1310
1686
|
}, { isFullscreen: !fullscreen });
|
|
1311
|
-
const openPanel = (panel) =>
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1687
|
+
const openPanel = (panel) => {
|
|
1688
|
+
const isOpen = activePanel !== panel;
|
|
1689
|
+
return action(
|
|
1690
|
+
getPanelActionName(panel),
|
|
1691
|
+
() => setActivePanel((current) => current === panel ? null : panel),
|
|
1692
|
+
{ tab: panel, isOpen, close: () => setActivePanel(null) },
|
|
1693
|
+
);
|
|
1694
|
+
};
|
|
1319
1695
|
const control = (name, label, callback, options = {}) => renderControlButton({
|
|
1320
1696
|
name, label, callback, options, overrides: controlOverrides, icons, theme,
|
|
1321
1697
|
});
|
|
@@ -1335,18 +1711,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1335
1711
|
toggleMute,
|
|
1336
1712
|
muted,
|
|
1337
1713
|
hasRecording,
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
streamUrl,
|
|
1343
|
-
title,
|
|
1714
|
+
recordingStatus,
|
|
1715
|
+
startRecording,
|
|
1716
|
+
resumeRecording,
|
|
1717
|
+
stopRecording,
|
|
1344
1718
|
activePanel,
|
|
1345
1719
|
hasChat,
|
|
1346
1720
|
hasEpg,
|
|
1347
1721
|
hasDiagnostics,
|
|
1348
1722
|
handleOpenPanel: openPanel,
|
|
1349
|
-
setRecording,
|
|
1350
1723
|
});
|
|
1351
1724
|
const webPanel = createWebPanelNode({
|
|
1352
1725
|
renderer: panelRenderer,
|
|
@@ -1369,8 +1742,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1369
1742
|
});
|
|
1370
1743
|
|
|
1371
1744
|
const handleYouTubeProgress = (event) => {
|
|
1372
|
-
|
|
1745
|
+
const seconds = Number(event.currentTime) / 1000 || 0;
|
|
1746
|
+
setCurrentTime(seconds);
|
|
1373
1747
|
setDuration(Number(event.duration) / 1000 || 0);
|
|
1748
|
+
emitProgressBarTime(seconds, onProgressBarChange, lastProgressBarSecondRef);
|
|
1374
1749
|
onProgress?.(event);
|
|
1375
1750
|
};
|
|
1376
1751
|
const mediaSurface = h(WebPlayerSurface, {
|
|
@@ -1413,11 +1788,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1413
1788
|
showAspectMenu,
|
|
1414
1789
|
setShowAspectMenu,
|
|
1415
1790
|
selectAspect,
|
|
1416
|
-
|
|
1417
|
-
|
|
1791
|
+
aspectRatios: aspectRatiosNormalized,
|
|
1792
|
+
selectedAspectRatio: aspectRatio,
|
|
1418
1793
|
audioOnly,
|
|
1419
1794
|
setAudioOnlyMode,
|
|
1420
1795
|
availableTracks,
|
|
1796
|
+
selectedAudioTrack,
|
|
1421
1797
|
showAudioMenu,
|
|
1422
1798
|
setShowAudioMenu,
|
|
1423
1799
|
selectAudio,
|
|
@@ -1425,6 +1801,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1425
1801
|
setPlaybackRateAction,
|
|
1426
1802
|
fullscreen,
|
|
1427
1803
|
toggleFullscreen,
|
|
1804
|
+
recordingStatus,
|
|
1805
|
+
recordingElapsed,
|
|
1806
|
+
recordingError,
|
|
1807
|
+
recordingDownloadLink,
|
|
1808
|
+
pauseRecording,
|
|
1809
|
+
resumeRecording,
|
|
1810
|
+
stopRecording,
|
|
1811
|
+
downloadRecording: downloadCompletedRecording,
|
|
1812
|
+
clearRecordingError: dismissRecording,
|
|
1428
1813
|
};
|
|
1429
1814
|
return h(WebPlayerLayout, {
|
|
1430
1815
|
playerRef,
|
|
@@ -1506,10 +1891,7 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
|
|
|
1506
1891
|
playbackRate: false, playPause: true, mute: true, fullscreen: true,
|
|
1507
1892
|
...controls,
|
|
1508
1893
|
},
|
|
1509
|
-
actions
|
|
1510
|
-
...actions,
|
|
1511
|
-
...(onFullscreen && !actions.onFullscreen ? { onFullscreen: () => onFullscreen() } : {}),
|
|
1512
|
-
},
|
|
1894
|
+
actions,
|
|
1513
1895
|
theme,
|
|
1514
1896
|
icons,
|
|
1515
1897
|
onError,
|