@cinecrew/cinecrew-player 0.1.3 β†’ 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,7 @@ flowchart LR
53
53
  | Area | Included capabilities | Designed for |
54
54
  |---|---|---|
55
55
  | 🎞️ **Playback** | On-demand and live media; URLs and local URIs; HLS and MPEG-TS paths on web; native VLC path; embedded YouTube playback | Movies, episodes, trailers, and channels |
56
- | πŸŽ›οΈ **Player controls** | Play/pause, seek, restart, mute, aspect ratio, lock, video-only/audio-only modes, audio tracks, playback speed, fullscreen, back | A complete control surface without hard-wiring your app navigation |
56
+ | πŸŽ›οΈ **Player controls** | Play/pause, seek, restart, mute, aspect ratio, lock, audio-only mode, audio tracks, playback speed, fullscreen, back | A complete control surface without hard-wiring your app navigation |
57
57
  | 🎨 **Branding** | Theme colors, radius, platform styles, replaceable icons, custom panel render slots | Match your app without forking the player |
58
58
  | πŸ“‘ **Live TV extensions** | Inline preview component; optional chat and EPG panels; recording adapter hooks | Channel browsing and live-viewing workflows |
59
59
  | πŸ”Œ **App integration** | Per-action callbacks, imperative ref API, source resolver, progress/presence/events hooks, sleep timer callback | Keep account, IPTV, analytics, and storage logic in your app |
@@ -70,12 +70,12 @@ flowchart LR
70
70
  | πŸ”’ | `lock` | Lock / unlock controls | Prevent accidental touches |
71
71
  | πŸ”Š | `mute` | Mute / unmute | Volume prop also sets initial level |
72
72
  | πŸ–ΌοΈ | `aspectRatio` | Fit / fill / stretch | Available choices depend on renderer |
73
- | πŸ”‡ | `videoOnly` | Video-only mode | Keeps video presentation while muting audio |
73
+ | πŸ”‡ | `videoOnly` | Legacy video-only visibility key | Kept for compatibility; no separate video-only button is rendered |
74
74
  | 🎧 | `audioOnly` | Audio-only presentation | Playback continues behind the audio card |
75
75
  | 🎚️ | `audioTracks` | Audio-track selection | Depends on exposed tracks / platform engine |
76
76
  | ⏩ | `playbackRate` | Playback speed | On-demand experience |
77
77
  | β›Ά | `fullscreen` | Fullscreen / promote preview | Native full-player presentation is platform-specific |
78
- | ⏺️ | `recording` | Recording controls | Requires an app recording adapter or callback |
78
+ | ⏺️ | `recording` | Recording controls | Web uses built-in MediaRecorder when supported; native uses an app recording adapter |
79
79
  | πŸ’¬ | `liveChat` | Live chat panel | Requires an adapter, render slot, or callback |
80
80
  | πŸ“… | `epg` | Electronic program guide | Requires an adapter, render slot, or callback |
81
81
  | ⏱️ | `seek` | Seek bar | Meaningful for seekable media |
@@ -89,7 +89,7 @@ flowchart LR
89
89
  |---|---|
90
90
  | 🎨 Colors and shape | `theme`: accent, background, control, surface, error colors, border radius, and native palette |
91
91
  | πŸͺ„ Icons | `icons`: provide a glyph/string, React node, or icon component; omitted icons keep CineCrew defaults |
92
- | 🧠 Per-control behavior | `actions`: override only the actions your app wants to own; built-in behavior remains the default otherwise |
92
+ | 🧠 Per-control behavior | `actions`: observe completed player actions in your app without replacing built-in behavior |
93
93
  | 🧱 Chat and EPG drawers | `drawerMode`, `drawerStyle`, and optional app-owned `renderLiveChat` / `renderEpg` views |
94
94
  | πŸ”— Source handling | `resolveSource` for share pages or host-specific resolution; direct media sources pass through unchanged |
95
95
  | πŸ“ Layout | `style`, web `className`, inline preview geometry, and `InlineLivePlayer` height |
@@ -343,13 +343,15 @@ In short: CineCrew’s intended distinction is **one app-facing player package f
343
343
  | `muted` | `boolean` | `false` | Initial mute state. |
344
344
  | `volume` | `number` | `1` | Initial volume from `0` to `1`. |
345
345
  | `playbackRate` | `number` | `1` | Initial playback speed; the on-demand speed control can change it afterward. |
346
+ | `aspectRatios` | `(string \| { value, label? })[]` | built-in choices | Customize the aspect-ratio menu. Values include `FIT`, `FILL`, `STRETCH`, or a ratio such as `1:1`; labels are optional. |
347
+ | `defaultAspectRatio` | `string` | `'FIT'` | Initial and source-reset aspect mode. Must match an item in `aspectRatios` to appear selected. |
346
348
  | `controls` | `PlayerControls` | defaults below | Show/hide individual control buttons. |
347
349
  | `features` | feature flags | `{}` | Optional player features, including web stream diagnostics with `{ diagnostics: true }`. |
348
- | `actions` | `PlayerActions` | `{}` | Replace the built-in behavior for individual actions. If a callback is provided, that callback owns the action. |
350
+ | `actions` | `PlayerActions` | `{}` | Observe built-in actions. The player performs its core action first, then invokes the callback with the resulting action payload. |
349
351
  | `integrations` | `PlayerIntegrations` | `{}` | Inject user identity, chat, EPG, recording, analytics, and presence services. |
350
352
  | `drawerMode` | `'overlay' \| 'resize'` | `'overlay'` | Web/Electron drawer behavior: overlay the video or resize it to make room for chat, EPG, and diagnostics. |
351
353
  | `drawerStyle` | `React.CSSProperties` / React Native `ViewStyle` | β€” | Platform-specific style overrides for the chat, EPG, and diagnostics drawer. |
352
- | `messagePageSize` | `number` | `50` | Number of live-chat messages fetched per page; older pages load from the drawer’s β€œSee more” control. |
354
+ | `messagePageSize` | `number` | `50` | Number of live-chat messages fetched per page; older messages load automatically when the list is scrolled to the top. |
353
355
  | `theme` | `PlayerTheme` | built-in theme | Customize player colors, borders, and shape. |
354
356
  | `icons` | `PlayerIcons` | built-in icons | Override any control icon by key. |
355
357
  | `style` | platform style | β€” | Outer player style. On web this is a CSS style object; native uses React Native style props. |
@@ -365,13 +367,15 @@ In short: CineCrew’s intended distinction is **one app-facing player package f
365
367
  | `playlist` | `object[]` | β€” | Episode list used for automatic next-episode behavior. |
366
368
  | `shuffle` | `boolean` | `false` | Select a random next episode when the current episode ends. |
367
369
  | `onClose`, `onBack` | callbacks | β€” | Player lifecycle/navigation callbacks. |
370
+ | `onAspectRatioChange` | `PlayerAction` | β€” | Top-level callback invoked after the player applies the selected aspect ratio; `actions.onAspectRatioChange` takes precedence if both are supplied. |
371
+ | `onProgressBarChange` | `(time: string) => void` | β€” | Reports the played position as zero-padded `HH:MM:SS` once per elapsed playback second, and immediately after a completed seek or restart. Scrubbing reports the committed position, not every intermediate drag update. |
368
372
  | `onReady`, `onProgress`, `onPlaying`, `onBuffering`, `onError`, `onEnded`, `onPlaybackRoute` | callbacks | β€” | Playback lifecycle callbacks. Progress payloads are platform-specific native/browser events. |
369
373
  | `onNextEpisode`, `onCwRefresh` | callbacks | β€” | Episode advancement and post-close refresh hooks. |
370
374
  | `renderLiveChat`, `renderEpg` | render functions | β€” | Web custom-panel render slots. On native, use the chat/EPG integration adapters. |
371
375
  | `initialShowLiveChat`, `liveChatNonce` | `boolean`, `number` | `false`, `0` | Open or re-open the live-chat panel (when available). |
372
376
  | `inlinePreview`, `inlinePreviewRect`, `onInlinePreviewWheel`, `onPromotePreview`, `onPlayerHostRef` | preview options and callbacks | β€” | Embed/manage the player as a movable inline preview. Mainly useful for app-level player shells. |
373
377
 
374
- `features={{ diagnostics: true }}` enables the diagnostics button and built-in stream status panel on web; `controls.diagnostics` can hide it and `actions.onDiagnosticsOpen` can replace the panel action. `onFullscreen` receives `{ isFullscreen }`. All lifecycle callbacks in the table are optional; native event objects differ from browser events.
378
+ `features={{ diagnostics: true }}` enables the diagnostics button and built-in stream status panel on web; `controls.diagnostics` can hide it. `onFullscreen` receives `{ isFullscreen }` after the player requests the fullscreen change. All lifecycle callbacks in the table are optional; native event objects differ from browser events.
375
379
 
376
380
  ### Player source
377
381
 
@@ -406,7 +410,7 @@ type PlayerSource = string | {
406
410
  | `onActivate` | `() => void` | β€” | Called when an inactive preview poster is selected. |
407
411
  | `onFullscreen` | `() => void` | β€” | Called to promote/open the full player. |
408
412
  | `controls` | `Pick<PlayerControls, 'playPause' \| 'mute' \| 'fullscreen'>` | all shown | Toggle its compact controls. |
409
- | `actions` | matching `PlayerActions` subset | built-in | Replace play/pause, mute, or promote behavior. |
413
+ | `actions` | matching `PlayerActions` subset | built-in | Observe play/pause, mute, or fullscreen actions after their built-in behavior runs. |
410
414
  | `initialMuted` | `boolean` | `true` | Initial preview mute state. |
411
415
  | `theme`, `icons`, `style` | `PlayerTheme`, `PlayerIcons`, platform style | defaults | Customize preview colors, controls, and layout. |
412
416
  | `onError`, `onPlaying` | callbacks | β€” | Playback lifecycle callbacks. |
@@ -447,20 +451,22 @@ Every control can be hidden with `false`. Defaults are designed to be useful out
447
451
  | `lock` | Lock/unlock touch controls. |
448
452
  | `mute` | Mute/unmute. |
449
453
  | `aspectRatio` | Fit/fill/stretch and available aspect choices. |
450
- | `videoOnly` | Mute audio while keeping video visible. |
454
+ | `videoOnly` | Legacy compatibility setting; the player does not render a separate video-only button. |
451
455
  | `audioOnly` | Show the audio-only card while playback continues. |
452
456
  | `audioTracks` | Audio-track picker when tracks are exposed. |
453
457
  | `playbackRate` | On-demand playback speed. |
454
458
  | `fullscreen` | Fullscreen button on web and inline previews. Native player opens full-screen. |
455
- | `recording` | Recording controls; requires `integrations.recording` or an action override. |
456
- | `liveChat` | Chat drawer/panel; requires a chat adapter, render slot, or action override. |
457
- | `epg` | EPG drawer/panel; requires an EPG adapter, render slot, or action override. |
458
- | `diagnostics` | Stream diagnostics button; enable with `features={{ diagnostics: true }}` or provide `actions.onDiagnosticsOpen`. |
459
+ | `recording` | Recording controls; web has a built-in MediaRecorder flow where supported, while native requires an app recording adapter. Browser security requires local media or a source that permits capture (typically same-origin or CORS-enabled); the player reports when a source cannot be captured. |
460
+ | `liveChat` | Chat drawer/panel; requires a chat adapter or render slot. |
461
+ | `epg` | EPG drawer/panel; requires an EPG adapter or render slot. |
462
+ | `diagnostics` | Stream diagnostics button; enable with `features={{ diagnostics: true }}`. |
459
463
  | `seek` | On-demand seek bar. |
460
464
 
461
465
  ## Actions and callbacks
462
466
 
463
- Callbacks passed to `actions` **replace** built-in behavior; this is useful when an app wants to own navigation, playback state, or a control. Each receives an action payload and a context with the imperative `player` API (and the web video element where available).
467
+ Callbacks passed to `actions` are notifications, not replacements: the player executes the built-in control behavior first, then calls the matching callback. This keeps core playback working while allowing your app to show a snackbar, update analytics, or synchronize app state. Each callback receives an action payload and a context with the imperative `player` API (and the web video element where available). For app navigation, use `onBack` / `onClose` as appropriate. If a notification callback throws, the player logs that callback error without undoing or blocking the already-completed player action. The callback should not repeat the player commandβ€”the control has already performed it.
468
+
469
+ `onProgressBarChange` is separate from the platform-specific `onProgress` event: it emits a compact time string such as `00:00:05` once per playback second. A seek emits its final target time after the player applies the seek; skipped positions are not reported as watched time.
464
470
 
465
471
  ```tsx
466
472
  const playerRef = React.useRef(null);
@@ -469,17 +475,18 @@ const playerRef = React.useRef(null);
469
475
  ref={playerRef}
470
476
  source={source}
471
477
  actions={{
472
- onRestart: (_payload, { player }) => player?.restart(),
473
- onBack: () => navigation.goBack(),
474
- onMute: ({ muted }, { player }) => player?.setMuted(muted),
475
- onAspectRatioChange: ({ aspectRatio }, { player }) => player?.setAspectRatio(aspectRatio),
478
+ onRestart: () => analytics.track('player_restart'),
479
+ onPlayPause: ({ isPlaying }) => analytics.track('player_play_pause', { isPlaying }),
480
+ onMute: ({ muted }) => analytics.track('player_mute', { muted }),
481
+ onAspectRatioChange: ({ aspectRatio }) => analytics.track('player_aspect_ratio', { aspectRatio }),
476
482
  }}
483
+ onBack={() => navigation.goBack()}
477
484
  />
478
485
  ```
479
486
 
480
487
  Available action keys: `onBack`, `onPlayPause`, `onSeek`, `onRestart`, `onLock`, `onMute`, `onAspectRatioChange`, `onVideoOnlyChange`, `onAudioOnlyChange`, `onAudioTrackChange`, `onPlaybackRateChange`, `onFullscreen`, `onRecordingStart`, `onRecordingPause`, `onRecordingResume`, `onRecordingStop`, `onLiveChatOpen`, `onEpgOpen`, and `onDiagnosticsOpen`.
481
488
 
482
- The ref exposes `play`, `pause`, `togglePlayPause`, `restart`, `setMuted`, `toggleMute`, `setAspectRatio`, `setAudioTrack`, `setAudioOnly`, `setVideoOnly`, `setPlaybackRate`, `seekTo`, `seekBy`, `back`, `getVideoElement`, `getAudioTracks`, and fullscreen methods where supported.
489
+ The ref exposes `play`, `pause`, `togglePlayPause`, `restart`, `setMuted`, `toggleMute`, `setAspectRatio`, `setAudioTrack`, `setAudioOnly`, `setVideoOnly`, `setPlaybackRate`, `seekTo`, `seekBy`, `back`, `setPanel`, `closePanel`, `getVideoElement`, `getAudioTracks`, and fullscreen methods where supported.
483
490
 
484
491
  ## Integrations
485
492
 
@@ -517,10 +524,14 @@ Integrations are optional. The package has no CineCrew account, database, or wor
517
524
  />
518
525
  ```
519
526
 
520
- On web and Electron, the player supplies the chat drawer UIβ€”including the composer and emoji pickerβ€”when `integrations.liveChat.loadMessages` is provided. `sendMessage` connects the built-in composer to your chat service; omit it to show a read-only chat. Messages may include `id`, `username`, `comment` (or `message`), and `timestamp` (or `createdAt`). The drawer requests the newest page with `offset: 0`, then requests older pages with the same `limit` and an increasing `offset` when β€œSee more messages” is selected. Return each page in chronological order (oldest first); return `{ messages, hasMore }` when your service can report whether older pages exist. Otherwise, a full page implies there may be more. Live chat polls for new messages at `pollIntervalMs` (defaults to five seconds).
527
+ On web and Electron, the player supplies the chat drawer UIβ€”including the composer and searchable, grouped emoji pickerβ€”when `integrations.liveChat.loadMessages` is provided. `sendMessage` connects the built-in composer to your chat service; omit it to show a read-only chat. Messages may include `id`, `username`, `comment` (or `message`), and `timestamp` (or `createdAt`). The drawer requests the newest page with `offset: 0`, then automatically requests older pages with the same `limit` and increasing `offset` as the viewer scrolls to the top. Return each page in chronological order (oldest first); return `{ messages, hasMore }` when your service can report whether older pages exist. Otherwise, a full page implies there may be more. Live chat polls for new messages at `pollIntervalMs` (defaults to five seconds); new messages scroll into view at the bottom.
521
528
 
522
529
  The EPG drawer uses `integrations.epg.loadListings`, which returns entries with `startMs` and `endMs` epoch-millisecond timestamps. Both drawers default to a semi-transparent right-side overlay, so video size does not change. Set `drawerMode="resize"` to reserve space and shrink the video; customize the drawer with `drawerStyle`. You may supply `renderLiveChat` / `renderEpg` or integration render callbacks to replace the built-in drawer contents. The native player renders its platform-native chat and EPG UI from the same adapters.
523
530
 
531
+ ### Web recording
532
+
533
+ On supported browsers, the built-in recording control captures the media video and audio tracks, shows a compact timer at the top of the video with pause/resume and stop actions, then attempts a WebM download when stopped. A `Download recording` action remains available afterward as a user-gesture retry if the browser blocks the automatic download. YouTube embeds are not recordable through this built-in path. Aspect changes are reflected in the recording when the browser permits the player to draw the cross-origin video into a canvas; if the source does not grant canvas CORS access, the browser only permits capture of the media element's original frame, so the recording keeps its source aspect ratio. Use `integrations.recording` to supply a different recording implementation or use a native adapter.
534
+
524
535
  ## Themes and icons
525
536
 
526
537
  Defaults are used unless the caller supplies an override. Web theme properties include `accentColor`, `backgroundColor`, `controlBackground`, `controlColor`, `surfaceColor`, `errorColor`, and `borderRadius`. Native also accepts a `colors` palette object.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cinecrew/cinecrew-player",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Customizable cross-platform live and on-demand video player for React and React Native.",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@ import { WebVideoPlayer } from './media/WebVideoPlayer';
13
13
  import { ElectronVideoPlayer } from './media/ElectronVideoPlayer';
14
14
  import { isAndroid, isElectron, isIOS, isWeb } from '../utils/runtimePlatform';
15
15
  import { USER_AGENT } from './media/player/playerConstants';
16
+ import { invokePlayerAction } from '../utils/invokePlayerAction.js';
16
17
 
17
18
  function getArtwork(channel) {
18
19
  return channel?.logoUrl || channel?.logo || channel?.stream_icon || channel?.posterUrl || channel?.image || '';
@@ -82,8 +83,7 @@ function InlineControlButton({
82
83
  accessibilityLabel: label,
83
84
  onPress: () => {
84
85
  const callback = actions?.[actionName];
85
- if (typeof callback === 'function') callback(payload, { player: null });
86
- else fallback?.();
86
+ invokePlayerAction(fallback, callback, payload, { player: null });
87
87
  },
88
88
  style: [styles.button, { backgroundColor: palette.controlBackground }, active && { borderColor: palette.accentColor, borderWidth: 1 }],
89
89
  }, React.createElement(PlayerIcon, { name: icon, size: 19, color: palette.controlColor }));
@@ -213,15 +213,16 @@ function InlineLivePlayerView({
213
213
  }, [onPlaying]);
214
214
 
215
215
  const performAction = (name, fallback, payload) => {
216
- if (typeof actions?.[name] === 'function') return actions[name](payload);
217
- return fallback?.();
216
+ return invokePlayerAction(fallback, actions?.[name], payload, { player: null });
218
217
  };
219
218
 
220
219
  const togglePlay = (event) => {
221
220
  event?.stopPropagation?.();
222
221
  if (!shouldRenderVideo) {
223
- onActivate?.();
224
- setInternallyPaused(false);
222
+ performAction('onPlayPause', () => {
223
+ onActivate?.();
224
+ setInternallyPaused(false);
225
+ }, true);
225
226
  return;
226
227
  }
227
228
  performAction('onPlayPause', () => setInternallyPaused((value) => !value), !pausedNow);
@@ -23,6 +23,8 @@ import { PlayerIcon } from './customization';
23
23
  import { isWeb, isAndroid, isElectron } from '../utils/runtimePlatform';
24
24
  import { getFontSize, getFontWeight } from '../utils/layoutUtils';
25
25
  import { isLocalMediaUri } from '../utils/mediaUtils';
26
+ import { invokePlayerAction } from '../utils/invokePlayerAction.js';
27
+ import { emitProgressBarTime } from '../utils/progressBarTime.js';
26
28
  import {
27
29
  USER_AGENT,
28
30
  calculateScreenAspectRatio,
@@ -69,6 +71,17 @@ function pickShuffleCandidate(candidates, season, episode) {
69
71
  return candidates[(hash >>> 0) % candidates.length];
70
72
  }
71
73
 
74
+ function normalizeAspectOptions(values) {
75
+ if (!Array.isArray(values) || values.length === 0) return ASPECT_OPTIONS;
76
+ const unique = new Map();
77
+ values.forEach((item) => {
78
+ const value = typeof item === 'string' ? item : item?.value;
79
+ if (!value || typeof value !== 'string') return;
80
+ unique.set(value, { value, label: typeof item === 'string' ? item : item.label || value });
81
+ });
82
+ return unique.size ? [...unique.values()] : ASPECT_OPTIONS;
83
+ }
84
+
72
85
  // Gesture events can arrive much faster than the native player can consume
73
86
  // volume updates. Coalesce them to display-rate updates for the UI and a
74
87
  // slower native volume cadence so a drag stays smooth without audio crackle.
@@ -590,7 +603,7 @@ function FullscreenControlsPanel(props) {
590
603
  duration={props.duration}
591
604
  isAudioOnlyFeatureEnabled={props.isAudioOnlyFeatureEnabled}
592
605
  isAudioOnly={props.isAudioOnly}
593
- isVideoOnly={props.videoOnlyMode}
606
+ aspectRatios={props.aspectRatios}
594
607
  controls={props.controls}
595
608
  showAspectPicker={props.showAspectPicker}
596
609
  aspectRatio={props.aspectRatio}
@@ -611,7 +624,6 @@ function FullscreenControlsPanel(props) {
611
624
  onToggleAudioPicker={props.onToggleAudioPicker}
612
625
  onSelectAudioTrack={props.handleAudioTrackAction}
613
626
  onToggleFullscreen={props.handleFullscreenAction}
614
- onToggleVideoOnly={props.handleVideoOnlyAction}
615
627
  />
616
628
  </>
617
629
  );
@@ -790,6 +802,9 @@ export const MediaPlayerView = ({
790
802
  liveChatNonce = 0,
791
803
  messagePageSize = 50,
792
804
  drawerStyle,
805
+ aspectRatios,
806
+ defaultAspectRatio = DEFAULT_ASPECT_RATIO,
807
+ onAspectRatioChange,
793
808
  genre,
794
809
  categoryName,
795
810
  controls = {},
@@ -805,6 +820,7 @@ export const MediaPlayerView = ({
805
820
  style,
806
821
  onReady,
807
822
  onProgress,
823
+ onProgressBarChange,
808
824
  onPlaying,
809
825
  onBuffering,
810
826
  onError,
@@ -830,12 +846,16 @@ export const MediaPlayerView = ({
830
846
  const vlcRef = useRef(null);
831
847
  const playerRef = useRef(null);
832
848
  const invokeAction = useCallback((name, fallback, payload) => {
833
- const callback = actions?.[name];
834
- if (typeof callback === 'function') {
835
- return callback(payload, { player: playerApiRef?.current || null });
836
- }
837
- return fallback?.();
838
- }, [actions, playerApiRef]);
849
+ const callback = name === 'onAspectRatioChange'
850
+ ? actions?.[name] || onAspectRatioChange
851
+ : actions?.[name];
852
+ return invokePlayerAction(
853
+ fallback,
854
+ callback,
855
+ payload,
856
+ { player: playerApiRef?.current || null },
857
+ );
858
+ }, [actions, onAspectRatioChange, playerApiRef]);
839
859
  const handlePlayerHostRef = useCallback((node) => {
840
860
  playerRef.current = node;
841
861
  onPlayerHostRef?.(node);
@@ -981,8 +1001,9 @@ export const MediaPlayerView = ({
981
1001
  useEffect(() => {
982
1002
  isLockedRef.current = isLocked;
983
1003
  }, [isLocked]);
984
- const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT_RATIO);
985
- const aspectRatioRef = useRef(DEFAULT_ASPECT_RATIO);
1004
+ const [aspectRatio, setAspectRatio] = useState(defaultAspectRatio || DEFAULT_ASPECT_RATIO);
1005
+ const aspectRatioRef = useRef(defaultAspectRatio || DEFAULT_ASPECT_RATIO);
1006
+ const aspectOptions = useMemo(() => normalizeAspectOptions(aspectRatios), [aspectRatios]);
986
1007
  const [isFullscreen, setIsFullscreen] = useState(false);
987
1008
 
988
1009
  const [audioTracks, setAudioTracks] = useState([]);
@@ -1112,7 +1133,7 @@ export const MediaPlayerView = ({
1112
1133
  let targetRatio = val;
1113
1134
  if (val === 'FIT') {
1114
1135
  targetRatio = 'FIT';
1115
- } else if (val === 'FILL_SCREEN') {
1136
+ } else if (val === 'FILL_SCREEN' || val === 'FILL') {
1116
1137
  targetRatio = calculateScreenAspectRatio(windowWidth, windowHeight);
1117
1138
  }
1118
1139
 
@@ -1148,6 +1169,7 @@ export const MediaPlayerView = ({
1148
1169
  const isSeeking = useRef(false);
1149
1170
  const bufferingTimerRef = useRef(null);
1150
1171
  const seekCompletedAt = useRef(0);
1172
+ const lastProgressBarSecondRef = useRef(null);
1151
1173
  const lastKnownTimeRef = useRef(0);
1152
1174
  const lastKnownDurRef = useRef(Number(durationSecs) || 0);
1153
1175
  const hasResumedRef = useRef(false);
@@ -1336,6 +1358,7 @@ export const MediaPlayerView = ({
1336
1358
  setIsPlaying(!initialPaused);
1337
1359
  isPlayingRef.current = !initialPaused;
1338
1360
  setVideoOnlyMode(!!videoOnly);
1361
+ lastProgressBarSecondRef.current = null;
1339
1362
  setPlaybackUrl(streamUrl || '');
1340
1363
  setIsLocked(false);
1341
1364
  setIsAudioOnly(!!initialAudioOnly);
@@ -1374,14 +1397,14 @@ export const MediaPlayerView = ({
1374
1397
  // Reset aspect ratio & zoom state every time player opens or streamUrl changes
1375
1398
  useEffect(() => {
1376
1399
  if (visible) {
1377
- setAspectRatio(DEFAULT_ASPECT_RATIO);
1378
- aspectRatioRef.current = DEFAULT_ASPECT_RATIO;
1400
+ setAspectRatio(defaultAspectRatio || DEFAULT_ASPECT_RATIO);
1401
+ aspectRatioRef.current = defaultAspectRatio || DEFAULT_ASPECT_RATIO;
1379
1402
  setZoomScale(1);
1380
1403
  zoomScaleRef.current = 1;
1381
1404
  setZoomBadgeText('');
1382
1405
  setSeekRipple(null);
1383
1406
  }
1384
- }, [visible, streamUrl]);
1407
+ }, [visible, streamUrl, defaultAspectRatio]);
1385
1408
 
1386
1409
  // Auto-hide controls 4 seconds after inactivity
1387
1410
  const scheduleHide = useCallback(() => {
@@ -1575,8 +1598,9 @@ export const MediaPlayerView = ({
1575
1598
  setSliderPos(0);
1576
1599
  lastKnownTimeRef.current = 0;
1577
1600
  seekCompletedAt.current = Date.now();
1601
+ emitProgressBarTime(0, onProgressBarChange, lastProgressBarSecondRef, { force: true });
1578
1602
  if (!showControls) setShowControls(true);
1579
- }, [showControls]);
1603
+ }, [onProgressBarChange, showControls]);
1580
1604
 
1581
1605
  // Episode finished: auto-advance to the next episode (sequential) or to a
1582
1606
  // random episode (shuffle mode). Last episode in the playlist stops playback.
@@ -1775,6 +1799,7 @@ export const MediaPlayerView = ({
1775
1799
  lastKnownTimeRef,
1776
1800
  setSliderPosition: setSliderPos,
1777
1801
  });
1802
+ if (progress) emitProgressBarTime(progress.seconds, onProgressBarChange, lastProgressBarSecondRef);
1778
1803
  };
1779
1804
 
1780
1805
  const handleNativeOpen = (event) => {
@@ -1882,10 +1907,14 @@ export const MediaPlayerView = ({
1882
1907
  setSliderPos(target);
1883
1908
  seekCompletedAt.current = Date.now();
1884
1909
  debouncedSaveProgress();
1910
+ emitProgressBarTime(target, onProgressBarChange, lastProgressBarSecondRef, { force: true });
1885
1911
  }, { seconds: Number(seconds) || 0, currentTime: lastKnownTimeRef.current },
1886
- ), [invokeAction, handleSeekTo, debouncedSaveProgress]);
1912
+ ), [invokeAction, handleSeekTo, debouncedSaveProgress, onProgressBarChange]);
1887
1913
  const handleSeekByAction = (deltaSeconds) => invokeAction(
1888
- 'onSeek', () => handleSeekBy(deltaSeconds), { deltaSeconds: Number(deltaSeconds) || 0, currentTime: lastKnownTimeRef.current },
1914
+ 'onSeek', () => {
1915
+ handleSeekBy(deltaSeconds);
1916
+ emitProgressBarTime(lastKnownTimeRef.current, onProgressBarChange, lastProgressBarSecondRef, { force: true });
1917
+ }, { deltaSeconds: Number(deltaSeconds) || 0, currentTime: lastKnownTimeRef.current },
1889
1918
  );
1890
1919
  const handleMuteAction = useCallback(() => invokeAction(
1891
1920
  'onMute', toggleMute, { muted: !mutedRef.current },
@@ -1912,14 +1941,17 @@ export const MediaPlayerView = ({
1912
1941
  event?.stopPropagation?.();
1913
1942
  return invokeAction(name, fallback, { title, streamUrl, mediaId });
1914
1943
  }, [invokeAction, title, streamUrl, mediaId]);
1915
- const handlePanelAction = useCallback((tab) => invokeAction(
1916
- getPanelActionName(tab),
1917
- () => {
1918
- if (showLiveChat && drawerTab === tab) setShowLiveChat(false);
1919
- else { setDrawerTab(tab); setShowLiveChat(true); }
1920
- },
1921
- { tab, isOpen: showLiveChat && drawerTab === tab },
1922
- ), [invokeAction, showLiveChat, drawerTab]);
1944
+ const handlePanelAction = useCallback((tab) => {
1945
+ const isOpen = !(showLiveChat && drawerTab === tab);
1946
+ return invokeAction(
1947
+ getPanelActionName(tab),
1948
+ () => {
1949
+ if (isOpen) { setDrawerTab(tab); setShowLiveChat(true); }
1950
+ else setShowLiveChat(false);
1951
+ },
1952
+ { tab, isOpen },
1953
+ );
1954
+ }, [invokeAction, showLiveChat, drawerTab]);
1923
1955
 
1924
1956
  useEffect(() => {
1925
1957
  if (!playerApiRef) return undefined;
@@ -2591,6 +2623,7 @@ export const MediaPlayerView = ({
2591
2623
  videoOnlyMode,
2592
2624
  showAspectPicker,
2593
2625
  aspectRatio,
2626
+ aspectRatios: aspectOptions,
2594
2627
  showSpeedPicker,
2595
2628
  playbackRate,
2596
2629
  showAudioPicker,
@@ -124,7 +124,6 @@ function LiveChatPanel({
124
124
  messages,
125
125
  flatListRef,
126
126
  renderMessageItem,
127
- hasMoreMessages,
128
127
  loadOlderMessages,
129
128
  loadingOlderMessages,
130
129
  chatError,
@@ -161,19 +160,16 @@ function LiveChatPanel({
161
160
  contentContainerStyle={styles.messagesList}
162
161
  showsVerticalScrollIndicator
163
162
  keyboardShouldPersistTaps="handled"
164
- ListHeaderComponent={hasMoreMessages ? (
165
- <TouchableOpacity
166
- style={styles.loadMoreMessages}
167
- onPress={loadOlderMessages}
168
- disabled={loadingOlderMessages}
169
- accessibilityRole="button"
170
- >
171
- {loadingOlderMessages ? <ActivityIndicator size="small" color="#00E5FF" /> : null}
172
- <Text style={styles.loadMoreMessagesText}>
173
- {loadingOlderMessages ? 'Loading older messages…' : 'See more messages'}
174
- </Text>
175
- </TouchableOpacity>
163
+ ListHeaderComponent={loadingOlderMessages ? (
164
+ <View style={styles.loadingOlderMessages}>
165
+ <ActivityIndicator size="small" color="#00E5FF" />
166
+ <Text style={styles.loadingOlderMessagesText}>Loading older messages…</Text>
167
+ </View>
176
168
  ) : null}
169
+ onScroll={(event) => {
170
+ if (event.nativeEvent.contentOffset.y <= 24) loadOlderMessages();
171
+ }}
172
+ scrollEventThrottle={16}
177
173
  ListEmptyComponent={<Text style={styles.epgStatusText}>No messages yet. Start the conversation.</Text>}
178
174
  />
179
175
  )}
@@ -670,7 +666,6 @@ export const LiveChatDrawer = ({
670
666
  messages={messages}
671
667
  flatListRef={flatListRef}
672
668
  renderMessageItem={renderMessageItem}
673
- hasMoreMessages={hasMoreMessages}
674
669
  loadOlderMessages={loadOlderMessages}
675
670
  loadingOlderMessages={loadingOlderMessages}
676
671
  chatError={chatError}
@@ -994,22 +989,18 @@ const styles = StyleSheet.create({
994
989
  paddingVertical: 8,
995
990
  gap: 8,
996
991
  },
997
- loadMoreMessages: {
998
- minHeight: 40,
992
+ loadingOlderMessages: {
993
+ minHeight: 36,
999
994
  flexDirection: 'row',
1000
995
  alignItems: 'center',
1001
996
  justifyContent: 'center',
1002
- gap: 8,
1003
- paddingHorizontal: 14,
1004
- borderRadius: 12,
1005
- borderWidth: StyleSheet.hairlineWidth,
1006
- borderColor: 'rgba(0, 229, 255, 0.35)',
1007
- backgroundColor: 'rgba(0, 229, 255, 0.08)',
997
+ gap: 7,
998
+ paddingVertical: 6,
1008
999
  },
1009
- loadMoreMessagesText: {
1000
+ loadingOlderMessagesText: {
1010
1001
  color: '#00E5FF',
1011
- fontSize: 13,
1012
- fontWeight: '700',
1002
+ fontSize: 12,
1003
+ fontWeight: '600',
1013
1004
  },
1014
1005
  chatErrorText: {
1015
1006
  color: '#FF7A8A',
@@ -109,10 +109,12 @@ export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
109
109
  enablejsapi: 1,
110
110
  fs: 0,
111
111
  iv_load_policy: 3,
112
+ modestbranding: 1,
112
113
  mute: latestPropsRef.current.muted ? 1 : 0,
113
114
  origin: window.location.origin,
114
115
  playsinline: 1,
115
116
  rel: 0,
117
+ showinfo: 0,
116
118
  },
117
119
  events: {
118
120
  onReady: (event) => {
@@ -21,7 +21,7 @@ function SeekControls({ isLive, controls, insets, scale, isSeeking, sliderPos, c
21
21
  );
22
22
  }
23
23
 
24
- function AspectRatioControl({ controls, open, aspectRatio, onToggle, onSelect }) {
24
+ function AspectRatioControl({ controls, open, aspectRatio, aspectRatios = ASPECT_OPTIONS, onToggle, onSelect }) {
25
25
  const palette = usePlayerColors();
26
26
  if (controls.aspectRatio === false) return null;
27
27
  const selectedStyle = { backgroundColor: palette.surfaceColor, borderWidth: 1.5, borderColor: palette.accentColor };
@@ -29,7 +29,7 @@ function AspectRatioControl({ controls, open, aspectRatio, onToggle, onSelect })
29
29
  <View style={styles.speedButtonContainer}>
30
30
  {open ? <View style={[styles.speedPickerPopup, { right: 'auto', left: 0, backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}>
31
31
  <Text style={[styles.speedPickerTitle, { color: palette.mutedColor }]}>Aspect Ratio</Text>
32
- {ASPECT_OPTIONS.map((option) => {
32
+ {aspectRatios.map((option) => {
33
33
  const selected = aspectRatio === option.value;
34
34
  return <TouchableOpacity key={option.label} style={styles.speedOption} onPress={(event) => { event.stopPropagation(); onSelect(option.value); }} activeOpacity={0.7}>
35
35
  <PlayerIcon name="check" size={15} color={selected ? palette.accentColor : 'transparent'} />
@@ -50,12 +50,6 @@ function AudioOnlyModeControl({ isAudioOnly, onToggle }) {
50
50
  return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle(); }} accessibilityLabel="Audio-Only Mode"><PlayerIcon name="headphones" size={18} color={isAudioOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
51
51
  }
52
52
 
53
- function VideoOnlyControl({ isVideoOnly, onToggle }) {
54
- const palette = usePlayerColors();
55
- const selectedStyle = isVideoOnly ? { backgroundColor: palette.surfaceColor, borderWidth: 1.5, borderColor: palette.accentColor } : null;
56
- return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle?.(); }} accessibilityLabel={isVideoOnly ? 'Disable video-only mode' : 'Video only'}><PlayerIcon name="video" size={18} color={isVideoOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
57
- }
58
-
59
53
  function SpeedControl({ enabled, open, playbackRate, onToggle, onSelect }) {
60
54
  const palette = usePlayerColors();
61
55
  if (!enabled) return null;
@@ -129,6 +123,7 @@ export const PlayerBottomBar = ({
129
123
  isAudioOnly,
130
124
  showAspectPicker,
131
125
  aspectRatio,
126
+ aspectRatios = ASPECT_OPTIONS,
132
127
  showSpeedPicker,
133
128
  playbackRate,
134
129
  showAudioPicker,
@@ -147,8 +142,6 @@ export const PlayerBottomBar = ({
147
142
  onSelectAudioTrack,
148
143
  onToggleFullscreen,
149
144
  controls = {},
150
- isVideoOnly = false,
151
- onToggleVideoOnly,
152
145
  }) => {
153
146
  return (
154
147
  <View
@@ -167,8 +160,7 @@ export const PlayerBottomBar = ({
167
160
  <View style={styles.bottomControlsRow} pointerEvents="box-none">
168
161
  <View style={styles.leftActionsContainer}>
169
162
  {isAudioOnlyFeatureEnabled && controls.audioOnly !== false ? <AudioOnlyModeControl isAudioOnly={isAudioOnly} onToggle={onToggleAudioOnly} /> : null}
170
- <AspectRatioControl controls={controls} open={showAspectPicker} aspectRatio={aspectRatio} onToggle={onToggleAspectPicker} onSelect={onSelectAspectRatio} />
171
- {controls.videoOnly ? <VideoOnlyControl isVideoOnly={isVideoOnly} onToggle={onToggleVideoOnly} /> : null}
163
+ <AspectRatioControl controls={controls} open={showAspectPicker} aspectRatio={aspectRatio} aspectRatios={aspectRatios} onToggle={onToggleAspectPicker} onSelect={onSelectAspectRatio} />
172
164
  </View>
173
165
 
174
166
  {/* RIGHT ACTIONS: Playback Speed, Audio, Fullscreen */}
@@ -38,8 +38,9 @@ export function nearestPipCorner(x, y, winW, winH, insets) {
38
38
  }
39
39
 
40
40
  export const ASPECT_OPTIONS = [
41
- { label: 'Fit Screen', value: 'FIT' },
42
- { label: 'Fill Screen', value: 'FILL_SCREEN' },
41
+ { label: 'Fit', value: 'FIT' },
42
+ { label: 'Fill', value: 'FILL' },
43
+ { label: 'Stretch', value: 'STRETCH' },
43
44
  { label: '16:9', value: '16:9' },
44
45
  { label: '4:3', value: '4:3' },
45
46
  { label: '21:9', value: '21:9' },
@@ -156,4 +157,3 @@ export function applyTrackDefaults(selected, setSelected, setTracks, tracksProp)
156
157
  const active = tracksProp.find((t) => t.selected || t.active) || tracksProp[0];
157
158
  if (active) setSelected(active.id);
158
159
  }
159
-
@@ -121,11 +121,13 @@ export function useWebAc3AudioPlayback({
121
121
 
122
122
  const context = new AudioContextConstructor({ latencyHint: 'interactive' });
123
123
  const gain = context.createGain();
124
+ const recordingDestination = context.createMediaStreamDestination?.();
124
125
  gain.gain.value = enabledRef.current
125
126
  ? Math.max(0, Math.min(1, Number(volumeRef.current) / 100))
126
127
  : 0;
127
128
  gain.connect(context.destination);
128
- audioRef.current = { context, gain };
129
+ if (recordingDestination) gain.connect(recordingDestination);
130
+ audioRef.current = { context, gain, recordingDestination };
129
131
  return audioRef.current;
130
132
  }, []);
131
133
 
@@ -212,8 +214,12 @@ export function useWebAc3AudioPlayback({
212
214
  useEffect(() => () => {
213
215
  const { context } = audioRef.current;
214
216
  if (context && context.state !== 'closed') context.close().catch(() => {});
215
- audioRef.current = { context: null, gain: null };
217
+ audioRef.current = { context: null, gain: null, recordingDestination: null };
216
218
  }, []);
217
219
 
218
- return { activateAudio, deactivateAudio };
220
+ return {
221
+ activateAudio,
222
+ deactivateAudio,
223
+ getRecordingAudioStream: () => audioRef.current.recordingDestination?.stream || null,
224
+ };
219
225
  }
@@ -17,8 +17,13 @@ export function useWebVideoAspectRatio(videoAspectRatio, audioOnly) {
17
17
  } else if (aspect === 'CENTER') {
18
18
  setAspectFit('none');
19
19
  setAspectRatioVal(undefined);
20
- } else if (typeof aspect === 'string' && aspect.includes(':')) {
21
- const parts = aspect.split(':');
20
+ } else if (typeof aspect === 'string' && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(aspect)) {
21
+ const parts = aspect.split(':').map(Number);
22
+ if (!parts[0] || !parts[1]) {
23
+ setAspectFit('contain');
24
+ setAspectRatioVal(undefined);
25
+ return;
26
+ }
22
27
  setAspectFit('contain');
23
28
  setAspectRatioVal(`${parts[0]}/${parts[1]}`);
24
29
  } else {
@@ -0,0 +1,24 @@
1
+ /** Run the player's built-in action before notifying the consuming app. */
2
+ export function invokePlayerAction(coreAction, callback, payload, context) {
3
+ const result = typeof coreAction === 'function' ? coreAction(payload) : undefined;
4
+ if (typeof callback !== 'function') return result;
5
+
6
+ const notify = () => {
7
+ try {
8
+ callback(payload, context);
9
+ } catch (error) {
10
+ // A consumer notification must never roll back or block the player action.
11
+ console.error('CineCrewPlayer action callback failed:', error);
12
+ }
13
+ };
14
+
15
+ if (result && typeof result.then === 'function') {
16
+ return result.then((value) => {
17
+ notify();
18
+ return value;
19
+ });
20
+ }
21
+
22
+ notify();
23
+ return result;
24
+ }