@cinecrew/cinecrew-player 0.1.11 → 0.1.12

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
@@ -157,15 +157,16 @@ The items below are planned for more consistent, user-facing support across plat
157
157
 
158
158
  [![Open React demo in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/fork/github/nahushr/cinecrew-player/tree/main/examples/web-demo?startScript=dev)
159
159
 
160
- [Try the Android demo in Expo Snack](https://snack.expo.dev/?name=CineCrew%20Player%20Android&dependencies=%40cinecrew%2Fcinecrew-player%2Cexpo-document-picker&sourceUrl=https%3A%2F%2Fraw.githubusercontent.com%2Fnahushr%2Fcinecrew-player%2Fmain%2Fexamples%2Fsnack%2FApp.js&platform=android&supportedPlatforms=android) · [Try the iOS demo in Expo Snack](https://snack.expo.dev/?name=CineCrew%20Player%20iOS&dependencies=%40cinecrew%2Fcinecrew-player%2Cexpo-document-picker&sourceUrl=https%3A%2F%2Fraw.githubusercontent.com%2Fnahushr%2Fcinecrew-player%2Fmain%2Fexamples%2Fsnack%2FApp.js&platform=ios&supportedPlatforms=ios)
160
+ [Try the Android demo in Expo Snack](https://snack.expo.dev/?name=CineCrew%20Player%20Android&sdkVersion=57.0.0&dependencies=%40cinecrew%2Fcinecrew-player%40*%2C%40expo%2Fvector-icons%40*%2Creact-native-svg%40*%2Creact-native-safe-area-context%40*%2C%40react-native-community%2Fslider%40*%2Cexpo-document-picker%40~57.0.2%2Cexpo-file-system%40~57.0.7%2Cexpo-sharing%40~57.0.22&sourceUrl=https%3A%2F%2Fraw.githubusercontent.com%2Fnahushr%2Fcinecrew-player%2Fmain%2Fexamples%2Fsnack%2FApp.js&platform=android&supportedPlatforms=android) · [Try the iOS demo in Expo Snack](https://snack.expo.dev/?name=CineCrew%20Player%20iOS&sdkVersion=57.0.0&dependencies=%40cinecrew%2Fcinecrew-player%40*%2C%40expo%2Fvector-icons%40*%2Creact-native-svg%40*%2Creact-native-safe-area-context%40*%2C%40react-native-community%2Fslider%40*%2Cexpo-document-picker%40~57.0.2%2Cexpo-file-system%40~57.0.7%2Cexpo-sharing%40~57.0.22&sourceUrl=https%3A%2F%2Fraw.githubusercontent.com%2Fnahushr%2Fcinecrew-player%2Fmain%2Fexamples%2Fsnack%2FApp.js&platform=ios&supportedPlatforms=ios)
161
161
 
162
162
  ## Demos
163
163
 
164
164
  - **[React + Vite web demo](examples/web-demo)** — try HLS, MPEG-TS, MP4, MKV, or a local video file. Switch between the full player (all controls and demo chat/EPG/recording adapters enabled) and the compact inline player. [Open a fresh StackBlitz copy](https://stackblitz.com/fork/github/nahushr/cinecrew-player/tree/main/examples/web-demo?startScript=dev).
165
165
  - **[Expo / React Native Web demo](examples/expo-web-demo)** — the same source tests and controls in an Expo app rendered for the web.
166
- - **[Android and iOS Expo Snack demos](examples/snack/App.js)** — both platform links load a native playground (`.ts`, `.mp4`, `.mkv`, and local-file upload), with full-player and inline-player modes. Snack runs in Expo Go, which cannot load this package's custom VLC module; MPEG-TS and MKV playback should be tested in a native development build. [Expo documents this Expo Go limitation](https://docs.expo.dev/faq/#what-can-i-do-or-cannot-do-with-expo-go).
166
+ - **[Android and iOS Expo Snack demos](examples/snack/App.js)** — component-based platform previews include HLS, MPEG-TS, MP4, MKV, MOV, M4V, 3GP, FLV, OGV, WebM, and local-file selection. Snack runs in Expo Go, so these links verify the shared UI and Expo fallback only; Expo Go cannot load the custom VLC native module and cannot validate VLC playback or native VLC recording. [Expo documents this limitation](https://docs.expo.dev/workflow/customizing/#using-libraries-with-custom-native-code-in-development-builds).
167
+ - **[Native Android/iOS development-build demo](examples/native-demo)** — modular Expo project that uses the bundled VLC module for direct streams and local files, with a VLC recording adapter and system share/save flow. It is the correct target for codec and native recording validation; build it with `npx expo run:android` or `npx expo run:ios` after `npm install`.
167
168
 
168
- The React/Vite demo is self-contained and installs the released `@cinecrew/cinecrew-player` package, so its StackBlitz link works from the `examples/web-demo` subdirectory. Its MPEG-TS button uses a small same-origin H.264/AAC fixture to exercise the TS parser without relying on an external server's CORS configuration. The Expo Web demo uses this repository's package source (`file:../..`) so contributors can test unreleased changes locally. Snack loads the native example from this repository's `main` branch and installs `@cinecrew/cinecrew-player` from npm. The React DOM entry resolves to the browser renderer; it does not evaluate React Native or VLC code. The Expo native entry bundles VLC into the same installed package. External media hosts must allow browser CORS requests; format/codec support also depends on the browser. MKV playback is generally more reliable through the native VLC adapter than a browser video element.
169
+ The React/Vite demo is self-contained and installs the released `@cinecrew/cinecrew-player` package, so its StackBlitz link works from the `examples/web-demo` subdirectory. Its MPEG-TS button uses a small same-origin H.264/AAC fixture to exercise the TS parser without relying on an external server's CORS configuration. The Expo Web demo uses this repository's package source (`file:../..`) so contributors can test unreleased changes locally. Snack loads the one-file native playground from this repository's `main` branch, uses the root `@cinecrew/cinecrew-player` import, and declares the package's required peers. Refresh/reopen the Snack link after pushing code to load the current `main` source. Snack remains an Expo Go preview—not a VLC test. The modular native development demo links the local package source, prebuilds the custom VLC module, and is used for actual Android/iOS VLC tests. The React DOM entry resolves to the browser renderer; it does not evaluate React Native or VLC code. External media hosts must allow browser CORS requests; format/codec support also depends on the browser. MKV playback is generally more reliable through the native VLC adapter than a browser video element.
169
170
 
170
171
  Run either demo:
171
172
 
@@ -496,7 +497,7 @@ const playerRef = React.useRef(null);
496
497
 
497
498
  Available action keys: `onBack`, `onPlayPause`, `onSeek`, `onRestart`, `onLock`, `onMute`, `onAspectRatioChange`, `onVideoOnlyChange`, `onAudioOnlyChange`, `onAudioTrackChange`, `onPlaybackRateChange`, `onFullscreen`, `onRecordingStart`, `onRecordingPause`, `onRecordingResume`, `onRecordingStop`, `onLiveChatOpen`, `onEpgOpen`, and `onDiagnosticsOpen`.
498
499
 
499
- 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.
500
+ 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. Native VLC builds additionally expose `startNativeRecording(path)` and `stopNativeRecording()` to an app-owned recording adapter; both return `false` when VLC is not attached (such as in Expo Go).
500
501
 
501
502
  ## Integrations
502
503
 
@@ -519,10 +520,12 @@ Integrations are optional. The package has no CineCrew account, database, or wor
519
520
  loadListings: ({ channelId, limit }) => api.getEpg(channelId, limit),
520
521
  },
521
522
  recording: {
522
- start: ({ getVideoElement, streamUrl, title }) => recorder.start({ getVideoElement, streamUrl, title }),
523
+ supportsOnDemand: true,
524
+ start: ({ getVideoElement, streamUrl, title, player }) => recorder.start({ getVideoElement, streamUrl, title, player }),
523
525
  pause: () => recorder.pause(),
524
526
  resume: () => recorder.resume(),
525
527
  stop: () => recorder.stop(),
528
+ onNativeRecordingCreated: (path) => recorder.onNativeRecordingCreated(path),
526
529
  isActive: () => recorder.isActive(),
527
530
  subscribe: (listener) => recorder.subscribe(listener),
528
531
  },
@@ -540,7 +543,7 @@ The EPG drawer uses `integrations.epg.loadListings`, which returns entries with
540
543
 
541
544
  ### Web recording
542
545
 
543
- 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. Native React Native apps need an `integrations.recording` implementation backed by Android MediaProjection or iOS ReplayKit (with the platform's permission flow).
546
+ 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. Native React Native apps provide an `integrations.recording` adapter; with the bundled VLC module, the player ref exposes native recording start/stop commands and the adapter receives the finalized path through `onNativeRecordingCreated`. Set `supportsOnDemand: true` when that adapter supports recording non-live media. Platform file locations and the destination/share flow remain app-owned.
544
547
 
545
548
  For ordinary web media, 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 recording keeps its source aspect ratio. Use `integrations.recording` to supply a different recording implementation.
546
549
 
@@ -38,6 +38,7 @@ class ReactVlcPlayerView extends TextureView
38
38
  private static final String EVENT_PROP_DURATION = "duration";
39
39
  private static final String EVENT_PROP_SUCCESS = "success";
40
40
  private static final String EVENT_PROP_ERROR = "error";
41
+ private static final String EVENT_PROP_IS_RECORDING = "isRecording";
41
42
 
42
43
  private final VideoEventEmitter eventEmitter;
43
44
  private LibVLC libvlc;
@@ -307,7 +308,7 @@ class ReactVlcPlayerView extends TextureView
307
308
  break;
308
309
  case MediaPlayer.Event.RecordChanged:
309
310
  map.putString("type", "RecordingPath");
310
- map.putBoolean("isRecording", event.getRecording());
311
+ map.putBoolean(EVENT_PROP_IS_RECORDING, event.getRecording());
311
312
  // Record started emits and event with the record path (but no file).
312
313
  // Only want to emit when recording has stopped and the recording is created.
313
314
  if (!event.getRecording() && event.getRecordPath() != null) {
@@ -810,19 +811,27 @@ class ReactVlcPlayerView extends TextureView
810
811
  }
811
812
 
812
813
  public void startRecording(String recordingPath) {
813
- if (mMediaPlayer == null) {
814
- return;
815
- }
816
- if (recordingPath != null) {
817
- mMediaPlayer.record(recordingPath);
814
+ boolean accepted = mMediaPlayer != null && recordingPath != null && mMediaPlayer.record(recordingPath);
815
+ WritableMap map = Arguments.createMap();
816
+ map.putString("operation", "start");
817
+ map.putBoolean("requestAccepted", accepted);
818
+ map.putBoolean(EVENT_PROP_IS_RECORDING, accepted);
819
+ if (!accepted) {
820
+ map.putString(EVENT_PROP_ERROR, "VLC rejected the recording request for this media source.");
818
821
  }
822
+ eventEmitter.sendEvent(map, VideoEventEmitter.EVENT_RECORDING_STATE);
819
823
  }
820
824
 
821
825
  public void stopRecording() {
822
- if (mMediaPlayer == null) {
823
- return;
824
- }
825
- mMediaPlayer.record(null);
826
+ boolean accepted = mMediaPlayer != null && mMediaPlayer.record(null);
827
+ WritableMap map = Arguments.createMap();
828
+ map.putString("operation", "stop");
829
+ map.putBoolean("requestAccepted", accepted);
830
+ map.putBoolean(EVENT_PROP_IS_RECORDING, !accepted);
831
+ if (!accepted) {
832
+ map.putString(EVENT_PROP_ERROR, "VLC rejected the request to stop recording.");
833
+ }
834
+ eventEmitter.sendEvent(map, VideoEventEmitter.EVENT_RECORDING_STATE);
826
835
  }
827
836
 
828
837
  public void stopPlayer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cinecrew/cinecrew-player",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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",
@@ -21,6 +21,7 @@ const VLCPlayer = forwardRef(function VLCPlayer(props, forwardedRef) {
21
21
  onPlaying,
22
22
  onLoad,
23
23
  onRecordingCreated,
24
+ onRecordingState,
24
25
  onSnapshot,
25
26
  ...otherProps
26
27
  } = props;
@@ -40,7 +41,10 @@ const VLCPlayer = forwardRef(function VLCPlayer(props, forwardedRef) {
40
41
 
41
42
  return {
42
43
  setNativeProps,
43
- startRecording: (path) => dispatchCommand('startRecording', [path]),
44
+ startRecording: (path) => {
45
+ lastRecordingRef.current = null;
46
+ dispatchCommand('startRecording', [path]);
47
+ },
44
48
  stopRecording: () => dispatchCommand('stopRecording', []),
45
49
  stopPlayer: () => dispatchCommand('stopPlayer', []),
46
50
  snapshot: (path) => dispatchCommand('snapshot', [path]),
@@ -69,13 +73,14 @@ const VLCPlayer = forwardRef(function VLCPlayer(props, forwardedRef) {
69
73
  const handleRecordingState = useCallback(
70
74
  (event) => {
71
75
  const { isRecording, recordPath } = event.nativeEvent;
76
+ onRecordingState?.(event.nativeEvent);
72
77
  if (lastRecordingRef.current === recordPath) return;
73
78
  if (!isRecording && recordPath) {
74
79
  lastRecordingRef.current = recordPath;
75
80
  onRecordingCreated?.(recordPath);
76
81
  }
77
82
  },
78
- [onRecordingCreated],
83
+ [onRecordingCreated, onRecordingState],
79
84
  );
80
85
  const handleSnapshot = useCallback(
81
86
  (event) => {
@@ -177,6 +177,17 @@ export type VLCPlayerCallbackProps = {
177
177
  */
178
178
  onRecordingCreated?: (recordingPath: string) => void;
179
179
 
180
+ /**
181
+ * Called with native VLC recording request/state events.
182
+ */
183
+ onRecordingState?: (event: {
184
+ operation?: 'start' | 'stop';
185
+ requestAccepted?: boolean;
186
+ isRecording: boolean;
187
+ recordPath?: string | null;
188
+ error?: string | null;
189
+ }) => void;
190
+
180
191
  /**
181
192
  * Called when a new snapshot is created
182
193
  *
@@ -462,18 +462,30 @@ static NSString *const playbackRate = @"rate";
462
462
 
463
463
  - (void)startRecording:(NSString*)path
464
464
  {
465
- [_player startRecordingAtPath:path];
465
+ BOOL accepted = _player != nil && path != nil && [_player startRecordingAtPath:path];
466
466
  if (self.onRecordingState) {
467
467
  self.onRecordingState(@{
468
468
  @"target": self.reactTag,
469
- @"isRecording": @YES
469
+ @"operation": @"start",
470
+ @"requestAccepted": @(accepted),
471
+ @"isRecording": @(accepted),
472
+ @"error": accepted ? (id)[NSNull null] : @"VLC rejected the recording request for this media source."
470
473
  });
471
474
  }
472
475
  }
473
476
 
474
477
  - (void)stopRecording
475
478
  {
476
- [_player stopRecording];
479
+ BOOL accepted = _player != nil && [_player stopRecording];
480
+ if (!accepted && self.onRecordingState) {
481
+ self.onRecordingState(@{
482
+ @"target": self.reactTag,
483
+ @"operation": @"stop",
484
+ @"requestAccepted": @NO,
485
+ @"isRecording": @YES,
486
+ @"error": @"VLC rejected the request to stop recording."
487
+ });
488
+ }
477
489
  }
478
490
 
479
491
  - (void)stopPlayer
@@ -632,4 +644,4 @@ static NSString *const playbackRate = @"rate";
632
644
  [super removeFromSuperview];
633
645
  }
634
646
 
635
- @end
647
+ @end
@@ -19,13 +19,14 @@ import { LiveChatDrawer } from './media/LiveChatDrawer';
19
19
  import { LiveRecordingNotice, LiveRecordingOverlay } from './media/LiveRecordingOverlay';
20
20
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
21
21
  import { PlayerIcon } from './customization';
22
- import { isWeb, isAndroid, isElectron } from '../utils/runtimePlatform';
22
+ import { isWeb, isElectron } from '../utils/runtimePlatform';
23
23
  import { getFontSize, getFontWeight } from '../utils/layoutUtils';
24
24
  import { isLocalMediaUri } from '../utils/mediaUtils';
25
25
  import { invokePlayerAction } from '../utils/invokePlayerAction.js';
26
26
  import { emitProgressBarTime } from '../utils/progressBarTime.js';
27
27
  import {
28
28
  USER_AGENT,
29
+ ASPECT_OPTIONS,
29
30
  calculateScreenAspectRatio,
30
31
  isSafariOrIOS,
31
32
  getWebPoint,
@@ -222,9 +223,9 @@ function PlatformMediaSurface(props) {
222
223
  volume, playbackRate, aspectRatio, title, posterUrl, isLive, isAudioOnly,
223
224
  selectedAudioTrack, handleTracksChanged, handleProgress, handleNativePlaying,
224
225
  handleWebBuffering, handleEpisodeEnded, handleWebError, togglePlayPause,
225
- handleSeekByAction, handlePlaybackRoute, exoFallback, useExoForAndroidLocalMedia,
226
+ handleSeekByAction, handlePlaybackRoute, exoFallback,
226
227
  nativeSource, computedAspectRatio, handleNativeLoadStart,
227
- handleNativeOpen, handleNativeBuffering,
228
+ handleNativeOpen, handleNativeBuffering, onRecordingCreated, recording,
228
229
  } = props;
229
230
  if (isElectron()) {
230
231
  return (
@@ -283,7 +284,7 @@ function PlatformMediaSurface(props) {
283
284
  />
284
285
  );
285
286
  }
286
- if (VLC_AVAILABLE && !useExoForAndroidLocalMedia) {
287
+ if (VLC_AVAILABLE) {
287
288
  return (
288
289
  <VLCBoundary key={`vlcb-${playerStreamUrl}`} fallback={exoFallback}>
289
290
  <VLCPlayer
@@ -313,6 +314,8 @@ function PlatformMediaSurface(props) {
313
314
  onVLCBuffering={handleNativeBuffering}
314
315
  onError={handleWebError}
315
316
  onVLCError={handleWebError}
317
+ onRecordingCreated={onRecordingCreated}
318
+ onRecordingState={recording?.onNativeRecordingState}
316
319
  />
317
320
  </VLCBoundary>
318
321
  );
@@ -495,11 +498,20 @@ function FullscreenVideoLayer({ videoPlayer, zoomScale, isAudioOnly }) {
495
498
  );
496
499
  }
497
500
 
498
- function FullscreenGestureLayer({ isAudioOnly, panResponder, handlers }) {
501
+ function FullscreenGestureLayer({ isAudioOnly, showControls, isLocked, isRecording, panResponder, handlers }) {
499
502
  if (isAudioOnly) return null;
500
503
  if (!isWeb() && panResponder) {
504
+ // Keep the full-screen gesture responder out of the hit-test tree while
505
+ // controls are visible. PanResponder can otherwise claim a touch before
506
+ // nested TouchableOpacity controls receive it on Android.
507
+ if (isRecording || (showControls && !isLocked)) return null;
501
508
  return (
502
- <View collapsable={false} style={[StyleSheet.absoluteFill, styles.gestureCatcher]} {...panResponder.panHandlers} />
509
+ <View
510
+ collapsable={false}
511
+ style={[StyleSheet.absoluteFill, styles.gestureCatcher]}
512
+ pointerEvents={showControls ? 'none' : 'auto'}
513
+ {...panResponder.panHandlers}
514
+ />
503
515
  );
504
516
  }
505
517
  return (
@@ -533,6 +545,7 @@ function FullscreenControlsPanel(props) {
533
545
  episodeLabel={props.episodeLabel}
534
546
  isLive={props.isLive}
535
547
  isScreenRecorderEnabled={props.isScreenRecorderEnabled}
548
+ canRecord={props.canRecord}
536
549
  recStatus={props.recStatus}
537
550
  isLoading={props.isLoading}
538
551
  showLiveChat={props.showLiveChat}
@@ -673,7 +686,7 @@ function FullscreenChatLayer(props) {
673
686
  }
674
687
 
675
688
  function FullscreenRecordingLayer(props) {
676
- const recordingEligible = props.isLive;
689
+ const recordingEligible = props.canRecord;
677
690
  return (
678
691
  <>
679
692
  {!props.isAudioOnly && recordingEligible && (props.isScreenRecorderEnabled || props.recStatus !== 'idle') ? (
@@ -942,6 +955,7 @@ export const MediaPlayerView = ({
942
955
  const isAudioOnlyFeatureEnabled = controls.audioOnly ?? true;
943
956
  const isScreenRecorderEnabled = controls.recording ?? Boolean(integrations.recording);
944
957
  const recording = integrations.recording;
958
+ const canRecord = isLive || recording?.supportsOnDemand === true;
945
959
  const [currentUser, setCurrentUser] = useState(integrations.user || { id: '0', username: 'Viewer' });
946
960
 
947
961
  const badgeService = useMemo(() => ({
@@ -1491,7 +1505,7 @@ export const MediaPlayerView = ({
1491
1505
 
1492
1506
  const handleStartRecording = useCallback(async (e) => {
1493
1507
  e?.stopPropagation?.();
1494
- if (!isScreenRecorderEnabled || !isLive || recStatusRef.current !== 'idle') return;
1508
+ if (!isScreenRecorderEnabled || !canRecord || recStatusRef.current !== 'idle') return;
1495
1509
  try {
1496
1510
  await recording?.start?.({
1497
1511
  getVideoElement: () => {
@@ -1501,6 +1515,7 @@ export const MediaPlayerView = ({
1501
1515
  },
1502
1516
  streamUrl: playbackUrl || streamUrl,
1503
1517
  title,
1518
+ player: playerApiRef?.current || null,
1504
1519
  });
1505
1520
  if (muted) {
1506
1521
  showRecNotice({
@@ -1511,7 +1526,7 @@ export const MediaPlayerView = ({
1511
1526
  } catch (err) {
1512
1527
  showRecNotice({ type: 'error', message: err?.message || 'Could not start recording.' });
1513
1528
  }
1514
- }, [isLive, isScreenRecorderEnabled, muted, playbackUrl, showRecNotice, streamUrl, title, recording]);
1529
+ }, [canRecord, isScreenRecorderEnabled, muted, playbackUrl, showRecNotice, streamUrl, title, recording, playerApiRef]);
1515
1530
 
1516
1531
  const handlePauseRecording = useCallback(async (e) => {
1517
1532
  e?.stopPropagation?.();
@@ -1945,12 +1960,29 @@ export const MediaPlayerView = ({
1945
1960
  back: handleBackAction,
1946
1961
  getVideoElement: () => vlcRef.current?.getVideoElement?.() || null,
1947
1962
  getAudioTracks: () => audioTracks,
1963
+ startNativeRecording: (path) => {
1964
+ if (typeof vlcRef.current?.startRecording !== 'function') return false;
1965
+ vlcRef.current.startRecording(path);
1966
+ return true;
1967
+ },
1968
+ stopNativeRecording: () => {
1969
+ if (typeof vlcRef.current?.stopRecording !== 'function') return false;
1970
+ vlcRef.current.stopRecording();
1971
+ return true;
1972
+ },
1948
1973
  };
1949
1974
  Object.assign(playerApiRef.current, api);
1950
1975
  return () => {
1951
1976
  for (const key of Object.keys(api)) delete playerApiRef.current[key];
1952
1977
  };
1953
- }, [playerApiRef, togglePlayPause, handleRestart, toggleMute, handleSelectAspectRatio, handleAudioSelect, handleSeekTo, handleSeekBy, handleBackAction, audioTracks]);
1978
+ }, [playerApiRef, togglePlayPause, handleRestart, toggleMute, handleSelectAspectRatio, handleAudioSelect, handleSeekTo, handleSeekBy, handleBackAction, audioTracks, vlcRef]);
1979
+
1980
+ const handleNativeRecordingCreated = useCallback((path) => {
1981
+ recording?.onNativeRecordingCreated?.(path);
1982
+ }, [recording]);
1983
+ const handleNativeRecordingState = useCallback((state) => {
1984
+ recording?.onNativeRecordingState?.(state);
1985
+ }, [recording]);
1954
1986
 
1955
1987
  const handleSpeedSelect = useCallback((speed) => invokeAction(
1956
1988
  'onPlaybackRateChange', () => {
@@ -2037,9 +2069,13 @@ export const MediaPlayerView = ({
2037
2069
  };
2038
2070
 
2039
2071
  return PanResponder.create({
2040
- onStartShouldSetPanResponder: (evt) => !isSeekScrubGesture(evt),
2072
+ onStartShouldSetPanResponder: (evt) => (
2073
+ !(showControlsRef.current && !isLockedRef.current)
2074
+ && !isSeekScrubGesture(evt)
2075
+ ),
2041
2076
  onStartShouldSetPanResponderCapture: () => false,
2042
2077
  onMoveShouldSetPanResponder: (evt, gestureState) => {
2078
+ if (showControlsRef.current && !isLockedRef.current) return false;
2043
2079
  if (isSeekScrubGesture(evt)) return false;
2044
2080
  return Math.abs(gestureState.dx) > 3 || Math.abs(gestureState.dy) > 3;
2045
2081
  },
@@ -2432,13 +2468,6 @@ export const MediaPlayerView = ({
2432
2468
  mediaOptions: nativeMediaOptions,
2433
2469
  }), [playerStreamUrl, nativeMediaOptions]);
2434
2470
 
2435
- // Android DownloadManager / local storage can expose downloads as
2436
- // content:// or file:// URIs. Media3/Expo (ExoPlayer) can consume both provider URIs
2437
- // and local filesystem URIs directly with full hardware acceleration;
2438
- // this VLC build's native local-string path cannot, so route local media to ExoVideoFallback.
2439
- const useExoForAndroidLocalMedia = isAndroid()
2440
- && isLocalMediaUri(playerStreamUrl);
2441
-
2442
2471
  if (!visible || !streamUrl) return null;
2443
2472
 
2444
2473
  const exoFallback = (
@@ -2487,13 +2516,15 @@ export const MediaPlayerView = ({
2487
2516
  handleSeekByAction={handleSeekByAction}
2488
2517
  handlePlaybackRoute={handlePlaybackRoute}
2489
2518
  exoFallback={exoFallback}
2490
- useExoForAndroidLocalMedia={useExoForAndroidLocalMedia}
2491
2519
  nativeSource={nativeSource}
2492
2520
  computedAspectRatio={computedAspectRatio}
2493
2521
  handleNativeLoadStart={handleNativeLoadStart}
2494
2522
  handleNativeOpen={handleNativeOpen}
2495
2523
  handleClose={handleClose}
2496
2524
  handleNativeBuffering={handleNativeBuffering}
2525
+ onRecordingCreated={handleNativeRecordingCreated}
2526
+ onRecordingState={handleNativeRecordingState}
2527
+ recording={recording}
2497
2528
  />
2498
2529
  );
2499
2530
  const isValidPreviewRect = isUsableInlinePreviewRect(inlinePreviewRect);
@@ -2561,6 +2592,7 @@ export const MediaPlayerView = ({
2561
2592
  episodeLabel,
2562
2593
  isLive,
2563
2594
  isScreenRecorderEnabled,
2595
+ canRecord,
2564
2596
  recStatus,
2565
2597
  isLoading,
2566
2598
  showLiveChat,
@@ -2658,6 +2690,9 @@ export const MediaPlayerView = ({
2658
2690
  />
2659
2691
  <FullscreenGestureLayer
2660
2692
  isAudioOnly={isAudioOnly}
2693
+ showControls={showControls}
2694
+ isLocked={isLocked}
2695
+ isRecording={recStatus === 'recording' || recStatus === 'paused'}
2661
2696
  panResponder={panResponder}
2662
2697
  handlers={{
2663
2698
  onPointerDown: handleWebPointerDown,
@@ -2672,7 +2707,11 @@ export const MediaPlayerView = ({
2672
2707
 
2673
2708
  {/* Controls Layer */}
2674
2709
  <View style={[StyleSheet.absoluteFill, { zIndex: 60, elevation: 60 }]} pointerEvents="box-none">
2675
- <FullscreenControlsLayer showControls={showControls} isLocked={isLocked} isAudioOnly={isAudioOnly}>
2710
+ <FullscreenControlsLayer
2711
+ showControls={showControls}
2712
+ isLocked={isLocked}
2713
+ isAudioOnly={isAudioOnly}
2714
+ >
2676
2715
  <FullscreenControlsPanel {...fullscreenControlsProps} />
2677
2716
  </FullscreenControlsLayer>
2678
2717
  </View>
@@ -2707,7 +2746,7 @@ export const MediaPlayerView = ({
2707
2746
  />
2708
2747
  <FullscreenRecordingLayer
2709
2748
  isAudioOnly={isAudioOnly}
2710
- isLive={isLive}
2749
+ canRecord={canRecord}
2711
2750
  isScreenRecorderEnabled={isScreenRecorderEnabled}
2712
2751
  recStatus={recStatus}
2713
2752
  recElapsedMs={recElapsedMs}
@@ -93,7 +93,7 @@ export function LiveRecordingOverlay({
93
93
 
94
94
  return (
95
95
  <View
96
- pointerEvents="box-none"
96
+ pointerEvents="auto"
97
97
  style={[styles.wrap, { top: Math.max(topInset, 10) }]}
98
98
  >
99
99
  <View
@@ -202,7 +202,8 @@ const styles = StyleSheet.create({
202
202
  position: 'absolute',
203
203
  left: 0,
204
204
  right: 0,
205
- zIndex: 80,
205
+ zIndex: 1000,
206
+ ...(isWeb() || isElectron() ? {} : { elevation: 1000 }),
206
207
  alignItems: 'center',
207
208
  pointerEvents: 'box-none',
208
209
  },
@@ -21,8 +21,8 @@ function BackButton({ visible, palette, scale, onClose }) {
21
21
  );
22
22
  }
23
23
 
24
- function RecordingControls({ isLive, enabled, status, loading, controls, onStart, onResume, onPause, onStop, palette }) {
25
- if (!isLive || !enabled || controls.recording === false) return null;
24
+ function RecordingControls({ canRecord, enabled, status, loading, controls, onStart, onResume, onPause, onStop, palette }) {
25
+ if (!canRecord || !enabled || controls.recording === false) return null;
26
26
  if (status === 'idle') {
27
27
  return (
28
28
  <TouchableOpacity style={[styles.pill, loading && { opacity: 0.45 }]} onPress={onStart} hitSlop={12} disabled={loading} accessibilityLabel="Start recording">
@@ -95,6 +95,7 @@ export const PlayerTopBar = ({
95
95
  episodeLabel,
96
96
  isLive,
97
97
  isScreenRecorderEnabled,
98
+ canRecord,
98
99
  recStatus,
99
100
  isLoading,
100
101
  showLiveChat,
@@ -145,7 +146,7 @@ export const PlayerTopBar = ({
145
146
  <PlayerTitle isPortrait={isPortrait} displayTitle={displayTitle} episodeLabel={episodeLabel} showEpisodeSubtitle={showEpisodeSubtitle} palette={palette} scale={scale} />
146
147
 
147
148
  <View style={styles.topRightActions}>
148
- <RecordingControls isLive={isLive} enabled={isScreenRecorderEnabled} status={recStatus} loading={isLoading} controls={controls} onStart={onStartRecording} onResume={onResumeRecording} onPause={onPauseRecording} onStop={onStopRecording} palette={palette} />
149
+ <RecordingControls canRecord={canRecord ?? isLive} enabled={isScreenRecorderEnabled} status={recStatus} loading={isLoading} controls={controls} onStart={onStartRecording} onResume={onResumeRecording} onPause={onPauseRecording} onStop={onStopRecording} palette={palette} />
149
150
  <LiveServiceControls isLive={isLive} controls={controls} showLiveChat={showLiveChat} drawerTab={drawerTab} showChat={isLiveCommentsEnabled} showEpg={isEpgEnabled} showDiagnostics={diagnosticsOverlayEnabled} onToggle={onToggleChatTab} palette={palette} />
150
151
  <PlaybackSessionControls isLive={isLive} controls={controls} muted={muted} onRestart={onRestart} onMute={onToggleMute} onLock={onToggleLock} palette={palette} />
151
152
  </View>
@@ -1,3 +1,11 @@
1
+ /** True for app-local media URIs/paths rather than network streams. */
2
+ export function isLocalMediaUri(uri) {
3
+ const value = String(uri || '').trim();
4
+ if (!value) return false;
5
+ return /^(?:file|content|assets-library|ph|data):/i.test(value)
6
+ || /^(?:\.\.?\/|\/|~\/)/.test(value);
7
+ }
8
+
1
9
  export function cleanPlayerTitle(title, episodeLabel, isMobile = false) {
2
10
  const clean = String(title || '').trim();
3
11
  if (!clean) return 'Now Playing';
package/types/index.d.ts CHANGED
@@ -62,6 +62,10 @@ export interface PlayerApi {
62
62
  closePanel(): void;
63
63
  getVideoElement(): unknown | null;
64
64
  getAudioTracks(): AudioTrack[];
65
+ /** Start a native VLC recording to a platform-specific path. Returns false when VLC is unavailable. */
66
+ startNativeRecording?(path: string): boolean;
67
+ /** Stop an active native VLC recording. Returns false when VLC is unavailable. */
68
+ stopNativeRecording?(): boolean;
65
69
  enterFullscreen?(): void | Promise<void>;
66
70
  exitFullscreen?(): void | Promise<void>;
67
71
  }
@@ -193,10 +197,14 @@ export interface PlayerIntegrations {
193
197
  };
194
198
  recording?: {
195
199
  /** Starts host-managed recording for the active direct media source. */
196
- start?: (args: { getVideoElement: () => unknown | null; streamUrl: string; title?: string }) => Promise<unknown>;
200
+ start?: (args: { getVideoElement: () => unknown | null; streamUrl: string; title?: string; player?: PlayerApi | null }) => Promise<unknown>;
197
201
  pause?: () => Promise<unknown>;
198
202
  resume?: () => Promise<unknown>;
199
203
  stop?: () => Promise<{ filename?: string } | unknown>;
204
+ /** Allow the recording control on on-demand media, not only live sources. */
205
+ supportsOnDemand?: boolean;
206
+ /** Receives the completed local path emitted by the native VLC recorder. */
207
+ onNativeRecordingCreated?: (recordingPath: string) => void;
200
208
  isActive?: () => boolean;
201
209
  subscribe?: (listener: (state: { status: string; elapsedMs: number }) => void) => (() => void) | void;
202
210
  };