extended-vlc-player 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +411 -17
- package/android/build.gradle +3 -15
- package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerModule.kt +2 -2
- package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerSession.kt +17 -13
- package/app.plugin.js +2 -2
- package/ios/ExtendedVlcPlayer.podspec +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,23 +1,177 @@
|
|
|
1
1
|
# extended-vlc-player
|
|
2
2
|
|
|
3
|
-
A React Native video player for Expo SDK 57 / RN 0.86 / New Architecture that
|
|
3
|
+
A React Native video player for **Expo SDK 57 / RN 0.86 / New Architecture (Fabric)** that decodes every container that VLC's mobile engines support, with a drop-in `expo-video`-compatible JSX surface and **true system Picture-in-Picture on iOS** — including for codecs the system `AVPlayer` cannot play (MKV, AVI, FLV, WMV, WebM, etc.).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- Delivers true system **Picture-in-Picture** on iOS, even for codecs the system AVPlayer cannot decode, by bridging MobileVLCKit's snapshot output to `AVSampleBufferDisplayLayer` + `AVPictureInPictureController.ContentSource` (the same path WebRTC video-call apps use).
|
|
7
|
-
- Native Android PiP via `enterPictureInPictureMode`.
|
|
8
|
-
- Mirrors the `expo-video` JSX surface so the existing `BackgroundVideoPlayer.jsx` can be migrated with a one-line import swap.
|
|
5
|
+
Under the hood:
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
| Platform | Engine | Default version | Min OS |
|
|
8
|
+
| -------- | ----------------------------------- | --------------- | --------- |
|
|
9
|
+
| iOS | [MobileVLCKit](https://code.videolan.org/videolan/VLCKit) | `~3.7.3` | iOS 16.4+ |
|
|
10
|
+
| Android | [libVLC](https://code.videolan.org/videolan/vlc-android) | `3.6.0` | API 26+ |
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
The JS surface intentionally mirrors `expo-video` so an existing `<VideoView>` consumer (e.g. `BackgroundVideoPlayer.jsx`) can be migrated with a one-line import swap.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
---
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## Table of contents
|
|
17
|
+
|
|
18
|
+
- [Why?](#why)
|
|
19
|
+
- [Supported containers](#supported-containers)
|
|
20
|
+
- [Supported features](#supported-features)
|
|
21
|
+
- [Picture-in-Picture (PiP)](#picture-in-picture-pip)
|
|
22
|
+
- [Explicitly out of scope](#explicitly-out-of-scope)
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Usage](#usage)
|
|
25
|
+
- [API reference](#api-reference)
|
|
26
|
+
- [Plugin options](#plugin-options)
|
|
27
|
+
- [Background audio & iOS session](#background-audio--ios-session)
|
|
28
|
+
- [Bundle size impact](#bundle-size-impact)
|
|
29
|
+
- [Platform support matrix](#platform-support-matrix)
|
|
30
|
+
- [Known limitations](#known-limitations)
|
|
31
|
+
- [Troubleshooting](#troubleshooting)
|
|
32
|
+
- [Roadmap](#roadmap)
|
|
33
|
+
- [License](#license)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Why?
|
|
38
|
+
|
|
39
|
+
`expo-video` (backed by `AVPlayer` on iOS and `ExoPlayer` on Android) is the default RN video stack — and the right choice in most apps. But it cannot decode several containers IPTV, anime-fansub, and many other long-tail sources ship with:
|
|
40
|
+
|
|
41
|
+
- **MKV / Matroska** with H.264, H.265/HEVC, VP9, AV1
|
|
42
|
+
- **AVI** (DivX, Xvid, older codecs)
|
|
43
|
+
- **FLV** (Flash video, still common in live IPTV)
|
|
44
|
+
- **WMV** (Windows Media)
|
|
45
|
+
- **WebM** with VP8/VP9/Opus
|
|
46
|
+
- **RM / RMVB** (RealMedia)
|
|
47
|
+
- **VOB** (DVD)
|
|
48
|
+
- **3GP** (legacy mobile)
|
|
49
|
+
- **OGG / OGV** (Theora/Vorbis)
|
|
50
|
+
- **M3U8 / TS** HLS streams that `AVPlayer` rejects (some non-standard muxings, HEVC over HLS, etc.)
|
|
51
|
+
|
|
52
|
+
`extended-vlc-player` exists for the cases where the default `expo-video` stack throws `AVFoundationErrorDomain Code=-11828 "Cannot Open"` and you need the broadest possible "it just plays" surface. The two players can **coexist in the same app** — keep `expo-video` for DRM content and use this module for everything else (see [Explicitly out of scope](#explicitly-out-of-scope)).
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Supported containers
|
|
57
|
+
|
|
58
|
+
Everything MobileVLCKit / libVLC can demux. In practice that means:
|
|
59
|
+
|
|
60
|
+
| Container | Extensions | Notes |
|
|
61
|
+
| ----------- | ---------------------- | ----- |
|
|
62
|
+
| MP4 | `.mp4`, `.m4v` | Also handles HEVC, AV1 |
|
|
63
|
+
| MOV | `.mov` | QuickTime, ProRes |
|
|
64
|
+
| Matroska | `.mkv`, `.mk3d`, `.mka` | Multi-audio, multi-subtitle, chapters |
|
|
65
|
+
| WebM | `.webm` | VP8 / VP9 / AV1 / Opus |
|
|
66
|
+
| AVI | `.avi` | DivX, Xvid, legacy |
|
|
67
|
+
| FLV | `.flv` | Common in IPTV |
|
|
68
|
+
| Windows Media | `.wmv`, `.asf` | |
|
|
69
|
+
| OGG | `.ogv`, `.ogg` | Theora / Vorbis |
|
|
70
|
+
| 3GP | `.3gp`, `.3g2` | Legacy mobile |
|
|
71
|
+
| MPEG-TS | `.ts`, `.m2ts`, `.mts` | Raw transport streams |
|
|
72
|
+
| HLS | `.m3u8` | VLC handles muxings AVPlayer rejects |
|
|
73
|
+
| VOB | `.vob` | DVD |
|
|
74
|
+
| RealMedia | `.rm`, `.rmvb` | Limited support, depends on build |
|
|
75
|
+
|
|
76
|
+
> Codec coverage comes from the underlying VLC build, not from this module. MobileVLCKit 3.7.x and libVLC 3.6.x both ship the ffmpeg-based demuxer stack, so the same codec matrix applies on iOS and Android.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Supported features
|
|
81
|
+
|
|
82
|
+
| Feature | iOS | Android |
|
|
83
|
+
| -------------------------------------- | --- | ------- |
|
|
84
|
+
| Play / pause / stop | ✅ | ✅ |
|
|
85
|
+
| Seek by seconds | ✅ | ✅ |
|
|
86
|
+
| Playback rate (0.1x – 4x) | ✅ | ✅ |
|
|
87
|
+
| Volume control (0 – 1) | ✅ | ✅ |
|
|
88
|
+
| Audio track selection (by index) | ✅ | ✅ |
|
|
89
|
+
| Subtitle track selection (by index) | ✅ | ✅ |
|
|
90
|
+
| `replace()` source swap without remount | ✅ | ✅ |
|
|
91
|
+
| `contentFit`: contain / cover / fill | ✅ | ✅ |
|
|
92
|
+
| Custom HTTP headers per source | ✅ | ✅ |
|
|
93
|
+
| Network / live / file caching (1.5s) | ✅ | ✅ |
|
|
94
|
+
| Progress / time / duration events | ✅ | ✅ |
|
|
95
|
+
| Buffering events | ✅ | ✅ |
|
|
96
|
+
| Play / pause / ended / error events | ✅ | ✅ |
|
|
97
|
+
| System Picture-in-Picture (PiP) | ✅ (see below) | ✅ (API 26+) |
|
|
98
|
+
| Background audio session | ✅ | ⚠️ (see below) |
|
|
99
|
+
| Foreground service / media notification | ❌ | ⚠️ (planned) |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Picture-in-Picture (PiP)
|
|
104
|
+
|
|
105
|
+
PiP is the headline differentiator of this module. iOS PiP is hard to get right for non-`AVPlayer` renderers, so this section is intentionally detailed.
|
|
106
|
+
|
|
107
|
+
### iOS
|
|
108
|
+
|
|
109
|
+
`AVPictureInPictureController` only accepts content from a `PlayerLayer`, `AVSampleBufferDisplayLayer`, or a manually constructed `ContentSource`. `MobileVLCKit` exposes neither a `CALayer` nor a `CVPixelBuffer` of the decoded frame — its only public "frame out" is the snapshot API.
|
|
110
|
+
|
|
111
|
+
The module bridges that gap with a three-step pipeline:
|
|
112
|
+
|
|
113
|
+
1. `PlayerSession.snapshotTick` (a `CADisplayLink` at the display refresh rate) calls `VLCMediaPlayer.saveVideoSnapshot(at:withWidth:andHeight:)` to capture the current frame as a JPEG.
|
|
114
|
+
2. `PipBridge.feed(image:)` decodes the `UIImage`, draws it into a pooled `CVPixelBuffer` via Core Graphics, wraps it in a `CMSampleBuffer`, and enqueues it on an `AVSampleBufferDisplayLayer` that is parented inside the player view.
|
|
115
|
+
3. `AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer:playbackDelegate:)` uses that layer as the PiP source — the same path WebRTC video-call apps use. The `SampleBufferPlaybackDelegate` forwards the iOS "play/pause/seek from PiP overlay" gestures back to the VLC media player, so the system controls in the PiP window actually work.
|
|
116
|
+
|
|
117
|
+
Why not go straight from VLC → `CVPixelBuffer`? MobileVLCKit does not expose the underlying `CVPixelBufferRef` of a decoded frame. A snapshot-bridge is the only path that doesn't require forking the VLCKit pod.
|
|
118
|
+
|
|
119
|
+
Trade-offs:
|
|
120
|
+
|
|
121
|
+
- **Latency** — the snapshot path is one frame behind the live drawable. In practice the user-perceivable delay is sub-100 ms.
|
|
122
|
+
- **CPU** — the bridge re-encodes each snapshot to BGRA pixel buffers at 30+ fps. On older devices this shows up as ~3-5% sustained CPU while PiP is active. The iOS PiP overlay caps its own frame rate at 30 fps, which keeps this manageable.
|
|
123
|
+
- **Drift** — `AVPictureInPictureController` re-evaluates the sample buffer cadence itself; we only need to keep feeding the layer.
|
|
124
|
+
|
|
125
|
+
### Android
|
|
126
|
+
|
|
127
|
+
Native PiP via `Activity.enterPictureInPictureMode(PictureInPictureParams)`. The `app.plugin.js` patches `MainActivity` to add `android:supportsPictureInPicture="true"` and the `configChanges` set required for PiP to actually work, so the JS side only has to call `player.startPictureInPicture()`.
|
|
128
|
+
|
|
129
|
+
### PiP API
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
await player.startPictureInPicture(); // returns true if entered
|
|
133
|
+
await player.stopPictureInPicture(); // returns true if it was active
|
|
134
|
+
const active = await player.isPictureInPictureActive();
|
|
135
|
+
const supported = await player.isPictureInPictureSupported(); // device + OS check
|
|
136
|
+
|
|
137
|
+
<ExtendedVlcPlayerView
|
|
138
|
+
player={player}
|
|
139
|
+
onPictureInPictureStart={() => console.log('PiP started')}
|
|
140
|
+
onPictureInPictureStop={() => console.log('PiP stopped')}
|
|
141
|
+
/>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Explicitly out of scope
|
|
147
|
+
|
|
148
|
+
These are **not** in this module. If you need them, keep using `expo-video` alongside it.
|
|
149
|
+
|
|
150
|
+
- **DRM** — Widevine (Android) and FairPlay (iOS). libVLC has no DRM path. The `drm` field on `ExtendedVlcSource` is accepted for API parity but is ignored; if a DRM source reaches the player it will emit `onError` and you should fall back to `expo-video`.
|
|
151
|
+
- **Apple TV / Android TV** — the module is phone/tablet only. A separate `ExtendedVlcPlayerTV` Fabric component can be added later by reusing the same `MobileVLCKit` / `libVLC` pod and adding a tvOS-targeted build configuration.
|
|
152
|
+
- **Web** — there is no WebAssembly VLC build wired into this module.
|
|
153
|
+
- **AirPlay / Chromecast** — VLC has its own renderer / output subsystems but they are not exposed.
|
|
154
|
+
- **Recording** — the module does not write a transcoded output.
|
|
155
|
+
- **DASH / MSS adaptive streaming** — only HLS via the container layer is exposed; DASH can be added by routing the manifest URL through `VLCMedia` explicitly.
|
|
156
|
+
|
|
157
|
+
The two players can sit side-by-side in the same app:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
const useVlc = prefersWideFormat || knownUnplayableOnAVPlayer;
|
|
161
|
+
|
|
162
|
+
if (useVlc) {
|
|
163
|
+
const player = useExtendedVlcPlayer(source);
|
|
164
|
+
return <ExtendedVlcPlayerView player={player} style={{ flex: 1 }} />;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return <VideoView player={expoVideoPlayer} style={{ flex: 1 }} />;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
17
171
|
|
|
18
172
|
## Install
|
|
19
173
|
|
|
20
|
-
In `
|
|
174
|
+
In the host app's `package.json`:
|
|
21
175
|
|
|
22
176
|
```json
|
|
23
177
|
"dependencies": {
|
|
@@ -25,7 +179,7 @@ In `mobile/package.json`:
|
|
|
25
179
|
}
|
|
26
180
|
```
|
|
27
181
|
|
|
28
|
-
Then
|
|
182
|
+
Then in `app.json`:
|
|
29
183
|
|
|
30
184
|
```json
|
|
31
185
|
"plugins": [
|
|
@@ -36,10 +190,27 @@ Then add the plugin to `app.json`:
|
|
|
36
190
|
]
|
|
37
191
|
```
|
|
38
192
|
|
|
39
|
-
|
|
193
|
+
Then wire the native side:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npx expo prebuild --clean
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The plugin (`app.plugin.js`) does the following, idempotently:
|
|
200
|
+
|
|
201
|
+
- **iOS Podfile** — adds `pod 'MobileVLCKit', '~> 3.7.3'` and a `post_install` hook that pins `IPHONEOS_DEPLOYMENT_TARGET = 16.4` on the MobileVLCKit target.
|
|
202
|
+
- **iOS Info.plist** — adds `"audio"` to `UIBackgroundModes` so the audio session is eligible for background playback and PiP.
|
|
203
|
+
- **Android `android/app/build.gradle`** — adds `implementation "org.videolan.android:libvlc:3.6.0"`. ABI filters are inherited from the host app.
|
|
204
|
+
- **Android `AndroidManifest.xml`** — adds `android:supportsPictureInPicture="true"` and the `configChanges` set to `MainActivity`.
|
|
205
|
+
|
|
206
|
+
To publish to npm, run `npm publish` from the module root. To consume from a local checkout, `file:` works during development and you should switch to the registry version before shipping (see [Troubleshooting](#troubleshooting)).
|
|
207
|
+
|
|
208
|
+
---
|
|
40
209
|
|
|
41
210
|
## Usage
|
|
42
211
|
|
|
212
|
+
Minimal:
|
|
213
|
+
|
|
43
214
|
```tsx
|
|
44
215
|
import { useExtendedVlcPlayer, ExtendedVlcPlayerView } from 'extended-vlc-player';
|
|
45
216
|
|
|
@@ -49,11 +220,234 @@ function MyPlayer({ uri }: { uri: string }) {
|
|
|
49
220
|
}
|
|
50
221
|
```
|
|
51
222
|
|
|
223
|
+
With the full event surface:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import {
|
|
227
|
+
useExtendedVlcPlayer,
|
|
228
|
+
ExtendedVlcPlayerView,
|
|
229
|
+
type ExtendedVlcSource,
|
|
230
|
+
} from 'extended-vlc-player';
|
|
231
|
+
|
|
232
|
+
const source: ExtendedVlcSource = {
|
|
233
|
+
uri: 'https://example.com/video.mkv',
|
|
234
|
+
headers: { 'X-Auth-Token': 'abc' },
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function ChannelPlayer({ uri }: { uri: string }) {
|
|
238
|
+
const player = useExtendedVlcPlayer(source, {
|
|
239
|
+
onReady: (p) => p.setVolume(0.8),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<ExtendedVlcPlayerView
|
|
244
|
+
player={player}
|
|
245
|
+
style={{ flex: 1 }}
|
|
246
|
+
contentFit="contain"
|
|
247
|
+
onLoad={(e) => {
|
|
248
|
+
console.log(`duration=${e.duration}s, ${e.audioTracks.length} audio tracks`);
|
|
249
|
+
}}
|
|
250
|
+
onProgress={({ currentTime, duration }) => {
|
|
251
|
+
// throttle as needed
|
|
252
|
+
}}
|
|
253
|
+
onBuffering={({ isBuffering }) => setLoading(isBuffering)}
|
|
254
|
+
onError={(err) => console.warn('VLC error:', err)}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Migrating from `expo-video`:
|
|
261
|
+
|
|
262
|
+
```diff
|
|
263
|
+
-import { useVideoPlayer, VideoView } from 'expo-video';
|
|
264
|
+
+import { useExtendedVlcPlayer, ExtendedVlcPlayerView } from 'extended-vlc-player';
|
|
265
|
+
|
|
266
|
+
-const player = useVideoPlayer(uri, p => { p.play(); });
|
|
267
|
+
- <VideoView player={player} style={{ flex: 1 }} />
|
|
268
|
+
+const player = useExtendedVlcPlayer(uri);
|
|
269
|
+
+ <ExtendedVlcPlayerView player={player} style={{ flex: 1 }} />
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Player method names (`play` / `pause` / `seek` / `setRate` / `setVolume`) are identical to `expo-video`, so the surrounding controls keep working unchanged.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## API reference
|
|
277
|
+
|
|
278
|
+
### `useExtendedVlcPlayer(source, options?)`
|
|
279
|
+
|
|
280
|
+
Returns a stable player object. Methods are closures over the current native instance; the object identity does not change across renders, so it is safe in `useEffect` dependency arrays.
|
|
281
|
+
|
|
282
|
+
| Method | Returns | Description |
|
|
283
|
+
| --------------------------------------- | ---------------------- | ----------- |
|
|
284
|
+
| `play()` | `void` | Start playback. |
|
|
285
|
+
| `pause()` | `void` | Pause. |
|
|
286
|
+
| `stop()` | `void` | Stop and detach the current media. |
|
|
287
|
+
| `seek(seconds: number)` | `void` | Jump to a wall-clock position in seconds. |
|
|
288
|
+
| `setRate(rate: number)` | `void` | Playback rate multiplier (0.1 – 4.0, clamped). |
|
|
289
|
+
| `setVolume(volume: number)` | `void` | `0` – `1`, mapped to VLC's internal `0` – `100` / `200` scale. |
|
|
290
|
+
| `setAudioTrack(index: number)` | `void` | Index from the most recent `onLoad` payload's `audioTracks`, or `-1` to disable. |
|
|
291
|
+
| `setSubtitleTrack(index: number)` | `void` | Same for `textTracks`, or `-1` to disable. |
|
|
292
|
+
| `replace(source)` | `void` | Swap media without remounting the view. |
|
|
293
|
+
| `startPictureInPicture()` | `Promise<boolean>` | Enters the system PiP overlay. |
|
|
294
|
+
| `stopPictureInPicture()` | `Promise<boolean>` | Exits PiP. |
|
|
295
|
+
| `isPictureInPictureActive()` | `Promise<boolean>` | Whether the PiP controller is currently active. |
|
|
296
|
+
| `isPictureInPictureSupported()` | `Promise<boolean>` | Whether the device + OS support PiP. |
|
|
297
|
+
|
|
298
|
+
### `<ExtendedVlcPlayerView>`
|
|
299
|
+
|
|
300
|
+
| Prop | Type | Default | Description |
|
|
301
|
+
| ------------------------------- | --------------------- | ------------ | ----------- |
|
|
302
|
+
| `player` | `ExtendedVlcPlayer` | required | From `useExtendedVlcPlayer`. |
|
|
303
|
+
| `style` | `ViewStyle` | — | Layout style. |
|
|
304
|
+
| `contentFit` | `'contain' \| 'cover' \| 'fill'` | `'contain'` | How the video is scaled within the view. |
|
|
305
|
+
| `onLoad` | `(e) => void` | — | Fired once VLC has parsed the media. `e.duration` in seconds, plus `audioTracks` and `textTracks`. |
|
|
306
|
+
| `onProgress` | `(e) => void` | — | Time updates. `e.currentTime`, `e.duration`, `e.position` (0..1). |
|
|
307
|
+
| `onPlaying` | `(e) => void` | — | First `playing` state. |
|
|
308
|
+
| `onPaused` | `(e) => void` | — | `e.target` is the position the user paused at. |
|
|
309
|
+
| `onEnded` | `() => void` | — | `stopped` or `ended` state reached. |
|
|
310
|
+
| `onError` | `(e) => void` | — | `e.message`, `e.code`, `e.domain`. |
|
|
311
|
+
| `onBuffering` | `(e) => void` | — | `e.isBuffering`. |
|
|
312
|
+
| `onPictureInPictureStart` | `() => void` | — | |
|
|
313
|
+
| `onPictureInPictureStop` | `() => void` | — | |
|
|
314
|
+
|
|
315
|
+
### `ExtendedVlcSource`
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
type ExtendedVlcSource = string | {
|
|
319
|
+
uri: string;
|
|
320
|
+
headers?: Record<string, string>;
|
|
321
|
+
/** Accepted for API parity. Ignored — VLC has no DRM path. */
|
|
322
|
+
drm?: unknown;
|
|
323
|
+
};
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Plugin options
|
|
329
|
+
|
|
330
|
+
All options are optional. Defaults match what the plugin is pinned to in CI.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
[
|
|
334
|
+
'extended-vlc-player',
|
|
335
|
+
{
|
|
336
|
+
ios: {
|
|
337
|
+
// MobileVLCKit pod version. Locked to ~3.7.x.
|
|
338
|
+
mobileVlcKitVersion: '3.7.3',
|
|
339
|
+
// Whether to embed bitcode. App Store no longer accepts bitcode;
|
|
340
|
+
// this is here for completeness and is effectively a no-op.
|
|
341
|
+
enableBitcode: false,
|
|
342
|
+
},
|
|
343
|
+
android: {
|
|
344
|
+
// org.videolan.android:libvlc version.
|
|
345
|
+
libVlcVersion: '3.6.0',
|
|
346
|
+
},
|
|
347
|
+
pip: {
|
|
348
|
+
// Reserved for the snapshot bridge. The current bridge runs at the
|
|
349
|
+
// display refresh rate; future revisions may sample down.
|
|
350
|
+
snapshotFps: 30,
|
|
351
|
+
// Reserved for the snapshot bridge. 'low' | 'medium' | 'high'.
|
|
352
|
+
snapshotQuality: 'medium',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
]
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Background audio & iOS session
|
|
361
|
+
|
|
362
|
+
iOS rejects background audio unless `UIBackgroundModes` includes `"audio"`. The plugin adds it idempotently on `npx expo prebuild`, so the VLC media player keeps playing and the PiP window keeps being controllable while the user backgrounds the app.
|
|
363
|
+
|
|
364
|
+
On Android, libVLC continues decoding as long as the host activity is alive, but the module does **not** ship a foreground media notification service yet. If the user switches apps, playback is at the mercy of the system's process priority. See the [Roadmap](#roadmap).
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
52
368
|
## Bundle size impact
|
|
53
369
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
|
57
|
-
|
|
|
370
|
+
The VLC engines are large — they ship the ffmpeg-based demuxer stack with most codecs. Plan the budget accordingly.
|
|
371
|
+
|
|
372
|
+
| Platform | Pre-existing | After install (raw) | After install (user-visible) |
|
|
373
|
+
| ------------------------ | ------------ | ------------------- | --------------------------- |
|
|
374
|
+
| iOS IPA | ~50 – 60 MB | **+50 – 65 MB** (MobileVLCKit) | **~25 – 35 MB** (App Thinning) |
|
|
375
|
+
| Android AAB per-ABI | ~30 – 40 MB | **+30 – 40 MB** (libVLC `.so`) | **~10 – 15 MB** (after ABI splits) |
|
|
376
|
+
|
|
377
|
+
The **download** size still grows by the pre-split number; App Thinning and ABI splits only reduce the *installed* footprint. If you can detect a stream is MP4/H.264 with AAC, prefer `expo-video` and keep the VLC engine for fallback only.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Platform support matrix
|
|
382
|
+
|
|
383
|
+
| | iOS | Android |
|
|
384
|
+
| ------------------------ | -------------------------------- | -------------------------------- |
|
|
385
|
+
| OS minimum | 16.4 | API 26 (Android 8.0) |
|
|
386
|
+
| New Architecture (Fabric)| ✅ required | ✅ required |
|
|
387
|
+
| Expo | SDK 57 | SDK 57 |
|
|
388
|
+
| React Native | 0.86 | 0.86 |
|
|
389
|
+
| Architectures (Android) | — | `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64` (filtered by host app) |
|
|
390
|
+
| PiP | ✅ via sample-buffer bridge | ✅ via `enterPictureInPictureMode` |
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## Known limitations
|
|
395
|
+
|
|
396
|
+
- **iOS PiP is one frame behind** the live drawable due to the snapshot bridge (sub-100 ms in practice).
|
|
397
|
+
- **No DRM.** `Widevine` / `FairPlay` sources will fail with `onError`; use `expo-video` for them.
|
|
398
|
+
- **Apple TV / Android TV** are not built. The host platform decides whether to instantiate this module; the `iOS` config in `expo-module.config.json` currently lists only `apple` (phone) targets, not `appletvos`.
|
|
399
|
+
- **Android background audio** is not yet bound to a foreground service. Long-running audio in the background may be paused by the system.
|
|
400
|
+
- **Snapshot-bridge CPU** on iOS is ~3-5% sustained while PiP is active. Older devices (iPhone 8 / X) may see a small thermal impact.
|
|
401
|
+
- **The `audioTracks` / `textTracks` payloads** in the `onLoad` event currently expose only `{ index }` — the human-readable label / language / codec are typed in the JS surface (`ExtendedVlcTrack`) but the iOS bridge does not yet read them from `MobileVLCKit` (Android is the same). A future patch adds the `audioTrackNames` / `videoSubTitlesNames` arrays.
|
|
402
|
+
- **`file:` npm installs** in a monorepo can pull duplicate transitive deps. See [Troubleshooting](#troubleshooting).
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Troubleshooting
|
|
407
|
+
|
|
408
|
+
- **"Native module is not available"** — run `npx expo prebuild --clean` and rebuild. If the host app was generated before the plugin was added, the prebuild was skipped; rerun it so the Podfile / build.gradle / AndroidManifest changes land.
|
|
409
|
+
- **PiP is supported on the device but `startPictureInPicture()` returns false** — on iOS, the underlying drawable may not be in the view hierarchy. Ensure the `ExtendedVlcPlayerView` is mounted and visible at least once before calling `startPictureInPicture`. The view's `onLoad` is a safe trigger.
|
|
410
|
+
- **MKV streams still fail with "Cannot Open" on iOS** — the iOS path goes through `AVSampleBufferDisplayLayer` for PiP but the actual decode is still done by MobileVLCKit. If the stream fails, the `onError` event carries the VLC error message; check it before assuming a codec issue.
|
|
411
|
+
- **`expo-doctor` flags `expo-modules-core` as a direct dep** — this module imports from `expo-modules-core` (`requireNativeModule`) and lists it as a `peerDependency`, which the npm resolver forces onto the consumer. The flag is a known false positive. It is fixed in the published module by removing `expo-modules-core` from `peerDependencies` so it is resolved transitively via `expo`.
|
|
412
|
+
- **`npm install file:../extended-vlc-player` brings duplicate deps** — when you eventually pin to the published version, use `npm install extended-vlc-player@<published-version> --save-exact` to force npm to re-resolve from the registry and dedupe.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Roadmap
|
|
417
|
+
|
|
418
|
+
Ordered roughly by near-term value. Nothing here is a promise — items depend on user demand and the upstream VLC release cadence.
|
|
419
|
+
|
|
420
|
+
### Near term
|
|
421
|
+
|
|
422
|
+
- **Apple TV / Android TV** — `ExtendedVlcPlayerTV` Fabric component reusing the same `MobileVLCKit` / `libVLC` pod with a `tvOSTargetOSVersion` build config.
|
|
423
|
+
- **Track label enrichment** — populate `label` / `language` / `codec` on `ExtendedVlcTrack` from `audioTrackNames` / `videoSubTitlesNames` and the `MediaPlayer.TrackDescription` API.
|
|
424
|
+
- **Android background audio** — foreground service + `MediaSession` so playback survives the app being swiped away.
|
|
425
|
+
- **DASH manifest support** — route `*.mpd` URLs through `VLCMedia` explicitly so the demuxer sees a DASH source rather than a generic file.
|
|
426
|
+
|
|
427
|
+
### Medium term
|
|
428
|
+
|
|
429
|
+
- **Hardware-accelerated iOS PiP bridge** — drop the snapshot path by tapping into MobileVLCKit's internal `CVPixelBufferRef` once a stable private API is documented; expected to cut PiP CPU from ~3-5% to < 1%.
|
|
430
|
+
- **LL-HLS tuning** — lower the live cache window for low-latency HLS sources, expose `--http-reconnect` and `--network-caching` knobs per-source.
|
|
431
|
+
- **AirPlay routing** — secondary display via VLC's `VDPAU` / `mmal` output path. Currently a no-op.
|
|
432
|
+
- **Chromecast support** — the receiver side needs a custom cast app; the module will expose a `castUrl` shortcut.
|
|
433
|
+
|
|
434
|
+
### Long term
|
|
435
|
+
|
|
436
|
+
- **Web build** — emscripten / WebAssembly VLC for Expo Web, behind the same `useExtendedVlcPlayer` hook.
|
|
437
|
+
- **Recording / transcoding** — a `recordToFile()` API that wires `VLCMediaPlayer`'s `media`-output path.
|
|
438
|
+
- **Latency-targeted mode** for live IPTV (HLS LL / WebRTC ingest), including `jitter-buffer` / `live-caching` tuning per source.
|
|
439
|
+
- **Adaptive ABR** — surface libVLC's adaptive logic as JS events so the app can render its own quality switcher.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## License
|
|
444
|
+
|
|
445
|
+
MIT — see [`LICENSE`](./LICENSE). MobileVLCKit and libVLC are LGPL-2.1-or-later; their licenses are inherited by the engines that this module links against, not by this module's source.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Related
|
|
58
450
|
|
|
59
|
-
|
|
451
|
+
- `expo-video` — the default RN video player. Use alongside this one for DRM content.
|
|
452
|
+
- [MobileVLCKit](https://code.videolan.org/videolan/VLCKit) — iOS engine.
|
|
453
|
+
- [libVLC for Android](https://code.videolan.org/videolan/vlc-android/-/tree/master/libvlc) — Android engine.
|
package/android/build.gradle
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
// Top-level build file for the `extended-vlc-player` local Expo Module.
|
|
2
|
-
buildscript {
|
|
3
|
-
repositories {
|
|
4
|
-
google()
|
|
5
|
-
mavenCentral()
|
|
6
|
-
}
|
|
7
|
-
dependencies {
|
|
8
|
-
classpath 'com.android.tools.build:gradle'
|
|
9
|
-
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin'
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
1
|
apply plugin: 'com.android.library'
|
|
14
2
|
apply plugin: 'kotlin-android'
|
|
15
3
|
apply plugin: 'expo-module-gradle-plugin'
|
|
@@ -20,8 +8,8 @@ version = '0.1.0'
|
|
|
20
8
|
android {
|
|
21
9
|
namespace 'expo.modules.extendedvlcplayer'
|
|
22
10
|
defaultConfig {
|
|
23
|
-
|
|
24
|
-
|
|
11
|
+
versionCode 1
|
|
12
|
+
versionName '0.1.0'
|
|
25
13
|
}
|
|
26
14
|
// The libVLC .so files ship native code for four ABIs. The Expo app
|
|
27
15
|
// already filters to its own ABIs in android/app/build.gradle; the host
|
|
@@ -32,5 +20,5 @@ android {
|
|
|
32
20
|
}
|
|
33
21
|
|
|
34
22
|
dependencies {
|
|
35
|
-
implementation 'org.videolan.android:libvlc:3.6.0'
|
|
23
|
+
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
|
36
24
|
}
|
|
@@ -24,7 +24,7 @@ class ExtendedVlcPlayerModule : Module() {
|
|
|
24
24
|
|
|
25
25
|
AsyncFunction("isPictureInPictureSupported") {
|
|
26
26
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
|
27
|
-
appContext.
|
|
27
|
+
appContext.currentActivity?.application?.packageManager?.hasSystemFeature(
|
|
28
28
|
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
|
|
29
29
|
) == true
|
|
30
30
|
}
|
|
@@ -76,7 +76,7 @@ class ExtendedVlcPlayerModule : Module() {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
AsyncFunction("startPictureInPicture") { instanceId: Int ->
|
|
79
|
-
val activity = appContext.
|
|
79
|
+
val activity = appContext.currentActivity ?: return@AsyncFunction false
|
|
80
80
|
val session = PlayerRegistry.session(instanceId) ?: return@AsyncFunction false
|
|
81
81
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@AsyncFunction false
|
|
82
82
|
val params = PictureInPictureParams.Builder()
|
|
@@ -49,9 +49,10 @@ class PlayerSession(
|
|
|
49
49
|
init {
|
|
50
50
|
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
|
|
51
51
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
|
52
|
-
mediaPlayer.
|
|
52
|
+
val vout = mediaPlayer.getVLCVout()
|
|
53
|
+
vout.setVideoView(surfaceView)
|
|
53
54
|
if (!isAttached) {
|
|
54
|
-
|
|
55
|
+
vout.attachViews()
|
|
55
56
|
isAttached = true
|
|
56
57
|
}
|
|
57
58
|
mediaPlayer.play()
|
|
@@ -60,36 +61,40 @@ class PlayerSession(
|
|
|
60
61
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
|
61
62
|
|
|
62
63
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
|
63
|
-
mediaPlayer.
|
|
64
|
+
val vout = mediaPlayer.getVLCVout()
|
|
65
|
+
if (vout.areViewsAttached()) {
|
|
66
|
+
vout.detachViews()
|
|
67
|
+
}
|
|
68
|
+
isAttached = false
|
|
64
69
|
}
|
|
65
70
|
})
|
|
66
71
|
|
|
67
72
|
mediaPlayer.setEventListener { event ->
|
|
68
73
|
when (event.type) {
|
|
69
|
-
MediaPlayer.Event.Playing -> onPlaying?.invoke(mapOf("duration" to (
|
|
74
|
+
MediaPlayer.Event.Playing -> onPlaying?.invoke(mapOf("duration" to durationSeconds()))
|
|
70
75
|
MediaPlayer.Event.Paused -> onPaused?.invoke(emptyMap())
|
|
71
76
|
MediaPlayer.Event.Stopped -> onEnded?.invoke()
|
|
72
77
|
MediaPlayer.Event.EndReached -> onEnded?.invoke()
|
|
73
78
|
MediaPlayer.Event.EncounteredError -> onError?.invoke(
|
|
74
79
|
mapOf(
|
|
75
|
-
"message" to
|
|
80
|
+
"message" to "libVLC error",
|
|
76
81
|
"code" to "VLC_ERROR",
|
|
77
82
|
"domain" to "libVLC"
|
|
78
83
|
)
|
|
79
84
|
)
|
|
80
|
-
MediaPlayer.Event.Buffering -> onBuffering?.invoke(mapOf("isBuffering" to event.
|
|
85
|
+
MediaPlayer.Event.Buffering -> onBuffering?.invoke(mapOf("isBuffering" to (event.getBuffering() < 100.0f)))
|
|
81
86
|
MediaPlayer.Event.Opening -> onLoad?.invoke(
|
|
82
87
|
mapOf(
|
|
83
|
-
"duration" to (
|
|
88
|
+
"duration" to durationSeconds(),
|
|
84
89
|
"audioTracks" to emptyList<Map<String, Any>>(),
|
|
85
90
|
"textTracks" to emptyList<Map<String, Any>>()
|
|
86
91
|
)
|
|
87
92
|
)
|
|
88
93
|
MediaPlayer.Event.TimeChanged -> onProgress?.invoke(
|
|
89
94
|
mapOf(
|
|
90
|
-
"currentTime" to (event.
|
|
91
|
-
"duration" to (
|
|
92
|
-
"position" to (if (
|
|
95
|
+
"currentTime" to (event.getTimeChanged() / 1000.0),
|
|
96
|
+
"duration" to durationSeconds(),
|
|
97
|
+
"position" to (if (mediaPlayer.getLength() > 0) event.getTimeChanged().toDouble() / mediaPlayer.getLength() else 0.0)
|
|
93
98
|
)
|
|
94
99
|
)
|
|
95
100
|
}
|
|
@@ -103,6 +108,8 @@ class PlayerSession(
|
|
|
103
108
|
mediaPlayer.media = media
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
private fun durationSeconds(): Double = mediaPlayer.getLength() / 1000.0
|
|
112
|
+
|
|
106
113
|
fun play() = mediaPlayer.play()
|
|
107
114
|
fun pause() = mediaPlayer.pause()
|
|
108
115
|
fun stop() = mediaPlayer.stop()
|
|
@@ -163,6 +170,3 @@ class PlayerSession(
|
|
|
163
170
|
libVlc.release()
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
|
-
|
|
167
|
-
// Helper to convert Kotlin Array to Java Array (libVLC's attachViews is Java).
|
|
168
|
-
private inline fun <reified T> Array<out T>.toJavaArray(): Array<T> = this as Array<T>
|
package/app.plugin.js
CHANGED
|
@@ -65,7 +65,7 @@ function withIosPods(config, options) {
|
|
|
65
65
|
// ---- iOS Info.plist -------------------------------------------------------
|
|
66
66
|
|
|
67
67
|
const INFOPLIST_TAG = 'extended-vlc-player-info-plist';
|
|
68
|
-
const INFOPLIST_AUDIO_SESSION = '
|
|
68
|
+
const INFOPLIST_AUDIO_SESSION = '// extended-vlc-player: keep audio session active for PiP and background playback';
|
|
69
69
|
|
|
70
70
|
function withIosInfoPlist(config) {
|
|
71
71
|
return withInfoPlist(config, (infoPlist) => {
|
|
@@ -80,7 +80,7 @@ function withIosInfoPlist(config) {
|
|
|
80
80
|
// ---- Android build.gradle ------------------------------------------------
|
|
81
81
|
|
|
82
82
|
const GRADLE_TAG = 'extended-vlc-player-gradle';
|
|
83
|
-
const GRADLE_VLC = (vlcVersion) => ` implementation "org.videolan.android:libvlc:${vlcVersion}"`;
|
|
83
|
+
const GRADLE_VLC = (vlcVersion) => ` implementation "org.videolan.android:libvlc-all:${vlcVersion}"`;
|
|
84
84
|
|
|
85
85
|
function withAndroidGradle(config, options) {
|
|
86
86
|
const vlcVersion = (options?.android?.libVlcVersion || '3.6.0').toString();
|
|
@@ -24,6 +24,8 @@ Pod::Spec.new do |s|
|
|
|
24
24
|
s.source_files = '**/*.{h,m,mm,swift}'
|
|
25
25
|
s.pod_target_xcconfig = {
|
|
26
26
|
'DEFINES_MODULE' => 'YES',
|
|
27
|
+
'CLANG_CXX_LANGUAGE_STANDARD' => 'c++20',
|
|
28
|
+
'CLANG_CXX_LIBRARY' => 'libc++',
|
|
27
29
|
'SWIFT_OBJC_INTERFACE_HEADER_NAME' => 'ExtendedVlcPlayer-Swift.h',
|
|
28
30
|
'OTHER_SWIFT_FLAGS' => '$(inherited) -D COCOAPODS -Xfrontend -module-name -Xfrontend ExtendedVlcPlayer',
|
|
29
31
|
# The host app's Podfile already enables use_frameworks! :linkage => :static
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "extended-vlc-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "React Native video player built on MobileVLCKit (iOS) and libVLC (Android) with true iOS Picture-in-Picture via AVSampleBufferDisplayLayer bridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "build/index.js",
|