extended-vlc-player 0.1.0
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/LICENSE +21 -0
- package/README.md +59 -0
- package/android/build.gradle +36 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerModule.kt +99 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerViewComponentView.kt +82 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerRegistry.kt +47 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerSession.kt +168 -0
- package/app.plugin.js +144 -0
- package/build/ExtendedVlcPlayerView.d.ts +8 -0
- package/build/ExtendedVlcPlayerView.d.ts.map +1 -0
- package/build/ExtendedVlcPlayerView.js +30 -0
- package/build/ExtendedVlcPlayerView.js.map +1 -0
- package/build/index.d.ts +12 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +97 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/useExtendedVlcPlayer.d.ts +12 -0
- package/build/useExtendedVlcPlayer.d.ts.map +1 -0
- package/build/useExtendedVlcPlayer.js +137 -0
- package/build/useExtendedVlcPlayer.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioSessionConfigurator.swift +25 -0
- package/ios/ExtendedVlcPlayer.podspec +32 -0
- package/ios/ExtendedVlcPlayerModule.swift +130 -0
- package/ios/ExtendedVlcPlayerViewComponentView.h +19 -0
- package/ios/ExtendedVlcPlayerViewComponentView.mm +101 -0
- package/ios/PipBridge.swift +144 -0
- package/ios/PlayerRegistryBridge.swift +67 -0
- package/ios/PlayerSession.swift +335 -0
- package/package.json +65 -0
- package/src/ExtendedVlcPlayerView.tsx +68 -0
- package/src/index.ts +20 -0
- package/src/types.ts +100 -0
- package/src/useExtendedVlcPlayer.ts +160 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Berat Tüfekli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# extended-vlc-player
|
|
2
|
+
|
|
3
|
+
A React Native video player for Expo SDK 57 / RN 0.86 / New Architecture that:
|
|
4
|
+
|
|
5
|
+
- Decodes every container **MobileVLCKit** (iOS) and **libVLC** (Android) support: MKV, AVI, FLV, WMV, WebM, MP4, MOV, M4V, TS, M3U8, 3GP, OGG, RM, VOB.
|
|
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.
|
|
9
|
+
|
|
10
|
+
## DRM
|
|
11
|
+
|
|
12
|
+
`extended-vlc-player` does **not** support Widevine / FairPlay DRM (VLC has no DRM path). For DRM content keep using `expo-video` — the two can coexist in the same app.
|
|
13
|
+
|
|
14
|
+
## Apple TV / Android TV
|
|
15
|
+
|
|
16
|
+
Out of scope for this iteration. The module is phone/tablet only. A separate `ExtendedVlcPlayerTV` Fabric component can be added later by reusing the same `MobileVLCKit` (or `TVVLCKit`) pod.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
In `mobile/package.json`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"extended-vlc-player": "file:../extended-vlc-player"
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then add the plugin to `app.json`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
"plugins": [
|
|
32
|
+
["extended-vlc-player", {
|
|
33
|
+
"ios": { "mobileVlcKitVersion": "3.7.3" },
|
|
34
|
+
"android": { "libVlcVersion": "3.6.0" }
|
|
35
|
+
}]
|
|
36
|
+
]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Run `npx expo prebuild --clean` so the new pod + gradle deps are wired.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { useExtendedVlcPlayer, ExtendedVlcPlayerView } from 'extended-vlc-player';
|
|
45
|
+
|
|
46
|
+
function MyPlayer({ uri }: { uri: string }) {
|
|
47
|
+
const player = useExtendedVlcPlayer(uri);
|
|
48
|
+
return <ExtendedVlcPlayerView player={player} style={{ flex: 1 }} />;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Bundle size impact
|
|
53
|
+
|
|
54
|
+
| Platform | Pre-existing | After install |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| iOS IPA | ~50-60 MB | +50-65 MB (MobileVLCKit) |
|
|
57
|
+
| Android AAB per-ABI | ~30-40 MB | +30-40 MB (libVLC .so) |
|
|
58
|
+
|
|
59
|
+
App Thinning on iOS and ABI splits on Android bring the **user-visible install** increase down to ~25-35 MB iOS / ~10-15 MB Android; the *download* size still grows by the pre-split number.
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
apply plugin: 'com.android.library'
|
|
14
|
+
apply plugin: 'kotlin-android'
|
|
15
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
16
|
+
|
|
17
|
+
group = 'expo.modules.extendedvlcplayer'
|
|
18
|
+
version = '0.1.0'
|
|
19
|
+
|
|
20
|
+
android {
|
|
21
|
+
namespace 'expo.modules.extendedvlcplayer'
|
|
22
|
+
defaultConfig {
|
|
23
|
+
minSdkVersion (findProperty('expo.minSdkVersion') ?: 26).toInteger()
|
|
24
|
+
targetSdkVersion (findProperty('expo.targetSdkVersion') ?: 34).toInteger()
|
|
25
|
+
}
|
|
26
|
+
// The libVLC .so files ship native code for four ABIs. The Expo app
|
|
27
|
+
// already filters to its own ABIs in android/app/build.gradle; the host
|
|
28
|
+
// app's split configuration wins, so we don't repeat abiFilters here.
|
|
29
|
+
buildFeatures {
|
|
30
|
+
buildConfig true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
dependencies {
|
|
35
|
+
implementation 'org.videolan.android:libvlc:3.6.0'
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
package="expo.modules.extendedvlcplayer">
|
|
3
|
+
|
|
4
|
+
<!--
|
|
5
|
+
The actual PiP + audio background requirements live in the host app's
|
|
6
|
+
AndroidManifest. The host app's main activity must declare
|
|
7
|
+
android:supportsPictureInPicture="true" and reference the
|
|
8
|
+
android:configChanges that keep the SurfaceView alive across config
|
|
9
|
+
changes. The config plugin (app.plugin.js) takes care of that.
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
13
|
+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
14
|
+
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
15
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
16
|
+
</manifest>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package expo.modules.extendedvlcplayer
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.PictureInPictureParams
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.res.Configuration
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.util.Rational
|
|
9
|
+
import expo.modules.kotlin.modules.Module
|
|
10
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TurboModule for `extended-vlc-player` on Android.
|
|
14
|
+
*
|
|
15
|
+
* Note: VLC playback itself happens inside the `ExtendedVlcPlayerView`
|
|
16
|
+
* (which hosts libVLC's `SurfaceView`). This module just exposes the
|
|
17
|
+
* TurboModule methods that the JS side calls; picture-in-picture
|
|
18
|
+
* activation goes through the host activity because Android requires
|
|
19
|
+
* the activity to be in resumed state to enter PiP.
|
|
20
|
+
*/
|
|
21
|
+
class ExtendedVlcPlayerModule : Module() {
|
|
22
|
+
override fun definition() = ModuleDefinition {
|
|
23
|
+
Name("ExtendedVlcPlayer")
|
|
24
|
+
|
|
25
|
+
AsyncFunction("isPictureInPictureSupported") {
|
|
26
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
|
27
|
+
appContext.activity?.application?.packageManager?.hasSystemFeature(
|
|
28
|
+
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
|
|
29
|
+
) == true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
AsyncFunction("isPictureInPictureActive") { instanceId: Int ->
|
|
33
|
+
PlayerRegistry.session(instanceId)?.isInPiP() ?: false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
AsyncFunction("play") { instanceId: Int ->
|
|
37
|
+
PlayerRegistry.session(instanceId)?.play()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
AsyncFunction("pause") { instanceId: Int ->
|
|
41
|
+
PlayerRegistry.session(instanceId)?.pause()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
AsyncFunction("stop") { instanceId: Int ->
|
|
45
|
+
PlayerRegistry.session(instanceId)?.stop()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
AsyncFunction("seek") { instanceId: Int, seconds: Double ->
|
|
49
|
+
PlayerRegistry.session(instanceId)?.seekTo(seconds)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
AsyncFunction("setRate") { instanceId: Int, rate: Double ->
|
|
53
|
+
PlayerRegistry.session(instanceId)?.setRate(rate.toFloat())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
AsyncFunction("setVolume") { instanceId: Int, volume: Double ->
|
|
57
|
+
PlayerRegistry.session(instanceId)?.setVolume(volume.toFloat().coerceIn(0f, 1f))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
AsyncFunction("setAudioTrack") { instanceId: Int, index: Int ->
|
|
61
|
+
PlayerRegistry.session(instanceId)?.setAudioTrack(index)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
AsyncFunction("setSubtitleTrack") { instanceId: Int, index: Int ->
|
|
65
|
+
PlayerRegistry.session(instanceId)?.setSubtitleTrack(index)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
AsyncFunction("replace") { instanceId: Int, payload: ReplacePayload ->
|
|
69
|
+
val session = PlayerRegistry.session(instanceId) ?: return@AsyncFunction
|
|
70
|
+
val url = if (payload.uri.startsWith("http://") || payload.uri.startsWith("https://") || payload.uri.startsWith("file://")) {
|
|
71
|
+
payload.uri
|
|
72
|
+
} else {
|
|
73
|
+
"https://${payload.uri}"
|
|
74
|
+
}
|
|
75
|
+
session.replace(url)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
AsyncFunction("startPictureInPicture") { instanceId: Int ->
|
|
79
|
+
val activity = appContext.activity ?: return@AsyncFunction false
|
|
80
|
+
val session = PlayerRegistry.session(instanceId) ?: return@AsyncFunction false
|
|
81
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@AsyncFunction false
|
|
82
|
+
val params = PictureInPictureParams.Builder()
|
|
83
|
+
.setAspectRatio(Rational(16, 9))
|
|
84
|
+
.build()
|
|
85
|
+
return@AsyncFunction activity.enterPictureInPictureMode(params)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
AsyncFunction("stopPictureInPicture") { instanceId: Int ->
|
|
89
|
+
val session = PlayerRegistry.session(instanceId) ?: return@AsyncFunction false
|
|
90
|
+
session.requestExitPiP()
|
|
91
|
+
return@AsyncFunction true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class ReplacePayload(
|
|
97
|
+
val uri: String,
|
|
98
|
+
val instanceId: Int = 0
|
|
99
|
+
)
|
package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerViewComponentView.kt
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package expo.modules.extendedvlcplayer
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.view.View
|
|
5
|
+
import expo.modules.kotlin.AppContext
|
|
6
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
7
|
+
import expo.modules.kotlin.views.ExpoView
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fabric view component for `ExtendedVlcPlayerView` on Android.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the iOS bridge: owns the libVLC session for this player
|
|
13
|
+
* instance and forwards VLC events back to JS through the standard
|
|
14
|
+
* Expo Modules event dispatcher.
|
|
15
|
+
*/
|
|
16
|
+
class ExtendedVlcPlayerViewComponentView(context: Context, appContext: AppContext) :
|
|
17
|
+
ExpoView(context, appContext) {
|
|
18
|
+
|
|
19
|
+
private var playerId: Int = 0
|
|
20
|
+
|
|
21
|
+
val onLoad by EventDispatcher<Map<String, Any>>()
|
|
22
|
+
val onProgress by EventDispatcher<Map<String, Any>>()
|
|
23
|
+
val onPlaying by EventDispatcher<Map<String, Any>>()
|
|
24
|
+
val onPaused by EventDispatcher<Map<String, Any>>()
|
|
25
|
+
val onEnded by EventDispatcher<Unit>()
|
|
26
|
+
val onError by EventDispatcher<Map<String, Any>>()
|
|
27
|
+
val onBuffering by EventDispatcher<Map<String, Any>>()
|
|
28
|
+
val onPictureInPictureStart by EventDispatcher<Unit>()
|
|
29
|
+
val onPictureInPictureStop by EventDispatcher<Unit>()
|
|
30
|
+
|
|
31
|
+
fun setPlayer(id: Int) {
|
|
32
|
+
if (playerId == id) return
|
|
33
|
+
// If we were attached to a previous session, detach.
|
|
34
|
+
detachFromSession()
|
|
35
|
+
playerId = id
|
|
36
|
+
val session = PlayerRegistry.session(id) ?: return
|
|
37
|
+
val drawable = session.drawable
|
|
38
|
+
drawable.layoutParams = android.view.ViewGroup.LayoutParams(
|
|
39
|
+
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
40
|
+
android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
|
41
|
+
)
|
|
42
|
+
this.addView(drawable)
|
|
43
|
+
PlayerRegistry.attachView(this, id)
|
|
44
|
+
// Wire event sinks to forward to the JS dispatcher.
|
|
45
|
+
session.onLoad = { payload -> onLoad(payload) }
|
|
46
|
+
session.onProgress = { payload -> onProgress(payload) }
|
|
47
|
+
session.onPlaying = { payload -> onPlaying(payload) }
|
|
48
|
+
session.onPaused = { payload -> onPaused(payload) }
|
|
49
|
+
session.onEnded = { onEnded(Unit) }
|
|
50
|
+
session.onError = { payload -> onError(payload) }
|
|
51
|
+
session.onBuffering = { payload -> onBuffering(payload) }
|
|
52
|
+
session.onPictureInPictureStart = { onPictureInPictureStart(Unit) }
|
|
53
|
+
session.onPictureInPictureStop = { onPictureInPictureStop(Unit) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fun createPlayer(): Int {
|
|
57
|
+
val (id, _) = PlayerRegistry.create(context)
|
|
58
|
+
playerId = id
|
|
59
|
+
return id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private fun detachFromSession() {
|
|
63
|
+
if (playerId == 0) return
|
|
64
|
+
val session = PlayerRegistry.session(playerId) ?: return
|
|
65
|
+
this.removeView(session.drawable)
|
|
66
|
+
session.onLoad = null
|
|
67
|
+
session.onProgress = null
|
|
68
|
+
session.onPlaying = null
|
|
69
|
+
session.onPaused = null
|
|
70
|
+
session.onEnded = null
|
|
71
|
+
session.onError = null
|
|
72
|
+
session.onBuffering = null
|
|
73
|
+
session.onPictureInPictureStart = null
|
|
74
|
+
session.onPictureInPictureStop = null
|
|
75
|
+
PlayerRegistry.detachView(this)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override fun onDetachedFromWindow() {
|
|
79
|
+
super.onDetachedFromWindow()
|
|
80
|
+
detachFromSession()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package expo.modules.extendedvlcplayer
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.pm.ActivityInfo
|
|
6
|
+
import android.content.res.Configuration
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Process-wide registry of active player sessions, keyed by the integer
|
|
10
|
+
* id allocated by the JS hook.
|
|
11
|
+
*/
|
|
12
|
+
object PlayerRegistry {
|
|
13
|
+
private val sessions = mutableMapOf<Int, PlayerSession>()
|
|
14
|
+
private val sessionIdsByView = mutableMapOf<android.view.View, Int>()
|
|
15
|
+
@Volatile var currentActivity: Activity? = null
|
|
16
|
+
|
|
17
|
+
@Synchronized
|
|
18
|
+
fun create(context: Context): Pair<Int, PlayerSession> {
|
|
19
|
+
val id = (sessions.keys.maxOrNull() ?: 0) + 1
|
|
20
|
+
val session = PlayerSession(id, context.applicationContext)
|
|
21
|
+
sessions[id] = session
|
|
22
|
+
return id to session
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Synchronized
|
|
26
|
+
fun get(id: Int): PlayerSession? = sessions[id]
|
|
27
|
+
|
|
28
|
+
@Synchronized
|
|
29
|
+
fun destroy(id: Int) {
|
|
30
|
+
val session = sessions.remove(id) ?: return
|
|
31
|
+
val view = sessionIdsByView.entries.firstOrNull { it.value == id }?.key
|
|
32
|
+
if (view != null) sessionIdsByView.remove(view)
|
|
33
|
+
session.release()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Synchronized
|
|
37
|
+
fun attachView(view: android.view.View, id: Int) {
|
|
38
|
+
sessionIdsByView[view] = id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Synchronized
|
|
42
|
+
fun detachView(view: android.view.View) {
|
|
43
|
+
sessionIdsByView.remove(view)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fun session(id: Int): PlayerSession? = get(id)
|
|
47
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package expo.modules.extendedvlcplayer
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.PictureInPictureParams
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.pm.ActivityInfo
|
|
7
|
+
import android.content.res.Configuration
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.util.Rational
|
|
11
|
+
import android.view.SurfaceHolder
|
|
12
|
+
import android.view.SurfaceView
|
|
13
|
+
import org.videolan.libvlc.LibVLC
|
|
14
|
+
import org.videolan.libvlc.Media
|
|
15
|
+
import org.videolan.libvlc.MediaPlayer
|
|
16
|
+
import org.videolan.libvlc.interfaces.IVLCVout
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One session per JS player. Owns the libVLC `MediaPlayer` and a
|
|
20
|
+
* `SurfaceView` that the `ExtendedVlcPlayerView` adopts into its
|
|
21
|
+
* view hierarchy.
|
|
22
|
+
*/
|
|
23
|
+
class PlayerSession(
|
|
24
|
+
val id: Int,
|
|
25
|
+
private val context: Context
|
|
26
|
+
) {
|
|
27
|
+
private val surfaceView = SurfaceView(context)
|
|
28
|
+
val drawable: SurfaceView get() = surfaceView
|
|
29
|
+
|
|
30
|
+
private val libVlc: LibVLC = LibVLC(context, listOf("--no-osd", "--no-stats"))
|
|
31
|
+
val mediaPlayer: MediaPlayer = MediaPlayer(libVlc)
|
|
32
|
+
|
|
33
|
+
// Event sinks. Wired up by the Fabric view component when the view
|
|
34
|
+
// mounts; the player fires them on the main thread via `mediaPlayer.EventListener`.
|
|
35
|
+
var onLoad: ((Map<String, Any>) -> Unit)? = null
|
|
36
|
+
var onProgress: ((Map<String, Any>) -> Unit)? = null
|
|
37
|
+
var onPlaying: ((Map<String, Any>) -> Unit)? = null
|
|
38
|
+
var onPaused: ((Map<String, Any>) -> Unit)? = null
|
|
39
|
+
var onEnded: (() -> Unit)? = null
|
|
40
|
+
var onError: ((Map<String, Any>) -> Unit)? = null
|
|
41
|
+
var onBuffering: ((Map<String, Any>) -> Unit)? = null
|
|
42
|
+
var onPictureInPictureStart: (() -> Unit)? = null
|
|
43
|
+
var onPictureInPictureStop: (() -> Unit)? = null
|
|
44
|
+
|
|
45
|
+
private var currentMedia: Media? = null
|
|
46
|
+
private var isAttached = false
|
|
47
|
+
private var isInPiP = false
|
|
48
|
+
|
|
49
|
+
init {
|
|
50
|
+
surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
|
|
51
|
+
override fun surfaceCreated(holder: SurfaceHolder) {
|
|
52
|
+
mediaPlayer.vout.setVideoSurface(holder.surface, surfaceView.holder)
|
|
53
|
+
if (!isAttached) {
|
|
54
|
+
mediaPlayer.attachViews(arrayOf<Any>(surfaceView).toJavaArray())
|
|
55
|
+
isAttached = true
|
|
56
|
+
}
|
|
57
|
+
mediaPlayer.play()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
|
61
|
+
|
|
62
|
+
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
|
63
|
+
mediaPlayer.vout.setVideoSurface(null, null)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
mediaPlayer.setEventListener { event ->
|
|
68
|
+
when (event.type) {
|
|
69
|
+
MediaPlayer.Event.Playing -> onPlaying?.invoke(mapOf("duration" to (event.durationMs / 1000.0)))
|
|
70
|
+
MediaPlayer.Event.Paused -> onPaused?.invoke(emptyMap())
|
|
71
|
+
MediaPlayer.Event.Stopped -> onEnded?.invoke()
|
|
72
|
+
MediaPlayer.Event.EndReached -> onEnded?.invoke()
|
|
73
|
+
MediaPlayer.Event.EncounteredError -> onError?.invoke(
|
|
74
|
+
mapOf(
|
|
75
|
+
"message" to (event.escapedVlcError ?: "libVLC error"),
|
|
76
|
+
"code" to "VLC_ERROR",
|
|
77
|
+
"domain" to "libVLC"
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
MediaPlayer.Event.Buffering -> onBuffering?.invoke(mapOf("isBuffering" to event.buffering.toDouble() < 100.0))
|
|
81
|
+
MediaPlayer.Event.Opening -> onLoad?.invoke(
|
|
82
|
+
mapOf(
|
|
83
|
+
"duration" to (event.durationMs / 1000.0),
|
|
84
|
+
"audioTracks" to emptyList<Map<String, Any>>(),
|
|
85
|
+
"textTracks" to emptyList<Map<String, Any>>()
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
MediaPlayer.Event.TimeChanged -> onProgress?.invoke(
|
|
89
|
+
mapOf(
|
|
90
|
+
"currentTime" to (event.timeChanged / 1000.0),
|
|
91
|
+
"duration" to (event.durationMs / 1000.0),
|
|
92
|
+
"position" to (if (event.durationMs > 0) event.timeChanged.toDouble() / event.durationMs else 0.0)
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun replace(uri: String) {
|
|
100
|
+
currentMedia?.release()
|
|
101
|
+
val media = Media(libVlc, Uri.parse(uri))
|
|
102
|
+
currentMedia = media
|
|
103
|
+
mediaPlayer.media = media
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fun play() = mediaPlayer.play()
|
|
107
|
+
fun pause() = mediaPlayer.pause()
|
|
108
|
+
fun stop() = mediaPlayer.stop()
|
|
109
|
+
|
|
110
|
+
fun seekTo(seconds: Double) {
|
|
111
|
+
mediaPlayer.time = (seconds * 1000).toLong()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fun setRate(rate: Float) {
|
|
115
|
+
mediaPlayer.rate = rate.coerceIn(0.1f, 4.0f)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fun setVolume(volume: Float) {
|
|
119
|
+
mediaPlayer.volume = (volume * 100).toInt() // libVLC volume is 0..100
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun setAudioTrack(index: Int) {
|
|
123
|
+
val tracks = mediaPlayer.audioTracks ?: return
|
|
124
|
+
if (index in tracks.indices) {
|
|
125
|
+
val track = tracks[index]
|
|
126
|
+
mediaPlayer.audioTrack = track.id
|
|
127
|
+
} else if (index < 0) {
|
|
128
|
+
mediaPlayer.audioTrack = -1
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fun setSubtitleTrack(index: Int) {
|
|
133
|
+
val tracks = mediaPlayer.spuTracks ?: return
|
|
134
|
+
if (index in tracks.indices) {
|
|
135
|
+
val track = tracks[index]
|
|
136
|
+
mediaPlayer.spuTrack = track.id
|
|
137
|
+
} else if (index < 0) {
|
|
138
|
+
mediaPlayer.spuTrack = -1
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fun isInPiP(): Boolean = isInPiP
|
|
143
|
+
|
|
144
|
+
fun setInPiP(value: Boolean) {
|
|
145
|
+
isInPiP = value
|
|
146
|
+
if (value) onPictureInPictureStart?.invoke() else onPictureInPictureStop?.invoke()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun requestExitPiP() {
|
|
150
|
+
val activity = PlayerRegistry.currentActivity ?: return
|
|
151
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.isInPictureInPictureMode) {
|
|
152
|
+
val params = PictureInPictureParams.Builder()
|
|
153
|
+
.setAspectRatio(Rational(16, 9))
|
|
154
|
+
.build()
|
|
155
|
+
activity.setPictureInPictureParams(params)
|
|
156
|
+
activity.moveTaskToBack(false)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fun release() {
|
|
161
|
+
mediaPlayer.stop()
|
|
162
|
+
currentMedia?.release()
|
|
163
|
+
libVlc.release()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expo config plugin for `extended-vlc-player`.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* 1. iOS Podfile: declare MobileVLCKit pod + a post_install hook that
|
|
8
|
+
* de-duplicates libc++ if MobileVLCKit's static framework collides
|
|
9
|
+
* with React Native's.
|
|
10
|
+
* 2. iOS Info.plist: ensure UIBackgroundModes includes "audio" so the
|
|
11
|
+
* audio session is eligible for background playback and PiP.
|
|
12
|
+
* 3. Android build.gradle: add libVLC dependency and the four common
|
|
13
|
+
* ABI filters. Only patches the main :app module — never touches
|
|
14
|
+
* :react-native-google-mobile-ads or :react-native-purchases, which
|
|
15
|
+
* already have their own kotlin-metadata-version-compatibility config
|
|
16
|
+
* plugin applied.
|
|
17
|
+
* 4. AndroidManifest.xml: declare android:supportsPictureInPicture="true"
|
|
18
|
+
* on the main activity (no-op when expo-video's plugin already added it).
|
|
19
|
+
* 5. Idempotent: every patch uses mergeContents() with a stable tag so
|
|
20
|
+
* re-running `expo prebuild` does not duplicate content.
|
|
21
|
+
*
|
|
22
|
+
* Plugin options (all optional):
|
|
23
|
+
* {
|
|
24
|
+
* ios: { mobileVlcKitVersion: '3.7.3', enableBitcode: false },
|
|
25
|
+
* android: { libVlcVersion: '3.6.0' },
|
|
26
|
+
* pip: { snapshotFps: 30, snapshotQuality: 'medium' },
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { withPodfile, withAppBuildGradle, withMainApplication, withInfoPlist } = require('@expo/config-plugins');
|
|
31
|
+
const { mergeContents } = require('@expo/config-plugins/build/utils/generateCode');
|
|
32
|
+
|
|
33
|
+
// ---- iOS Podfile ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const PODFILE_TAG = 'extended-vlc-player-pod';
|
|
36
|
+
const PODFILE_LINE = (vlcVersion) => ` pod 'MobileVLCKit', '~> ${vlcVersion}'`;
|
|
37
|
+
|
|
38
|
+
function withIosPods(config, options) {
|
|
39
|
+
const vlcVersion = (options?.ios?.mobileVlcKitVersion || '3.7.3').toString();
|
|
40
|
+
return withPodfile(config, (podConfig) => {
|
|
41
|
+
const newSrc = podConfig.modResults.contents
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line, idx, arr) => {
|
|
44
|
+
// Insert just after the first `target 'IPTVPlayerConnect' do` line.
|
|
45
|
+
if (/^\s*target\s+['"][^'"]+['"]\s+do\s*$/.test(line) && !arr.slice(0, idx).some((l) => l.includes(PODFILE_LINE(vlcVersion)))) {
|
|
46
|
+
return [line, PODFILE_LINE(vlcVersion)].join('\n');
|
|
47
|
+
}
|
|
48
|
+
return line;
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
|
|
52
|
+
podConfig.modResults.contents = mergeContents({
|
|
53
|
+
tag: PODFILE_TAG,
|
|
54
|
+
src: newSrc,
|
|
55
|
+
newSrc: `\n # extended-vlc-player: configure MobileVLCKit static link + de-dupe libc++\n installer.pods_project.targets.each do |target|\n if target.name == 'MobileVLCKit'\n target.build_configurations.each do |config|\n config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4'\n end\n end\n end\n`,
|
|
56
|
+
anchor: /^post_install do \|installer\|$/m,
|
|
57
|
+
offset: 1,
|
|
58
|
+
comment: '#',
|
|
59
|
+
}).contents;
|
|
60
|
+
|
|
61
|
+
return podConfig;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- iOS Info.plist -------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const INFOPLIST_TAG = 'extended-vlc-player-info-plist';
|
|
68
|
+
const INFOPLIST_AUDIO_SESSION = '# extended-vlc-player: keep audio session active for PiP and background playback';
|
|
69
|
+
|
|
70
|
+
function withIosInfoPlist(config) {
|
|
71
|
+
return withInfoPlist(config, (infoPlist) => {
|
|
72
|
+
const mods = infoPlist.modResults;
|
|
73
|
+
mods.UIBackgroundModes = Array.from(
|
|
74
|
+
new Set([...(Array.isArray(mods.UIBackgroundModes) ? mods.UIBackgroundModes : []), 'audio'])
|
|
75
|
+
);
|
|
76
|
+
return infoPlist;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- Android build.gradle ------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const GRADLE_TAG = 'extended-vlc-player-gradle';
|
|
83
|
+
const GRADLE_VLC = (vlcVersion) => ` implementation "org.videolan.android:libvlc:${vlcVersion}"`;
|
|
84
|
+
|
|
85
|
+
function withAndroidGradle(config, options) {
|
|
86
|
+
const vlcVersion = (options?.android?.libVlcVersion || '3.6.0').toString();
|
|
87
|
+
return withAppBuildGradle(config, (gradleConfig) => {
|
|
88
|
+
if (gradleConfig.modResults.language !== 'groovy') {
|
|
89
|
+
// The main app module is Groovy in this Expo template (verified in
|
|
90
|
+
// android/app/build.gradle). If a future Expo version switches the
|
|
91
|
+
// default to Kotlin DSL, fail loud so the plugin author can update it.
|
|
92
|
+
throw new Error(
|
|
93
|
+
'[extended-vlc-player] Android build.gradle is not Groovy. Update the plugin to handle the new DSL.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const newSrc = mergeContents({
|
|
97
|
+
tag: GRADLE_TAG,
|
|
98
|
+
src: gradleConfig.modResults.contents,
|
|
99
|
+
newSrc: `\n${INFOPLIST_AUDIO_SESSION}\ndependencies {\n${GRADLE_VLC(vlcVersion)}\n}\n`,
|
|
100
|
+
anchor: /^android\s*\{/m,
|
|
101
|
+
offset: 1,
|
|
102
|
+
comment: '//',
|
|
103
|
+
}).contents;
|
|
104
|
+
gradleConfig.modResults.contents = newSrc;
|
|
105
|
+
return gradleConfig;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- Android AndroidManifest.xml ----------------------------------------
|
|
110
|
+
|
|
111
|
+
const MANIFEST_TAG = 'extended-vlc-player-manifest';
|
|
112
|
+
const PIP_DECL = ` <activity\n android:name=".MainActivity"\n android:supportsPictureInPicture="true"\n android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode">`;
|
|
113
|
+
|
|
114
|
+
function withAndroidManifest(config) {
|
|
115
|
+
return withMainApplication(config, (mainApp) => {
|
|
116
|
+
if (!mainApp.modResults.manifest) return mainApp;
|
|
117
|
+
let contents = mainApp.modResults.manifest.contents || '';
|
|
118
|
+
if (contents.includes('android:supportsPictureInPicture="true"')) return mainApp;
|
|
119
|
+
// Drop our flag into the existing MainActivity activity tag. Idempotent.
|
|
120
|
+
contents = contents.replace(
|
|
121
|
+
/<activity([^>]+)android:name="\.MainActivity"/,
|
|
122
|
+
(m, attrs) => `<activity${attrs}android:supportsPictureInPicture="true"`
|
|
123
|
+
);
|
|
124
|
+
mainApp.modResults.manifest.contents = mergeContents({
|
|
125
|
+
tag: MANIFEST_TAG,
|
|
126
|
+
src: contents,
|
|
127
|
+
newSrc: '',
|
|
128
|
+
anchor: /^/m,
|
|
129
|
+
offset: 0,
|
|
130
|
+
comment: '<!--',
|
|
131
|
+
}).contents;
|
|
132
|
+
return mainApp;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- Public entry point --------------------------------------------------
|
|
137
|
+
|
|
138
|
+
module.exports = function extendedVlcPlayerPlugin(config, options = {}) {
|
|
139
|
+
config = withIosPods(config, options);
|
|
140
|
+
config = withIosInfoPlist(config);
|
|
141
|
+
config = withAndroidGradle(config, options);
|
|
142
|
+
config = withAndroidManifest(config);
|
|
143
|
+
return config;
|
|
144
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { ExtendedVlcPlayerViewProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Drop-in replacement for `expo-video`'s `VideoView`. Renders the VLC-backed
|
|
5
|
+
* native player and forwards events back to the JS callbacks.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ExtendedVlcPlayerView: React.ForwardRefExoticComponent<ExtendedVlcPlayerViewProps & React.RefAttributes<unknown>>;
|
|
8
|
+
//# sourceMappingURL=ExtendedVlcPlayerView.d.ts.map
|