diff --git a/.gitignore b/.gitignore
index 41f392c..25d74b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,8 @@ ffmpegreadme.md
sliderreadme.md
bottomsheet.md
fastimage.md
+
+# Backup directories
+backup_sdk54_upgrade/
+SDK54_UPGRADE_SUMMARY.md
+SDK54_UPGRADE_SUMMARY.md
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2212a88..9590358 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -14,6 +14,7 @@ react {
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
+ enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
@@ -63,9 +64,9 @@ react {
}
/**
- * Set this to true to Run Proguard on Release builds to minify the Java bytecode.
+ * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
-def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
+def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
@@ -78,7 +79,7 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
-def jscFlavor = 'org.webkit:android-jsc:+'
+def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle")
@@ -95,15 +96,8 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 19
versionName "1.2.4"
- }
-
- splits {
- abi {
- reset()
- enable true
- universalApk false // If true, also generate a universal APK
- include "arm64-v8a", "armeabi-v7a", "x86", "x86_64"
- }
+
+ buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
@@ -121,15 +115,18 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
- shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
- minifyEnabled enableProguardInReleaseBuilds
+ def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
+ shrinkResources enableShrinkResources.toBoolean()
+ minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
- crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
+ def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
+ crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
- useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
+ def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
+ useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
@@ -167,15 +164,15 @@ dependencies {
if (isGifEnabled) {
// For animated gif support
- implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
+ implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
- implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
+ implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
- implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
+ implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
diff --git a/android/app/src/debugOptimized/AndroidManifest.xml b/android/app/src/debugOptimized/AndroidManifest.xml
new file mode 100644
index 0000000..3ec2507
--- /dev/null
+++ b/android/app/src/debugOptimized/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6259c1b..667d1f0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -13,7 +13,7 @@
-
+
@@ -29,7 +29,6 @@
-
diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt
index 31160e6..2a6f8de 100644
--- a/android/app/src/main/java/com/nuvio/app/MainApplication.kt
+++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt
@@ -5,13 +5,13 @@ import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
+import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
-import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
+import com.facebook.react.common.ReleaseLevel
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
-import com.facebook.react.soloader.OpenSourceMergedSoMapping
-import com.facebook.soloader.SoLoader
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
@@ -19,21 +19,19 @@ import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
- this,
- object : DefaultReactNativeHost(this) {
- override fun getPackages(): List {
- val packages = PackageList(this).packages
- // Packages that cannot be autolinked yet can be added manually here, for example:
- // packages.add(new MyReactNativePackage());
- return packages
- }
+ this,
+ object : DefaultReactNativeHost(this) {
+ override fun getPackages(): List =
+ PackageList(this).packages.apply {
+ // Packages that cannot be autolinked yet can be added manually here, for example:
+ // add(MyReactNativePackage())
+ }
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
- override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
)
@@ -42,11 +40,12 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
- SoLoader.init(this, OpenSourceMergedSoMapping)
- if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
- // If you opted-in for the New Architecture, we load the native entry point for this app.
- load()
+ DefaultNewArchitectureEntryPoint.releaseLevel = try {
+ ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
+ } catch (e: IllegalArgumentException) {
+ ReleaseLevel.STABLE
}
+ loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index 7ee63e3..1dee011 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -1,17 +1,11 @@
-
-
diff --git a/android/build.gradle b/android/build.gradle
index abbcb8e..0554dd1 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,41 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext {
- buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
- minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
- compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
- targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
- kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
-
- ndkVersion = "26.1.10909125"
- }
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath('com.android.tools.build:gradle')
- classpath('com.facebook.react:react-native-gradle-plugin')
- classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
- }
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath('com.android.tools.build:gradle')
+ classpath('com.facebook.react:react-native-gradle-plugin')
+ classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
+ }
}
-apply plugin: "com.facebook.react.rootproject"
-
allprojects {
- repositories {
- maven {
- // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
- url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
- }
- maven {
- // Android JSC is installed from npm
- url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
- }
-
- google()
- mavenCentral()
- maven { url 'https://www.jitpack.io' }
- }
+ repositories {
+ google()
+ mavenCentral()
+ maven { url 'https://www.jitpack.io' }
+ }
}
+
+apply plugin: "expo-root-project"
+apply plugin: "com.facebook.react.rootproject"
diff --git a/android/gradle.properties b/android/gradle.properties
index bc2e641..8e39f82 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -15,7 +15,7 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
+org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
@@ -41,6 +41,11 @@ newArchEnabled=true
# If set to false, you will be using JSC instead.
hermesEnabled=true
+# Use this property to enable edge-to-edge display support.
+# This allows your app to draw behind system bars for an immersive UI.
+# Note: Only works with ReactActivity and should not be used with custom Activity.
+edgeToEdgeEnabled=true
+
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
@@ -54,8 +59,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
-android.minSdkVersion=26
-RNVideo_media3Version=1.8.0
-# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin
-expo.edgeToEdgeEnabled=false
\ No newline at end of file
+# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
+# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
+expo.edgeToEdgeEnabled=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
index a4b76b9..1b33c55 100644
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 79eb9d0..d4081da 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/android/gradlew b/android/gradlew
index f5feea6..7f94d3d 100755
--- a/android/gradlew
+++ b/android/gradlew
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
-' "$PWD" ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/android/gradlew.bat b/android/gradlew.bat
index 9b42019..5eed7ee 100644
--- a/android/gradlew.bat
+++ b/android/gradlew.bat
@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/android/settings.gradle b/android/settings.gradle
index a39f8ed..ce00a2f 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -1,38 +1,39 @@
pluginManagement {
- includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
+ def reactNativeGradlePlugin = new File(
+ providers.exec {
+ workingDir(rootDir)
+ commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
+ }.standardOutput.asText.get().trim()
+ ).getParentFile().absolutePath
+ includeBuild(reactNativeGradlePlugin)
+
+ def expoPluginsPath = new File(
+ providers.exec {
+ workingDir(rootDir)
+ commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
+ }.standardOutput.asText.get().trim(),
+ "../android/expo-gradle-plugin"
+ ).absolutePath
+ includeBuild(expoPluginsPath)
+}
+
+plugins {
+ id("com.facebook.react.settings")
+ id("expo-autolinking-settings")
}
-plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
- def command = [
- 'node',
- '--no-warnings',
- '--eval',
- 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
- 'react-native-config',
- '--json',
- '--platform',
- 'android'
- ].toList()
- ex.autolinkLibrariesFromCommand(command)
+ ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
+expoAutolinking.useExpoModules()
rootProject.name = 'Nuvio'
-dependencyResolutionManagement {
- versionCatalogs {
- reactAndroidLibs {
- from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
- }
- }
-}
-
-apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
-useExpoModules()
+expoAutolinking.useExpoVersionCatalog()
include ':app'
-includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
+includeBuild(expoAutolinking.reactNativeGradlePlugin)
diff --git a/babel.config.js b/babel.config.js
index 25fa974..a5c5a10 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -3,7 +3,7 @@ module.exports = function (api) {
return {
presets: ['babel-preset-expo'],
plugins: [
- 'react-native-reanimated/plugin',
+ 'react-native-worklets/plugin',
],
env: {
production: {
diff --git a/ios/Nuvio/KSPlayerManager.m b/backup_sdk54_upgrade/KSPlayerManager.m
similarity index 100%
rename from ios/Nuvio/KSPlayerManager.m
rename to backup_sdk54_upgrade/KSPlayerManager.m
diff --git a/ios/Nuvio/KSPlayerModule.swift b/backup_sdk54_upgrade/KSPlayerModule.swift
similarity index 100%
rename from ios/Nuvio/KSPlayerModule.swift
rename to backup_sdk54_upgrade/KSPlayerModule.swift
diff --git a/ios/Nuvio/KSPlayerView.swift b/backup_sdk54_upgrade/KSPlayerView.swift
similarity index 100%
rename from ios/Nuvio/KSPlayerView.swift
rename to backup_sdk54_upgrade/KSPlayerView.swift
diff --git a/ios/Nuvio/KSPlayerViewManager.swift b/backup_sdk54_upgrade/KSPlayerViewManager.swift
similarity index 100%
rename from ios/Nuvio/KSPlayerViewManager.swift
rename to backup_sdk54_upgrade/KSPlayerViewManager.swift
diff --git a/backup_sdk54_upgrade/NATIVE_MODIFICATIONS_GUIDE.md b/backup_sdk54_upgrade/NATIVE_MODIFICATIONS_GUIDE.md
new file mode 100644
index 0000000..66b0a0a
--- /dev/null
+++ b/backup_sdk54_upgrade/NATIVE_MODIFICATIONS_GUIDE.md
@@ -0,0 +1,173 @@
+# Native Modifications Guide - SDK 54 Upgrade
+
+**Created:** October 14, 2025
+**From SDK:** 52
+**To SDK:** 54
+
+## Overview
+
+This document records all custom native modifications made to the Nuvio app that need to be preserved during the Expo SDK 54 upgrade.
+
+---
+
+## iOS Modifications
+
+### 1. KSPlayer Bridge Integration
+
+**Purpose:** Custom video player for iOS using KSPlayer library
+
+**Files Added/Modified:**
+- `ios/KSPlayerManager.m` - Objective-C bridge header
+- `ios/KSPlayerModule.swift` - Swift module for KSPlayer
+- `ios/KSPlayerView.swift` - Main player view implementation
+- `ios/KSPlayerViewManager.swift` - React Native view manager
+
+**Location in Xcode Project:**
+- Files are in `ios/Nuvio/` directory
+- Referenced in `ios/Nuvio.xcodeproj/project.pbxproj`
+
+**Podfile Dependencies (lines 52-56):**
+```ruby
+# KSPlayer dependencies
+pod 'KSPlayer',:git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main', :modular_headers => true
+pod 'DisplayCriteria',:git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main', :modular_headers => true
+pod 'FFmpegKit',:git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main', :modular_headers => true
+pod 'Libass',:git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main', :modular_headers => true
+```
+
+**Features:**
+- Custom video player with multi-codec support
+- Audio track selection
+- Subtitle track selection
+- Advanced playback controls
+- Header injection for streaming
+- Multi-channel audio downmixing
+
+**Restoration Steps:**
+1. Copy KSPlayer bridge files to `ios/` directory after prebuild
+2. Add Podfile dependencies
+3. Run `pod install`
+4. Ensure files are linked in Xcode project
+
+---
+
+## Android Modifications
+
+### 1. FFmpeg Audio Decoder Extension
+
+**Purpose:** Enable ExoPlayer to play AC3, E-AC3, DTS, TrueHD audio codecs via FFmpeg
+
+**Files Added:**
+- `android/app/libs/lib-decoder-ffmpeg-release.aar` - FFmpeg decoder AAR from Media3
+
+**build.gradle Modifications (line 189):**
+```gradle
+// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
+implementation files("libs/lib-decoder-ffmpeg-release.aar")
+```
+
+**proguard-rules.pro Additions (lines 16-18):**
+```proguard
+# Media3 / ExoPlayer keep (extensions and reflection)
+-keep class androidx.media3.** { *; }
+-dontwarn androidx.media3.**
+```
+
+**Node Modules Modification:**
+- File: `node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java`
+- Change: Set extension renderer mode to PREFER to use FFmpeg decoders
+```java
+new DefaultRenderersFactory(getContext())
+ .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
+```
+
+**Important Notes:**
+- FFmpeg module provides AUDIO decoders only (AC3, E-AC3, DTS, TrueHD)
+- Does NOT provide video decoders (HEVC/Dolby Vision rely on device hardware or VLC fallback)
+- The AAR is from Just Player base (`exobase/app/libs`)
+
+**Restoration Steps:**
+1. Copy `lib-decoder-ffmpeg-release.aar` to `android/app/libs/`
+2. Add implementation line to `android/app/build.gradle`
+3. Add keep rules to `android/app/proguard-rules.pro`
+4. Modify `ReactExoplayerView.java` in node_modules (after npm install)
+
+---
+
+## Application Logic Changes
+
+### Player Fallback Strategy
+
+**Modified Files:**
+- `src/screens/StreamsScreen.tsx` - Removed MKV pre-forcing to VLC
+- `src/components/player/AndroidVideoPlayer.tsx` - Error handler toggles `forceVlc`
+
+**Behavior:**
+- Start with ExoPlayer + FFmpeg audio decoders by default
+- On decoder errors (codec not supported), automatically switch to VLC
+- Do not pre-force VLC based on file extension
+
+---
+
+## Backup Location
+
+All backups are stored in: `/Users/nayifnoushad/Documents/Projects/NuvioStreaming/backup_sdk54_upgrade/`
+
+**Backup Contents:**
+- `android_original/` - Complete Android directory
+- `ios_original/` - Complete iOS directory (partial - Pods symlinks failed)
+- `KSPlayerManager.m` - iOS bridge file
+- `KSPlayerModule.swift` - iOS module file
+- `KSPlayerView.swift` - iOS view file
+- `KSPlayerViewManager.swift` - iOS view manager file
+- `lib-decoder-ffmpeg-release.aar` - FFmpeg AAR
+- `build.gradle.backup` - Android build.gradle
+- `proguard-rules.pro.backup` - ProGuard rules
+- `Podfile.backup` - iOS Podfile
+- `package.json.backup` - Original package.json
+- `ReactExoplayerView.java.backup` - Modified react-native-video file
+
+---
+
+## SDK 54 Upgrade Process
+
+### Pre-Upgrade Checklist
+- ✅ All native files backed up
+- ✅ Custom modifications documented
+- ✅ FFmpeg AAR preserved
+- ✅ KSPlayer bridge files preserved
+- ✅ Build configuration files backed up
+
+### Upgrade Steps
+1. Update package.json to SDK 54
+2. Run `npx expo install` to update compatible packages
+3. Run `npx expo prebuild --clean` to regenerate native projects
+4. Restore Android FFmpeg integration
+5. Restore iOS KSPlayer integration
+6. Test builds on both platforms
+
+### Post-Upgrade Verification
+- [ ] Android: FFmpeg audio decoders working (test AC3/DTS stream)
+- [ ] iOS: KSPlayer bridge working
+- [ ] Audio track selection functional
+- [ ] Subtitle track selection functional
+- [ ] VLC fallback working on decoder errors
+- [ ] App builds successfully for both platforms
+
+---
+
+## Critical Notes
+
+1. **react-native-video modification:** This must be reapplied after every `npm install` or package update
+2. **FFmpeg limitations:** Audio codecs only - video codecs require hardware decoder or VLC
+3. **KSPlayer Podfile:** Uses git branches, may need version pinning for stability
+4. **Xcode project:** KSPlayer files must be linked in project.pbxproj after prebuild
+
+---
+
+## References
+
+- FFmpeg integration guide: `ffmpegreadme.md`
+- KSPlayer repo: https://github.com/kingslay/KSPlayer
+- Expo SDK 54 changelog: https://expo.dev/changelog/2025/
+
diff --git a/backup_sdk54_upgrade/Podfile.backup b/backup_sdk54_upgrade/Podfile.backup
new file mode 100644
index 0000000..4516ea1
--- /dev/null
+++ b/backup_sdk54_upgrade/Podfile.backup
@@ -0,0 +1,72 @@
+require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
+require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
+
+require 'json'
+podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
+
+ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
+ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
+
+platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
+install! 'cocoapods',
+ :deterministic_uuids => false
+
+prepare_react_native_project!
+
+target 'Nuvio' do
+ use_expo_modules!
+
+ if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
+ config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
+ else
+ config_command = [
+ 'node',
+ '--no-warnings',
+ '--eval',
+ 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
+ 'react-native-config',
+ '--json',
+ '--platform',
+ 'ios'
+ ]
+ end
+
+ config = use_native_modules!(config_command)
+
+ use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
+ use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
+
+ use_react_native!(
+ :path => config[:reactNativePath],
+ :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
+ # An absolute path to your application root.
+ :app_path => "#{Pod::Config.instance.installation_root}/..",
+ :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
+ )
+
+ # KSPlayer dependencies
+ pod 'KSPlayer',:git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main', :modular_headers => true
+ pod 'DisplayCriteria',:git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main', :modular_headers => true
+ pod 'FFmpegKit',:git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main', :modular_headers => true
+ pod 'Libass',:git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main', :modular_headers => true
+
+ post_install do |installer|
+ react_native_post_install(
+ installer,
+ config[:reactNativePath],
+ :mac_catalyst_enabled => false,
+ :ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
+ )
+
+ # This is necessary for Xcode 14, because it signs resource bundles by default
+ # when building for devices.
+ installer.target_installation_results.pod_target_installation_results
+ .each do |pod_name, target_installation_result|
+ target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
+ resource_bundle_target.build_configurations.each do |config|
+ config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
+ end
+ end
+ end
+ end
+end
diff --git a/backup_sdk54_upgrade/ReactExoplayerView.java.backup b/backup_sdk54_upgrade/ReactExoplayerView.java.backup
new file mode 100644
index 0000000..5ab082c
--- /dev/null
+++ b/backup_sdk54_upgrade/ReactExoplayerView.java.backup
@@ -0,0 +1,2740 @@
+package com.brentvatne.exoplayer;
+
+import static androidx.media3.common.C.CONTENT_TYPE_DASH;
+import static androidx.media3.common.C.CONTENT_TYPE_HLS;
+import static androidx.media3.common.C.CONTENT_TYPE_OTHER;
+import static androidx.media3.common.C.CONTENT_TYPE_RTSP;
+import static androidx.media3.common.C.CONTENT_TYPE_SS;
+import static androidx.media3.common.C.TIME_END_OF_SOURCE;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.app.PictureInPictureParams;
+import android.app.RemoteAction;
+import android.app.AlertDialog;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.CaptioningManager;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.activity.OnBackPressedCallback;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import androidx.media3.common.AudioAttributes;
+import androidx.media3.common.C;
+import androidx.media3.common.Format;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.MediaMetadata;
+import androidx.media3.common.Metadata;
+import androidx.media3.common.PlaybackException;
+import androidx.media3.common.PlaybackParameters;
+import androidx.media3.common.Player;
+import androidx.media3.common.StreamKey;
+import androidx.media3.common.Timeline;
+import androidx.media3.common.TrackGroup;
+import androidx.media3.common.TrackSelectionOverride;
+import androidx.media3.common.Tracks;
+import androidx.media3.common.text.CueGroup;
+import androidx.media3.common.util.Util;
+import androidx.media3.datasource.DataSource;
+import androidx.media3.datasource.DataSpec;
+import androidx.media3.datasource.HttpDataSource;
+import androidx.media3.exoplayer.DefaultLoadControl;
+import androidx.media3.exoplayer.DefaultRenderersFactory;
+import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.exoplayer.dash.DashMediaSource;
+import androidx.media3.exoplayer.dash.DashUtil;
+import androidx.media3.exoplayer.dash.DefaultDashChunkSource;
+import androidx.media3.exoplayer.dash.manifest.AdaptationSet;
+import androidx.media3.exoplayer.dash.manifest.DashManifest;
+import androidx.media3.exoplayer.dash.manifest.Period;
+import androidx.media3.exoplayer.dash.manifest.Representation;
+import androidx.media3.exoplayer.drm.DefaultDrmSessionManager;
+import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider;
+import androidx.media3.exoplayer.drm.DrmSessionEventListener;
+import androidx.media3.exoplayer.drm.DrmSessionManager;
+import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
+import androidx.media3.exoplayer.drm.FrameworkMediaDrm;
+import androidx.media3.exoplayer.drm.HttpMediaDrmCallback;
+import androidx.media3.exoplayer.drm.UnsupportedDrmException;
+import androidx.media3.exoplayer.hls.HlsMediaSource;
+import androidx.media3.exoplayer.ima.ImaAdsLoader;
+import androidx.media3.exoplayer.mediacodec.MediaCodecInfo;
+import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;
+import androidx.media3.exoplayer.rtsp.RtspMediaSource;
+import androidx.media3.exoplayer.smoothstreaming.DefaultSsChunkSource;
+import androidx.media3.exoplayer.smoothstreaming.SsMediaSource;
+import androidx.media3.exoplayer.source.ClippingMediaSource;
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
+import androidx.media3.exoplayer.source.MediaSource;
+import androidx.media3.exoplayer.source.MergingMediaSource;
+import androidx.media3.exoplayer.source.ProgressiveMediaSource;
+import androidx.media3.exoplayer.source.TrackGroupArray;
+import androidx.media3.exoplayer.source.ads.AdsMediaSource;
+import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection;
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
+import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
+import androidx.media3.exoplayer.trackselection.MappingTrackSelector;
+import androidx.media3.exoplayer.trackselection.TrackSelection;
+import androidx.media3.exoplayer.trackselection.TrackSelectionArray;
+import androidx.media3.exoplayer.upstream.BandwidthMeter;
+import androidx.media3.exoplayer.upstream.CmcdConfiguration;
+import androidx.media3.exoplayer.upstream.DefaultAllocator;
+import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter;
+import androidx.media3.exoplayer.util.EventLogger;
+import androidx.media3.extractor.metadata.emsg.EventMessage;
+import androidx.media3.extractor.metadata.id3.Id3Frame;
+import androidx.media3.extractor.metadata.id3.TextInformationFrame;
+import androidx.media3.session.MediaSessionService;
+
+import com.brentvatne.common.api.AdsProps;
+import com.brentvatne.common.api.BufferConfig;
+import com.brentvatne.common.api.BufferingStrategy;
+import com.brentvatne.common.api.ControlsConfig;
+import com.brentvatne.common.api.DRMProps;
+import com.brentvatne.common.api.ResizeMode;
+import com.brentvatne.common.api.SideLoadedTextTrack;
+import com.brentvatne.common.api.Source;
+import com.brentvatne.common.api.SubtitleStyle;
+import com.brentvatne.common.api.TimedMetadata;
+import com.brentvatne.common.api.Track;
+import com.brentvatne.common.api.VideoTrack;
+import com.brentvatne.common.react.VideoEventEmitter;
+import com.brentvatne.common.toolbox.DebugLog;
+import com.brentvatne.common.toolbox.ReactBridgeUtils;
+import com.brentvatne.react.BuildConfig;
+import com.brentvatne.react.R;
+import com.brentvatne.react.ReactNativeVideoManager;
+import com.brentvatne.receiver.AudioBecomingNoisyReceiver;
+import com.brentvatne.receiver.BecomingNoisyListener;
+import com.brentvatne.receiver.PictureInPictureReceiver;
+import com.facebook.react.bridge.LifecycleEventListener;
+import com.facebook.react.bridge.Promise;
+import com.facebook.react.bridge.UiThreadUtil;
+import com.facebook.react.uimanager.ThemedReactContext;
+import com.google.ads.interactivemedia.v3.api.AdError;
+import com.google.ads.interactivemedia.v3.api.AdErrorEvent;
+import com.google.ads.interactivemedia.v3.api.AdEvent;
+import com.google.ads.interactivemedia.v3.api.ImaSdkFactory;
+import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
+import com.google.common.collect.ImmutableList;
+
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+@SuppressLint("ViewConstructor")
+public class ReactExoplayerView extends FrameLayout implements
+ LifecycleEventListener,
+ Player.Listener,
+ BandwidthMeter.EventListener,
+ BecomingNoisyListener,
+ DrmSessionEventListener,
+ AdEvent.AdEventListener,
+ AdErrorEvent.AdErrorListener {
+
+ public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1;
+ public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0;
+
+ private static final String TAG = "ReactExoplayerView";
+
+ private static final CookieManager DEFAULT_COOKIE_MANAGER;
+ private static final int SHOW_PROGRESS = 1;
+
+ static {
+ DEFAULT_COOKIE_MANAGER = new CookieManager();
+ DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
+ }
+
+ protected final VideoEventEmitter eventEmitter;
+ private final ReactExoplayerConfig config;
+ private DefaultBandwidthMeter bandwidthMeter;
+ private Player.Listener eventListener;
+
+ private ExoPlayerView exoPlayerView;
+ private FullScreenPlayerView fullScreenPlayerView;
+ private ImaAdsLoader adsLoader;
+
+ private DataSource.Factory mediaDataSourceFactory;
+ private ExoPlayer player;
+ private DefaultTrackSelector trackSelector;
+ private boolean playerNeedsSource;
+ private ServiceConnection playbackServiceConnection;
+ private PlaybackServiceBinder playbackServiceBinder;
+
+ // logger to be enable by props
+ private EventLogger debugEventLogger = null;
+ private boolean enableDebug = false;
+ private static final String TAG_EVENT_LOGGER = "RNVExoplayer";
+
+ private int resumeWindow;
+ private long resumePosition;
+ private boolean loadVideoStarted;
+ private boolean isFullscreen;
+ private boolean isInBackground;
+ private boolean isPaused;
+ private boolean isBuffering;
+ private boolean muted = false;
+ public boolean enterPictureInPictureOnLeave = false;
+ private PictureInPictureParams.Builder pictureInPictureParamsBuilder;
+ private boolean hasAudioFocus = false;
+ private float rate = 1f;
+ private AudioOutput audioOutput = AudioOutput.SPEAKER;
+ private float audioVolume = 1f;
+ private int maxBitRate = 0;
+ private boolean hasDrmFailed = false;
+ private boolean isUsingContentResolution = false;
+ private boolean selectTrackWhenReady = false;
+ private final Handler mainHandler;
+ private Runnable mainRunnable;
+ private Runnable pipListenerUnsubscribe;
+ private boolean useCache = false;
+ private boolean disableCache = false;
+ private ControlsConfig controlsConfig = new ControlsConfig();
+ private ArrayList rootViewChildrenOriginalVisibility = new ArrayList();
+
+ /*
+ * When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK
+ * Then we set if to false when playback is back in onIsPlayingChanged -> true
+ */
+ private boolean isSeeking = false;
+ private long seekPosition = -1;
+
+ // Props from React
+ private Source source = new Source();
+ private boolean repeat;
+ private String audioTrackType;
+ private String audioTrackValue;
+ private String videoTrackType;
+ private String videoTrackValue;
+ private String textTrackType = "disabled";
+ private String textTrackValue;
+ private boolean disableFocus;
+ private boolean focusable = true;
+ private BufferingStrategy.BufferingStrategyEnum bufferingStrategy;
+ private boolean disableDisconnectError;
+ private boolean preventsDisplaySleepDuringVideoPlayback = true;
+ private float mProgressUpdateInterval = 250.0f;
+ protected boolean playInBackground = false;
+ private boolean mReportBandwidth = false;
+ private boolean controls = false;
+
+ private boolean showNotificationControls = false;
+ // \ End props
+
+ // React
+ private final ThemedReactContext themedReactContext;
+ private final AudioManager audioManager;
+ private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver;
+ private final PictureInPictureReceiver pictureInPictureReceiver;
+ private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
+
+ // store last progress event values to avoid sending unnecessary messages
+ private long lastPos = -1;
+ private long lastBufferDuration = -1;
+ private long lastDuration = -1;
+
+ private boolean viewHasDropped = false;
+ private int selectedSpeedIndex = 1; // Default is 1.0x
+
+ private final String instanceId = String.valueOf(UUID.randomUUID());
+
+ private CmcdConfiguration.Factory cmcdConfigurationFactory;
+
+ public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) {
+ this.cmcdConfigurationFactory = factory;
+ }
+
+ private void updateProgress() {
+ if (player != null) {
+ if (exoPlayerView != null && isPlayingAd() && controls) {
+ exoPlayerView.hideController();
+ }
+ long bufferedDuration = player.getBufferedPercentage() * player.getDuration() / 100;
+ long duration = player.getDuration();
+ long pos = player.getCurrentPosition();
+ if (pos > duration) {
+ pos = duration;
+ }
+
+ if (lastPos != pos
+ || lastBufferDuration != bufferedDuration
+ || lastDuration != duration) {
+ lastPos = pos;
+ lastBufferDuration = bufferedDuration;
+ lastDuration = duration;
+ eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos));
+ }
+ }
+ }
+
+ private final Handler progressHandler = new Handler(Looper.getMainLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == SHOW_PROGRESS) {
+ updateProgress();
+ msg = obtainMessage(SHOW_PROGRESS);
+ sendMessageDelayed(msg, Math.round(mProgressUpdateInterval));
+ }
+ }
+ };
+
+ public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) {
+ Timeline.Window window = new Timeline.Window();
+ if(!player.getCurrentTimeline().isEmpty()) {
+ player.getCurrentTimeline().getWindow(player.getCurrentMediaItemIndex(), window);
+ }
+ return window.windowStartTimeMs + currentPosition;
+ }
+
+ public ReactExoplayerView(ThemedReactContext context, ReactExoplayerConfig config) {
+ super(context);
+ this.themedReactContext = context;
+ this.eventEmitter = new VideoEventEmitter();
+ this.config = config;
+ this.bandwidthMeter = config.getBandwidthMeter();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pictureInPictureParamsBuilder == null) {
+ this.pictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
+ }
+ mainHandler = new Handler();
+
+ createViews();
+
+ audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ themedReactContext.addLifecycleEventListener(this);
+ audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext);
+ audioFocusChangeListener = new OnAudioFocusChangedListener(this, themedReactContext);
+ pictureInPictureReceiver = new PictureInPictureReceiver(this, themedReactContext);
+ }
+
+ private boolean isPlayingAd() {
+ return player != null && player.isPlayingAd();
+ }
+
+ private void createViews() {
+ if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) {
+ CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER);
+ }
+
+ LayoutParams layoutParams = new LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT);
+ exoPlayerView = new ExoPlayerView(getContext());
+ exoPlayerView.addOnLayoutChangeListener( (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) ->
+ PictureInPictureUtil.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView)
+ );
+ exoPlayerView.setLayoutParams(layoutParams);
+ addView(exoPlayerView, 0, layoutParams);
+
+ exoPlayerView.setFocusable(this.focusable);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ cleanupPlaybackService();
+ super.onDetachedFromWindow();
+ }
+
+ // LifecycleEventListener implementation
+ @Override
+ public void onHostResume() {
+ if (!playInBackground || !isInBackground) {
+ setPlayWhenReady(!isPaused);
+ }
+ isInBackground = false;
+ }
+
+ @Override
+ public void onHostPause() {
+ isInBackground = true;
+ Activity activity = themedReactContext.getCurrentActivity();
+ boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode();
+ boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode();
+ if (playInBackground || isInPictureInPicture || isInMultiWindowMode) {
+ return;
+ }
+ setPlayWhenReady(false);
+ }
+
+ @Override
+ public void onHostDestroy() {
+ cleanUpResources();
+ }
+
+ public void cleanUpResources() {
+ stopPlayback();
+ themedReactContext.removeLifecycleEventListener(this);
+ releasePlayer();
+ viewHasDropped = true;
+ }
+
+ //BandwidthMeter.EventListener implementation
+ @Override
+ public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) {
+ if (mReportBandwidth) {
+ if (player == null) {
+ eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null);
+ } else {
+ Format videoFormat = player.getVideoFormat();
+ boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
+ int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0;
+ int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0;
+ String trackId = videoFormat != null ? videoFormat.id : null;
+ eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, height, width, trackId);
+ }
+ }
+ }
+
+ // Internal methods
+
+ /**
+ * Toggling the visibility of the player control view
+ */
+ private void togglePlayerControlVisibility() {
+ if (player == null) return;
+ if (exoPlayerView.isControllerVisible()) {
+ exoPlayerView.hideController();
+ } else {
+ exoPlayerView.showController();
+ }
+ }
+
+ private void initializePlayerControl() {
+ exoPlayerView.setPlayer(player);
+
+ exoPlayerView.setControllerVisibilityListener(visibility -> {
+ boolean isVisible = visibility == View.VISIBLE;
+ eventEmitter.onControlsVisibilityChange.invoke(isVisible);
+ });
+
+ exoPlayerView.setFullscreenButtonClickListener(isFullscreen -> {
+ setFullscreen(!this.isFullscreen);
+ });
+
+ updateControllerConfig();
+ }
+
+ private void updateControllerConfig() {
+ if (exoPlayerView == null) return;
+
+ exoPlayerView.setControllerShowTimeoutMs(5000);
+
+ exoPlayerView.setControllerAutoShow(true);
+ exoPlayerView.setControllerHideOnTouch(true);
+
+ updateControllerVisibility();
+ }
+
+ private void updateControllerVisibility() {
+ if (exoPlayerView == null) return;
+
+ exoPlayerView.setUseController(!controlsConfig.getHideFullscreen());
+ }
+
+ private void openSettings() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext);
+ builder.setTitle(R.string.settings);
+ String[] settingsOptions = {themedReactContext.getString(R.string.playback_speed)};
+ builder.setItems(settingsOptions, (dialog, which) -> {
+ if (which == 0) {
+ showPlaybackSpeedOptions();
+ }
+ });
+ builder.show();
+ }
+
+ private void showPlaybackSpeedOptions() {
+ String[] speedOptions = {"0.5x", "1.0x", "1.5x", "2.0x"};
+ AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext);
+ builder.setTitle(R.string.select_playback_speed);
+
+ builder.setSingleChoiceItems(speedOptions, selectedSpeedIndex, (dialog, which) -> {
+ selectedSpeedIndex = which;
+ float speed = 1.0f;
+ switch (which) {
+ case 0:
+ speed = 0.5f;
+ break;
+ case 2:
+ speed = 1.5f;
+ break;
+ case 3:
+ speed = 2.0f;
+ break;
+ default:
+ speed = 1.0f;;
+ };
+ setRateModifier(speed);
+ });
+ builder.show();
+ }
+
+ private void addPlayerControl() {
+ updateControllerConfig();
+ }
+
+ /**
+ * Update the layout
+ * @param view view needs to update layout
+ *
+ * This is a workaround for the open bug in react-native: ...
+ */
+ private void reLayout(View view) {
+ if (view == null) return;
+ view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY));
+ view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight());
+ }
+
+ private void refreshControlsStyles() {
+ if (exoPlayerView == null || player == null || !controls) return;
+ updateControllerVisibility();
+ }
+
+ // Note: The following methods for live content and button visibility are no longer needed
+ // as PlayerView handles controls automatically. Some functionality may need to be
+ // reimplemented using PlayerView's APIs if custom behavior is required.
+
+ private void reLayoutControls() {
+ reLayout(exoPlayerView);
+ }
+
+ /// returns true is adaptive bitrate shall be used
+ public boolean isUsingVideoABR() {
+ return videoTrackType == null || "auto".equals(videoTrackType);
+ }
+
+ public void setDebug(boolean enableDebug) {
+ this.enableDebug = enableDebug;
+ refreshDebugState();
+ }
+
+ private void refreshDebugState() {
+ if (player == null) {
+ return;
+ }
+ if (enableDebug) {
+ debugEventLogger = new EventLogger(TAG_EVENT_LOGGER);
+ player.addAnalyticsListener(debugEventLogger);
+ } else if (debugEventLogger != null) {
+ player.removeAnalyticsListener(debugEventLogger);
+ debugEventLogger = null;
+ }
+ }
+
+ public void setViewType(int viewType) {
+ exoPlayerView.updateSurfaceView(viewType);
+ }
+
+ private class RNVLoadControl extends DefaultLoadControl {
+ private final int availableHeapInBytes;
+ private final Runtime runtime;
+ public RNVLoadControl(DefaultAllocator allocator, BufferConfig config) {
+ super(allocator,
+ config.getMinBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt()
+ ? config.getMinBufferMs()
+ : DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
+ config.getMaxBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt()
+ ? config.getMaxBufferMs()
+ : DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
+ config.getBufferForPlaybackMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt()
+ ? config.getBufferForPlaybackMs()
+ : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS ,
+ config.getBufferForPlaybackAfterRebufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt()
+ ? config.getBufferForPlaybackAfterRebufferMs()
+ : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS,
+ -1,
+ true,
+ config.getBackBufferDurationMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt()
+ ? config.getBackBufferDurationMs()
+ : DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS,
+ DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME);
+ runtime = Runtime.getRuntime();
+ ActivityManager activityManager = (ActivityManager) themedReactContext.getSystemService(ThemedReactContext.ACTIVITY_SERVICE);
+ double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble()
+ ? config.getMaxHeapAllocationPercent()
+ : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT;
+ availableHeapInBytes = (int) Math.floor(activityManager.getMemoryClass() * maxHeap * 1024 * 1024);
+ }
+
+ @Override
+ public boolean shouldContinueLoading(long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) {
+ if (bufferingStrategy == BufferingStrategy.BufferingStrategyEnum.DisableBuffering) {
+ return false;
+ } else if (bufferingStrategy == BufferingStrategy.BufferingStrategyEnum.DependingOnMemory) {
+ // The goal of this algorithm is to pause video loading (increasing the buffer)
+ // when available memory on device become low.
+ int loadedBytes = getAllocator().getTotalBytesAllocated();
+ boolean isHeapReached = availableHeapInBytes > 0 && loadedBytes >= availableHeapInBytes;
+ if (isHeapReached) {
+ return false;
+ }
+ long usedMemory = runtime.totalMemory() - runtime.freeMemory();
+ long freeMemory = runtime.maxMemory() - usedMemory;
+ double minBufferMemoryReservePercent = source.getBufferConfig().getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble()
+ ? source.getBufferConfig().getMinBufferMemoryReservePercent()
+ : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE;
+ long reserveMemory = (long) minBufferMemoryReservePercent * runtime.maxMemory();
+ long bufferedMs = bufferedDurationUs / (long) 1000;
+ if (reserveMemory > freeMemory && bufferedMs > 2000) {
+ // We don't have enough memory in reserve so we stop buffering to allow other components to use it instead
+ return false;
+ }
+ if (runtime.freeMemory() == 0) {
+ DebugLog.w(TAG, "Free memory reached 0, forcing garbage collection");
+ runtime.gc();
+ return false;
+ }
+ }
+ // "default" case or normal case for "DependingOnMemory"
+ return super.shouldContinueLoading(playbackPositionUs, bufferedDurationUs, playbackSpeed);
+ }
+ }
+
+ private void initializePlayer() {
+ disableCache = ReactNativeVideoManager.Companion.getInstance().shouldDisableCache(source);
+
+ ReactExoplayerView self = this;
+ Activity activity = themedReactContext.getCurrentActivity();
+ // This ensures all props have been settled, to avoid async racing conditions.
+ Source runningSource = source;
+ mainRunnable = () -> {
+ if (viewHasDropped && runningSource == source) {
+ return;
+ }
+ try {
+ if (runningSource.getUri() == null) {
+ return;
+ }
+
+ if (player == null) {
+ // Initialize core configuration and listeners
+ initializePlayerCore(self);
+ pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(themedReactContext, this);
+ PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
+ }
+ if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) {
+ RNVSimpleCache.INSTANCE.setSimpleCache(
+ this.getContext(),
+ source.getBufferConfig().getCacheSize()
+ );
+ useCache = true;
+ } else {
+ useCache = false;
+ }
+ if (playerNeedsSource) {
+ // Will force display of shutter view if needed
+ exoPlayerView.invalidateAspectRatio();
+ // DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
+ ExecutorService es = Executors.newSingleThreadExecutor();
+ es.execute(() -> {
+ // DRM initialization must run on a different thread
+ if (viewHasDropped && runningSource == source) {
+ return;
+ }
+ if (activity == null) {
+ DebugLog.e(TAG, "Failed to initialize Player!, null activity");
+ eventEmitter.onVideoError.invoke("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001");
+ return;
+ }
+
+ // Initialize handler to run on the main thread
+ activity.runOnUiThread(() -> {
+ if (viewHasDropped && runningSource == source) {
+ return;
+ }
+ try {
+ // Source initialization must run on the main thread
+ initializePlayerSource(runningSource);
+ } catch (Exception ex) {
+ self.playerNeedsSource = true;
+ DebugLog.e(TAG, "Failed to initialize Player! 1");
+ DebugLog.e(TAG, ex.toString());
+ ex.printStackTrace();
+ eventEmitter.onVideoError.invoke(ex.toString(), ex, "1001");
+ }
+ });
+ });
+ } else if (runningSource == source) {
+ initializePlayerSource(runningSource);
+ }
+ } catch (Exception ex) {
+ self.playerNeedsSource = true;
+ DebugLog.e(TAG, "Failed to initialize Player! 2");
+ DebugLog.e(TAG, ex.toString());
+ ex.printStackTrace();
+ eventEmitter.onVideoError.invoke(ex.toString(), ex, "1001");
+ }
+ };
+ mainHandler.postDelayed(mainRunnable, 1);
+ }
+
+ public void getCurrentPosition(Promise promise) {
+ if (player != null) {
+ float currentPosition = player.getCurrentPosition() / 1000.0f;
+ promise.resolve(currentPosition);
+ } else {
+ promise.reject("PLAYER_NOT_AVAILABLE", "Player is not initialized.");
+ }
+ }
+
+ private void initializePlayerCore(ReactExoplayerView self) {
+ ExoTrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory();
+ self.trackSelector = new DefaultTrackSelector(getContext(), videoTrackSelectionFactory);
+ self.trackSelector.setParameters(trackSelector.buildUponParameters()
+ .setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate));
+
+ DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
+ RNVLoadControl loadControl = new RNVLoadControl(
+ allocator,
+ source.getBufferConfig()
+ );
+
+ long initialBitrate = source.getBufferConfig().getInitialBitrate();
+ if (initialBitrate > 0) {
+ config.setInitialBitrate(initialBitrate);
+ this.bandwidthMeter = config.getBandwidthMeter();
+ }
+
+ DefaultRenderersFactory renderersFactory =
+ new DefaultRenderersFactory(getContext())
+ .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
+ .setEnableDecoderFallback(true)
+ .forceEnableMediaCodecAsynchronousQueueing();
+
+ DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory);
+ if (useCache && !disableCache) {
+ mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)));
+ }
+
+ mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
+
+ player = new ExoPlayer.Builder(getContext(), renderersFactory)
+ .setTrackSelector(self.trackSelector)
+ .setBandwidthMeter(bandwidthMeter)
+ .setLoadControl(loadControl)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .build();
+ ReactNativeVideoManager.Companion.getInstance().onInstanceCreated(instanceId, player);
+ refreshDebugState();
+ player.addListener(self);
+ player.setVolume(muted ? 0.f : audioVolume * 1);
+ exoPlayerView.setPlayer(player);
+
+ audioBecomingNoisyReceiver.setListener(self);
+ pictureInPictureReceiver.setListener();
+ bandwidthMeter.addEventListener(new Handler(), self);
+ setPlayWhenReady(!isPaused);
+ playerNeedsSource = true;
+
+ PlaybackParameters params = new PlaybackParameters(rate, 1f);
+ player.setPlaybackParameters(params);
+ changeAudioOutput(this.audioOutput);
+
+ if(showNotificationControls) {
+ setupPlaybackService();
+ }
+ }
+
+ private AdsMediaSource initializeAds(MediaSource videoSource, Source runningSource) {
+ AdsProps adProps = runningSource.getAdsProps();
+ Uri uri = runningSource.getUri();
+ if (adProps != null && uri != null) {
+ Uri adTagUrl = adProps.getAdTagUrl();
+ if (adTagUrl != null) {
+ // Create an AdsLoader.
+ ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader
+ .Builder(themedReactContext)
+ .setAdEventListener(this)
+ .setAdErrorListener(this);
+
+ if (adProps.getAdLanguage() != null) {
+ ImaSdkSettings imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings();
+ imaSdkSettings.setLanguage(adProps.getAdLanguage());
+ imaLoaderBuilder.setImaSdkSettings(imaSdkSettings);
+ }
+ adsLoader = imaLoaderBuilder.build();
+ adsLoader.setPlayer(player);
+ if (adsLoader != null) {
+ DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory)
+ .setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
+ DataSpec adTagDataSpec = new DataSpec(adTagUrl);
+ return new AdsMediaSource(videoSource,
+ adTagDataSpec,
+ ImmutableList.of(uri, adTagUrl),
+ mediaSourceFactory, adsLoader, exoPlayerView.getPlayerView());
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private DrmSessionManager buildDrmSessionManager(UUID uuid, DRMProps drmProps) throws UnsupportedDrmException {
+ if (Util.SDK_INT < 18) {
+ return null;
+ }
+
+ try {
+ // First check if there's a custom DRM manager registered through the plugin system
+ DRMManagerSpec drmManager = ReactNativeVideoManager.Companion.getInstance().getDRMManager();
+ if (drmManager == null) {
+ // If no custom manager is registered, use the default implementation
+ drmManager = new DRMManager(buildHttpDataSourceFactory(false));
+ }
+
+ DrmSessionManager drmSessionManager = drmManager.buildDrmSessionManager(uuid, drmProps);
+ if (drmSessionManager == null) {
+ eventEmitter.onVideoError.invoke("Failed to build DRM session manager", new Exception("DRM session manager is null"), "3007");
+ }
+
+ // Allow plugins to override the DrmSessionManager
+ DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance().overrideDrmSessionManager(source, drmSessionManager);
+ return overriddenManager != null ? overriddenManager : drmSessionManager;
+ } catch (UnsupportedDrmException ex) {
+ // Unsupported DRM exceptions are handled by the calling method
+ throw ex;
+ } catch (Exception ex) {
+ // Handle any other exception and emit to JS
+ eventEmitter.onVideoError.invoke(ex.toString(), ex, "3006");
+ return null;
+ }
+ }
+
+ private void initializePlayerSource(Source runningSource) {
+ if (runningSource.getUri() == null) {
+ return;
+ }
+ /// init DRM
+ DrmSessionManager drmSessionManager = initializePlayerDrm();
+ if (drmSessionManager == null && runningSource.getDrmProps() != null && runningSource.getDrmProps().getDrmType() != null) {
+ // Failed to initialize DRM session manager - cannot continue
+ DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!");
+ return;
+ }
+ // init source to manage ads (external text tracks are now handled in MediaItem)
+ MediaSource videoSource = buildMediaSource(runningSource.getUri(),
+ runningSource.getExtension(),
+ drmSessionManager,
+ runningSource.getCropStartMs(),
+ runningSource.getCropEndMs());
+ MediaSource mediaSourceWithAds = initializeAds(videoSource, runningSource);
+ MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
+
+ // wait for player to be set
+ while (player == null) {
+ try {
+ wait();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ DebugLog.e(TAG, ex.toString());
+ }
+ }
+
+ boolean haveResumePosition = resumeWindow != C.INDEX_UNSET;
+ if (haveResumePosition) {
+ player.seekTo(resumeWindow, resumePosition);
+ player.setMediaSource(mediaSource, false);
+ } else if (runningSource.getStartPositionMs() > 0) {
+ player.setMediaSource(mediaSource, runningSource.getStartPositionMs());
+ } else {
+ player.setMediaSource(mediaSource, true);
+ }
+ player.prepare();
+ playerNeedsSource = false;
+
+ reLayoutControls();
+
+ eventEmitter.onVideoLoadStart.invoke();
+ loadVideoStarted = true;
+
+ finishPlayerInitialization();
+ }
+
+ private DrmSessionManager initializePlayerDrm() {
+ DrmSessionManager drmSessionManager = null;
+ DRMProps drmProps = source.getDrmProps();
+ // need to realign UUID in DRM Props from source
+ if (drmProps != null && drmProps.getDrmType() != null) {
+ UUID uuid = Util.getDrmUuid(drmProps.getDrmType());
+ if (uuid != null) {
+ try {
+ DebugLog.d(TAG, "drm buildDrmSessionManager");
+ drmSessionManager = buildDrmSessionManager(uuid, drmProps);
+ } catch (UnsupportedDrmException e) {
+ int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported
+ : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME
+ ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown);
+ eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003");
+ }
+ }
+ }
+ return drmSessionManager;
+ }
+
+ private void finishPlayerInitialization() {
+ // Initializing the playerControlView
+ initializePlayerControl();
+ setControls(controls);
+ applyModifiers();
+ }
+
+ private void setupPlaybackService() {
+ if (!showNotificationControls || player == null) {
+ return;
+ }
+
+ playbackServiceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ playbackServiceBinder = (PlaybackServiceBinder) service;
+
+ try {
+ Activity currentActivity = themedReactContext.getCurrentActivity();
+ if (currentActivity != null) {
+ playbackServiceBinder.getService().registerPlayer(player,
+ (Class) currentActivity.getClass());
+ } else {
+ // Handle the case where currentActivity is null
+ DebugLog.w(TAG, "Could not register ExoPlayer: currentActivity is null");
+ }
+ } catch (Exception e) {
+ DebugLog.e(TAG, "Could not register ExoPlayer: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ try {
+ if (playbackServiceBinder != null) {
+ playbackServiceBinder.getService().unregisterPlayer(player);
+ }
+ } catch (Exception ignored) {}
+
+ playbackServiceBinder = null;
+ }
+
+ @Override
+ public void onNullBinding(ComponentName name) {
+ DebugLog.e(TAG, "Could not register ExoPlayer");
+ }
+ };
+
+ Intent intent = new Intent(themedReactContext, VideoPlaybackService.class);
+ intent.setAction(MediaSessionService.SERVICE_INTERFACE);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ themedReactContext.startForegroundService(intent);
+ } else {
+ themedReactContext.startService(intent);
+ }
+
+ int flags;
+ if (Build.VERSION.SDK_INT >= 29) {
+ flags = Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES;
+ } else {
+ flags = Context.BIND_AUTO_CREATE;
+ }
+
+ themedReactContext.bindService(intent, playbackServiceConnection, flags);
+ }
+
+ private void cleanupPlaybackService() {
+ try {
+ if(player != null && playbackServiceBinder != null) {
+ playbackServiceBinder.getService().unregisterPlayer(player);
+ }
+
+ playbackServiceBinder = null;
+
+ if(playbackServiceConnection != null) {
+ themedReactContext.unbindService(playbackServiceConnection);
+ }
+ } catch(Exception e) {
+ DebugLog.w(TAG, "Cloud not cleanup playback service");
+ }
+ }
+
+ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, long cropStartMs, long cropEndMs) {
+ if (uri == null) {
+ throw new IllegalStateException("Invalid video uri");
+ }
+ int type;
+ if ("rtsp".equals(overrideExtension)) {
+ type = CONTENT_TYPE_RTSP;
+ } else {
+ type = Util.inferContentType(!TextUtils.isEmpty(overrideExtension) ? "." + overrideExtension
+ : uri.getLastPathSegment());
+ }
+ config.setDisableDisconnectError(this.disableDisconnectError);
+
+ MediaItem.Builder mediaItemBuilder = new MediaItem.Builder()
+ .setUri(uri);
+
+ // refresh custom Metadata
+ MediaMetadata customMetadata = ConfigurationUtils.buildCustomMetadata(source.getMetadata());
+ if (customMetadata != null) {
+ mediaItemBuilder.setMediaMetadata(customMetadata);
+ }
+
+ // Add external subtitles to MediaItem
+ List subtitleConfigurations = buildSubtitleConfigurations();
+ if (subtitleConfigurations != null) {
+ mediaItemBuilder.setSubtitleConfigurations(subtitleConfigurations);
+ }
+
+ if (source.getAdsProps() != null) {
+ Uri adTagUrl = source.getAdsProps().getAdTagUrl();
+ if (adTagUrl != null) {
+ mediaItemBuilder.setAdsConfiguration(
+ new MediaItem.AdsConfiguration.Builder(adTagUrl).build()
+ );
+ }
+ }
+
+ MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils.getLiveConfiguration(source.getBufferConfig());
+ mediaItemBuilder.setLiveConfiguration(liveConfiguration.build());
+
+ MediaSource.Factory mediaSourceFactory;
+ DrmSessionManagerProvider drmProvider;
+ List streamKeys = new ArrayList<>();
+ if (drmSessionManager != null) {
+ drmProvider = ((_mediaItem) -> drmSessionManager);
+ } else {
+ drmProvider = new DefaultDrmSessionManagerProvider();
+ }
+
+
+ switch (type) {
+ case CONTENT_TYPE_SS:
+ if(!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) {
+ DebugLog.e("Exo Player Exception", "Smooth Streaming is not enabled!");
+ throw new IllegalStateException("Smooth Streaming is not enabled!");
+ }
+
+ mediaSourceFactory = new SsMediaSource.Factory(
+ new DefaultSsChunkSource.Factory(mediaDataSourceFactory),
+ buildDataSourceFactory(false)
+ );
+ break;
+ case CONTENT_TYPE_DASH:
+ if(!BuildConfig.USE_EXOPLAYER_DASH) {
+ DebugLog.e("Exo Player Exception", "DASH is not enabled!");
+ throw new IllegalStateException("DASH is not enabled!");
+ }
+
+ mediaSourceFactory = new DashMediaSource.Factory(
+ new DefaultDashChunkSource.Factory(mediaDataSourceFactory),
+ buildDataSourceFactory(false)
+ );
+ break;
+ case CONTENT_TYPE_HLS:
+ if (!BuildConfig.USE_EXOPLAYER_HLS) {
+ DebugLog.e("Exo Player Exception", "HLS is not enabled!");
+ throw new IllegalStateException("HLS is not enabled!");
+ }
+
+ DataSource.Factory dataSourceFactory = mediaDataSourceFactory;
+
+ if (useCache && !disableCache) {
+ dataSourceFactory = RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true));
+ }
+
+ mediaSourceFactory = new HlsMediaSource.Factory(
+ dataSourceFactory
+ ).setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation());
+ break;
+ case CONTENT_TYPE_OTHER:
+ if ("asset".equals(uri.getScheme())) {
+ try {
+ DataSource.Factory assetDataSourceFactory = DataSourceUtil.buildAssetDataSourceFactory(themedReactContext, uri);
+ mediaSourceFactory = new ProgressiveMediaSource.Factory(assetDataSourceFactory);
+ } catch (Exception e) {
+ throw new IllegalStateException("cannot open input file:" + uri);
+ }
+ } else if ("file".equals(uri.getScheme()) ||
+ !useCache) {
+ mediaSourceFactory = new ProgressiveMediaSource.Factory(
+ mediaDataSourceFactory
+ );
+ } else {
+ mediaSourceFactory = new ProgressiveMediaSource.Factory(
+ RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))
+ );
+
+ }
+ break;
+ case CONTENT_TYPE_RTSP:
+ if (!BuildConfig.USE_EXOPLAYER_RTSP) {
+ DebugLog.e("Exo Player Exception", "RTSP is not enabled!");
+ throw new IllegalStateException("RTSP is not enabled!");
+ }
+
+ mediaSourceFactory = new RtspMediaSource.Factory();
+ break;
+ default: {
+ throw new IllegalStateException("Unsupported type: " + type);
+ }
+ }
+
+ if (cmcdConfigurationFactory != null) {
+ mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory(
+ cmcdConfigurationFactory::createCmcdConfiguration
+ );
+ }
+
+ mediaSourceFactory = Objects.requireNonNullElse(
+ ReactNativeVideoManager.Companion.getInstance()
+ .overrideMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory),
+ mediaSourceFactory
+ );
+
+ mediaItemBuilder.setStreamKeys(streamKeys);
+
+ @Nullable
+ final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance().overrideMediaItemBuilder(source, mediaItemBuilder);
+
+ MediaItem mediaItem = overridenMediaItemBuilder != null
+ ? overridenMediaItemBuilder.build()
+ : mediaItemBuilder.build();
+
+ MediaSource mediaSource = mediaSourceFactory
+ .setDrmSessionManagerProvider(drmProvider)
+ .setLoadErrorHandlingPolicy(
+ config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount())
+ )
+ .createMediaSource(mediaItem);
+
+ if (cropStartMs >= 0 && cropEndMs >= 0) {
+ return new ClippingMediaSource(mediaSource, cropStartMs * 1000, cropEndMs * 1000);
+ } else if (cropStartMs >= 0) {
+ return new ClippingMediaSource(mediaSource, cropStartMs * 1000, TIME_END_OF_SOURCE);
+ } else if (cropEndMs >= 0) {
+ return new ClippingMediaSource(mediaSource, 0, cropEndMs * 1000);
+ }
+
+ return mediaSource;
+ }
+
+ @Nullable
+ private List buildSubtitleConfigurations() {
+ if (source.getSideLoadedTextTracks() == null || source.getSideLoadedTextTracks().getTracks().isEmpty()) {
+ return null;
+ }
+
+ List subtitleConfigurations = new ArrayList<>();
+ int trackIndex = 0;
+
+ for (SideLoadedTextTrack track : source.getSideLoadedTextTracks().getTracks()) {
+ try {
+ // Create a more descriptive ID that PlayerView can use
+ String trackId = "external-subtitle-" + trackIndex;
+ String label = track.getTitle();
+ if (label == null || label.isEmpty()) {
+ label = "External " + (trackIndex + 1);
+ if (track.getLanguage() != null && !track.getLanguage().isEmpty()) {
+ label += " (" + track.getLanguage() + ")";
+ }
+ }
+
+ MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(track.getUri())
+ .setId(trackId)
+ .setMimeType(track.getType())
+ .setLabel(label)
+ .setRoleFlags(C.ROLE_FLAG_SUBTITLE);
+
+ // Set language if available
+ if (track.getLanguage() != null && !track.getLanguage().isEmpty()) {
+ configBuilder.setLanguage(track.getLanguage());
+ }
+
+ // Set selection flags - make first track default if no specific track is selected
+ if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) {
+ configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT);
+ } else {
+ configBuilder.setSelectionFlags(0);
+ }
+
+ MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build();
+ subtitleConfigurations.add(subtitleConfiguration);
+
+ DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")");
+ trackIndex++;
+ } catch (Exception e) {
+ DebugLog.e(TAG, "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage());
+ }
+ }
+
+ if (!subtitleConfigurations.isEmpty()) {
+ DebugLog.d(TAG, "Built " + subtitleConfigurations.size() + " external subtitle configurations");
+ }
+
+ return subtitleConfigurations.isEmpty() ? null : subtitleConfigurations;
+ }
+
+ private void releasePlayer() {
+ if (player != null) {
+ if(playbackServiceBinder != null) {
+ playbackServiceBinder.getService().unregisterPlayer(player);
+ themedReactContext.unbindService(playbackServiceConnection);
+ }
+
+ updateResumePosition();
+ player.release();
+ player.removeListener(this);
+ PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, false);
+ if (pipListenerUnsubscribe != null) {
+ pipListenerUnsubscribe.run();
+ }
+ trackSelector = null;
+
+ ReactNativeVideoManager.Companion.getInstance().onInstanceRemoved(instanceId, player);
+ player = null;
+ }
+
+ if (adsLoader != null) {
+ adsLoader.release();
+ adsLoader = null;
+ }
+ progressHandler.removeMessages(SHOW_PROGRESS);
+ audioBecomingNoisyReceiver.removeListener();
+ pictureInPictureReceiver.removeListener();
+ bandwidthMeter.removeEventListener(this);
+
+ if (mainHandler != null && mainRunnable != null) {
+ mainHandler.removeCallbacks(mainRunnable);
+ mainRunnable = null;
+ }
+ }
+
+ private static class OnAudioFocusChangedListener implements AudioManager.OnAudioFocusChangeListener {
+ private final ReactExoplayerView view;
+ private final ThemedReactContext themedReactContext;
+
+ private OnAudioFocusChangedListener(ReactExoplayerView view, ThemedReactContext themedReactContext) {
+ this.view = view;
+ this.themedReactContext = themedReactContext;
+ }
+
+ @Override
+ public void onAudioFocusChange(int focusChange) {
+ Activity activity = themedReactContext.getCurrentActivity();
+
+ switch (focusChange) {
+ case AudioManager.AUDIOFOCUS_LOSS:
+ view.hasAudioFocus = false;
+ view.eventEmitter.onAudioFocusChanged.invoke(false);
+ // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel)
+ if (activity != null) {
+ activity.runOnUiThread(view::pausePlayback);
+ }
+ view.audioManager.abandonAudioFocus(this);
+ break;
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
+ view.eventEmitter.onAudioFocusChanged.invoke(false);
+ break;
+ case AudioManager.AUDIOFOCUS_GAIN:
+ view.hasAudioFocus = true;
+ view.eventEmitter.onAudioFocusChanged.invoke(true);
+ break;
+ default:
+ break;
+ }
+
+ if (view.player != null && activity != null) {
+ if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {
+ // Lower the volume
+ if (!view.muted) {
+ activity.runOnUiThread(() ->
+ view.player.setVolume(view.audioVolume * 0.8f)
+ );
+ }
+ } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
+ // Raise it back to normal
+ if (!view.muted) {
+ activity.runOnUiThread(() ->
+ view.player.setVolume(view.audioVolume * 1)
+ );
+ }
+ }
+ }
+ }
+ }
+
+ private boolean requestAudioFocus() {
+ if (disableFocus || source.getUri() == null || this.hasAudioFocus) {
+ return true;
+ }
+ int result = audioManager.requestAudioFocus(audioFocusChangeListener,
+ AudioManager.STREAM_MUSIC,
+ AudioManager.AUDIOFOCUS_GAIN);
+ return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
+ }
+
+ private void setPlayWhenReady(boolean playWhenReady) {
+ if (player == null) {
+ return;
+ }
+
+ if (playWhenReady) {
+ this.hasAudioFocus = requestAudioFocus();
+ if (this.hasAudioFocus) {
+ player.setPlayWhenReady(true);
+ }
+ } else {
+ player.setPlayWhenReady(false);
+ }
+ }
+
+ private void resumePlayback() {
+ if (player != null) {
+ if (!player.getPlayWhenReady()) {
+ setPlayWhenReady(true);
+ }
+ setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
+ }
+ }
+
+ private void pausePlayback() {
+ if (player != null) {
+ if (player.getPlayWhenReady()) {
+ setPlayWhenReady(false);
+ }
+ }
+ setKeepScreenOn(false);
+ }
+
+ private void stopPlayback() {
+ onStopPlayback();
+ releasePlayer();
+ }
+
+ private void onStopPlayback() {
+ audioManager.abandonAudioFocus(audioFocusChangeListener);
+ }
+
+ private void updateResumePosition() {
+ resumeWindow = player.getCurrentMediaItemIndex();
+ resumePosition = player.isCurrentMediaItemSeekable() ? Math.max(0, player.getCurrentPosition())
+ : C.TIME_UNSET;
+ }
+
+ private void clearResumePosition() {
+ resumeWindow = C.INDEX_UNSET;
+ resumePosition = C.TIME_UNSET;
+ }
+
+ /**
+ * Returns a new DataSource factory.
+ *
+ * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new
+ * DataSource factory.
+ * @return A new DataSource factory.
+ */
+ private DataSource.Factory buildDataSourceFactory(boolean useBandwidthMeter) {
+ return DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext,
+ useBandwidthMeter ? bandwidthMeter : null, source.getHeaders());
+ }
+
+ /**
+ * Returns a new HttpDataSource factory.
+ *
+ * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new
+ * DataSource factory.
+ * @return A new HttpDataSource factory.
+ */
+ private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) {
+ return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, useBandwidthMeter ? bandwidthMeter : null, source.getHeaders());
+ }
+
+ // AudioBecomingNoisyListener implementation
+ @Override
+ public void onAudioBecomingNoisy() {
+ eventEmitter.onVideoAudioBecomingNoisy.invoke();
+ }
+
+ // Player.Listener implementation
+ @Override
+ public void onIsLoadingChanged(boolean isLoading) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onEvents(@NonNull Player player, Player.Events events) {
+ if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
+ int playbackState = player.getPlaybackState();
+ boolean playWhenReady = player.getPlayWhenReady();
+ String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState=";
+ eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
+ switch (playbackState) {
+ case Player.STATE_IDLE:
+ text += "idle";
+ eventEmitter.onVideoIdle.invoke();
+ clearProgressMessageHandler();
+ if (!player.getPlayWhenReady()) {
+ setKeepScreenOn(false);
+ }
+ break;
+ case Player.STATE_BUFFERING:
+ text += "buffering";
+ onBuffering(true);
+ clearProgressMessageHandler();
+ setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
+ break;
+ case Player.STATE_READY:
+ text += "ready";
+ eventEmitter.onReadyForDisplay.invoke();
+ onBuffering(false);
+ clearProgressMessageHandler(); // ensure there is no other message
+ startProgressHandler();
+ videoLoaded();
+ if (selectTrackWhenReady && isUsingContentResolution) {
+ selectTrackWhenReady = false;
+ setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
+ }
+ // Setting the visibility for the player controls
+ if (exoPlayerView != null) {
+ exoPlayerView.showController();
+ }
+ setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
+ break;
+ case Player.STATE_ENDED:
+ text += "ended";
+ updateProgress();
+ eventEmitter.onVideoEnd.invoke();
+ onStopPlayback();
+ setKeepScreenOn(false);
+ break;
+ default:
+ text += "unknown";
+ break;
+ }
+ DebugLog.d(TAG, text);
+ }
+ }
+
+ private void startProgressHandler() {
+ progressHandler.sendEmptyMessage(SHOW_PROGRESS);
+ }
+
+ /**
+ * The progress message handler will duplicate recursions of the onProgressMessage handler
+ * on change of player state from any state to STATE_READY with playWhenReady is true (when
+ * the video is not paused). This clears all existing messages.
+ */
+ private void clearProgressMessageHandler() {
+ progressHandler.removeMessages(SHOW_PROGRESS);
+ }
+
+ private void videoLoaded() {
+ if (!player.isPlayingAd() && loadVideoStarted) {
+ loadVideoStarted = false;
+ if (audioTrackType != null) {
+ setSelectedAudioTrack(audioTrackType, audioTrackValue);
+ }
+ if (videoTrackType != null) {
+ setSelectedVideoTrack(videoTrackType, videoTrackValue);
+ }
+ if (textTrackType != null) {
+ setSelectedTextTrack(textTrackType, textTrackValue);
+ }
+ Format videoFormat = player.getVideoFormat();
+ boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
+ int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0;
+ int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0;
+ String trackId = videoFormat != null ? videoFormat.id : null;
+
+ // Properties that must be accessed on the main thread
+ long duration = player.getDuration();
+ long currentPosition = player.getCurrentPosition();
+ ArrayList