@cinecrew/cinecrew-player 0.1.2 → 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;
@@ -21,10 +24,23 @@ const DEFAULT_THEME = {
21
24
  const DEFAULT_ICONS = {
22
25
  play: 'play', pause: 'pause', restart: 'restart', lock: 'lock', unlock: 'lock-open',
23
26
  mute: 'volume-mute', unmute: 'volume-high', aspectRatio: 'aspect-ratio', videoOnly: 'video',
24
- audio: 'music-note', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
27
+ audio: 'music-note', audioOnly: 'headphones', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
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.
@@ -34,11 +50,12 @@ const WEB_ICON_PATHS = {
34
50
  restart: 'M12 5V1L7 6l5 5V7a6 6 0 1 1-5.65 8H4.26A9 9 0 1 0 12 5z',
35
51
  lock: 'M18 8h-1V6a5 5 0 0 0-10 0v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zM9 6a3 3 0 0 1 6 0v2H9zm3 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z',
36
52
  'lock-open': 'M18 8h-1V6a5 5 0 0 0-9.8-1H9a3 3 0 0 1 6 .8V8H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zm-6 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4z',
37
- 'volume-mute': 'M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z',
53
+ 'volume-mute': 'M3 9v6h4l5 5V4L7 9H3zm13.59 3 2.12-2.12 1.41 1.41L19 13.41l2.12 2.12-1.41 1.41-2.12-2.12-2.12 2.12-1.41-1.41 2.12-2.12-2.12-2.12 1.41-1.41z',
38
54
  'volume-high': 'M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z',
39
55
  'aspect-ratio': 'M3 5h18v14H3zm2 2v10h14V7zm2 2h4v2H9v4H7zm10 6h-4v-2h2V9h2z',
40
56
  video: 'M18 7V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2l4 4V5zm-2 12H4V5h12z',
41
57
  'music-note': 'M12 3v12.26A4 4 0 1 0 14 19V7h6V3z',
58
+ headphones: 'M12 3a9 9 0 0 0-9 9v6a3 3 0 0 0 3 3h3v-9H5v-1a7 7 0 0 1 14 0v1h-4v9h3a3 3 0 0 0 3-3v-6a9 9 0 0 0-9-9z',
42
59
  'arrow-collapse': 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
43
60
  logs: 'M3 3h18v18H3zm2 2v14h14V5zm3 3h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7z',
44
61
  'arrow-left': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20z',
@@ -48,6 +65,13 @@ const WEB_ICON_PATHS = {
48
65
  'television-classic': 'M21 3H3a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h7v2h4v-2h7a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 15H3V5h18zM5 7h14v9H5z',
49
66
  fullscreen: 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
50
67
  close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
68
+ send: 'M2 21 23 12 2 3v7l15 2-15 2z',
69
+ network: 'M12 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM4 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm16 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM10.7 7.4 5.3 16.7l1.7 1 5.4-9.3zm2.6 0 5.4 9.3-1.7 1-5.4-9.3z',
70
+ speedometer: 'M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-2.05-4.95l1.42-1.42A8.96 8.96 0 0 0 12 3zm7.7 1.3-6.4 6.4a2.5 2.5 0 1 0 1.4 1.4l6.4-6.4z',
71
+ buffer: 'M4 5h16v3H4zm0 5.5h16v3H4zm0 5.5h16v3H4z',
72
+ clock: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm1-13h-2v6l5 3 1-1.7-4-2.3z',
73
+ display: 'M3 4h18v13H3zm2 2v9h14V6zm5 13h4v2h-4z',
74
+ frames: 'M4 4h16v12H4zm2 2v8h12V6zm-4 13h16v2H2zm4-3h16v2H6z',
51
75
  };
52
76
 
53
77
  function getSource(source, url) {
@@ -119,71 +143,94 @@ function getPanelActionName(panel) {
119
143
  function WebSeekControl({ currentTime, duration, theme, onSeek }) {
120
144
  const [seekDraft, setSeekDraft] = useState(currentTime);
121
145
  const [isDragging, setIsDragging] = useState(false);
146
+ const seekDraftRef = useRef(currentTime);
122
147
 
123
148
  useEffect(() => {
124
- if (!isDragging) setSeekDraft(currentTime);
149
+ if (!isDragging) {
150
+ seekDraftRef.current = currentTime;
151
+ setSeekDraft(currentTime);
152
+ }
125
153
  }, [currentTime, isDragging]);
126
154
 
127
- const updateSeek = (event) => {
155
+ const updateDraft = (event) => {
128
156
  const nextTime = Number(event.currentTarget.value);
157
+ seekDraftRef.current = nextTime;
129
158
  setSeekDraft(nextTime);
130
- onSeek(nextTime);
131
159
  };
132
160
 
133
161
  const finishSeek = (event) => {
134
162
  if (!isDragging) return;
135
- updateSeek(event);
163
+ const nextTime = event?.currentTarget && event.type !== 'blur'
164
+ ? Number(event.currentTarget.value)
165
+ : seekDraftRef.current;
166
+ seekDraftRef.current = nextTime;
167
+ setSeekDraft(nextTime);
136
168
  setIsDragging(false);
169
+ onSeek(nextTime);
137
170
  };
171
+ const displayTime = Math.min(isDragging ? seekDraft : currentTime, duration);
172
+ const remainingTime = Math.max(0, duration - displayTime);
138
173
 
139
174
  return h('div', { className: 'cinecrew-player__seek' },
140
- h('span', null, formatTime(currentTime)),
175
+ h('span', null, formatTime(displayTime)),
141
176
  h('input', {
142
177
  type: 'range',
143
178
  'aria-label': 'Seek video',
144
179
  min: 0,
145
180
  max: duration,
146
181
  step: 'any',
147
- value: Math.min(isDragging ? seekDraft : currentTime, duration),
148
- onChange: updateSeek,
182
+ value: displayTime,
183
+ onChange: updateDraft,
149
184
  onPointerDown: (event) => {
150
185
  event.stopPropagation();
151
186
  setIsDragging(true);
152
- setSeekDraft(Number(event.currentTarget.value));
187
+ seekDraftRef.current = Number(event.currentTarget.value);
188
+ setSeekDraft(seekDraftRef.current);
153
189
  },
154
190
  onPointerUp: finishSeek,
155
191
  onPointerCancel: finishSeek,
156
192
  onBlur: finishSeek,
193
+ onKeyDown: (event) => {
194
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) setIsDragging(true);
195
+ },
196
+ onKeyUp: (event) => {
197
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) finishSeek(event);
198
+ },
157
199
  style: { accentColor: theme.accentColor },
158
200
  }),
159
- h('span', null, formatTime(duration)));
201
+ h('span', null, `−${formatTime(remainingTime)}`));
160
202
  }
161
203
 
162
- function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
163
- const ratios = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '1:1'];
204
+ function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect, ratios, selectedRatio }) {
164
205
  let menu = null;
165
206
  if (open) {
166
207
  menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
167
208
  ratios.map((ratio) => h('button', {
168
- key: ratio,
209
+ key: ratio.value,
169
210
  type: 'button',
211
+ className: `cinecrew-player__menu-option${selectedRatio === ratio.value ? ' is-selected' : ''}`,
212
+ 'aria-pressed': selectedRatio === ratio.value,
170
213
  onClick: () => onSelect(ratio),
171
- }, ratio)));
214
+ }, ratio.label)));
172
215
  }
173
216
  return h('div', { className: 'cinecrew-player__menu-wrap cinecrew-player__menu-wrap--aspect', key: 'aspectRatio' },
174
217
  h(PlayerButton, { name: 'aspectRatio', label: 'Aspect ratio', icons, theme, onClick: onToggle, active: open }),
175
218
  menu);
176
219
  }
177
220
 
178
- function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
221
+ function WebAudioTrackMenu({ open, tracks, selectedTrackId, theme, icons, onToggle, onSelect }) {
179
222
  let menu = null;
180
223
  if (open) {
181
224
  menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
182
- tracks.map((track) => h('button', {
183
- key: track.id,
184
- type: 'button',
185
- onClick: () => onSelect(track.id),
186
- }, track.name)));
225
+ tracks.length
226
+ ? tracks.map((track, index) => h('button', {
227
+ key: track.id ?? index,
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),
231
+ onClick: () => onSelect(track.id),
232
+ }, track.name || track.language || `Track ${index + 1}`))
233
+ : h('div', { className: 'cinecrew-player__menu-empty', role: 'status' }, 'No audio tracks available'));
187
234
  }
188
235
  return h('div', { className: 'cinecrew-player__menu-wrap', key: 'audioTracks' },
189
236
  h(PlayerButton, { name: 'audio', label: 'Audio tracks', icons, theme, onClick: onToggle, active: open }),
@@ -203,8 +250,8 @@ function WebPlaybackRateControl({ value, onChange }) {
203
250
  function WebBottomControls(props) {
204
251
  const {
205
252
  isLive, overrides, theme, icons, currentTime, duration, seekTo,
206
- showAspectMenu, setShowAspectMenu, selectAspect, videoOnly,
207
- setVideoOnlyMode, audioOnly, setAudioOnlyMode, availableTracks,
253
+ showAspectMenu, setShowAspectMenu, selectAspect, aspectRatios, selectedAspectRatio,
254
+ audioOnly, setAudioOnlyMode, availableTracks, selectedAudioTrack,
208
255
  showAudioMenu, setShowAudioMenu, selectAudio, playbackRate,
209
256
  setPlaybackRateAction, fullscreen, toggleFullscreen,
210
257
  } = props;
@@ -220,16 +267,19 @@ function WebBottomControls(props) {
220
267
  icons,
221
268
  onToggle: () => setShowAspectMenu((value) => !value),
222
269
  onSelect: (ratio) => {
223
- selectAspect(ratio);
270
+ selectAspect(ratio.value);
224
271
  setShowAspectMenu(false);
225
272
  },
273
+ ratios: aspectRatios,
274
+ selectedRatio: selectedAspectRatio,
226
275
  });
227
276
  }
228
277
  let audioTracks = null;
229
- if (availableTracks.length > 1 && isControlEnabled(overrides, 'audioTracks', true)) {
278
+ if (isControlEnabled(overrides, 'audioTracks', true)) {
230
279
  audioTracks = h(WebAudioTrackMenu, {
231
280
  open: showAudioMenu,
232
281
  tracks: availableTracks,
282
+ selectedTrackId: selectedAudioTrack,
233
283
  theme,
234
284
  icons,
235
285
  onToggle: () => setShowAudioMenu((value) => !value),
@@ -243,25 +293,66 @@ function WebBottomControls(props) {
243
293
  if (isControlEnabled(overrides, 'playbackRate', true) && !isLive) {
244
294
  playbackRateControl = h(WebPlaybackRateControl, { value: playbackRate, onChange: setPlaybackRateAction });
245
295
  }
246
- const videoOnlyLabel = videoOnly ? 'Enable audio' : 'Video only';
247
- const audioOnlyLabel = audioOnly ? 'Switch to audio card' : 'Audio only';
296
+ const audioOnlyLabel = 'Audio only';
248
297
  const fullscreenLabel = fullscreen ? 'Exit full screen' : 'Full screen';
249
298
 
250
299
  return h('div', { className: 'cinecrew-player__bottom-controls' },
251
300
  seek,
252
301
  h('div', { className: 'cinecrew-player__bottom-actions' },
253
302
  h('div', { className: 'cinecrew-player__bottom-left-actions' },
254
- renderControlButton({ name: 'videoOnly', label: videoOnlyLabel, callback: () => setVideoOnlyMode(!videoOnly), options: { active: videoOnly, defaultVisible: false }, overrides, icons, theme }),
303
+ renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { active: audioOnly }, overrides, icons, theme }),
255
304
  aspect),
256
305
  h('div', { className: 'cinecrew-player__bottom-right-actions' },
257
- renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { icon: 'audio', active: audioOnly }, overrides, icons, theme }),
258
306
  audioTracks,
259
307
  playbackRateControl,
260
308
  renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
261
309
  }
262
310
 
263
- function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
264
- let leftControls = unlockedControls.left;
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 }) {
355
+ let leftControls = locked ? null : unlockedControls.left;
265
356
  let rightControls = unlockedControls.right;
266
357
  if (locked) {
267
358
  rightControls = renderControlButton({
@@ -272,7 +363,7 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
272
363
  let centerControls = null;
273
364
  let bottomControls = null;
274
365
  if (!locked) {
275
- if (!buffering) {
366
+ if (!buffering && bottomProps.recordingStatus === 'idle') {
276
367
  const playLabel = paused ? 'Play' : 'Pause';
277
368
  const playIcon = paused ? 'play' : 'pause';
278
369
  centerControls = h('div', { className: 'cinecrew-player__center-controls' },
@@ -280,11 +371,25 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
280
371
  }
281
372
  bottomControls = h(WebBottomControls, bottomProps);
282
373
  }
283
- 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 } },
284
376
  h('div', { className: 'cinecrew-player__top-controls' },
285
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,
286
379
  h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
287
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
+ }),
288
393
  bottomControls);
289
394
  }
290
395
 
@@ -330,10 +435,31 @@ function WebPlayerSurface({
330
435
  onStateChange,
331
436
  onError,
332
437
  onEnded,
333
- style: drawerResize ? { width: '64%', height: '64%', inset: '18% auto auto 0' } : undefined,
438
+ style: drawerResize ? { width: 'var(--cinecrew-media-width, 64%)', height: '100%', inset: '0 auto 0 0' } : undefined,
334
439
  });
335
440
  }
336
441
  if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
442
+ let resizedVideoStyle = {};
443
+ if (drawerResize && videoStyle.width === 'auto') {
444
+ resizedVideoStyle = {
445
+ left: 'calc(var(--cinecrew-media-width, 64%) / 2)',
446
+ top: '50%',
447
+ right: 'auto',
448
+ bottom: 'auto',
449
+ maxWidth: 'var(--cinecrew-media-width, 64%)',
450
+ transform: 'translate(-50%, -50%)',
451
+ };
452
+ } else if (drawerResize) {
453
+ resizedVideoStyle = {
454
+ width: 'var(--cinecrew-media-width, 64%)',
455
+ height: '100%',
456
+ left: 0,
457
+ top: 0,
458
+ right: 'auto',
459
+ bottom: 0,
460
+ transform: 'none',
461
+ };
462
+ }
337
463
  return h('video', {
338
464
  ref: videoRef,
339
465
  className: 'cinecrew-player__video',
@@ -345,7 +471,7 @@ function WebPlayerSurface({
345
471
  preload: 'auto',
346
472
  style: {
347
473
  ...videoStyle,
348
- ...(drawerResize ? { width: '64%', height: '64%', top: '18%', right: 'auto', bottom: 'auto' } : {}),
474
+ ...resizedVideoStyle,
349
475
  opacity: audioOnly ? 0 : 1,
350
476
  },
351
477
  onClick: inlinePreview ? onPromotePreview : undefined,
@@ -357,12 +483,15 @@ function WebPlayerSurface({
357
483
  });
358
484
  }
359
485
 
360
- function WebAudioOnlyCard({ poster, title, theme, onSwitchToVideo }) {
486
+ function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
361
487
  return h('div', {
362
488
  className: 'cinecrew-player__audio-card',
363
489
  style: { background: theme.surfaceColor, color: theme.controlColor, borderColor: theme.accentColor },
364
490
  },
365
- poster ? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' }) : null,
491
+ poster
492
+ ? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
493
+ : h('div', { className: 'cinecrew-player__audio-placeholder', role: 'img', 'aria-label': 'Audio artwork placeholder' },
494
+ h(Icon, { name: 'audioOnly', icons, color: theme.accentColor })),
366
495
  h('span', { className: 'cinecrew-player__audio-wave', style: { color: theme.accentColor }, 'aria-hidden': true }, '•••••••'),
367
496
  h('strong', null, title || 'Audio only'),
368
497
  h('button', {
@@ -404,23 +533,59 @@ function getInlinePlayerStyle(inlinePreview, rect) {
404
533
  return { position: 'absolute', left: rect.x, top: rect.y, width: rect.width, height: rect.height };
405
534
  }
406
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
+
407
580
  function buildUnlockedControls({
408
581
  control, onBack, isLive, restart, toggleLock, toggleMute, muted, hasRecording,
409
- recording, integrations, action, videoRef, streamUrl, title, activePanel,
410
- hasChat, hasEpg, hasDiagnostics, handleOpenPanel, setRecording, backVisible,
582
+ recordingStatus, startRecording, resumeRecording, stopRecording, activePanel,
583
+ hasChat, hasEpg, hasDiagnostics, handleOpenPanel, backVisible,
411
584
  }) {
412
585
  const leftControls = [control('back', 'Back', onBack, { defaultVisible: backVisible })];
413
586
  let recordingControl = null;
414
587
  if (hasRecording) {
415
- const recordingLabel = recording ? 'Stop recording' : 'Start recording';
416
- const recordingAction = recording ? 'onRecordingStop' : 'onRecordingStart';
417
- const handleRecordingToggle = async () => {
418
- const handler = recording ? integrations.recording?.stop : integrations.recording?.start;
419
- const fallback = handler ? () => handler({ getVideoElement: () => videoRef.current, streamUrl, title }) : undefined;
420
- await action(recordingAction, fallback, { source: streamUrl, title });
421
- setRecording((value) => !value);
422
- };
423
- recordingControl = control('recording', recordingLabel, handleRecordingToggle, { active: recording });
588
+ recordingControl = buildRecordingControl(control, recordingStatus, startRecording, resumeRecording, stopRecording);
424
589
  }
425
590
  const chatLabel = activePanel === 'chat' ? 'Close live chat' : 'Live chat';
426
591
  const epgLabel = activePanel === 'epg' ? 'Close programme guide' : 'Programme guide';
@@ -439,6 +604,23 @@ function buildUnlockedControls({
439
604
  return { left: leftControls, right: rightControls };
440
605
  }
441
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
+
442
624
  function getDrawerLabel(activePanel) {
443
625
  if (activePanel === 'chat') return 'Live chat drawer';
444
626
  if (activePanel === 'epg') return 'Programme guide drawer';
@@ -463,14 +645,11 @@ function WebPlayerLayout(props) {
463
645
  unlockedControls: props.unlockedControls,
464
646
  toggleLock: props.toggleLock,
465
647
  paused: props.isPaused,
648
+ title: props.title,
466
649
  togglePlay: props.togglePlay,
467
650
  bottomProps: props.bottomControlProps,
468
651
  });
469
652
  }
470
- let playerTitle = null;
471
- if (props.title && !props.audioOnly) {
472
- playerTitle = h('div', { className: 'cinecrew-player__title', style: { color: props.theme.controlColor } }, props.title);
473
- }
474
653
  let loadingNotice = null;
475
654
  if (props.buffering && !props.error) {
476
655
  loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
@@ -478,7 +657,7 @@ function WebPlayerLayout(props) {
478
657
  }
479
658
  let audioCard = null;
480
659
  if (props.audioOnly) {
481
- audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, onSwitchToVideo: props.onSwitchToVideo });
660
+ audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, icons: props.icons, onSwitchToVideo: props.onSwitchToVideo });
482
661
  }
483
662
  let panelNode = null;
484
663
  if (props.activePanel && props.webPanel) {
@@ -491,15 +670,14 @@ function WebPlayerLayout(props) {
491
670
  return h('div', {
492
671
  ref: props.playerRef,
493
672
  className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''}${props.drawerMode === 'resize' && props.activePanel ? ' cinecrew-player--drawer-resize' : ''} ${props.className}`.trim(),
494
- style: { ...props.rootStyle, ...props.style, background: props.theme.backgroundColor, borderRadius: props.theme.borderRadius, '--cinecrew-accent': props.theme.accentColor, '--cinecrew-text': props.theme.controlColor, '--cinecrew-surface': props.theme.surfaceColor },
673
+ style: { ...props.rootStyle, ...props.style, background: props.theme.backgroundColor, borderRadius: props.theme.borderRadius, '--cinecrew-accent': props.theme.accentColor, '--cinecrew-text': props.theme.controlColor, '--cinecrew-surface': props.theme.surfaceColor, '--cinecrew-media-width': '64%' },
495
674
  onWheel: props.onWheel,
496
675
  'data-stream-mode': props.streamMode,
497
676
  },
498
677
  props.mediaSurface,
499
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))' } }),
500
- playerTitle,
501
679
  loadingNotice,
502
- h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.renderBackButton }),
680
+ h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.locked ? () => null : props.renderBackButton }),
503
681
  controlLayer,
504
682
  audioCard,
505
683
  panelNode);
@@ -544,11 +722,11 @@ function getPanelRenderer(activePanel, renderLiveChat, renderEpg, integrations)
544
722
  }
545
723
 
546
724
  function createWebPanelNode({
547
- renderer, integration, activePanel, integrations, source, title, theme, onClose,
725
+ renderer, integration, activePanel, integrations, source, title, theme, icons, onClose,
548
726
  diagnostics, messagePageSize,
549
727
  }) {
550
728
  if (activePanel === 'diagnostics') {
551
- return h(WebDiagnosticsPanel, { title, theme, ...diagnostics });
729
+ return h(WebDiagnosticsPanel, { title, theme, icons, onClose, ...diagnostics });
552
730
  }
553
731
  if (renderer) return h(renderer, { title, source, onClose });
554
732
  if (!integration) return null;
@@ -568,26 +746,73 @@ function createWebPanelNode({
568
746
  });
569
747
  }
570
748
 
571
- function WebDiagnosticsPanel({ title, theme, streamMode, status, currentTime, duration, videoRef }) {
749
+ function getBufferedAhead(video, currentTime) {
750
+ if (!video?.buffered) return null;
751
+ for (let index = 0; index < video.buffered.length; index += 1) {
752
+ const start = video.buffered.start(index);
753
+ const end = video.buffered.end(index);
754
+ if (currentTime >= start && currentTime <= end) return Math.max(0, end - currentTime);
755
+ }
756
+ return 0;
757
+ }
758
+
759
+ function getFrameQuality(video) {
760
+ try {
761
+ const quality = video?.getVideoPlaybackQuality?.();
762
+ if (quality) return { dropped: quality.droppedVideoFrames, total: quality.totalVideoFrames };
763
+ } catch {
764
+ // Playback quality is optional and may not be exposed by a media engine.
765
+ }
766
+ if (Number.isFinite(video?.webkitDroppedFrameCount) || Number.isFinite(video?.webkitDecodedFrameCount)) {
767
+ return { dropped: video.webkitDroppedFrameCount, total: video.webkitDecodedFrameCount };
768
+ }
769
+ return null;
770
+ }
771
+
772
+ function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status, currentTime, duration, videoRef }) {
572
773
  const video = videoRef?.current;
573
774
  const resolution = video?.videoWidth && video?.videoHeight
574
775
  ? `${video.videoWidth} × ${video.videoHeight}`
575
776
  : 'Not available';
576
- const rows = [
577
- ['Title', title || 'Untitled'],
578
- ['Stream type', streamMode || 'Unknown'],
579
- ['Playback', status || 'Unknown'],
580
- ['Resolution', resolution],
581
- ['Position', `${formatTime(currentTime)} / ${formatTime(duration)}`],
777
+ const connection = globalThis.navigator?.connection;
778
+ const buffered = getBufferedAhead(video, currentTime);
779
+ const frames = getFrameQuality(video);
780
+ const cards = [
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' },
582
789
  ];
583
790
  return h('section', {
584
791
  className: 'cinecrew-player__diagnostics',
585
792
  style: { color: theme.controlColor },
586
793
  'aria-label': 'Stream diagnostics',
587
794
  },
588
- h('strong', null, 'Stream diagnostics'),
589
- rows.map(([label, value]) => h('div', { className: 'cinecrew-player__diagnostic-row', key: label },
590
- h('span', null, label), h('span', null, value))));
795
+ h('header', { className: 'cinecrew-player__diagnostics-heading' },
796
+ h('div', null,
797
+ h('strong', null, 'Stream diagnostics'),
798
+ h('span', null, title || 'Current playback')),
799
+ h('button', {
800
+ type: 'button',
801
+ className: 'cinecrew-player__diagnostics-close',
802
+ onClick: onClose,
803
+ 'aria-label': 'Close stream diagnostics',
804
+ title: 'Close diagnostics',
805
+ }, h(Icon, { name: 'close', icons, color: theme.controlColor }))),
806
+ h('div', { className: 'cinecrew-player__diagnostics-status' },
807
+ h('span', { className: `cinecrew-player__status-dot is-${String(status || 'unknown').toLowerCase()}` }),
808
+ h('span', null, status || 'Unknown'),
809
+ h('span', null, streamMode || 'Unknown format')),
810
+ h('div', { className: 'cinecrew-player__diagnostic-grid' },
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 })),
813
+ h('span', { className: 'cinecrew-player__diagnostic-label' }, label),
814
+ h('strong', null, value)))),
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.'));
591
816
  }
592
817
 
593
818
  function getChatMessageKey(message, index = 0) {
@@ -680,6 +905,24 @@ function WebEmojiPicker({ onSelect }) {
680
905
  function WebChatComposer({ sending, canSend, onSend }) {
681
906
  const [message, setMessage] = useState('');
682
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]);
683
926
  const handleSubmit = async (event) => {
684
927
  event.preventDefault();
685
928
  const comment = message.trim();
@@ -691,12 +934,14 @@ function WebChatComposer({ sending, canSend, onSend }) {
691
934
 
692
935
  return h('form', { className: 'cinecrew-player__chat-form', onSubmit: handleSubmit },
693
936
  h('div', { className: 'cinecrew-player__chat-composer' },
694
- h('button', {
695
- type: 'button',
696
- className: 'cinecrew-player__emoji-toggle',
697
- onClick: () => setEmojiOpen((open) => !open),
698
- 'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
699
- }, '☺'),
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),
700
945
  h('input', {
701
946
  value: message,
702
947
  onChange: (event) => setMessage(event.target.value),
@@ -704,8 +949,12 @@ function WebChatComposer({ sending, canSend, onSend }) {
704
949
  'aria-label': 'Chat message',
705
950
  maxLength: 1000,
706
951
  }),
707
- h('button', { type: 'submit', disabled: sending || !message.trim() || !canSend }, sending ? 'Sending…' : 'Send')),
708
- emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null);
952
+ h('button', {
953
+ type: 'submit',
954
+ disabled: sending || !message.trim() || !canSend,
955
+ 'aria-label': sending ? 'Sending message' : 'Send message',
956
+ title: sending ? 'Sending…' : 'Send message',
957
+ }, h(Icon, { name: 'send', color: '#07111e' }))));
709
958
  }
710
959
 
711
960
  function WebPanelRow({ row, index, isChat }) {
@@ -725,15 +974,6 @@ function WebPanelRow({ row, index, isChat }) {
725
974
  row.description ? h('span', null, row.description) : null);
726
975
  }
727
976
 
728
- function WebLoadMoreMessages({ loading, onClick }) {
729
- return h('button', {
730
- type: 'button',
731
- className: 'cinecrew-player__load-more',
732
- onClick,
733
- disabled: loading,
734
- }, loading ? 'Loading more…' : 'See more messages');
735
- }
736
-
737
977
  function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose, messagePageSize = 50 }) {
738
978
  const [rows, setRows] = useState([]);
739
979
  const [user, setUser] = useState(integrations.user || null);
@@ -744,6 +984,9 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
744
984
  const [sending, setSending] = useState(false);
745
985
  const [error, setError] = useState('');
746
986
  const rowsRef = useRef([]);
987
+ const panelListRef = useRef(null);
988
+ const olderScrollRef = useRef(null);
989
+ const shouldScrollBottomRef = useRef(false);
747
990
  const channelId = String(source.streamId || source.mediaId || source.id || '');
748
991
  const isChat = kind === 'chat';
749
992
  const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
@@ -771,6 +1014,7 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
771
1014
  const mergedRows = mergeChatMessages(rowsRef.current, nextRows);
772
1015
  updateRows(mergedRows);
773
1016
  const addedCount = Math.max(0, mergedRows.length - previousCount);
1017
+ if (addedCount > 0) shouldScrollBottomRef.current = true;
774
1018
  setMessageOffset((offset) => previousCount === 0
775
1019
  ? nextRows.length
776
1020
  : Math.max(offset, nextRows.length) + addedCount);
@@ -812,6 +1056,8 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
812
1056
 
813
1057
  const loadOlderMessages = async () => {
814
1058
  if (!isChat || !hasMoreMessages || loadingOlder) return;
1059
+ const panelList = panelListRef.current;
1060
+ if (panelList) olderScrollRef.current = { height: panelList.scrollHeight, top: panelList.scrollTop };
815
1061
  setLoadingOlder(true);
816
1062
  try {
817
1063
  setError('');
@@ -828,6 +1074,25 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
828
1074
  }
829
1075
  };
830
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
+
831
1096
  const send = async (comment) => {
832
1097
  if (!comment || sending || !integration.sendMessage) return false;
833
1098
  setSending(true);
@@ -859,8 +1124,15 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
859
1124
  loading ? h('div', { className: 'cinecrew-player__panel-state' }, 'Loading…') : null,
860
1125
  error ? h('div', { className: 'cinecrew-player__panel-state is-error', role: 'status' }, error) : null,
861
1126
  !loading && !error && rows.length === 0 ? h('div', { className: 'cinecrew-player__panel-state' }, emptyStateMessage) : null,
862
- h('div', { className: 'cinecrew-player__panel-list' },
863
- 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,
864
1136
  rows.map((row, index) => h(WebPanelRow, { key: getChatMessageKey(row, index), row, index, isChat }))),
865
1137
  isChat ? h(WebChatComposer, {
866
1138
  sending,
@@ -915,6 +1187,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
915
1187
  onFullscreen,
916
1188
  onReady,
917
1189
  onProgress,
1190
+ onProgressBarChange,
918
1191
  onPlaying,
919
1192
  onBuffering,
920
1193
  onError,
@@ -938,7 +1211,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
938
1211
  const [volume, setVolume] = useState(Math.max(0, Math.min(1, Number(volumeProp) || 0)));
939
1212
  const [videoOnly, setVideoOnly] = useState(!!videoOnlyProp);
940
1213
  const [audioOnly, setAudioOnly] = useState(!!audioOnlyProp);
941
- const [aspectRatio, setAspectRatio] = useState('FIT');
1214
+ const [aspectRatio, setAspectRatio] = useState(props.defaultAspectRatio || 'FIT');
942
1215
  const [locked, setLocked] = useState(false);
943
1216
  const [buffering, setBuffering] = useState(Boolean(streamUrl) || resolution.loading);
944
1217
  const [error, setError] = useState('');
@@ -947,11 +1220,26 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
947
1220
  const [showAspectMenu, setShowAspectMenu] = useState(false);
948
1221
  const [showAudioMenu, setShowAudioMenu] = useState(false);
949
1222
  const [activePanel, setActivePanel] = useState(null);
950
- 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);
951
1227
  const [availableTracks, setAvailableTracks] = useState([]);
952
1228
  const [fullscreen, setFullscreen] = useState(false);
953
1229
  const [playbackRate, setPlaybackRate] = useState(Number(playbackRateProp) || 1);
1230
+ const aspectRatiosNormalized = useMemo(() => normalizeAspectRatios(props.aspectRatios), [props.aspectRatios]);
954
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;
955
1243
  const pausedStateRef = pausedRef;
956
1244
  pausedRef.current = isPaused;
957
1245
  errorRef.current = onError;
@@ -978,6 +1266,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
978
1266
  setBuffering(Boolean(streamUrl) || resolution.loading);
979
1267
  setCurrentTime(0);
980
1268
  setDuration(0);
1269
+ lastProgressBarSecondRef.current = null;
981
1270
  setAvailableTracks([]);
982
1271
  if (resolution.error) {
983
1272
  handleError(resolution.error);
@@ -993,6 +1282,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
993
1282
  }, [pausedProp]);
994
1283
 
995
1284
  useEffect(() => setAudioOnly(!!audioOnlyProp), [audioOnlyProp]);
1285
+ useEffect(() => {
1286
+ setAspectRatio(props.defaultAspectRatio || 'FIT');
1287
+ }, [streamUrl, props.defaultAspectRatio]);
996
1288
 
997
1289
  useEffect(() => {
998
1290
  setMuted(!!mutedProp);
@@ -1034,7 +1326,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1034
1326
  onErrorRef,
1035
1327
  });
1036
1328
  const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
1037
- useWebAc3AudioPlayback({
1329
+ const ac3AudioPlayback = useWebAc3AudioPlayback({
1038
1330
  active: mpegTs.useAc3Fallback,
1039
1331
  streamUrl,
1040
1332
  enabled: isAudioPlaybackEnabled(muted, videoOnly),
@@ -1075,18 +1367,200 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1075
1367
  }, []);
1076
1368
 
1077
1369
  const action = useCallback((name, fallback, payload) => {
1078
- const callback = actions?.[name];
1079
- if (typeof callback === 'function') return callback(payload, { video: videoRef.current, player: publicPlayerRef.current });
1080
- return fallback?.(payload);
1081
- }, [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]);
1082
1456
 
1083
- const togglePlay = useCallback(() => action('onPlayPause', () => setPaused(), { isPlaying: !pausedRef.current }), [action, setPaused]);
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]);
1475
+
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]);
1084
1557
  const restart = useCallback(() => action('onRestart', () => {
1085
1558
  const video = videoRef.current;
1086
1559
  if (video) video.currentTime = 0;
1087
1560
  else youtubeRef.current?.seekTo?.(0);
1088
1561
  setPaused(false);
1089
- }, { 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]);
1090
1564
  const toggleMute = useCallback(() => action('onMute', () => setMuted((value) => !value), { muted: !muted }), [action, muted]);
1091
1565
  const toggleLock = useCallback(() => action('onLock', () => setLocked((value) => !value), { locked: !locked }), [action, locked]);
1092
1566
  const selectAspect = useCallback((next) => action('onAspectRatioChange', () => setAspectRatio(next), { aspectRatio: next }), [action]);
@@ -1100,9 +1574,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1100
1574
  const setAudioOnlyMode = useCallback((next) => action('onAudioOnlyChange', () => setAudioOnly(next), { enabled: next }), [action]);
1101
1575
  const setPlaybackRateAction = useCallback((next) => action('onPlaybackRateChange', () => setPlaybackRate(next), { playbackRate: next }), [action]);
1102
1576
  const seekTo = useCallback((seconds) => action('onSeek', () => {
1103
- if (videoRef.current) videoRef.current.currentTime = Math.max(0, Number(seconds) || 0);
1104
- else youtubeRef.current?.seekTo?.(Math.max(0, Number(seconds) || 0));
1105
- }, { 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]);
1106
1582
  const handleBack = useCallback(() => action('onBack', onBack || props.onClose, { title, source: media }), [action, onBack, props.onClose, title, media]);
1107
1583
  useImperativeHandle(ref, () => {
1108
1584
  const api = {
@@ -1118,12 +1594,14 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1118
1594
  setAudioTrack: (id) => {
1119
1595
  for (const item of availableTracks) if (item.nativeTrack) item.nativeTrack.enabled = String(item.id) === String(id);
1120
1596
  },
1121
- setAudioOnly: setAudioOnlyMode,
1597
+ setAudioOnly,
1122
1598
  setVideoOnly: setVideoOnlyMode,
1123
- setPlaybackRate: setPlaybackRateAction,
1599
+ setPlaybackRate,
1124
1600
  seekTo,
1125
1601
  seekBy: (delta) => seekTo((Number(videoRef.current?.currentTime) || currentTime) + (Number(delta) || 0)),
1126
1602
  back: handleBack,
1603
+ setPanel: (panel) => setActivePanel(panel || null),
1604
+ closePanel: () => setActivePanel(null),
1127
1605
  getVideoElement: () => videoRef.current,
1128
1606
  enterFullscreen: () => playerRef.current?.requestFullscreen?.(),
1129
1607
  exitFullscreen: () => typeof document !== 'undefined' ? document.exitFullscreen?.() : undefined,
@@ -1131,11 +1609,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1131
1609
  };
1132
1610
  publicPlayerRef.current = api;
1133
1611
  return api;
1134
- }, [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]);
1135
1613
 
1136
1614
  const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
1137
1615
  const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
1138
- 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'));
1139
1618
  const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
1140
1619
  const sourceType = String(media.type || media.mimeType || '').toLowerCase();
1141
1620
  const directVideoSource = getDirectVideoSource({
@@ -1157,6 +1636,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1157
1636
  const updateTime = () => {
1158
1637
  setCurrentTime(Number(video.currentTime) || 0);
1159
1638
  if (Number.isFinite(video.duration)) setDuration(video.duration);
1639
+ emitProgressBarTime(Number(video.currentTime) || 0, onProgressBarChange, lastProgressBarSecondRef);
1160
1640
  onProgress?.({ currentTime: (Number(video.currentTime) || 0) * 1000, duration: (Number(video.duration) || 0) * 1000, target: video.currentTime });
1161
1641
  };
1162
1642
  const onReadyEvent = () => {
@@ -1184,7 +1664,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1184
1664
  video.removeEventListener('timeupdate', updateTime);
1185
1665
  video.removeEventListener('ended', endedHandler);
1186
1666
  };
1187
- }, [onReady, onPlaying, onProgress, onEnded, tracksProp]);
1667
+ }, [onReady, onPlaying, onProgress, onProgressBarChange, onEnded, tracksProp]);
1188
1668
 
1189
1669
  useEffect(() => {
1190
1670
  if (selectedAudioTrack === undefined || selectedAudioTrack === null) return;
@@ -1200,18 +1680,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1200
1680
  }, []);
1201
1681
 
1202
1682
  const toggleFullscreen = () => action('onFullscreen', () => {
1203
- if (typeof onFullscreen === 'function') return onFullscreen({ isFullscreen: !fullscreen });
1683
+ if (inlinePreview && typeof onPromotePreview === 'function') return onPromotePreview({ title, source: media });
1204
1684
  if (document.fullscreenElement) document.exitFullscreen?.();
1205
1685
  else playerRef.current?.requestFullscreen?.();
1206
1686
  }, { isFullscreen: !fullscreen });
1207
- const openPanel = (panel) => action(
1208
- getPanelActionName(panel),
1209
- () => setActivePanel((current) => {
1210
- if (current === panel) return null;
1211
- return panel;
1212
- }),
1213
- { tab: panel, isOpen: activePanel === panel, close: () => setActivePanel(null) },
1214
- );
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
+ };
1215
1695
  const control = (name, label, callback, options = {}) => renderControlButton({
1216
1696
  name, label, callback, options, overrides: controlOverrides, icons, theme,
1217
1697
  });
@@ -1231,18 +1711,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1231
1711
  toggleMute,
1232
1712
  muted,
1233
1713
  hasRecording,
1234
- recording,
1235
- integrations,
1236
- action,
1237
- videoRef,
1238
- streamUrl,
1239
- title,
1714
+ recordingStatus,
1715
+ startRecording,
1716
+ resumeRecording,
1717
+ stopRecording,
1240
1718
  activePanel,
1241
1719
  hasChat,
1242
1720
  hasEpg,
1243
1721
  hasDiagnostics,
1244
1722
  handleOpenPanel: openPanel,
1245
- setRecording,
1246
1723
  });
1247
1724
  const webPanel = createWebPanelNode({
1248
1725
  renderer: panelRenderer,
@@ -1252,6 +1729,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1252
1729
  source: panelSource,
1253
1730
  title,
1254
1731
  theme,
1732
+ icons,
1255
1733
  onClose: () => setActivePanel(null),
1256
1734
  diagnostics: {
1257
1735
  streamMode,
@@ -1264,8 +1742,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1264
1742
  });
1265
1743
 
1266
1744
  const handleYouTubeProgress = (event) => {
1267
- setCurrentTime(Number(event.currentTime) / 1000 || 0);
1745
+ const seconds = Number(event.currentTime) / 1000 || 0;
1746
+ setCurrentTime(seconds);
1268
1747
  setDuration(Number(event.duration) / 1000 || 0);
1748
+ emitProgressBarTime(seconds, onProgressBarChange, lastProgressBarSecondRef);
1269
1749
  onProgress?.(event);
1270
1750
  };
1271
1751
  const mediaSurface = h(WebPlayerSurface, {
@@ -1308,11 +1788,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1308
1788
  showAspectMenu,
1309
1789
  setShowAspectMenu,
1310
1790
  selectAspect,
1311
- videoOnly,
1312
- setVideoOnlyMode,
1791
+ aspectRatios: aspectRatiosNormalized,
1792
+ selectedAspectRatio: aspectRatio,
1313
1793
  audioOnly,
1314
1794
  setAudioOnlyMode,
1315
1795
  availableTracks,
1796
+ selectedAudioTrack,
1316
1797
  showAudioMenu,
1317
1798
  setShowAudioMenu,
1318
1799
  selectAudio,
@@ -1320,6 +1801,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1320
1801
  setPlaybackRateAction,
1321
1802
  fullscreen,
1322
1803
  toggleFullscreen,
1804
+ recordingStatus,
1805
+ recordingElapsed,
1806
+ recordingError,
1807
+ recordingDownloadLink,
1808
+ pauseRecording,
1809
+ resumeRecording,
1810
+ stopRecording,
1811
+ downloadRecording: downloadCompletedRecording,
1812
+ clearRecordingError: dismissRecording,
1323
1813
  };
1324
1814
  return h(WebPlayerLayout, {
1325
1815
  playerRef,
@@ -1401,10 +1891,7 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
1401
1891
  playbackRate: false, playPause: true, mute: true, fullscreen: true,
1402
1892
  ...controls,
1403
1893
  },
1404
- actions: {
1405
- ...actions,
1406
- ...(onFullscreen && !actions.onFullscreen ? { onFullscreen: () => onFullscreen() } : {}),
1407
- },
1894
+ actions,
1408
1895
  theme,
1409
1896
  icons,
1410
1897
  onError,