@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 +9 -6
- package/android/src/main/java/com/yuanzhou/vlc/vlcplayer/ReactVlcPlayerView.java +19 -10
- package/package.json +1 -1
- package/packages/react-native-vlc-media-player/VLCPlayer.js +7 -2
- package/packages/react-native-vlc-media-player/index.d.ts +11 -0
- package/packages/react-native-vlc-media-player/ios/RCTVLCPlayer/RCTVLCPlayer.m +16 -4
- package/src/native/MediaPlayerView.js +60 -21
- package/src/native/media/LiveRecordingOverlay.js +3 -2
- package/src/native/media/player/PlayerTopBar.js +4 -3
- package/src/utils/mediaUtils.js +8 -0
- package/types/index.d.ts +9 -1
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
|
[](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)** —
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
@@ -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) =>
|
|
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
|
-
@"
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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 || !
|
|
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
|
-
}, [
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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="
|
|
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:
|
|
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({
|
|
25
|
-
if (!
|
|
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
|
|
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>
|
package/src/utils/mediaUtils.js
CHANGED
|
@@ -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
|
};
|