@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/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 { YouTubeVideoPlayer } from '../native/media/YouTubeVideoPlayer.web.js';
7
- import { getYouTubeVideoId, getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
6
+ import { getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
8
7
  import { EMOJI_GROUPS, searchEmojis } from '../data/emoji';
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
- showAspectMenu, setShowAspectMenu, selectAspect, videoOnly,
235
- setVideoOnlyMode, audioOnly, setAudioOnlyMode, availableTracks,
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 WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
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
- return h('div', { className: 'cinecrew-player__controls', style: { color: theme.controlColor } },
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
- youtubeVideoId,
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: theme.surfaceColor, color: theme.controlColor, borderColor: theme.accentColor },
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
- recording, integrations, action, videoRef, streamUrl, title, activePanel,
462
- hasChat, hasEpg, hasDiagnostics, handleOpenPanel, setRecording, backVisible,
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
- const recordingLabel = recording ? 'Stop recording' : 'Start recording';
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(youtubeVideoId, mpegTs, useHls) {
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: theme.accentColor })),
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('button', {
794
- type: 'button',
795
- className: 'cinecrew-player__emoji-toggle',
796
- onClick: () => setEmojiOpen((open) => !open),
797
- 'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
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', { className: 'cinecrew-player__panel-list' },
967
- isChat && hasMoreMessages ? h(WebLoadMoreMessages, { loading: loadingOlder, onClick: loadOlderMessages }) : null,
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 [recording, setRecording] = useState(false);
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 = getActiveMediaUrl(youtubeVideoId, streamUrl);
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(youtubeVideoId, mpegTs.useMpegTs, useHls);
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, youtubeVideoId, handleError]);
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, youtubeVideoId]);
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
- const callback = actions?.[name];
1183
- if (typeof callback === 'function') return callback(payload, { video: videoRef.current, player: publicPlayerRef.current });
1184
- return fallback?.(payload);
1185
- }, [actions]);
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: !pausedRef.current }), [action, setPaused]);
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
- }, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, setPaused]);
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
- if (videoRef.current) videoRef.current.currentTime = Math.max(0, Number(seconds) || 0);
1208
- else youtubeRef.current?.seekTo?.(Math.max(0, Number(seconds) || 0));
1209
- }, { seconds: Number(seconds) || 0 }), [action]);
1210
- const handleBack = useCallback(() => action('onBack', onBack || props.onClose, { title, source: media }), [action, onBack, props.onClose, title, media]);
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: setAudioOnlyMode,
1594
+ setAudioOnly,
1226
1595
  setVideoOnly: setVideoOnlyMode,
1227
- setPlaybackRate: setPlaybackRateAction,
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, setAudioOnlyMode, setVideoOnlyMode, setPlaybackRateAction, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
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 hasRecording = !!integrations.recording || typeof actions.onRecordingStart === 'function';
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 (typeof onFullscreen === 'function') return onFullscreen({ isFullscreen: !fullscreen });
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) => action(
1312
- getPanelActionName(panel),
1313
- () => setActivePanel((current) => {
1314
- if (current === panel) return null;
1315
- return panel;
1316
- }),
1317
- { tab: panel, isOpen: activePanel === panel, close: () => setActivePanel(null) },
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
- recording,
1339
- integrations,
1340
- action,
1341
- videoRef,
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
- youtubeVideoId,
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: handleYouTubeProgress,
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
- videoOnly,
1417
- setVideoOnlyMode,
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', props.onBack || props.onClose, { title, source: media }), { icon: 'close' }),
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
  });