@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/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, videoOnly,
235
- setVideoOnlyMode, audioOnly, setAudioOnlyMode, availableTracks,
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 WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
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
- return h('div', { className: 'cinecrew-player__controls', style: { color: theme.controlColor } },
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
- recording, integrations, action, videoRef, streamUrl, title, activePanel,
462
- hasChat, hasEpg, hasDiagnostics, handleOpenPanel, setRecording, backVisible,
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
- 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 });
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: theme.accentColor })),
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('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
- }, '☺'),
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', { className: 'cinecrew-player__panel-list' },
967
- isChat && hasMoreMessages ? h(WebLoadMoreMessages, { loading: loadingOlder, onClick: loadOlderMessages }) : null,
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 [recording, setRecording] = useState(false);
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
- const callback = actions?.[name];
1183
- if (typeof callback === 'function') return callback(payload, { video: videoRef.current, player: publicPlayerRef.current });
1184
- return fallback?.(payload);
1185
- }, [actions]);
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 togglePlay = useCallback(() => action('onPlayPause', () => setPaused(), { isPlaying: !pausedRef.current }), [action, setPaused]);
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
- }, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, setPaused]);
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
- 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]);
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: setAudioOnlyMode,
1597
+ setAudioOnly,
1226
1598
  setVideoOnly: setVideoOnlyMode,
1227
- setPlaybackRate: setPlaybackRateAction,
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, setAudioOnlyMode, setVideoOnlyMode, setPlaybackRateAction, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
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 = !!integrations.recording || typeof actions.onRecordingStart === 'function';
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 onFullscreen === 'function') return onFullscreen({ isFullscreen: !fullscreen });
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) => 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
- );
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
- recording,
1339
- integrations,
1340
- action,
1341
- videoRef,
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
- setCurrentTime(Number(event.currentTime) / 1000 || 0);
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
- videoOnly,
1417
- setVideoOnlyMode,
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,