This commit is contained in:
2025-10-21 23:29:08 +02:00
commit e0c879a47d
747 changed files with 39809 additions and 0 deletions

8
.eslintrc.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
root: true,
extends: ["../../config/.eslintrc.js"],
parserOptions: {
tsconfigRootDir: __dirname,
project: true,
},
};

1
.watchmanconfig Normal file
View File

@@ -0,0 +1 @@
{}

59
ReactNativeVideo.podspec Normal file
View File

@@ -0,0 +1,59 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
fabric_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
Pod::Spec.new do |s|
s.name = "ReactNativeVideo"
s.version = package["version"]
s.summary = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.authors = package["author"]
s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => "https://github.com/KrzysztofMoch/react-native-video.git", :tag => "#{s.version}" }
s.source_files = [
"ios/*.{h,m,mm,swift}",
"ios/Core/**/*.{h,m,mm,swift}", # Core library files
"ios/Hybrids/**/*.{h,m,mm,swift}", # Nitro Hybrid files
"ios/View/**/*.{h,m,mm,swift}" # Video View files
]
if fabric_enabled
s.exclude_files = ["ios/view/paper/**/*.{h,m,mm,swift}"]
else
s.exclude_files = ["ios/view/fabric/**/*.{h,m,mm,swift}"]
end
# Cxx to Swift bridging helpers
s.public_header_files = ["ios/Video-Bridging-Header.h"]
s.pod_target_xcconfig = {
"GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) FOLLY_NO_CONFIG FOLLY_CFG_NO_COROUTINES FOLLY_MOBILE"
}
# Try to manually add the dependencies
# because they are not automatically added by expo
# when USE_FRAMEWORKS is true
if ENV["USE_FRAMEWORKS"]
s.dependency "React-Core"
puts "[ReactNativeVideo] Detected USE_FRAMEWORKS, adding required dependencies..."
add_dependency(s, "React-jsinspector", :framework_name => "jsinspector_modern")
add_dependency(s, "React-rendererconsistency", :framework_name => "React_rendererconsistency")
# @KrzysztofMoch Note: We need to add this as well for newer versions of React Native, but it's not available in older versions
# add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing')
end
# Add all files generated by Nitrogen
load 'nitrogen/generated/ios/ReactNativeVideo+autolinking.rb'
add_nitrogen_files(s)
install_modules_dependencies(s)
end

29
android/CMakeLists.txt Normal file
View File

@@ -0,0 +1,29 @@
project(ReactNativeVideo)
cmake_minimum_required(VERSION 3.9.0)
set (PACKAGE_NAME ReactNativeVideo)
set (CMAKE_VERBOSE_MAKEFILE ON)
set (CMAKE_CXX_STANDARD 20)
# Define C++ library and add all sources
add_library(${PACKAGE_NAME} SHARED
src/main/cpp/cpp-adapter.cpp
)
# Add Nitrogen specs :)
include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/ReactNativeVideo+autolinking.cmake)
# Set up local includes
include_directories(
"src/main/cpp"
)
find_library(LOG_LIB log)
# Link all libraries together
target_link_libraries(
${PACKAGE_NAME}
${LOG_LIB}
android # <-- Android core
)

250
android/build.gradle Normal file
View File

@@ -0,0 +1,250 @@
buildscript {
// Buildscript is evaluated before everything else so we can't use getExtOrDefault
def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["RNVideo_kotlinVersion"]
def minKotlin_version = project.properties["RNVideo_minKotlinVersion"]
def androidx_version = rootProject.ext.has('androidxActivityVersion') ? rootProject.ext.get('androidxActivityVersion') : project.properties['RNVideo_androidxActivityVersion']
def minAndroidx_version = project.properties["RNVideo_androidxActivityVersion"]
def isVersionAtLeast = { version, minVersion ->
def (major, minor, patch) = version.tokenize('.')
def (minMajor, minMinor, minPatch) = minVersion.tokenize('.')
// major version is greater
if (major.toInteger() > minMajor.toInteger()) return true
// major version is equal, minor version is greater
if (major.toInteger() == minMajor.toInteger() && minor.toInteger() > minMinor.toInteger()) return true
// major version is equal, minor version is equal, patch version is greater
if (major.toInteger() == minMajor.toInteger() && minor.toInteger() == minMinor.toInteger() && patch.toInteger() >= minPatch.toInteger()) return true
return false
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.2.1"
// noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
ext {
if (!isVersionAtLeast(kotlin_version, minKotlin_version)) {
throw new GradleException("Kotlin version must be at least $minKotlin_version (current: $kotlin_version)")
}
if (!isVersionAtLeast(androidx_version, minAndroidx_version)) {
throw new GradleException("AndroidX version must be at least $minAndroidx_version (current: $androidx_version)")
}
}
}
def reactNativeArchitectures() {
def value = rootProject.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}
def isNewArchitectureEnabled() {
return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
apply from: '../nitrogen/generated/android/ReactNativeVideo+autolinking.gradle'
apply from: './fix-prefab.gradle'
if (isNewArchitectureEnabled()) {
apply plugin: "com.facebook.react"
}
def getExtOrDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["RNVideo_" + name]
}
def getExtOrIntegerDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNVideo_" + name]).toInteger()
}
def supportsNamespace() {
def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')
def major = parsed[0].toInteger()
def minor = parsed[1].toInteger()
// Namespace support was added in 7.3.0
return (major == 7 && minor >= 3) || major >= 8
}
def ExoplayerDependenciesList = [
"useExoplayerDash",
"useExoplayerHls",
]
def ExoplayerDependencies = ExoplayerDependenciesList.collectEntries { property ->
[(property): getExtOrDefault(property)?.toBoolean() ?: false]
}
// Print Configuration
println "[ReactNativeVideo] Exoplayer Dependencies Configuration:"
ExoplayerDependenciesList.each { propertyName ->
def propertyValue = ExoplayerDependencies[propertyName]
println "$propertyName: $propertyValue"
}
android {
if (supportsNamespace()) {
namespace "com.twg.video"
sourceSets {
main {
manifest.srcFile "src/main/AndroidManifestNew.xml"
}
}
}
ndkVersion getExtOrDefault("ndkVersion")
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
defaultConfig {
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
buildConfigField "boolean", "USE_EXOPLAYER_DASH", ExoplayerDependencies.useExoplayerDash.toString()
buildConfigField "boolean", "USE_EXOPLAYER_HLS", ExoplayerDependencies.useExoplayerHls.toString()
externalNativeBuild {
cmake {
cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all"
arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
abiFilters (*reactNativeArchitectures())
}
}
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
packagingOptions {
excludes = [
"META-INF",
"META-INF/**",
"**/libc++_shared.so",
"**/libfbjni.so",
"**/libjsi.so",
"**/libfolly_json.so",
"**/libfolly_runtime.so",
"**/libglog.so",
"**/libhermes.so",
"**/libhermes-executor-debug.so",
"**/libhermes_executor.so",
"**/libreactnativejni.so",
"**/libturbomodulejsijni.so",
"**/libreact_nativemodule_core.so",
"**/libjscexecutor.so",
"**/libreactnative.so"
]
}
buildFeatures {
buildConfig true
prefab true
}
buildTypes {
release {
minifyEnabled false
}
}
lintOptions {
disable "GradleCompatible"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
sourceSets {
main {
if (!isNewArchitectureEnabled()) {
java.srcDirs += ["src/paper/java"]
}
if (!ExoplayerDependencies.useExoplayerDash) {
java.srcDirs += ["src/stubs/dash"]
}
if (!ExoplayerDependencies.useExoplayerHls) {
java.srcDirs += ["src/stubs/hls"]
}
}
}
}
repositories {
mavenCentral()
google()
}
def kotlin_version = getExtOrDefault("kotlinVersion")
def media3_version = getExtOrDefault("media3Version")
def androidxCore_version = getExtOrDefault("androidxCoreVersion")
def androidxActivity_version = getExtOrDefault("androidxActivityVersion")
dependencies {
// For < 0.71, this will be from the local maven repo
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// Add a dependency on NitroModules
implementation project(":react-native-nitro-modules")
implementation "androidx.core:core-ktx:$androidxCore_version"
implementation "androidx.activity:activity-ktx:$androidxActivity_version"
// For media playback using ExoPlayer
implementation "androidx.media3:media3-exoplayer:$media3_version"
// Common functionality used across multiple media libraries
implementation "androidx.media3:media3-common:$media3_version"
// For building media playback UIs
implementation "androidx.media3:media3-ui:$media3_version"
// Common functionality for loading data
implementation "androidx.media3:media3-datasource:$media3_version"
// For loading data using the OkHttp network stack
implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
// For Media Session
implementation "androidx.media3:media3-session:$media3_version"
if (ExoplayerDependencies.useExoplayerDash) {
implementation "androidx.media3:media3-exoplayer-dash:$media3_version"
}
if (ExoplayerDependencies.useExoplayerHls) {
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
}
}
if (isNewArchitectureEnabled()) {
react {
jsRootDir = file("../src/spec/fabric")
libraryName = "RNCVideoView"
codegenJavaPackageName = "com.twg.video"
}
}

51
android/fix-prefab.gradle Normal file
View File

@@ -0,0 +1,51 @@
tasks.configureEach { task ->
// Make sure that we generate our prefab publication file only after having built the native library
// so that not a header publication file, but a full configuration publication will be generated, which
// will include the .so file
def prefabConfigurePattern = ~/^prefab(.+)ConfigurePackage$/
def matcher = task.name =~ prefabConfigurePattern
if (matcher.matches()) {
def variantName = matcher[0][1]
task.outputs.upToDateWhen { false }
task.dependsOn("externalNativeBuild${variantName}")
}
}
afterEvaluate {
def abis = reactNativeArchitectures()
rootProject.allprojects.each { proj ->
if (proj === rootProject) return
def dependsOnThisLib = proj.configurations.findAll { it.canBeResolved }.any { config ->
config.dependencies.any { dep ->
dep.group == project.group && dep.name == project.name
}
}
if (!dependsOnThisLib && proj != project) return
if (!proj.plugins.hasPlugin('com.android.application') && !proj.plugins.hasPlugin('com.android.library')) {
return
}
def variants = proj.android.hasProperty('applicationVariants') ? proj.android.applicationVariants : proj.android.libraryVariants
// Touch the prefab_config.json files to ensure that in ExternalNativeJsonGenerator.kt we will re-trigger the prefab CLI to
// generate a libnameConfig.cmake file that will contain our native library (.so).
// See this condition: https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/ExternalNativeJsonGenerator.kt;l=207-219?q=createPrefabBuildSystemGlue
variants.all { variant ->
def variantName = variant.name
abis.each { abi ->
def searchDir = new File(proj.projectDir, ".cxx/${variantName}")
if (!searchDir.exists()) return
def matches = []
searchDir.eachDir { randomDir ->
def prefabFile = new File(randomDir, "${abi}/prefab_config.json")
if (prefabFile.exists()) matches << prefabFile
}
matches.each { prefabConfig ->
prefabConfig.setLastModified(System.currentTimeMillis())
}
}
}
}
}

13
android/gradle.properties Normal file
View File

@@ -0,0 +1,13 @@
RNVideo_kotlinVersion=1.9.24
RNVideo_minKotlinVersion=1.8.0
RNVideo_minSdkVersion=24
RNVideo_targetSdkVersion=35
RNVideo_compileSdkVersion=35
RNVideo_ndkversion=27.1.12297006
RNVideo_useExoplayerDash=true
RNVideo_useExoplayerHls=true
RNVideo_media3Version=1.4.1
RNVideo_androidxCoreVersion=1.13.1
RNVideo_androidxActivityVersion=1.9.3

View File

@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,6 @@
#include <jni.h>
#include "ReactNativeVideoOnLoad.hpp"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
return margelo::nitro::video::initialize(vm);
}

View File

@@ -0,0 +1,233 @@
package com.twg.video.core
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.AudioFocusRequest
import android.os.Build
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.util.UnstableApi
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import com.margelo.nitro.video.MixAudioMode
import kotlin.getValue
import com.twg.video.core.utils.Threading
@OptIn(UnstableApi::class)
class AudioFocusManager() {
private val players = mutableListOf<HybridVideoPlayer>()
private var currentMixAudioMode: MixAudioMode? = null
private var audioFocusRequest: AudioFocusRequest? = null
val appContext by lazy {
NitroModules.applicationContext ?: throw UnknownError()
}
private val audioManager by lazy {
appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: throw UnknownError()
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
unDuckActivePlayers()
}
AudioManager.AUDIOFOCUS_LOSS -> {
pauseActivePlayers()
currentMixAudioMode = null
audioFocusRequest = null
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
val mixAudioMode = determineRequiredMixMode()
if (mixAudioMode != MixAudioMode.MIXWITHOTHERS) {
pauseActivePlayers()
currentMixAudioMode = null
audioFocusRequest = null
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
val mixAudioMode = determineRequiredMixMode()
when (mixAudioMode) {
MixAudioMode.DONOTMIX -> pauseActivePlayers()
else -> duckActivePlayers()
}
}
}
}
fun registerPlayer(player: HybridVideoPlayer) {
if (!players.contains(player)) {
players.add(player)
}
}
fun unregisterPlayer(player: HybridVideoPlayer) {
players.remove(player)
if (players.isEmpty()) {
abandonAudioFocus()
} else {
requestAudioFocusUpdate()
}
}
fun requestAudioFocusUpdate() {
Threading.runOnMainThread {
val requiredMixMode = determineRequiredMixMode()
if (requiredMixMode == null) {
abandonAudioFocus()
return@runOnMainThread
}
if (currentMixAudioMode != requiredMixMode) {
requestAudioFocus(requiredMixMode)
}
}
}
private fun determineRequiredMixMode(): MixAudioMode? {
val activePlayers = players.filter { player ->
player.player?.isPlaying == true && player.player?.volume != 0f
}
if (activePlayers.isEmpty()) {
return null
}
val anyPlayerNeedsMixWithOthers = activePlayers.any { player ->
player.mixAudioMode == MixAudioMode.MIXWITHOTHERS
}
if (anyPlayerNeedsMixWithOthers) {
abandonAudioFocus()
return MixAudioMode.MIXWITHOTHERS
}
val anyPlayerNeedsExclusiveFocus = activePlayers.any { player ->
player.mixAudioMode == MixAudioMode.DONOTMIX
}
val anyPlayerNeedsDucking = activePlayers.any { player ->
player.mixAudioMode == MixAudioMode.DUCKOTHERS
}
return when {
anyPlayerNeedsExclusiveFocus -> MixAudioMode.DONOTMIX
anyPlayerNeedsDucking -> MixAudioMode.DUCKOTHERS
else -> MixAudioMode.AUTO
}
}
private fun requestAudioFocus(mixMode: MixAudioMode) {
val focusType = when (mixMode) {
MixAudioMode.DONOTMIX -> AudioManager.AUDIOFOCUS_GAIN
MixAudioMode.DUCKOTHERS -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
MixAudioMode.AUTO -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
MixAudioMode.MIXWITHOTHERS -> return // No focus needed for mix with others
}
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
requestAudioFocusNew(focusType)
} else {
requestAudioFocusLegacy(focusType)
}
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
currentMixAudioMode = mixMode
} else {
currentMixAudioMode = null
// Pause players since audio focus couldn't be obtained
pauseActivePlayers()
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun requestAudioFocusNew(focusType: Int): Int {
audioFocusRequest = AudioFocusRequest.Builder(focusType)
.setAudioAttributes(
AudioAttributes.Builder().run {
setUsage(AudioAttributes.USAGE_MEDIA)
setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
build()
}
)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
return audioManager.requestAudioFocus(audioFocusRequest!!)
}
@Suppress("DEPRECATION")
private fun requestAudioFocusLegacy(focusType: Int): Int {
return audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
focusType
)
}
private fun abandonAudioFocus() {
if (currentMixAudioMode != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
abandonAudioFocusNew()
} else {
abandonAudioFocusLegacy()
}
currentMixAudioMode = null
audioFocusRequest = null
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun abandonAudioFocusNew() {
audioFocusRequest?.let { request ->
audioManager.abandonAudioFocusRequest(request)
}
}
@Suppress("DEPRECATION")
private fun abandonAudioFocusLegacy() {
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
private fun pauseActivePlayers() {
Threading.runOnMainThread {
players.forEach { player ->
player.player?.let { mediaPlayer ->
if (mediaPlayer.volume != 0f && mediaPlayer.isPlaying) {
mediaPlayer.pause()
}
}
}
}
}
private fun duckActivePlayers() {
Threading.runOnMainThread {
players.forEach { player ->
player.player?.let { mediaPlayer ->
// We need to duck the volume to 50%. After the audio focus is regained,
// we will restore the volume to the user's volume.
mediaPlayer.volume = mediaPlayer.volume * 0.5f
}
}
}
}
private fun unDuckActivePlayers() {
Threading.runOnMainThread {
// Resume players that were paused due to audio focus loss
players.forEach { player ->
player.player?.let { mediaPlayer ->
// Restore full volume if it was ducked
if (mediaPlayer.volume != 0f && mediaPlayer.volume.toDouble() != player.userVolume) {
mediaPlayer.volume = player.userVolume.toFloat()
}
}
}
}
}
}

View File

@@ -0,0 +1,95 @@
package com.twg.video.core
// Base class for all video errors
abstract class VideoError(
open val code: String,
message: String
) : Error("{%@$code::$message@%}")
// Library related errors
sealed class LibraryError(code: String, message: String) : VideoError(code, message) {
object Deallocated : LibraryError(
"library/deallocated",
"Object has been deallocated"
)
object ApplicationContextNotFound : LibraryError(
"library/application-context-not-found",
"Application context not found"
)
class MethodNotSupported(val methodName: String) : LibraryError(
"library/method-not-supported",
"Method $methodName() is not supported on Android"
)
object DRMPluginNotFound : LibraryError(
"library/drm-plugin-not-found",
"No DRM plugin have been found, please add one to the project",
)
}
// Player related errors
sealed class PlayerError(code: String, message: String) : VideoError(code, message) {
object NotInitialized : PlayerError(
"player/not-initialized",
"Player has not been initialized (Or has been set to null)"
)
object AssetNotInitialized : PlayerError(
"player/asset-not-initialized",
"Asset has not been initialized (Or has been set to null)"
)
object InvalidSource : PlayerError(
"player/invalid-source",
"Invalid source passed to player"
)
}
// Source related errors
sealed class SourceError(code: String, message: String) : VideoError(code, message) {
class InvalidUri(val uri: String) : SourceError(
"source/invalid-uri",
"Invalid source file uri: $uri"
)
class MissingReadFilePermission(val uri: String) : SourceError(
"source/missing-read-file-permission",
"Missing read file permission for source file at $uri"
)
class FileDoesNotExist(val uri: String) : SourceError(
"source/file-does-not-exist",
"File does not exist at URI: $uri"
)
object FailedToInitializeAsset : SourceError(
"source/failed-to-initialize-asset",
"Failed to initialize asset"
)
class UnsupportedContentType(val uri: String) : SourceError(
"source/unsupported-content-type",
"type of content (${uri}) is not supported"
)
}
// View related errors
sealed class VideoViewError(code: String, message: String) : VideoError(code, message) {
class ViewNotFound(val viewId: Int) : VideoViewError(
"view/not-found",
"View with viewId $viewId not found"
)
object ViewIsDeallocated : VideoViewError(
"view/deallocated",
"Attempt to access a view, but it has been deallocated (or not initialized)"
)
object PictureInPictureNotSupported : VideoViewError(
"view/picture-in-picture-not-supported",
"Picture in picture is not supported on this device"
)
}
// Unknown error
class UnknownError : VideoError("unknown/unknown", "Unknown error")

View File

@@ -0,0 +1,284 @@
package com.twg.video.core
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.facebook.react.bridge.LifecycleEventListener
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import com.margelo.nitro.video.MixAudioMode
import com.twg.video.core.plugins.PluginsRegistry
import com.twg.video.view.VideoView
import java.lang.ref.WeakReference
@OptIn(UnstableApi::class)
object VideoManager : LifecycleEventListener {
private const val TAG = "VideoManager"
// nitroId -> weak VideoView
private val views = mutableMapOf<Int, WeakReference<VideoView>>()
// player -> list of nitroIds of views that are using this player
private val players = mutableMapOf<HybridVideoPlayer, MutableList<Int>>()
// Keep track of players that were paused due to PiP so that they can be resumed later
private val playersPausedForPip = mutableSetOf<HybridVideoPlayer>()
private var currentPipVideoView: WeakReference<VideoView>? = null
var audioFocusManager = AudioFocusManager()
private var lastPlayedNitroId: Int? = null
init {
NitroModules.applicationContext?.apply {
addLifecycleEventListener(this@VideoManager)
}
}
fun requestPictureInPicture(videoView: VideoView): Boolean {
Log.d(TAG, "PiP requested for video nitroId: ${videoView.nitroId}")
if (videoView.isInPictureInPicture) {
Log.d(TAG, "Video nitroId: ${videoView.nitroId} is already in PiP")
return true
}
// Exit PiP from current video if there is one
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo != videoView && currentPipVideo.isInPictureInPicture) {
Log.d(TAG, "Forcing exit PiP for video nitroId: ${currentPipVideo.nitroId} to make room for nitroId: ${videoView.nitroId}")
currentPipVideo.forceExitPictureInPicture()
}
}
// Ensure the player that belongs to this view is attached back to this view
videoView.hybridPlayer?.movePlayerToVideoView(videoView)
// Pause every other player that might be playing in the background so that only the PiP video plays
pauseOtherPlayers(videoView)
// Set this video as the designated PiP video BEFORE entering PiP mode
// This ensures the PictureInPictureHelperFragment callbacks know which video should respond
currentPipVideoView = WeakReference(videoView)
Log.d(TAG, "Designated video nitroId: ${videoView.nitroId} as the PiP video")
val success = videoView.internalEnterPictureInPicture()
Log.d(TAG, "PiP enter result for video nitroId: ${videoView.nitroId} = $success")
if (!success) {
// If we failed to enter PiP, resume any players we just paused
resumePlayersPausedForPip()
currentPipVideoView = null
Log.w(TAG, "Failed to enter PiP, clearing designated PiP video")
}
return success
}
fun notifyPictureInPictureExited(videoView: VideoView) {
Log.d(TAG, "PiP exit notification for video nitroId: ${videoView.nitroId}")
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo == videoView) {
Log.d(TAG, "Clearing PiP reference for video nitroId: ${videoView.nitroId}")
currentPipVideoView = null
// Resume any players that were paused when PiP was entered
resumePlayersPausedForPip()
}
}
}
fun getCurrentPictureInPictureVideo(): VideoView? {
return currentPipVideoView?.get()
}
fun setCurrentPictureInPictureVideo(videoView: VideoView) {
Log.d(TAG, "Setting current PiP video to nitroId: ${videoView.nitroId}")
currentPipVideoView = WeakReference(videoView)
}
fun isAnyVideoInPictureInPicture(): Boolean {
return currentPipVideoView?.get()?.isInPictureInPicture == true
}
fun forceExitAllPictureInPicture() {
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo.isInPictureInPicture) {
currentPipVideo.forceExitPictureInPicture()
}
}
currentPipVideoView = null
}
fun maybePassPlayerToView(player: HybridVideoPlayer) {
val views = players[player]?.mapNotNull { getVideoViewWeakReferenceByNitroId(it)?.get() } ?: return
val latestView = views.lastOrNull() ?: return
player.movePlayerToVideoView(latestView)
}
fun registerView(view: VideoView) {
views[view.nitroId] = WeakReference<VideoView>(view)
PluginsRegistry.shared.notifyVideoViewCreated(WeakReference(view))
}
fun unregisterView(view: VideoView) {
view.hybridPlayer?.let {
removeViewFromPlayer(view, it)
}
// Clean up PiP reference if this view was in PiP
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo == view) {
currentPipVideoView = null
}
}
views.remove(view.nitroId)
PluginsRegistry.shared.notifyVideoViewDestroyed(WeakReference(view))
}
fun addViewToPlayer(view: VideoView, player: HybridVideoPlayer) {
// Add player to list if it doesn't exist (should not happen)
if(!players.containsKey(player)) players[player] = mutableListOf()
// Check if view is already added to player
if(players[player]?.contains(view.nitroId) == true) return
// Add view to player
players[player]?.add(view.nitroId)
}
fun removeViewFromPlayer(view: VideoView, player: HybridVideoPlayer) {
players[player]?.remove(view.nitroId)
// If this was the last view using this player, clean up
if (players[player]?.isEmpty() == true) {
players.remove(player)
} else {
// If there are other views using this player, move to the latest one
maybePassPlayerToView(player)
}
}
fun registerPlayer(player: HybridVideoPlayer) {
if (!players.containsKey(player)) {
players[player] = mutableListOf()
}
audioFocusManager.registerPlayer(player)
PluginsRegistry.shared.notifyPlayerCreated(WeakReference(player))
}
fun unregisterPlayer(player: HybridVideoPlayer) {
players.remove(player)
audioFocusManager.unregisterPlayer(player)
PluginsRegistry.shared.notifyPlayerDestroyed(WeakReference(player))
}
fun getPlayerByNitroId(nitroId: Int): HybridVideoPlayer? {
return players.keys.find { player ->
players[player]?.contains(nitroId) == true
}
}
fun updateVideoViewNitroId(oldNitroId: Int, newNitroId: Int, view: VideoView) {
// Remove old mapping
if (oldNitroId != -1) {
views.remove(oldNitroId)
// Update player mappings
players.keys.forEach { player ->
players[player]?.let { nitroIds ->
if (nitroIds.remove(oldNitroId)) {
nitroIds.add(newNitroId)
}
}
}
}
// Add new mapping
views[newNitroId] = WeakReference(view)
}
fun getVideoViewWeakReferenceByNitroId(nitroId: Int): WeakReference<VideoView>? {
return views[nitroId]
}
// ------------ Lifecycle Handler ------------
private fun onAppEnterForeground() {
players.keys.forEach { player ->
if (player.wasAutoPaused) {
player.play()
}
}
}
private fun onAppEnterBackground() {
players.keys.forEach { player ->
if (!player.playInBackground && player.isPlaying) {
player.wasAutoPaused = player.isPlaying
player.pause()
}
}
}
override fun onHostResume() {
onAppEnterForeground()
}
override fun onHostPause() {
onAppEnterBackground()
}
override fun onHostDestroy() {
forceExitAllPictureInPicture()
}
fun pauseOtherPlayers(pipVideoView: VideoView) {
val pipPlayer = pipVideoView.hybridPlayer
playersPausedForPip.clear()
players.keys.forEach { player ->
// Skip the player that is used for the PiP view
if (player == pipPlayer) return@forEach
// Pause only if it is currently playing
if (player.isPlaying && player.mixAudioMode != MixAudioMode.MIXWITHOTHERS) {
player.pause()
playersPausedForPip.add(player)
Log.v(TAG, "Paused player for PiP (nitroIds: ${players[player]})")
}
}
}
private fun resumePlayersPausedForPip() {
playersPausedForPip.forEach { player ->
// Ensure the player is attached to the latest visible VideoView before resuming
maybePassPlayerToView(player)
if (!player.isPlaying) {
player.play()
Log.v(TAG, "Resumed player after PiP exit (nitroIds: ${players[player]})")
}
}
playersPausedForPip.clear()
}
fun getAnyPlayingVideoView(): VideoView? {
return views.values.firstOrNull { ref ->
ref.get()?.hybridPlayer?.isPlaying == true
}?.get()
}
fun setLastPlayedPlayer(player: HybridVideoPlayer) {
// Resolve to the latest view using this player (usually the last one in the list)
val nitroIds = players[player] ?: return
if (nitroIds.isNotEmpty()) {
lastPlayedNitroId = nitroIds.last()
}
}
fun getLastPlayedVideoView(): VideoView? {
return lastPlayedNitroId?.let { views[it]?.get() }
}
}

View File

@@ -0,0 +1,20 @@
package com.twg.video.core.extensions
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import com.margelo.nitro.video.ResizeMode
@OptIn(UnstableApi::class)
/**
* Converts a [ResizeMode] to a [AspectRatioFrameLayout] resize mode.
* @return The corresponding [AspectRatioFrameLayout] resize mode.
*/
fun ResizeMode.toAspectRatioFrameLayout(): Int {
return when (this) {
ResizeMode.CONTAIN -> AspectRatioFrameLayout.RESIZE_MODE_FIT
ResizeMode.COVER -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
ResizeMode.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ResizeMode.NONE -> AspectRatioFrameLayout.RESIZE_MODE_FIT
}
}

View File

@@ -0,0 +1,14 @@
package com.twg.video.core.extensions
import com.margelo.nitro.video.SubtitleType
fun SubtitleType.toStringExtension(): String {
return when {
this == SubtitleType.AUTO -> "auto"
this == SubtitleType.VTT -> "vtt"
this == SubtitleType.SRT -> "srt"
this == SubtitleType.SSA -> "ssa"
this == SubtitleType.ASS -> "ass"
else -> throw IllegalArgumentException("Unknown SubtitleType: $this")
}
}

View File

@@ -0,0 +1,59 @@
package com.twg.video.core.extensions
import android.content.Context
import android.content.Context.BIND_AUTO_CREATE
import android.content.Context.BIND_INCLUDE_CAPABILITIES
import android.content.Intent
import android.os.Build
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import com.twg.video.core.services.playback.VideoPlaybackService
import com.twg.video.core.services.playback.VideoPlaybackServiceConnection
fun VideoPlaybackService.Companion.startService(
context: Context,
serviceConnection: VideoPlaybackServiceConnection
) {
val reactContext = NitroModules.applicationContext ?: return
val intent = Intent(context, VideoPlaybackService::class.java)
intent.action = VIDEO_PLAYBACK_SERVICE_INTERFACE
// Use startForegroundService on O+ so the service has the opportunity to call
// startForeground(...) quickly and avoid ForegroundServiceDidNotStartInTimeException.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
reactContext.startForegroundService(intent)
} catch (_: Exception) {
// Fall back to startService if anything goes wrong
try { reactContext.startService(intent) } catch (_: Exception) {}
}
} else {
reactContext.startService(intent)
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
BIND_AUTO_CREATE or BIND_INCLUDE_CAPABILITIES
} else {
BIND_AUTO_CREATE
}
context.bindService(intent, serviceConnection, flags)
}
@OptIn(UnstableApi::class)
fun VideoPlaybackService.Companion.stopService(
player: HybridVideoPlayer,
serviceConnection: VideoPlaybackServiceConnection
) {
try {
// Unregister the player first; this might stop the service if no players remain
serviceConnection.unregisterPlayer(player)
// Ask service (if still connected) to stop when idle
try { serviceConnection.serviceBinder?.service?.stopIfNoPlayers() } catch (_: Exception) {}
// Then unbind
NitroModules.applicationContext?.currentActivity?.unbindService(serviceConnection)
} catch (_: Exception) {}
}

View File

@@ -0,0 +1,249 @@
package com.twg.video.core.fragments
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageButton
import androidx.activity.OnBackPressedCallback
import androidx.annotation.OptIn
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.twg.video.core.utils.PictureInPictureUtils.createPictureInPictureParams
import com.twg.video.core.utils.SmallVideoPlayerOptimizer
import com.twg.video.view.VideoView
import java.util.UUID
@OptIn(UnstableApi::class)
class FullscreenVideoFragment(private val videoView: VideoView) : Fragment() {
val id: String = UUID.randomUUID().toString()
private var container: FrameLayout? = null
private var originalPlayerParent: ViewGroup? = null
private var originalPlayerLayoutParams: ViewGroup.LayoutParams? = null
private var rootContentViews: List<View> = listOf()
// Back press callback to handle back navigation
private val backPressCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
videoView.exitFullscreen()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Create a fullscreen container
this.container = FrameLayout(requireContext()).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(android.graphics.Color.BLACK)
keepScreenOn = true
}
return this.container
}
override fun onResume() {
super.onResume()
// System UI is re-enabled when user have exited app and go back
// We need to hide it again
hideSystemUI()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Register back press callback
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressCallback)
enterFullscreenMode()
setupPlayerView()
hideSystemUI()
// Update PiP params if supported
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val params = createPictureInPictureParams(videoView)
requireActivity().setPictureInPictureParams(params)
} catch (_: Exception) {}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Handle PiP mode changes
val isInPictureInPictureMode =
requireActivity().isInPictureInPictureMode
if (isInPictureInPictureMode) {
videoView.playerView.useController = false
} else {
videoView.playerView.useController = videoView.useController
}
}
private fun enterFullscreenMode() {
// Store original parent and layout params
originalPlayerParent = videoView.playerView.parent as? ViewGroup
originalPlayerLayoutParams = videoView.playerView.layoutParams
// Remove player from original parent
originalPlayerParent?.removeView(videoView.playerView)
// Hide all root content views
val currentActivity = requireActivity()
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContentViews = (0 until rootContent.childCount)
.map { rootContent.getChildAt(it) }
.filter { it.isVisible }
rootContentViews.forEach { view ->
view.visibility = View.GONE
}
// Add our fullscreen container to root
rootContent.addView(container,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
private fun setupPlayerView() {
// Add PlayerView to our container
container?.addView(videoView.playerView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
)
videoView.playerView.setBackgroundColor(android.graphics.Color.BLACK)
videoView.playerView.setShutterBackgroundColor(android.graphics.Color.BLACK)
// We need show controls in fullscreen
videoView.playerView.useController = true
setupFullscreenButton()
videoView.playerView.setShowSubtitleButton(true)
// Apply optimizations based on video player size in fullscreen mode
SmallVideoPlayerOptimizer.applyOptimizations(videoView.playerView, requireContext(), isFullscreen = true)
}
@SuppressLint("PrivateResource")
private fun setupFullscreenButton() {
videoView.playerView.setFullscreenButtonClickListener { _ ->
videoView.exitFullscreen()
}
// Change icon to exit fullscreen
val button = videoView.playerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
button?.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit)
}
@Suppress("DEPRECATION")
private fun hideSystemUI() {
val currentActivity = requireActivity()
container?.let { container ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
container.fitsSystemWindows = false
container.windowInsetsController?.let { controller ->
controller.hide(WindowInsets.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
currentActivity.window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
)
}
}
}
@Suppress("DEPRECATION")
private fun restoreSystemUI() {
val currentActivity = requireActivity()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
container?.windowInsetsController?.show(WindowInsets.Type.systemBars())
} else {
currentActivity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
}
fun exitFullscreen() {
// Remove back press callback since we're exiting
backPressCallback.remove()
restoreSystemUI()
if (videoView.useController == false) {
videoView.playerView.useController = false
}
// Ensure PlayerView keeps black background when returning to normal mode
videoView.playerView.setBackgroundColor(android.graphics.Color.BLACK)
videoView.playerView.setShutterBackgroundColor(android.graphics.Color.BLACK)
// Remove PlayerView from our container
container?.removeView(videoView.playerView)
// Remove our container from root
val currentActivity = requireActivity()
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContent.removeView(container)
// Restore root content views
rootContentViews.forEach { it.visibility = View.VISIBLE }
rootContentViews = listOf()
// Safely restore PlayerView to original parent
// First, ensure PlayerView is removed from any current parent
val currentParent = videoView.playerView.parent as? ViewGroup
currentParent?.removeView(videoView.playerView)
// Now add it back to the original parent if it's not already the parent
if (videoView.playerView.parent != originalPlayerParent) {
originalPlayerParent?.addView(videoView.playerView, originalPlayerLayoutParams)
}
// Remove this fragment
parentFragmentManager.beginTransaction()
.remove(this)
.commitAllowingStateLoss()
// Notify VideoView that we've exited fullscreen
videoView.isInFullscreen = false
}
override fun onDestroy() {
super.onDestroy()
// Ensure we clean up properly if fragment is destroyed
if (videoView.isInFullscreen) {
exitFullscreen()
}
}
}

View File

@@ -0,0 +1,72 @@
package com.twg.video.core.fragments
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.twg.video.core.VideoManager
import com.twg.video.view.VideoView
import java.util.UUID
@OptIn(UnstableApi::class)
class PictureInPictureHelperFragment(private val videoView: VideoView) : Fragment() {
val id: String = UUID.randomUUID().toString()
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
if (isInPictureInPictureMode) {
var currentPipVideo = VideoManager.getCurrentPictureInPictureVideo()
if (currentPipVideo == null) {
synchronized(VideoManager) {
currentPipVideo = VideoManager.getCurrentPictureInPictureVideo()
if (currentPipVideo == null) {
if (videoView.hybridPlayer?.isPlaying == true) {
val lastPlayed = VideoManager.getLastPlayedVideoView()
val shouldDesignate = lastPlayed == null || lastPlayed.nitroId == videoView.nitroId
if (shouldDesignate) {
VideoManager.setCurrentPictureInPictureVideo(videoView)
videoView.hybridPlayer?.movePlayerToVideoView(videoView)
VideoManager.pauseOtherPlayers(videoView)
currentPipVideo = videoView
}
} else {
if (!VideoManager.isAnyVideoInPictureInPicture()) {
val lastPlayed = VideoManager.getLastPlayedVideoView()
val targetView = lastPlayed ?: videoView
VideoManager.setCurrentPictureInPictureVideo(targetView)
targetView.hybridPlayer?.movePlayerToVideoView(targetView)
VideoManager.pauseOtherPlayers(targetView)
currentPipVideo = targetView
}
}
}
}
}
if (currentPipVideo == videoView) {
// If we're currently in fullscreen, exit it first to prevent parent conflicts
if (videoView.isInFullscreen) {
try {
videoView.exitFullscreen()
} catch (e: Exception) {
Log.w("ReactNativeVideo", "Failed to exit fullscreen before entering PiP for nitroId: ${videoView.nitroId}", e)
}
}
// Now move the PlayerView to the root for PiP and hide content
videoView.hideRootContentViews()
videoView.isInPictureInPicture = true
}
} else {
if (videoView.isInPictureInPicture) {
videoView.exitPictureInPicture()
}
}
}
}

View File

@@ -0,0 +1,23 @@
package com.twg.video.core.player
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.drm.DrmSessionManager
import com.margelo.nitro.video.NativeDrmParams
import java.util.UUID
@OptIn(UnstableApi::class)
interface DRMManagerSpec {
fun buildDrmSessionManager(drmParams: NativeDrmParams): DrmSessionManager {
val drmScheme = drmParams.type ?: "widevine"
val drmUuid = Util.getDrmUuid(drmScheme)
return buildDrmSessionManager(drmParams, drmUuid)
}
fun buildDrmSessionManager(drmParams: NativeDrmParams, drmUuid: UUID?, retryCount: Int = 0): DrmSessionManager
fun getDRMConfiguration(drmParams: NativeDrmParams): MediaItem.DrmConfiguration
}

View File

@@ -0,0 +1,51 @@
package com.twg.video.core.player
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.facebook.react.bridge.ReactContext
import com.facebook.react.modules.network.CookieJarContainer
import com.facebook.react.modules.network.ForwardingCookieHandler
import com.facebook.react.modules.network.OkHttpClientProvider
import com.margelo.nitro.video.HybridVideoPlayerSourceSpec
import okhttp3.JavaNetCookieJar
fun buildBaseDataSourceFactory(context: Context, source: HybridVideoPlayerSourceSpec): DefaultDataSource.Factory {
return if (source.uri.startsWith("http")) {
DefaultDataSource.Factory(context, buildHttpDataSourceFactory(context, source))
} else {
DefaultDataSource.Factory(context)
}
}
@OptIn(UnstableApi::class)
fun buildHttpDataSourceFactory(context: Context, source: HybridVideoPlayerSourceSpec): OkHttpDataSource.Factory {
val client = OkHttpClientProvider.getOkHttpClient()
if (context is ReactContext) {
val handler = ForwardingCookieHandler(context)
(client.cookieJar as CookieJarContainer).setCookieJar(JavaNetCookieJar(handler))
}
val factory = OkHttpDataSource.Factory(client)
val headers: Map<String, String>? = source.config.headers
if (headers != null) {
factory.setDefaultRequestProperties(headers)
}
if (headers == null || !headers.containsKey("User-Agent")) {
factory.setUserAgent(getUserAgent(context))
}
return factory
}
@OptIn(UnstableApi::class)
fun getUserAgent(context: Context): String {
return Util.getUserAgent(context, context.packageName)
}

View File

@@ -0,0 +1,141 @@
package com.twg.video.core.player
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import com.margelo.nitro.video.BufferConfig
import com.margelo.nitro.video.CustomVideoMetadata
import com.margelo.nitro.video.HybridVideoPlayerSource
import com.margelo.nitro.video.LivePlaybackParams
import com.margelo.nitro.video.NativeDrmParams
import com.margelo.nitro.video.NativeVideoConfig
import com.margelo.nitro.video.SubtitleType
import com.twg.video.core.LibraryError
import com.twg.video.core.SourceError
import com.twg.video.core.extensions.toStringExtension
import com.twg.video.core.plugins.PluginsRegistry
private const val TAG = "MediaItemUtils"
@OptIn(UnstableApi::class)
fun createMediaItemFromVideoConfig(
source: HybridVideoPlayerSource
): MediaItem {
val mediaItemBuilder = MediaItem.Builder()
mediaItemBuilder.setUri(source.config.uri)
source.config.drm?.let { drmParams ->
val drmManager = source.drmManager ?: throw LibraryError.DRMPluginNotFound
val drmConfiguration = drmManager.getDRMConfiguration(drmParams)
mediaItemBuilder.setDrmConfiguration(drmConfiguration)
}
source.config.bufferConfig?.livePlayback?.let { livePlaybackParams ->
mediaItemBuilder.setLiveConfiguration(getLiveConfiguration(livePlaybackParams))
}
source.config.metadata?.let { metadata ->
mediaItemBuilder.setMediaMetadata(getCustomMetadata(metadata))
}
return PluginsRegistry.shared.overrideMediaItemBuilder(
source,
mediaItemBuilder
).build()
}
fun getSubtitlesConfiguration(
config: NativeVideoConfig,
): List<MediaItem.SubtitleConfiguration> {
val subtitlesConfiguration: MutableList<MediaItem.SubtitleConfiguration> = mutableListOf()
if (config.externalSubtitles != null) {
for (subtitle in config.externalSubtitles) {
val ext = if (subtitle.type == SubtitleType.AUTO) {
MimeTypeMap.getFileExtensionFromUrl(subtitle.uri)
} else {
subtitle.type.toStringExtension()
}
val mimeType = when (ext?.lowercase()) {
"srt" -> MimeTypes.APPLICATION_SUBRIP
"vtt" -> MimeTypes.TEXT_VTT
"ssa", "ass" -> MimeTypes.TEXT_SSA
else -> {
Log.e(TAG, "Unsupported subtitle extension '$ext' for URI: ${subtitle.uri}. Skipping this subtitle.")
continue
}
}
try {
val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(subtitle.uri.toUri())
.setId("external-subtitle-${subtitle.uri}")
.setMimeType(mimeType)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE)
.setLabel(subtitle.label)
.build()
subtitlesConfiguration.add(subtitleConfig)
} catch (e: Exception) {
Log.e(TAG, "Error creating SubtitleConfiguration for URI ${subtitle.uri}: ${e.message}", e)
}
}
}
return subtitlesConfiguration
}
fun getLiveConfiguration(
livePlaybackParams: LivePlaybackParams
): MediaItem.LiveConfiguration {
val liveConfiguration = MediaItem.LiveConfiguration.Builder()
livePlaybackParams.maxOffsetMs?.let {
if (it >= 0) {
liveConfiguration.setMaxOffsetMs(it.toLong())
}
}
livePlaybackParams.minOffsetMs?.let {
if (it >= 0) {
liveConfiguration.setMinOffsetMs(it.toLong())
}
}
livePlaybackParams.targetOffsetMs?.let {
if (it >= 0) {
liveConfiguration.setTargetOffsetMs(it.toLong())
}
}
livePlaybackParams.maxPlaybackSpeed?.let {
if (it >= 0) {
liveConfiguration.setMaxPlaybackSpeed(it.toFloat())
}
}
livePlaybackParams.minPlaybackSpeed?.let {
if (it >= 0) {
liveConfiguration.setMinPlaybackSpeed(it.toFloat())
}
}
return liveConfiguration.build()
}
fun getCustomMetadata(metadata: CustomVideoMetadata): MediaMetadata {
return MediaMetadata.Builder()
.setDisplayTitle(metadata.title)
.setTitle(metadata.title)
.setSubtitle(metadata.subtitle)
.setDescription(metadata.description)
.setArtist(metadata.artist)
.setArtworkUri(metadata.imageUri?.toUri())
.build()
}

View File

@@ -0,0 +1,112 @@
package com.twg.video.core.player
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.source.MediaSource
import com.margelo.nitro.video.HybridVideoPlayerSourceSpec
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.drm.DrmSessionManager
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import com.margelo.nitro.video.HybridVideoPlayerSource
import com.twg.video.core.LibraryError
import com.twg.video.core.SourceError
import com.twg.video.core.plugins.PluginsRegistry
@OptIn(UnstableApi::class)
@Throws(SourceError::class)
fun buildMediaSource(context: Context, source: HybridVideoPlayerSource, mediaItem: MediaItem): MediaSource {
val uri = source.uri.toUri()
// Explanation:
// 1. Remove query params from uri to avoid getting false extension
// 2. Get extension from uri
val type = Util.inferContentType(uri)
val dataSourceFactory = PluginsRegistry.shared.overrideMediaDataSourceFactory(
source,
buildBaseDataSourceFactory(context, source)
)
if (!source.config.externalSubtitles.isNullOrEmpty()) {
return buildExternalSubtitlesMediaSource(context, source)
}
val mediaSourceFactory: MediaSource.Factory = when (type) {
C.CONTENT_TYPE_DASH -> {
DashMediaSource.Factory(dataSourceFactory)
}
C.CONTENT_TYPE_HLS -> {
HlsMediaSource.Factory(dataSourceFactory)
}
C.CONTENT_TYPE_OTHER -> {
DefaultMediaSourceFactory(context)
.setDataSourceFactory(dataSourceFactory)
}
else -> {
throw SourceError.UnsupportedContentType(source.uri)
}
}
source.config.drm?.let {
val drmSessionManager = source.drmSessionManager ?: throw LibraryError.DRMPluginNotFound
mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
}
return PluginsRegistry.shared.overrideMediaSourceFactory(
source,
mediaSourceFactory,
dataSourceFactory
).createMediaSource(mediaItem)
}
@OptIn(UnstableApi::class)
fun buildExternalSubtitlesMediaSource(context: Context, source: HybridVideoPlayerSource): MediaSource {
val dataSourceFactory = PluginsRegistry.shared.overrideMediaDataSourceFactory(
source,
buildBaseDataSourceFactory(context, source)
)
val mediaItemBuilderWithSubtitles = MediaItem.Builder()
.setUri(source.uri.toUri())
.setSubtitleConfigurations(getSubtitlesConfiguration(source.config))
source.config.metadata?.let { metadata ->
mediaItemBuilderWithSubtitles.setMediaMetadata(getCustomMetadata(metadata))
}
val mediaItemBuilder = PluginsRegistry.shared.overrideMediaItemBuilder(
source,
mediaItemBuilderWithSubtitles
)
val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(dataSourceFactory)
if (source.config.drm != null) {
if (source.drmManager == null) {
throw LibraryError.DRMPluginNotFound
}
mediaSourceFactory.setDrmSessionManagerProvider {
source.drmManager as DrmSessionManager
}
val drmConfiguration = source.drmManager!!.getDRMConfiguration(source.config.drm!!)
mediaItemBuilder.setDrmConfiguration(drmConfiguration)
}
return PluginsRegistry.shared.overrideMediaSourceFactory(
source,
mediaSourceFactory,
dataSourceFactory
).createMediaSource(mediaItemBuilder.build())
}

View File

@@ -0,0 +1,27 @@
package com.twg.video.core.player
import android.content.IntentFilter
import android.media.AudioManager
import androidx.core.content.ContextCompat
import com.margelo.nitro.video.HybridVideoPlayerEventEmitterSpec
// TODO: We should make VideoFocusManager that will track focus globally for now lets just do simple listener
class OnAudioFocusChangedListener : AudioManager.OnAudioFocusChangeListener {
private var eventEmitter: HybridVideoPlayerEventEmitterSpec? = null
override fun onAudioFocusChange(focusChange: Int) {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> eventEmitter?.onAudioFocusChange?.invoke(true)
AudioManager.AUDIOFOCUS_LOSS -> eventEmitter?.onAudioFocusChange?.invoke(false)
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> eventEmitter?.onAudioFocusChange?.invoke(false)
}
}
fun setEventEmitter(eventEmitter: HybridVideoPlayerEventEmitterSpec) {
this.eventEmitter = eventEmitter
}
fun removeEventEmitter() {
this.eventEmitter = null
}
}

View File

@@ -0,0 +1,223 @@
package com.twg.video.core.plugins
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.source.MediaSource
import com.margelo.nitro.video.HybridVideoPlayer
import com.margelo.nitro.video.HybridVideoPlayerSource
import com.twg.video.BuildConfig
import com.twg.video.core.LibraryError
import com.twg.video.core.player.DRMManagerSpec
import com.twg.video.view.VideoView
import java.lang.ref.WeakReference
// Keep these types for platform compatibility
// On iOS we cannot just export HybridVideoPlayer so we need to keep this typealias
typealias NativeVideoPlayer = HybridVideoPlayer
typealias NativeVideoPlayerSource = HybridVideoPlayerSource
class PluginsRegistry {
// Plugin ID -> ReactNativeVideoPluginSpec
private val plugins: MutableMap<String, ReactNativeVideoPluginSpec> = mutableMapOf()
companion object {
val shared = PluginsRegistry()
private const val TAG = "ReactNativeVideoPluginsRegistry"
}
// Public methods
fun register(plugin: ReactNativeVideoPluginSpec) {
if(hasPlugin(plugin)) {
plugins.replace(plugin.id, plugin)
if (BuildConfig.DEBUG) {
Log.d(TAG, "Replaced plugin ${plugin.name} (ID: ${plugin.id})")
}
return
}
plugins.put(plugin.id, plugin)
if (BuildConfig.DEBUG) {
Log.d(TAG, "Registered plugin ${plugin.name} (ID: ${plugin.id})")
}
}
@Suppress("unused")
fun unregister(plugin: ReactNativeVideoPluginSpec) {
if (!hasPlugin(plugin)) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Tried to unregister plugin ${plugin.name} (ID: ${plugin.id}), but it was not registered")
}
return
}
plugins.remove(plugin.id)
if (BuildConfig.DEBUG) {
Log.d(TAG, "Unregistered plugin ${plugin.name} (ID: ${plugin.id})")
}
}
// Notifications
@OptIn(UnstableApi::class)
internal fun notifyPlayerCreated(player: WeakReference<NativeVideoPlayer>) {
plugins.values.forEach { it.onPlayerCreated(player) }
}
@OptIn(UnstableApi::class)
internal fun notifyPlayerDestroyed(player: WeakReference<NativeVideoPlayer>) {
plugins.values.forEach { it.onPlayerDestroyed(player) }
}
@OptIn(UnstableApi::class)
internal fun notifyVideoViewCreated(view: WeakReference<VideoView>) {
plugins.values.forEach { it.onVideoViewCreated(view) }
}
@OptIn(UnstableApi::class)
internal fun notifyVideoViewDestroyed(view: WeakReference<VideoView>) {
plugins.values.forEach { it.onVideoViewDestroyed(view) }
}
// Internal methods
/**
* Maybe override the source with the plugins.
*
* This method is used to override the source with the plugins.
* It is called when a source is created and is used to override the source with the plugins.
*
* It is not guaranteed that the source will be overridden with the plugins.
* If no plugin overrides the source, the original source is returned.
*
* @param source The source instance.
* @return The maybe overridden source instance.
*/
internal fun overrideSource(source: NativeVideoPlayerSource): NativeVideoPlayerSource {
var overriddenSource = source
for (plugin in plugins.values) {
overriddenSource = plugin.overrideSource(overriddenSource)
}
return overriddenSource
}
/**
* Returns the DRM manager instance from the plugins.
*
* @throws LibraryError.DRMPluginNotFound If no DRM manager is found.
* @return Any
*/
internal fun getDRMManager(source: NativeVideoPlayerSource): DRMManagerSpec {
for (plugin in plugins.values) {
val manager = plugin.getDRMManager(source)
if (manager != null) return manager
}
throw LibraryError.DRMPluginNotFound
}
/**
* Maybe override the media data source factory with the plugins.
*
* If no plugin overrides the media data source factory, the original factory is returned.
*
* @param source The source instance.
* @param mediaDataSourceFactory The media data source factory instance.
* @return The maybe overridden media data source factory instance.
*/
internal fun overrideMediaDataSourceFactory(
source: NativeVideoPlayerSource,
mediaDataSourceFactory: DataSource.Factory
): DataSource.Factory {
for (plugin in plugins.values) {
val factory = plugin.getMediaDataSourceFactory(source, mediaDataSourceFactory)
if (factory != null) return factory
}
return mediaDataSourceFactory
}
/**
* Maybe override the media source factory with the plugins.
*
* If no plugin overrides the media source factory, the original factory is returned.
*
* @param source The source instance.
* @param mediaSourceFactory The media source factory instance.
* @param mediaDataSourceFactory The media data source factory instance.
* @return The maybe overridden media source factory instance.
*/
internal fun overrideMediaSourceFactory(
source: NativeVideoPlayerSource,
mediaSourceFactory: MediaSource.Factory,
mediaDataSourceFactory: DataSource.Factory
): MediaSource.Factory {
for (plugin in plugins.values) {
val factory = plugin.getMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory)
if (factory != null) return factory
}
return mediaSourceFactory
}
/**
* Maybe override the media item builder with the plugins.
*
* If no plugin overrides the media item builder, the original builder is returned.
*
* @param source The source instance.
* @param mediaItemBuilder The media item builder instance.
* @return The maybe overridden media item builder instance.
*/
internal fun overrideMediaItemBuilder(
source: NativeVideoPlayerSource,
mediaItemBuilder: MediaItem.Builder
): MediaItem.Builder {
for (plugin in plugins.values) {
val builder = plugin.getMediaItemBuilder(source, mediaItemBuilder)
if (builder != null) return builder
}
return mediaItemBuilder
}
/**
* Maybe disable the cache with the plugins.
*
* If no plugin disables the cache, the original cache is not disabled.
*
* @param source The source instance.
* @return The maybe disabled cache.
*/
internal fun shouldDisableCache(source: NativeVideoPlayerSource): Boolean {
for (plugin in plugins.values) {
val shouldDisable = plugin.shouldDisableCache(source)
if (shouldDisable) return true
}
return false
}
/**
* Checks if a plugin is registered.
*
* @param plugin The plugin instance.
* @return True if the plugin is registered, false otherwise.
*/
internal fun hasPlugin(plugin: ReactNativeVideoPluginSpec): Boolean {
return plugins.any { it.key == plugin.id }
}
}

View File

@@ -0,0 +1,173 @@
package com.twg.video.core.plugins
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.source.MediaSource
import com.twg.video.core.player.DRMManagerSpec
import com.twg.video.view.VideoView
import java.lang.ref.WeakReference
interface ReactNativeVideoPluginSpec {
/**
* The ID of the plugin.
*/
val id: String
/**
* The name of the plugin.
*/
val name: String
/**
* Called when a player is created.
*
* @param player The weak reference to the player instance.
*/
@UnstableApi
fun onPlayerCreated(player: WeakReference<NativeVideoPlayer>)
/**
* Called when a player is destroyed.
*
* @param player The weak reference to the player instance.
*/
@UnstableApi
fun onPlayerDestroyed(player: WeakReference<NativeVideoPlayer>)
/**
* Called when a video view is created.
*
* @param view The weak reference to the video view instance.
*/
@UnstableApi
fun onVideoViewCreated(view: WeakReference<VideoView>)
/**
* Called when a video view is destroyed.
*
* @param view The weak reference to the video view instance.
*/
@UnstableApi
fun onVideoViewDestroyed(view: WeakReference<VideoView>)
/**
* Called when a source is being used to create mediaItem or MediaSource.
* You can use it to modify the source before it is used.
*
* @param source The source instance.
* @return The overridden source instance.
*/
fun overrideSource(source: NativeVideoPlayerSource): NativeVideoPlayerSource
/**
* Called when a DRM manager is requested.
*
* @return The DRM manager instance.
*/
fun getDRMManager(source: NativeVideoPlayerSource): DRMManagerSpec?
/**
* Called when a media data source factory is requested.
*
* @param source The source instance.
* @param mediaDataSourceFactory The media data source factory.
* @return The media data source factory. If null is returned, the default factory will be used.
*/
fun getMediaDataSourceFactory(
source: NativeVideoPlayerSource,
mediaDataSourceFactory: DataSource.Factory
): DataSource.Factory?
/**
* Called when a media source factory is requested.
*
* @param source The source instance.
* @param mediaSourceFactory The media source factory.
* @param mediaDataSourceFactory The media data source factory.
* @return The media source factory. If null is returned, the default factory will be used.
*/
fun getMediaSourceFactory(
source: NativeVideoPlayerSource,
mediaSourceFactory: MediaSource.Factory,
mediaDataSourceFactory: DataSource.Factory
): MediaSource.Factory?
/**
* Called when a media item builder is requested.
*
* @param source The source instance.
* @param mediaItemBuilder The media item builder.
* @return The media item builder. If null is returned, the default builder will be used.
*/
fun getMediaItemBuilder(
source: NativeVideoPlayerSource,
mediaItemBuilder: MediaItem.Builder
): MediaItem.Builder?
/**
* Called when a cache should be disabled.
*
* @param source The source instance.
* @return True if cache should be disabled, false otherwise.
*/
fun shouldDisableCache(source: NativeVideoPlayerSource): Boolean
}
@Suppress("Unused")
/**
* A helper base implementation of the ReactNativeVideoPluginSpec interface.
*/
open class ReactNativeVideoPlugin(override val name: String) : ReactNativeVideoPluginSpec {
override val id = "RNV_Plugin_${name}"
init {
// Automatically register the plugin when it is created
PluginsRegistry.shared.register(this)
}
@UnstableApi
override fun onPlayerCreated(player: WeakReference<NativeVideoPlayer>) { /* NOOP */}
@UnstableApi
override fun onPlayerDestroyed(player: WeakReference<NativeVideoPlayer>) { /* NOOP */}
@UnstableApi
override fun onVideoViewCreated(view: WeakReference<VideoView>) { /* NOOP */}
@UnstableApi
override fun onVideoViewDestroyed(view: WeakReference<VideoView>) { /* NOOP */}
override fun overrideSource(source: NativeVideoPlayerSource): NativeVideoPlayerSource {
return source
}
override fun getDRMManager(source: NativeVideoPlayerSource): DRMManagerSpec? { return null }
override fun getMediaDataSourceFactory(
source: NativeVideoPlayerSource,
mediaDataSourceFactory: DataSource.Factory
): DataSource.Factory? {
return null
}
override fun getMediaSourceFactory(
source: NativeVideoPlayerSource,
mediaSourceFactory: MediaSource.Factory,
mediaDataSourceFactory: DataSource.Factory
): MediaSource.Factory? {
return null
}
override fun getMediaItemBuilder(
source: NativeVideoPlayerSource,
mediaItemBuilder: MediaItem.Builder
): MediaItem.Builder? {
return null
}
override fun shouldDisableCache(source: NativeVideoPlayerSource): Boolean {
return false
}
}

View File

@@ -0,0 +1,38 @@
package com.twg.video.core.recivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import androidx.core.content.ContextCompat
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayerEventEmitterSpec
import com.twg.video.core.LibraryError
class AudioBecomingNoisyReceiver() : BroadcastReceiver() {
private var eventEmitter: HybridVideoPlayerEventEmitterSpec? = null
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
eventEmitter?.onAudioBecomingNoisy?.invoke()
}
}
fun setEventEmitter(eventEmitter: HybridVideoPlayerEventEmitterSpec) {
val context = NitroModules.applicationContext ?: throw LibraryError.ApplicationContextNotFound
this.eventEmitter = eventEmitter
val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
ContextCompat.registerReceiver(
context.applicationContext,
this,
intentFilter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
fun removeEventEmitter() {
this.eventEmitter = null
}
}

View File

@@ -0,0 +1,147 @@
package com.twg.video.core.services.playback
import android.content.Context
import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import androidx.core.os.bundleOf
import android.os.Bundle
import com.margelo.nitro.NitroModules
import com.twg.video.core.LibraryError
@OptIn(UnstableApi::class)
class CustomMediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) {
init {
setSmallIcon(androidx.media3.session.R.drawable.media3_notification_small_icon)
}
fun getContext(): Context {
return NitroModules.applicationContext ?: run {
throw LibraryError.ApplicationContextNotFound
}
}
override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence? {
return metadata.title
?: metadata.displayTitle
?: metadata.subtitle
?: metadata.description
?: "${getAppName()} is playing"
}
override fun getNotificationContentText(metadata: MediaMetadata): CharSequence? {
return metadata.artist
?: metadata.subtitle
?: metadata.description
}
companion object {
private const val SEEK_INTERVAL_MS = 10000L
private const val TAG = "CustomMediaNotificationProvider"
enum class COMMAND(val stringValue: String) {
NONE("NONE"),
SEEK_FORWARD("COMMAND_SEEK_FORWARD"),
SEEK_BACKWARD("COMMAND_SEEK_BACKWARD"),
TOGGLE_PLAY("COMMAND_TOGGLE_PLAY"),
PLAY("COMMAND_PLAY"),
PAUSE("COMMAND_PAUSE")
}
fun commandFromString(value: String): COMMAND =
when (value) {
COMMAND.SEEK_FORWARD.stringValue -> COMMAND.SEEK_FORWARD
COMMAND.SEEK_BACKWARD.stringValue -> COMMAND.SEEK_BACKWARD
COMMAND.TOGGLE_PLAY.stringValue -> COMMAND.TOGGLE_PLAY
COMMAND.PLAY.stringValue -> COMMAND.PLAY
COMMAND.PAUSE.stringValue -> COMMAND.PAUSE
else -> COMMAND.NONE
}
fun handleCommand(command: COMMAND, session: MediaSession) {
// TODO: get somehow ControlsConfig here - for now hardcoded 10000ms
when (command) {
COMMAND.SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - SEEK_INTERVAL_MS)
COMMAND.SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + SEEK_INTERVAL_MS)
COMMAND.TOGGLE_PLAY -> handleCommand(if (session.player.isPlaying) COMMAND.PAUSE else COMMAND.PLAY, session)
COMMAND.PLAY -> session.player.play()
COMMAND.PAUSE -> session.player.pause()
else -> Log.w(TAG, "Received COMMAND.NONE - was there an error?")
}
}
}
private fun getAppName(): String {
return try {
val context = getContext()
val pm = context.packageManager
val label = pm.getApplicationLabel(context.applicationInfo)
label.toString()
} catch (e: Exception) {
return "Unknown"
}
}
override fun getMediaButtons(
session: MediaSession,
playerCommands: Player.Commands,
mediaButtonPreferences: ImmutableList<CommandButton>,
showPauseButton: Boolean
): ImmutableList<CommandButton> {
val rewind = CommandButton.Builder()
.setDisplayName("Rewind")
.setSessionCommand(androidx.media3.session.SessionCommand(
COMMAND.SEEK_BACKWARD.stringValue,
Bundle.EMPTY
))
.setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_10)
.setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 0))
.build()
val toggle = CommandButton.Builder()
.setDisplayName(if (showPauseButton) "Pause" else "Play")
.setSessionCommand(androidx.media3.session.SessionCommand(
COMMAND.TOGGLE_PLAY.stringValue,
Bundle.EMPTY
))
.setIconResId(
if (showPauseButton) androidx.media3.session.R.drawable.media3_icon_pause
else androidx.media3.session.R.drawable.media3_icon_play
)
.setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 1))
.build()
val forward = CommandButton.Builder()
.setDisplayName("Forward")
.setSessionCommand(androidx.media3.session.SessionCommand(
COMMAND.SEEK_FORWARD.stringValue,
Bundle.EMPTY
))
.setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward_10)
.setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 2))
.build()
return ImmutableList.of(rewind, toggle, forward)
}
override fun addNotificationActions(
mediaSession: MediaSession,
mediaButtons: ImmutableList<CommandButton>,
builder: androidx.core.app.NotificationCompat.Builder,
actionFactory: androidx.media3.session.MediaNotification.ActionFactory
): IntArray {
// Use default behavior to add actions from our custom buttons and return compact indices
val compact = super.addNotificationActions(mediaSession, mediaButtons, builder, actionFactory)
return if (compact.isEmpty()) intArrayOf(0, 1, 2) else compact
}
}

View File

@@ -0,0 +1,96 @@
package com.twg.video.core.services.playback
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.collect.ImmutableList
@OptIn(UnstableApi::class)
class VideoPlaybackCallback : MediaSession.Callback {
// For Android 13+
private fun buildCustomButtons(): ImmutableList<CommandButton> {
val rewind = CommandButton.Builder()
.setDisplayName("Rewind")
.setSessionCommand(
SessionCommand(
CustomMediaNotificationProvider.Companion.COMMAND.SEEK_BACKWARD.stringValue,
Bundle.EMPTY
)
)
.setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_10)
.build()
val forward = CommandButton.Builder()
.setDisplayName("Forward")
.setSessionCommand(
SessionCommand(
CustomMediaNotificationProvider.Companion.COMMAND.SEEK_FORWARD.stringValue,
Bundle.EMPTY
)
)
.setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward_10)
.build()
return ImmutableList.of(rewind, forward)
}
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
try {
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailablePlayerCommands(
MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.add(Player.COMMAND_SEEK_FORWARD)
.add(Player.COMMAND_SEEK_BACK)
.build()
).setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(
SessionCommand(
CustomMediaNotificationProvider.Companion.COMMAND.SEEK_FORWARD.stringValue,
Bundle.EMPTY
)
)
.add(
SessionCommand(
CustomMediaNotificationProvider.Companion.COMMAND.SEEK_BACKWARD.stringValue,
Bundle.EMPTY
)
)
.add(
SessionCommand(
CustomMediaNotificationProvider.Companion.COMMAND.TOGGLE_PLAY.stringValue,
Bundle.EMPTY
)
).build()
).build()
} catch (e: Exception) {
return MediaSession.ConnectionResult.reject()
}
}
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
session.setCustomLayout(buildCustomButtons())
super.onPostConnect(session, controller)
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
CustomMediaNotificationProvider.Companion.handleCommand(
CustomMediaNotificationProvider.Companion.commandFromString(
customCommand.customAction
), session
)
return super.onCustomCommand(session, controller, customCommand, args)
}
}

View File

@@ -0,0 +1,200 @@
package com.twg.video.core.services.playback
import android.app.Activity
import android.app.PendingIntent
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.BitmapLoader
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.SimpleBitmapLoader
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import androidx.core.app.NotificationCompat
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class VideoPlaybackServiceBinder(val service: VideoPlaybackService): Binder()
@OptIn(UnstableApi::class)
class VideoPlaybackService : MediaSessionService() {
private var mediaSessionsList = mutableMapOf<HybridVideoPlayer, MediaSession>()
private var binder = VideoPlaybackServiceBinder(this)
private var sourceActivity: Class<Activity>? = null // retained for future deep-links; currently unused
private var isForeground = false
override fun onCreate() {
super.onCreate()
setMediaNotificationProvider(CustomMediaNotificationProvider(this))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Ensure we call startForeground quickly on newer Android versions to avoid
// ForegroundServiceDidNotStartInTimeException when startForegroundService(...) was used.
try {
if (!isForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForeground(PLACEHOLDER_NOTIFICATION_ID, createPlaceholderNotification())
isForeground = true
}
} catch (_: Exception) {
Log.e(TAG, "Failed to start foreground service!")
}
return super.onStartCommand(intent, flags, startId)
}
// Player Registry
fun registerPlayer(player: HybridVideoPlayer, from: Class<Activity>) {
if (mediaSessionsList.containsKey(player)) {
return
}
sourceActivity = from
val builder = MediaSession.Builder(this, player.player)
.setId("RNVideoPlaybackService_" + player.hashCode())
.setCallback(VideoPlaybackCallback())
// Ensure tapping the notification opens the app via sessionActivity
try {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val contentIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.setSessionActivity(contentIntent)
}
} catch (_: Exception) {}
val mediaSession = builder.build()
mediaSessionsList[player] = mediaSession
addSession(mediaSession)
}
fun unregisterPlayer(player: HybridVideoPlayer) {
val session = mediaSessionsList.remove(player)
session?.release()
stopIfNoPlayers()
}
fun updatePlayerPreferences(player: HybridVideoPlayer) {
val session = mediaSessionsList[player]
if (session == null) {
// If not registered but now needs it, register
if (player.playInBackground || player.showNotificationControls) {
val activity = try { NitroModules.applicationContext?.currentActivity } catch (_: Exception) { null }
if (activity != null) registerPlayer(player, activity.javaClass)
}
return
}
// If no longer needs registration, unregister and possibly stop service
if (!player.playInBackground && !player.showNotificationControls) {
unregisterPlayer(player)
stopIfNoPlayers()
return
}
}
// Callbacks
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = null
override fun onBind(intent: Intent?): IBinder {
super.onBind(intent)
return binder
}
override fun onTaskRemoved(rootIntent: Intent?) {
stopForegroundSafely()
cleanup()
stopSelf()
}
override fun onDestroy() {
stopForegroundSafely()
cleanup()
super.onDestroy()
}
private fun stopForegroundSafely() {
try {
stopForeground(STOP_FOREGROUND_REMOVE)
} catch (_: Exception) {
Log.e(TAG, "Failed to stop foreground service!")
}
}
private fun cleanup() {
stopForegroundSafely()
stopSelf()
mediaSessionsList.forEach { (_, session) ->
session.release()
}
mediaSessionsList.clear()
}
// Stop the service if there are no active media sessions (no players need it)
fun stopIfNoPlayers() {
if (mediaSessionsList.isEmpty()) {
// Remove placeholder notification and stop the service when no active players exist
try {
if (isForeground) {
stopForegroundSafely()
isForeground = false
}
} catch (_: Exception) {
Log.e(TAG, "Failed to stop foreground service!")
}
cleanup()
}
}
companion object {
const val TAG = "VideoPlaybackService"
const val VIDEO_PLAYBACK_SERVICE_INTERFACE = SERVICE_INTERFACE
private const val PLACEHOLDER_NOTIFICATION_ID = 1729
private const val NOTIFICATION_CHANNEL_ID = "twg_video_playback"
}
private fun createPlaceholderNotification(): Notification {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Media playback",
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
nm.createNotificationChannel(channel)
} catch (_: Exception) {
Log.e(TAG, "Failed to create notification channel!")
}
}
val appName = try { applicationInfo.loadLabel(packageManager).toString() } catch (_: Exception) { "Media Playback" }
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(appName)
.setContentText("")
.setOngoing(true)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
}
}

View File

@@ -0,0 +1,59 @@
package com.twg.video.core.services.playback
import android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import java.lang.ref.WeakReference
@OptIn(UnstableApi::class)
class VideoPlaybackServiceConnection (private val player: WeakReference<HybridVideoPlayer>) :
ServiceConnection {
var serviceBinder: VideoPlaybackServiceBinder? = null
override fun onServiceConnected(componentName: ComponentName?, binder: IBinder?) {
val player = player.get() ?: return
try {
val activity = NitroModules.Companion.applicationContext?.currentActivity ?: run {
Log.e("VideoPlaybackServiceConnection", "Activity is null")
return
}
serviceBinder = binder as? VideoPlaybackServiceBinder
// Only register when the player actually needs background service/notification
if (player.playInBackground || player.showNotificationControls) {
serviceBinder?.service?.registerPlayer(player, activity.javaClass)
}
} catch (err: Exception) {
Log.e("VideoPlaybackServiceConnection", "Could not bind to playback service", err)
}
}
override fun onServiceDisconnected(componentName: ComponentName?) {
player.get()?.let {
unregisterPlayer(it)
}
serviceBinder = null
}
override fun onNullBinding(componentName: ComponentName?) {
Log.e(
"VideoPlaybackServiceConnection",
"Could not bind to playback service - there can be issues with background playback" +
"and notification controls"
)
}
fun unregisterPlayer(player: HybridVideoPlayer) {
try {
if (serviceBinder?.service != null) {
serviceBinder?.service?.unregisterPlayer(player)
}
} catch (_: Exception) {}
}
}

View File

@@ -0,0 +1,129 @@
package com.twg.video.core.utils
import android.app.PictureInPictureParams
import android.content.pm.PackageManager
import android.graphics.Rect
import android.os.Build
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.util.UnstableApi
import com.margelo.nitro.NitroModules
import com.twg.video.view.VideoView
@OptIn(UnstableApi::class)
object PictureInPictureUtils {
private const val TAG = "PictureInPictureUtils"
fun canEnterPictureInPicture(): Boolean {
val applicationContent = NitroModules.applicationContext
val currentActivity = applicationContent?.currentActivity
return currentActivity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) == true
}
@RequiresApi(Build.VERSION_CODES.O)
fun createPictureInPictureParams(videoView: VideoView): PictureInPictureParams {
val builder = PictureInPictureParams.Builder()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && videoView.autoEnterPictureInPicture) {
builder.setAutoEnterEnabled(videoView.autoEnterPictureInPicture)
}
return builder
.setAspectRatio(calculateAspectRatio(videoView.playerView))
.setSourceRectHint(calculateSourceRectHint(videoView.playerView))
.build()
}
@RequiresApi(Build.VERSION_CODES.O)
fun createDisabledPictureInPictureParams(videoView: VideoView): PictureInPictureParams {
val defaultParams = PictureInPictureParams.Builder()
.setAspectRatio(null) // Clear aspect ratio
.setSourceRectHint(null) // Clear source rect hint
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
defaultParams.setAutoEnterEnabled(false)
}
return defaultParams.build()
}
fun calculateAspectRatio(view: View): Rational {
// AspectRatio for PIP must be between 2.39:1 and 1:2.39
// see: https://developer.android.com/reference/android/app/PictureInPictureParams.Builder#setAspectRatio(android.util.Rational)
val maximumAspectRatio = Rational(239, 100)
val minimumAspectRatio = Rational(100, 239)
val currentAspectRatio = Rational(view.width, view.height)
return when {
currentAspectRatio > maximumAspectRatio -> maximumAspectRatio
currentAspectRatio < minimumAspectRatio -> minimumAspectRatio
else -> currentAspectRatio
}
}
fun calculateSourceRectHint(view: View): Rect {
// Get the visible rectangle of view in screen coordinates
val visibleRect = Rect()
view.getGlobalVisibleRect(visibleRect)
// Get the Y position of view on the screen
val locationOnScreen = IntArray(2)
view.getLocationOnScreen(locationOnScreen)
val yOnScreen = locationOnScreen[1]
// Preserve the original height
val height = visibleRect.height()
// Set the new top and bottom based on the view's screen position
visibleRect.top = yOnScreen
visibleRect.bottom = yOnScreen + height
return visibleRect
}
fun safeSetPictureInPictureParams(params: PictureInPictureParams) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val currentActivity = NitroModules.applicationContext?.currentActivity
currentActivity?.setPictureInPictureParams(params)
Log.d(TAG, "Successfully set PiP params")
} catch (e: Exception) {
Log.w(TAG, "Failed to set PiP params - PiP may not be enabled in manifest", e)
// Ignore: We cannot check if user has added support for PIP in manifest
// so we need to catch error if he did not add it.
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun safeEnterPictureInPictureMode(params: PictureInPictureParams): Boolean {
return try {
val currentActivity = NitroModules.applicationContext?.currentActivity
val result = currentActivity?.enterPictureInPictureMode(params) ?: false
Log.d(TAG, "PiP enter result: $result")
result
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP mode", e)
false
}
}
fun isCurrentlyInPictureInPictureMode(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val currentActivity = NitroModules.applicationContext?.currentActivity
currentActivity?.isInPictureInPictureMode == true
} catch (e: Exception) {
Log.w(TAG, "Failed to check PiP mode status", e)
false
}
} else {
false
}
}
}

View File

@@ -0,0 +1,157 @@
package com.twg.video.core.utils
import android.content.Context
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import androidx.media3.ui.PlayerView
object SmallVideoPlayerOptimizer {
fun isSmallVideoPlayer(playerView: PlayerView): Boolean {
// Check if the PlayerView dimensions are small enough to warrant optimizations
val width = playerView.width
val height = playerView.height
// If view hasn't been measured yet, use layout params or return false
if (width <= 0 || height <= 0) {
val layoutParams = playerView.layoutParams
if (layoutParams != null) {
// Convert any specific dimensions to pixels if needed
val widthPx = if (layoutParams.width > 0) layoutParams.width else playerView.measuredWidth
val heightPx = if (layoutParams.height > 0) layoutParams.height else playerView.measuredHeight
if (widthPx <= 0 || heightPx <= 0) return false
return isSmallDimensions(widthPx, heightPx, playerView.context)
}
return false
}
return isSmallDimensions(width, height, playerView.context)
}
private fun isSmallDimensions(widthPx: Int, heightPx: Int, context: Context): Boolean {
val density = context.resources.displayMetrics.density
val widthDp = widthPx / density
val heightDp = heightPx / density
// Consider the video player "small" if width <= 400dp or height <= 300dp
// These thresholds are more appropriate for actual video player sizes
return widthDp <= 400 || heightDp <= 300
}
fun applyOptimizations(
playerView: PlayerView,
context: Context,
isFullscreen: Boolean = false
) {
playerView.post {
try {
if (isFullscreen) {
// For fullscreen mode, use system defaults - no custom optimizations
// Let ExoPlayer use its default controller timeout and styling
return@post
}
// Only apply optimizations if the video player itself is small
if (!isSmallVideoPlayer(playerView)) {
return@post
}
val controllerView = playerView.findViewById<ViewGroup>(androidx.media3.ui.R.id.exo_controller)
controllerView?.let { controller ->
optimizeControlElementsForSmallPlayer(controller, context)
}
} catch (e: Exception) {
Log.w("ReactNativeVideo", "Error applying small video player optimizations: ${e.message}")
}
}
}
private fun optimizeControlElementsForSmallPlayer(
controller: ViewGroup,
context: Context
) {
val density = context.resources.displayMetrics.density
val primaryButtonSize = (48 * density).toInt()
val secondaryButtonSize = (44 * density).toInt()
optimizeButtons(controller, primaryButtonSize, secondaryButtonSize)
optimizeProgressBar(controller, context)
optimizeTextElements(controller)
}
private fun optimizeButtons(
container: ViewGroup,
primarySize: Int,
secondarySize: Int
) {
for (i in 0 until container.childCount) {
val child = container.getChildAt(i)
when (child) {
is ImageButton -> {
val buttonSize = when (child.id) {
androidx.media3.ui.R.id.exo_play_pause -> primarySize
androidx.media3.ui.R.id.exo_fullscreen -> primarySize
androidx.media3.ui.R.id.exo_settings -> primarySize
androidx.media3.ui.R.id.exo_rew -> secondarySize
androidx.media3.ui.R.id.exo_ffwd -> secondarySize
androidx.media3.ui.R.id.exo_subtitle -> secondarySize
androidx.media3.ui.R.id.exo_prev -> secondarySize
androidx.media3.ui.R.id.exo_next -> secondarySize
else -> secondarySize
}
val params = child.layoutParams
params.width = buttonSize
params.height = buttonSize
child.layoutParams = params
// Hide less essential buttons on small video players
when (child.id) {
androidx.media3.ui.R.id.exo_shuffle,
androidx.media3.ui.R.id.exo_repeat_toggle,
androidx.media3.ui.R.id.exo_vr -> {
child.visibility = View.GONE
}
}
}
is ViewGroup -> {
optimizeButtons(child, primarySize, secondarySize)
}
}
}
}
private fun optimizeProgressBar(
controller: ViewGroup,
context: Context
) {
val progressContainer = controller.findViewById<View>(androidx.media3.ui.R.id.exo_progress)
progressContainer?.let { progress ->
val params = progress.layoutParams as? ViewGroup.MarginLayoutParams
params?.let {
it.height = (4 * context.resources.displayMetrics.density).toInt()
progress.layoutParams = it
}
}
}
private fun optimizeTextElements(
controller: ViewGroup
) {
val timeContainer = controller.findViewById<ViewGroup>(androidx.media3.ui.R.id.exo_time)
timeContainer?.let { time ->
val positionView = time.findViewById<View>(androidx.media3.ui.R.id.exo_position)
val durationView = time.findViewById<View>(androidx.media3.ui.R.id.exo_duration)
listOf(positionView, durationView).forEach { textView ->
if (textView is android.widget.TextView) {
textView.textSize = 12f
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
package com.twg.video.core.utils
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.margelo.nitro.video.HybridVideoPlayerSourceSpec
import com.margelo.nitro.video.TextTrack
@UnstableApi
object TextTrackUtils {
fun getAvailableTextTracks(player: ExoPlayer, source: HybridVideoPlayerSourceSpec): Array<TextTrack> {
return Threading.runOnMainThreadSync {
val tracks = mutableListOf<TextTrack>()
val currentTracks = player.currentTracks
var globalTrackIndex = 0
// Get all text tracks from the current player tracks (includes both built-in and external)
for (trackGroup in currentTracks.groups) {
if (trackGroup.type == C.TRACK_TYPE_TEXT) {
for (trackIndex in 0 until trackGroup.length) {
val format = trackGroup.getTrackFormat(trackIndex)
val trackId = format.id ?: "text-$globalTrackIndex"
val label = format.label ?: "Unknown ${globalTrackIndex + 1}"
val language = format.language
val isSelected = trackGroup.isTrackSelected(trackIndex)
// Determine if this is an external track by checking if it matches external subtitle labels
val isExternal = source.config.externalSubtitles?.any { subtitle ->
label.contains(subtitle.label, ignoreCase = true)
} == true
val finalTrackId = if (isExternal) "external-$globalTrackIndex" else trackId
tracks.add(
TextTrack(
id = finalTrackId,
label = label,
language = language,
selected = isSelected
)
)
globalTrackIndex++
}
}
}
tracks.toTypedArray()
}
}
fun selectTextTrack(
player: ExoPlayer,
textTrack: TextTrack?,
source: HybridVideoPlayerSourceSpec,
onTrackChange: (TextTrack?) -> Unit,
): Int? {
return Threading.runOnMainThreadSync {
val trackSelector = player.trackSelectionParameters.buildUpon()
// If textTrack is null, disable all text tracks
if (textTrack == null) {
trackSelector.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
player.trackSelectionParameters = trackSelector.build()
onTrackChange(null)
return@runOnMainThreadSync null
}
if (textTrack.id.isEmpty()) {
// Disable all text tracks
trackSelector.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
player.trackSelectionParameters = trackSelector.build()
onTrackChange(null)
return@runOnMainThreadSync null
}
val currentTracks = player.currentTracks
var trackFound = false
var selectedExternalTrackIndex: Int? = null
var globalTrackIndex = 0
// Find and select the specific text track
for (trackGroup in currentTracks.groups) {
if (trackGroup.type == C.TRACK_TYPE_TEXT) {
for (trackIndex in 0 until trackGroup.length) {
val format = trackGroup.getTrackFormat(trackIndex)
val currentTrackId = format.id ?: "text-$globalTrackIndex"
val label = format.label ?: "Unknown ${globalTrackIndex + 1}"
// Check if this matches our target track (either by original ID or by external ID)
val isExternal = source.config.externalSubtitles?.any { subtitle ->
label.contains(subtitle.label, ignoreCase = true)
} == true
val finalTrackId =
if (isExternal) "external-$globalTrackIndex" else currentTrackId
if (finalTrackId == textTrack.id) {
// Enable this specific track
trackSelector.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
trackSelector.setOverrideForType(
TrackSelectionOverride(
trackGroup.mediaTrackGroup,
listOf(trackIndex)
)
)
// Update selection state
selectedExternalTrackIndex = if (isExternal) {
globalTrackIndex
} else {
null
}
onTrackChange(textTrack)
trackFound = true
break
}
globalTrackIndex++
}
if (trackFound) {
break
}
}
}
// Apply the track selection parameters regardless of whether we found a track
player.trackSelectionParameters = trackSelector.build()
selectedExternalTrackIndex
}
}
fun getSelectedTrack(player: ExoPlayer, source: HybridVideoPlayerSourceSpec): TextTrack? {
return Threading.runOnMainThreadSync {
val currentTracks = player.currentTracks
var globalTrackIndex = 0
// Find the currently selected text track
for (trackGroup in currentTracks.groups) {
if (trackGroup.type == C.TRACK_TYPE_TEXT && trackGroup.isSelected) {
for (trackIndex in 0 until trackGroup.length) {
if (trackGroup.isTrackSelected(trackIndex)) {
val format = trackGroup.getTrackFormat(trackIndex)
val trackId = format.id ?: "text-$globalTrackIndex"
val label = format.label ?: "Unknown ${globalTrackIndex + 1}"
val language = format.language
// Determine if this is an external track by checking if it matches external subtitle labels
val isExternal = source.config.externalSubtitles?.any { subtitle ->
label.contains(subtitle.label, ignoreCase = true)
} == true
val finalTrackId = if (isExternal) "external-$globalTrackIndex" else trackId
return@runOnMainThreadSync TextTrack(
id = finalTrackId,
label = label,
language = language,
selected = true
)
}
globalTrackIndex++
}
} else if (trackGroup.type == C.TRACK_TYPE_TEXT) {
// Still need to increment global index for non-selected text track groups
globalTrackIndex += trackGroup.length
}
}
null
}
}
}

View File

@@ -0,0 +1,75 @@
package com.twg.video.core.utils
import android.os.Handler
import android.os.Looper
import com.margelo.nitro.NitroModules
import com.twg.video.core.LibraryError
import java.util.concurrent.Callable
import java.util.concurrent.FutureTask
import kotlin.reflect.KProperty
object Threading {
@JvmStatic
fun runOnMainThread(action: () -> Unit) {
// We are already on the main thread, run and return
if (Looper.myLooper() == Looper.getMainLooper()) {
action()
return
}
// If application context is null, throw an error
if (NitroModules.applicationContext == null) {
throw LibraryError.ApplicationContextNotFound
}
// Post the action to the main thread
Handler(NitroModules.applicationContext!!.mainLooper).post {
action()
}
}
@JvmStatic
fun <T> runOnMainThreadSync(action: Callable<T>): T {
return if (Looper.myLooper() == Looper.getMainLooper()) {
// Already on the main thread, run and return the result
action.call()
} else {
// Post the action to the main thread and wait for the result
val futureTask = FutureTask(action)
Handler(Looper.getMainLooper()).post(futureTask)
futureTask.get()
}
}
class MainThreadProperty<Reference, Type>(
private val get: Reference.() -> Type,
private val set: (Reference.(Type) -> Unit)? = null
) {
operator fun getValue(thisRef: Reference, property: KProperty<*>): Type {
return runOnMainThreadSync { thisRef.get() }
}
operator fun setValue(thisRef: Reference, property: KProperty<*>, value: Type) {
val setter = set ?: throw IllegalStateException("Property ${property.name} is read-only")
runOnMainThread { thisRef.setter(value) }
}
}
/**
* Read-only property that runs on main thread
* @param get The getter function that runs synchronously on the main thread.
*
* @throws [IllegalStateException] if there will be a write operation
*/
fun <Reference, T> Reference.mainThreadProperty(get: Reference.() -> T) = MainThreadProperty(get)
/**
* Read-only property that runs on main thread
* @param get The getter function that runs synchronously on the main thread
* @param set The setter function that runs asynchronously on the main thread
*/
fun <Reference, T> Reference.mainThreadProperty(
get: Reference.() -> T,
set: Reference.(T) -> Unit
) = MainThreadProperty(get, set)
}

View File

@@ -0,0 +1,51 @@
package com.twg.video.core.utils
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.webkit.URLUtil
import com.margelo.nitro.NitroModules
import com.twg.video.core.SourceError
import java.io.File
import java.net.URL
import java.net.URLConnection
object VideoFileHelper {
private fun hasReadPermission(): Boolean {
return NitroModules.applicationContext?.checkSelfPermission(
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
}
fun validateReadPermission(uri: String) {
if (!hasReadPermission()) throw SourceError.MissingReadFilePermission(uri)
val file = File(Uri.parse(uri).path ?: throw SourceError.InvalidUri(uri))
// Check if file exists and is readable
if (!file.exists()) throw SourceError.FileDoesNotExist(uri)
// Check if file is readable
if (!file.canRead()) throw SourceError.MissingReadFilePermission(uri)
}
fun getFileSizeFromUri(uri: String): Long {
return try {
when {
URLUtil.isFileUrl(uri) -> {
validateReadPermission(uri)
val file = File(Uri.parse(uri).path ?: return -1)
if (file.exists()) file.length() else -1
}
URLUtil.isNetworkUrl(uri) -> {
val connection: URLConnection = URL(uri).openConnection()
connection.connect()
connection.contentLength.toLong()
}
else -> -1
}
} catch (e: Exception) {
-1
}
}
}

View File

@@ -0,0 +1,67 @@
package com.twg.video.core.utils
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.webkit.URLUtil
import com.margelo.nitro.video.VideoInformation
import androidx.core.net.toUri
object VideoInformationUtils {
fun fromUri(uri: String, headers: Map<String, String> = emptyMap()): VideoInformation {
val retriever = MediaMetadataRetriever()
when {
URLUtil.isFileUrl(uri) -> {
retriever.setDataSource(uri.toUri().path)
}
else -> {
retriever.setDataSource(uri, headers)
}
}
// Get dimensions
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toDoubleOrNull() ?: Double.NaN
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toDoubleOrNull() ?: Double.NaN
// Get duration in milliseconds, convert to long
val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: -1L
// If we have some valid info, but there is no duration it might be live
val isLive = !width.isNaN() && !height.isNaN() && duration <= 0
// Get bitrate
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toDoubleOrNull() ?: Double.NaN
// Get rotation
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
// Check for HDR by looking at color transfer (API 30+)
val isHDR = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val colorTransfer = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER)?.toIntOrNull()
colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084 || colorTransfer == MediaFormat.COLOR_TRANSFER_HLG
} else {
false
}
// Clean up
retriever.release()
// Get file size
val fileSize = VideoFileHelper.getFileSizeFromUri(uri)
val videoInfo = VideoInformation(
bitrate = bitrate,
width = width,
height = height,
duration = duration,
fileSize = fileSize,
isHDR = isHDR,
isLive = isLive,
orientation = VideoOrientationUtils.fromWHR(width.toInt(), height.toInt(), rotation)
)
return videoInfo
}
}

View File

@@ -0,0 +1,30 @@
package com.twg.video.core.utils
import com.margelo.nitro.video.VideoOrientation
object VideoOrientationUtils {
fun fromWHR(width: Int?, height: Int?, rotation: Int?): VideoOrientation {
if (width == 0 || height == 0 || height == null || width == null) return VideoOrientation.UNKNOWN
if (width == height) return VideoOrientation.SQUARE
// Check if video is portrait or landscape using natural size
val isNaturalSizePortrait = height > width
// If rotation is not available, use natural size to determine orientation
if (rotation == null) {
return if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT
}
// Normalize rotation to 0-360 range
val normalizedRotation = ((rotation % 360) + 360) % 360
return when (normalizedRotation) {
0 -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT
90 -> VideoOrientation.PORTRAIT
180 -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT_UPSIDE_DOWN else VideoOrientation.LANDSCAPE_LEFT
270 -> VideoOrientation.PORTRAIT_UPSIDE_DOWN
else -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT
}
}
}

View File

@@ -0,0 +1,606 @@
package com.margelo.nitro.video
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.Metadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.upstream.DefaultAllocator
import androidx.media3.extractor.metadata.emsg.EventMessage
import androidx.media3.extractor.metadata.id3.Id3Frame
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.ui.PlayerView
import com.facebook.proguard.annotations.DoNotStrip
import com.margelo.nitro.NitroModules
import com.margelo.nitro.core.Promise
import com.twg.video.core.LibraryError
import com.twg.video.core.PlayerError
import com.twg.video.core.VideoManager
import com.twg.video.core.extensions.startService
import com.twg.video.core.extensions.stopService
import com.twg.video.core.player.OnAudioFocusChangedListener
import com.twg.video.core.recivers.AudioBecomingNoisyReceiver
import com.twg.video.core.services.playback.VideoPlaybackService
import com.twg.video.core.services.playback.VideoPlaybackServiceConnection
import com.twg.video.core.utils.TextTrackUtils
import com.twg.video.core.utils.Threading.mainThreadProperty
import com.twg.video.core.utils.Threading.runOnMainThread
import com.twg.video.core.utils.Threading.runOnMainThreadSync
import com.twg.video.core.utils.VideoOrientationUtils
import com.twg.video.view.VideoView
import java.lang.ref.WeakReference
import kotlin.math.max
@UnstableApi
@DoNotStrip
class HybridVideoPlayer() : HybridVideoPlayerSpec() {
override lateinit var source: HybridVideoPlayerSourceSpec
override var eventEmitter = HybridVideoPlayerEventEmitter()
set(value) {
if (field != value) {
audioFocusChangedListener.setEventEmitter(value)
audioBecomingNoisyReceiver.setEventEmitter(value)
}
field = value
}
private var allocator: DefaultAllocator? = null
private var context = NitroModules.applicationContext
?: run {
throw LibraryError.ApplicationContextNotFound
}
lateinit var player: ExoPlayer
var loadedWithSource = false
private var currentPlayerView: WeakReference<PlayerView>? = null
var wasAutoPaused = false
// Buffer Config
private var bufferConfig: BufferConfig? = null
get() = source.config.bufferConfig
// Time updates
private val progressHandler = Handler(Looper.getMainLooper())
private var progressRunnable: Runnable? = null
// Listeners
private val audioFocusChangedListener = OnAudioFocusChangedListener()
private val audioBecomingNoisyReceiver = AudioBecomingNoisyReceiver()
// Service Connection
private val videoPlaybackServiceConnection = VideoPlaybackServiceConnection(WeakReference(this))
// Text track selection state
private var selectedExternalTrackIndex: Int? = null
private companion object {
const val PROGRESS_UPDATE_INTERVAL_MS = 250L
private const val TAG = "HybridVideoPlayer"
private const val DEFAULT_MIN_BUFFER_DURATION_MS = 5000
private const val DEFAULT_MAX_BUFFER_DURATION_MS = 10000
private const val DEFAULT_BUFFER_FOR_PLAYBACK_DURATION_MS = 1000
private const val DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_DURATION_MS = 2000
private const val DEFAULT_BACK_BUFFER_DURATION_MS = 0
}
override var status: VideoPlayerStatus = VideoPlayerStatus.IDLE
set(value) {
if (field != value) {
eventEmitter.onStatusChange(value)
}
field = value
}
override var showNotificationControls: Boolean = false
set(value) {
val wasRunning = (field || playInBackground)
val shouldRun = (value || playInBackground)
if (shouldRun && !wasRunning) {
VideoPlaybackService.startService(context, videoPlaybackServiceConnection)
}
if (!shouldRun && wasRunning) {
VideoPlaybackService.stopService(this, videoPlaybackServiceConnection)
}
field = value
// Inform service to refresh notification/session layout
try { videoPlaybackServiceConnection.serviceBinder?.service?.updatePlayerPreferences(this) } catch (_: Exception) {}
}
// Player Properties
override var currentTime: Double by mainThreadProperty(
get = { player.currentPosition.toDouble() / 1000.0 },
set = { value -> runOnMainThread { player.seekTo((value * 1000).toLong()) } }
)
// volume defined by user
var userVolume: Double = 1.0
override var volume: Double by mainThreadProperty(
get = { player.volume.toDouble() },
set = { value ->
userVolume = value
player.volume = value.toFloat()
}
)
override val duration: Double by mainThreadProperty(
get = {
val duration = player.duration
return@mainThreadProperty if (duration == C.TIME_UNSET) Double.NaN else duration.toDouble() / 1000.0
}
)
override var loop: Boolean by mainThreadProperty(
get = {
player.repeatMode == Player.REPEAT_MODE_ONE
},
set = { value ->
player.repeatMode = if (value) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
)
override var muted: Boolean by mainThreadProperty(
get = {
val playerVolume = player.volume.toDouble()
return@mainThreadProperty playerVolume == 0.0
},
set = { value ->
if (value) {
userVolume = volume
player.volume = 0f
} else {
player.volume = userVolume.toFloat()
}
eventEmitter.onVolumeChange(onVolumeChangeData(
volume = player.volume.toDouble(),
muted = muted
))
}
)
override var rate: Double by mainThreadProperty(
get = { player.playbackParameters.speed.toDouble() },
set = { value ->
player.playbackParameters = player.playbackParameters.withSpeed(value.toFloat())
}
)
override var mixAudioMode: MixAudioMode = MixAudioMode.AUTO
set(value) {
VideoManager.audioFocusManager.requestAudioFocusUpdate()
field = value
}
// iOS only property
override var ignoreSilentSwitchMode: IgnoreSilentSwitchMode = IgnoreSilentSwitchMode.AUTO
override var playInBackground: Boolean = false
set(value) {
val shouldRun = (value || showNotificationControls)
val wasRunning = (field || showNotificationControls)
if (shouldRun && !wasRunning) {
VideoPlaybackService.startService(context, videoPlaybackServiceConnection)
}
if (!shouldRun && wasRunning) {
VideoPlaybackService.stopService(this, videoPlaybackServiceConnection)
}
field = value
// Update preferences to refresh notifications/registration
try { videoPlaybackServiceConnection.serviceBinder?.service?.updatePlayerPreferences(this) } catch (_: Exception) {}
}
override var playWhenInactive: Boolean = false
override var isPlaying: Boolean by mainThreadProperty(
get = { player.isPlaying == true }
)
private fun initializePlayer() {
if (NitroModules.applicationContext == null) {
throw LibraryError.ApplicationContextNotFound
}
val hybridSource = source as? HybridVideoPlayerSource ?: throw PlayerError.InvalidSource
// Initialize the allocator
allocator = DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)
// Create a LoadControl with the allocator
val loadControl = DefaultLoadControl.Builder()
.setAllocator(allocator!!)
.setBufferDurationsMs(
bufferConfig?.minBufferMs?.toInt() ?: DEFAULT_MIN_BUFFER_DURATION_MS, // minBufferMs
bufferConfig?.maxBufferMs?.toInt() ?: DEFAULT_MAX_BUFFER_DURATION_MS, // maxBufferMs
bufferConfig?.bufferForPlaybackMs?.toInt()
?: DEFAULT_BUFFER_FOR_PLAYBACK_DURATION_MS, // bufferForPlaybackMs
bufferConfig?.bufferForPlaybackAfterRebufferMs?.toInt()
?: DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_DURATION_MS // bufferForPlaybackAfterRebufferMs
)
.setBackBuffer(
bufferConfig?.backBufferDurationMs?.toInt()
?: DEFAULT_BACK_BUFFER_DURATION_MS, // backBufferDurationMs,
false // retainBackBufferFromKeyframe
)
.build()
val renderersFactory = DefaultRenderersFactory(context)
.forceEnableMediaCodecAsynchronousQueueing()
.setEnableDecoderFallback(true)
// Build the player with the LoadControl
player = ExoPlayer.Builder(context)
.setLoadControl(loadControl)
.setLooper(Looper.getMainLooper())
.setRenderersFactory(renderersFactory)
.build()
loadedWithSource = true
player.addListener(playerListener)
player.addAnalyticsListener(analyticsListener)
player.setMediaSource(hybridSource.mediaSource)
// Emit onLoadStart
val sourceType = if (hybridSource.uri.startsWith("http")) SourceType.NETWORK else SourceType.LOCAL
eventEmitter.onLoadStart(onLoadStartData(sourceType = sourceType, source = hybridSource))
status = VideoPlayerStatus.LOADING
startProgressUpdates()
}
override fun initialize(): Promise<Unit> {
return Promise.async {
return@async runOnMainThreadSync {
initializePlayer()
player.prepare()
}
}
}
constructor(source: HybridVideoPlayerSource) : this() {
this.source = source
runOnMainThread {
if (source.config.initializeOnCreation == true) {
initializePlayer()
player.prepare()
} else {
player = ExoPlayer.Builder(context).build()
}
}
VideoManager.registerPlayer(this)
}
override fun play() {
runOnMainThread {
player.play()
}
}
override fun pause() {
runOnMainThread {
player.pause()
}
}
override fun seekBy(time: Double) {
currentTime = (currentTime + time).coerceIn(0.0, duration)
}
override fun seekTo(time: Double) {
currentTime = time.coerceIn(0.0, duration)
}
override fun replaceSourceAsync(source: HybridVideoPlayerSourceSpec?): Promise<Unit> {
return Promise.async {
if (source == null) {
release()
return@async
}
val hybridSource = source as? HybridVideoPlayerSource ?: throw PlayerError.InvalidSource
runOnMainThreadSync {
// Update source
this.source = source
player.setMediaSource(hybridSource.mediaSource)
// Prepare player
player.prepare()
}
}
}
override fun preload(): Promise<Unit> {
return Promise.async {
runOnMainThreadSync {
if (!loadedWithSource) {
initializePlayer()
}
if (player.playbackState != Player.STATE_IDLE) {
return@runOnMainThreadSync
}
player.prepare()
}
}
}
private fun release() {
if (playInBackground || showNotificationControls) {
VideoPlaybackService.stopService(this, videoPlaybackServiceConnection)
}
VideoManager.unregisterPlayer(this)
stopProgressUpdates()
loadedWithSource = false
runOnMainThread {
player.removeListener(playerListener)
player.removeAnalyticsListener(analyticsListener)
player.release() // Release player
// Clean Listeners
audioFocusChangedListener.removeEventEmitter()
audioBecomingNoisyReceiver.removeEventEmitter()
// Update status
status = VideoPlayerStatus.IDLE
}
}
fun movePlayerToVideoView(videoView: VideoView) {
VideoManager.addViewToPlayer(videoView, this)
runOnMainThreadSync {
PlayerView.switchTargetView(player, currentPlayerView?.get(), videoView.playerView)
currentPlayerView = WeakReference(videoView.playerView)
}
}
override fun dispose() {
release()
}
override val memorySize: Long
get() = allocator?.totalBytesAllocated?.toLong() ?: 0L
private fun startProgressUpdates() {
stopProgressUpdates() // Ensure no multiple runnables
progressRunnable = object : Runnable {
override fun run() {
if (player.playbackState != Player.STATE_IDLE && player.playbackState != Player.STATE_ENDED) {
val currentTimeSeconds = player.currentPosition / 1000.0
val bufferedDurationSeconds = player.bufferedPosition / 1000.0
// bufferDuration is the time from current time that is buffered.
val playableDurationFromNow = max(0.0, bufferedDurationSeconds - currentTimeSeconds)
eventEmitter.onProgress(
onProgressData(
currentTime = currentTimeSeconds,
bufferDuration = playableDurationFromNow
)
)
progressHandler.postDelayed(this, PROGRESS_UPDATE_INTERVAL_MS)
}
}
}
progressHandler.post(progressRunnable ?: return)
}
private fun stopProgressUpdates() {
progressRunnable?.let { progressHandler.removeCallbacks(it) }
progressRunnable = null
}
private val analyticsListener = object: AnalyticsListener {
override fun onBandwidthEstimate(
eventTime: AnalyticsListener.EventTime,
totalLoadTimeMs: Int,
totalBytesLoaded: Long,
bitrateEstimate: Long
) {
val videoFormat = player.videoFormat
eventEmitter.onBandwidthUpdate(
BandwidthData(
bitrate = bitrateEstimate.toDouble(),
width = if (videoFormat != null) videoFormat.width.toDouble() else null,
height = if (videoFormat != null) videoFormat.height.toDouble() else null
)
)
}
}
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
val isPlayingUpdate = player.isPlaying
val isBufferingUpdate = playbackState == Player.STATE_BUFFERING
eventEmitter.onPlaybackStateChange(
onPlaybackStateChangeData(
isPlaying = isPlayingUpdate,
isBuffering = isBufferingUpdate
)
)
when (playbackState) {
Player.STATE_IDLE -> {
status = VideoPlayerStatus.IDLE
eventEmitter.onBuffer(false)
}
Player.STATE_BUFFERING -> {
status = VideoPlayerStatus.LOADING
eventEmitter.onBuffer(true)
}
Player.STATE_READY -> {
status = VideoPlayerStatus.READYTOPLAY
eventEmitter.onBuffer(false)
val generalVideoFormat = player.videoFormat
val currentTracks = player.currentTracks
val selectedVideoTrackGroup = currentTracks.groups.find { group -> group.type == C.TRACK_TYPE_VIDEO && group.isSelected }
val selectedVideoTrackFormat = if (selectedVideoTrackGroup != null && selectedVideoTrackGroup.length > 0) {
selectedVideoTrackGroup.getTrackFormat(0)
} else {
null
}
val width = selectedVideoTrackFormat?.width ?: generalVideoFormat?.width ?: 0
val height = selectedVideoTrackFormat?.height ?: generalVideoFormat?.height ?: 0
val rotationDegrees = selectedVideoTrackFormat?.rotationDegrees ?: generalVideoFormat?.rotationDegrees
eventEmitter.onLoad(
onLoadData(
currentTime = player.currentPosition / 1000.0,
duration = if (player.duration == C.TIME_UNSET) Double.NaN else player.duration / 1000.0,
width = width.toDouble(),
height = height.toDouble(),
orientation = VideoOrientationUtils.fromWHR(width, height, rotationDegrees)
)
)
// If player becomes ready and is set to play, start progress updates
if (player.playWhenReady) {
startProgressUpdates()
}
eventEmitter.onReadyToDisplay()
}
Player.STATE_ENDED -> {
status = VideoPlayerStatus.IDLE // Or a specific 'COMPLETED' status if you add one
eventEmitter.onEnd()
eventEmitter.onBuffer(false)
stopProgressUpdates()
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
eventEmitter.onPlaybackStateChange(
onPlaybackStateChangeData(
isPlaying = isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING
)
)
if (isPlaying) {
VideoManager.setLastPlayedPlayer(this@HybridVideoPlayer)
startProgressUpdates()
} else {
if (player.playbackState == Player.STATE_ENDED || player.playbackState == Player.STATE_IDLE) {
stopProgressUpdates()
}
}
}
override fun onPlayerError(error: PlaybackException) {
status = VideoPlayerStatus.ERROR
stopProgressUpdates()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT) {
eventEmitter.onSeek(newPosition.positionMs / 1000.0)
}
// Update progress immediately after a discontinuity if needed by your logic
val currentTimeSeconds = newPosition.positionMs / 1000.0
val bufferedDurationSeconds = player.bufferedPosition / 1000.0
eventEmitter.onProgress(
onProgressData(
currentTime = currentTimeSeconds,
bufferDuration = max(0.0, bufferedDurationSeconds - currentTimeSeconds)
)
)
}
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
eventEmitter.onPlaybackRateChange(playbackParameters.speed.toDouble())
}
override fun onVolumeChanged(volume: Float) {
// We get here device volume changes, and if
// player is not muted we will sync it
if (!muted) {
this@HybridVideoPlayer.volume = volume.toDouble()
}
VideoManager.audioFocusManager.requestAudioFocusUpdate()
eventEmitter.onVolumeChange(onVolumeChangeData(
volume = volume.toDouble(),
muted = muted
))
}
override fun onCues(cueGroup: CueGroup) {
val texts = cueGroup.cues.mapNotNull { it.text?.toString() }
if (texts.isNotEmpty()) {
eventEmitter.onTextTrackDataChanged(texts.toTypedArray())
}
}
override fun onMetadata(metadata: Metadata) {
val timedMetadataObjects = mutableListOf<TimedMetadataObject>()
for (i in 0 until metadata.length()) {
val entry = metadata.get(i)
when (entry) {
is Id3Frame -> {
var value = ""
if (entry is TextInformationFrame) {
value = entry.values.first()
}
timedMetadataObjects.add(TimedMetadataObject(entry.id, value))
}
is EventMessage ->
timedMetadataObjects.add(TimedMetadataObject(entry.schemeIdUri, entry.value))
else -> Log.d(TAG, "Unknown metadata: $entry")
}
}
if (timedMetadataObjects.isNotEmpty()) {
eventEmitter.onTimedMetadata(TimedMetadata(metadata = timedMetadataObjects.toTypedArray()))
}
}
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
}
}
// MARK: - Text Track Management
override fun getAvailableTextTracks(): Array<TextTrack> {
return TextTrackUtils.getAvailableTextTracks(player, source)
}
override fun selectTextTrack(textTrack: TextTrack?) {
selectedExternalTrackIndex = TextTrackUtils.selectTextTrack(
player = player,
textTrack = textTrack,
source = source,
onTrackChange = { track -> eventEmitter.onTrackChange(track) }
)
}
override val selectedTrack: TextTrack?
get() = TextTrackUtils.getSelectedTrack(player, source)
}

View File

@@ -0,0 +1,16 @@
package com.margelo.nitro.video
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.facebook.proguard.annotations.DoNotStrip
@DoNotStrip
class HybridVideoPlayerFactory(): HybridVideoPlayerFactorySpec() {
@OptIn(UnstableApi::class)
override fun createPlayer(source: HybridVideoPlayerSourceSpec): HybridVideoPlayerSpec {
return HybridVideoPlayer(source as HybridVideoPlayerSource)
}
override val memorySize: Long
get() = 0
}

View File

@@ -0,0 +1,41 @@
package com.margelo.nitro.video
class HybridVideoPlayerEventEmitter(): HybridVideoPlayerEventEmitterSpec() {
override var onAudioBecomingNoisy: () -> Unit = {}
override var onAudioFocusChange: (Boolean) -> Unit = {}
override var onBandwidthUpdate: (BandwidthData) -> Unit = {}
override var onBuffer: (Boolean) -> Unit = {}
override var onControlsVisibleChange: (Boolean) -> Unit = {}
override var onEnd: () -> Unit = {}
override var onExternalPlaybackChange: (Boolean) -> Unit = {}
override var onLoad: (onLoadData) -> Unit = {}
override var onLoadStart: (onLoadStartData) -> Unit = {}
override var onPlaybackStateChange: (onPlaybackStateChangeData) -> Unit = {}
override var onPlaybackRateChange: (Double) -> Unit = {}
override var onProgress: (onProgressData) -> Unit = {}
override var onReadyToDisplay: () -> Unit = {}
override var onSeek: (Double) -> Unit = {}
override var onTimedMetadata: (TimedMetadata) -> Unit = {}
override var onTextTrackDataChanged: (Array<String>) -> Unit = {}
override var onTrackChange: (TextTrack?) -> Unit = {}
override var onVolumeChange: (onVolumeChangeData) -> Unit = {}
override var onStatusChange: (VideoPlayerStatus) -> Unit = {}
}

View File

@@ -0,0 +1,62 @@
package com.margelo.nitro.video
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.drm.DrmSessionManager
import androidx.media3.exoplayer.source.MediaSource
import com.margelo.nitro.NitroModules
import com.margelo.nitro.core.Promise
import com.twg.video.core.LibraryError
import com.twg.video.core.player.DRMManagerSpec
import com.twg.video.core.player.buildMediaSource
import com.twg.video.core.player.createMediaItemFromVideoConfig
import com.twg.video.core.plugins.PluginsRegistry
import com.twg.video.core.utils.VideoInformationUtils
class HybridVideoPlayerSource(): HybridVideoPlayerSourceSpec() {
override lateinit var uri: String
override lateinit var config: NativeVideoConfig
private lateinit var mediaItem: MediaItem
lateinit var mediaSource: MediaSource
var drmManager: DRMManagerSpec? = null
@UnstableApi
var drmSessionManager: DrmSessionManager? = null
constructor(config: NativeVideoConfig) : this() {
this.uri = config.uri
this.config = config
val overriddenSource = PluginsRegistry.shared.overrideSource(this)
config.drm?.let {
drmManager = PluginsRegistry.shared.getDRMManager(this)
drmSessionManager = drmManager?.buildDrmSessionManager(it)
}
this.mediaItem = createMediaItemFromVideoConfig(
overriddenSource
)
NitroModules.applicationContext?.let {
this.mediaSource = buildMediaSource(
context = it,
source = overriddenSource,
mediaItem
)
} ?: run {
throw LibraryError.ApplicationContextNotFound
}
}
override fun getAssetInformationAsync(): Promise<VideoInformation> {
return Promise.async {
return@async VideoInformationUtils.fromUri(uri, config.headers ?: emptyMap())
}
}
override val memorySize: Long
get() = 0
}

View File

@@ -0,0 +1,27 @@
package com.margelo.nitro.video
import com.facebook.proguard.annotations.DoNotStrip
@DoNotStrip
class HybridVideoPlayerSourceFactory: HybridVideoPlayerSourceFactorySpec() {
override fun fromUri(uri: String): HybridVideoPlayerSourceSpec {
val config = NativeVideoConfig(
uri = uri,
externalSubtitles = null,
drm = null,
headers = null,
bufferConfig = null,
metadata = null,
initializeOnCreation = true
)
return HybridVideoPlayerSource(config)
}
override fun fromVideoConfig(config: NativeVideoConfig): HybridVideoPlayerSourceSpec {
return HybridVideoPlayerSource(config)
}
override val memorySize: Long
get() = 0
}

View File

@@ -0,0 +1,130 @@
package com.margelo.nitro.video
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.facebook.proguard.annotations.DoNotStrip
import com.twg.video.core.VideoManager
import com.twg.video.core.VideoViewError
import com.twg.video.core.utils.PictureInPictureUtils
import com.twg.video.core.utils.Threading
@DoNotStrip
@OptIn(UnstableApi::class)
class HybridVideoViewViewManager(nitroId: Int): HybridVideoViewViewManagerSpec(), VideoViewEvents {
private var videoView =
VideoManager.getVideoViewWeakReferenceByNitroId(nitroId) ?: throw VideoViewError.ViewNotFound(nitroId)
override var player: HybridVideoPlayerSpec?
get() {
return Threading.runOnMainThreadSync { return@runOnMainThreadSync videoView.get()?.hybridPlayer }
}
set(value) {
Threading.runOnMainThread {
videoView.get()?.hybridPlayer = value as? HybridVideoPlayer
}
}
override fun canEnterPictureInPicture(): Boolean {
return PictureInPictureUtils.canEnterPictureInPicture()
}
override fun enterFullscreen() {
videoView.get()?.enterFullscreen()
}
override fun exitFullscreen() {
videoView.get()?.exitFullscreen()
}
override fun enterPictureInPicture() {
Threading.runOnMainThread {
videoView.get()?.enterPictureInPicture()
}
}
override fun exitPictureInPicture() {
Threading.runOnMainThread {
videoView.get()?.exitPictureInPicture()
}
}
override var autoEnterPictureInPicture: Boolean
get() = videoView.get()?.autoEnterPictureInPicture == true
set(value) {
videoView.get()?.autoEnterPictureInPicture = value
}
override var pictureInPicture: Boolean
get() = videoView.get()?.pictureInPictureEnabled == true
set(value) {
videoView.get()?.pictureInPictureEnabled = value
}
override var controls: Boolean
get() = videoView.get()?.useController == true
set(value) {
videoView.get()?.useController = value
}
override var resizeMode: ResizeMode
get() = videoView.get()?.resizeMode ?: ResizeMode.NONE
set(value) {
videoView.get()?.resizeMode = value
}
override var keepScreenAwake: Boolean
get() = videoView.get()?.keepScreenAwake == true
set(value) {
videoView.get()?.keepScreenAwake = value
}
override var surfaceType: SurfaceType
get() = videoView.get()?.surfaceType ?: SurfaceType.SURFACE
set(value) {
videoView.get()?.surfaceType = value
}
// View callbacks
override var onPictureInPictureChange: ((Boolean) -> Unit)? = null
set(value) {
field = value
videoView.get()?.events?.onPictureInPictureChange = value
}
override var onFullscreenChange: ((Boolean) -> Unit)? = null
set(value) {
field = value
videoView.get()?.events?.onFullscreenChange = value
}
override var willEnterFullscreen: (() -> Unit)? = null
set(value) {
field = value
videoView.get()?.events?.willEnterFullscreen = value
}
override var willExitFullscreen: (() -> Unit)? = null
set(value) {
field = value
videoView.get()?.events?.willExitFullscreen = value
}
override var willEnterPictureInPicture: (() -> Unit)? = null
set(value) {
field = value
videoView.get()?.events?.willEnterPictureInPicture = value
}
override var willExitPictureInPicture: (() -> Unit)? = null
set(value) {
field = value
videoView.get()?.events?.willExitPictureInPicture = value
}
override val memorySize: Long
get() = 0
}
interface VideoViewEvents {
var onPictureInPictureChange: ((Boolean) -> Unit)?
var onFullscreenChange: ((Boolean) -> Unit)?
var willEnterFullscreen: (() -> Unit)?
var willExitFullscreen: (() -> Unit)?
var willEnterPictureInPicture: (() -> Unit)?
var willExitPictureInPicture: (() -> Unit)?
}

View File

@@ -0,0 +1,13 @@
package com.margelo.nitro.video
import com.facebook.proguard.annotations.DoNotStrip
@DoNotStrip
class HybridVideoViewViewManagerFactory: HybridVideoViewViewManagerFactorySpec() {
override fun createViewManager(nitroId: Double): HybridVideoViewViewManagerSpec {
return HybridVideoViewViewManager(nitroId.toInt())
}
override val memorySize: Long
get() = 0
}

View File

@@ -0,0 +1,26 @@
package com.twg.video.react
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class VideoPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return emptyList()
}
@OptIn(UnstableApi::class)
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(VideoViewViewManager())
}
companion object {
init {
System.loadLibrary("ReactNativeVideo")
}
}
}

View File

@@ -0,0 +1,78 @@
package com.twg.video.react
import androidx.media3.common.util.UnstableApi
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.common.MapBuilder
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.uimanager.events.Event
import com.facebook.react.viewmanagers.RNCVideoViewManagerDelegate
import com.facebook.react.viewmanagers.RNCVideoViewManagerInterface
import com.twg.video.view.VideoView
internal class NitroIdChange(
surfaceId: Int,
viewTag: Int,
val nitroId: Int
) : Event<NitroIdChange>(surfaceId, viewTag) {
override fun getEventName() = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap().apply {
putInt("nitroId", nitroId)
}
companion object {
const val EVENT_NAME = "topNitroIdChange"
}
}
@UnstableApi
@ReactModule(name = VideoViewViewManager.NAME)
class VideoViewViewManager : SimpleViewManager<VideoView>(), RNCVideoViewManagerInterface<VideoView> {
private val mDelegate: ViewManagerDelegate<VideoView>
init {
mDelegate = RNCVideoViewManagerDelegate(this)
}
@ReactProp(name = "nitroId")
override fun setNitroId(view: VideoView, nitroId: Int) {
view.nitroId = nitroId
}
public override fun createViewInstance(reactContext: ThemedReactContext): VideoView {
return VideoView(reactContext)
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> {
return MapBuilder.builder<String, Any>()
.put(NitroIdChange.EVENT_NAME, MapBuilder.of("registrationName", "onNitroIdChange"))
.build()
}
override fun getName() = NAME
override fun getDelegate() = mDelegate
override fun addEventEmitters(reactContext: ThemedReactContext, view: VideoView) {
super.addEventEmitters(reactContext, view)
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
if (dispatcher != null) {
view.onNitroIdChange = {
dispatcher.dispatchEvent(NitroIdChange(surfaceId, view.id, view.nitroId))
}
}
}
companion object {
const val NAME = "RNCVideoView"
}
}

View File

@@ -0,0 +1,523 @@
package com.twg.video.view
import android.annotation.SuppressLint
import android.app.PictureInPictureParams
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageButton
import androidx.annotation.RequiresApi
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentActivity
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import com.facebook.react.bridge.ReactApplicationContext
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import com.margelo.nitro.video.ResizeMode
import com.margelo.nitro.video.SurfaceType
import com.margelo.nitro.video.VideoViewEvents
import com.twg.video.core.LibraryError
import com.twg.video.core.VideoManager
import com.twg.video.core.VideoViewError
import com.twg.video.core.fragments.FullscreenVideoFragment
import com.twg.video.core.fragments.PictureInPictureHelperFragment
import com.twg.video.core.utils.PictureInPictureUtils.canEnterPictureInPicture
import com.twg.video.core.utils.PictureInPictureUtils.createPictureInPictureParams
import com.twg.video.core.utils.PictureInPictureUtils.safeEnterPictureInPictureMode
import com.twg.video.core.utils.Threading.runOnMainThread
import com.twg.video.core.extensions.toAspectRatioFrameLayout
import com.twg.video.core.utils.PictureInPictureUtils
import com.twg.video.core.utils.PictureInPictureUtils.createDisabledPictureInPictureParams
import com.twg.video.core.utils.SmallVideoPlayerOptimizer
import com.twg.video.R.layout.player_view_surface
import com.twg.video.R.layout.player_view_texture
@UnstableApi
class VideoView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
var hybridPlayer: HybridVideoPlayer? = null
set(value) {
// Clear the SurfaceView when player is about to be set to null
if (value == null && field != null) {
VideoManager.removeViewFromPlayer(this, field!!)
}
field = value
field?.movePlayerToVideoView(this)
}
var nitroId: Int = -1
set(value) {
if (field == -1) {
post {
onNitroIdChange?.let { it(value) }
VideoManager.registerView(this)
}
}
VideoManager.updateVideoViewNitroId(oldNitroId = field, newNitroId = value, view = this)
field = value
}
var autoEnterPictureInPicture: Boolean = false
set(value) {
field = value
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PictureInPictureUtils.safeSetPictureInPictureParams(
if (value) createPictureInPictureParams(this)
else createDisabledPictureInPictureParams(this)
)
}
}
var useController: Boolean = false
set(value) {
field = value
runOnMainThread {
playerView.useController = value
}
}
var pictureInPictureEnabled: Boolean = false
var surfaceType: SurfaceType = SurfaceType.SURFACE
set(value) {
if (field == value) return
field = value
runOnMainThread {
removeView(playerView)
playerView.player = null
playerView = createPlayerView()
playerView.player = hybridPlayer?.player
addView(playerView)
}
}
var resizeMode: ResizeMode = ResizeMode.NONE
set(value) {
field = value
runOnMainThread {
applyResizeMode()
}
}
var keepScreenAwake: Boolean
get() = playerView.keepScreenOn
set(value) {
runOnMainThread {
playerView.keepScreenOn = value
}
}
var events = object : VideoViewEvents {
override var onPictureInPictureChange: ((Boolean) -> Unit)? = {}
override var onFullscreenChange: ((Boolean) -> Unit)? = {}
override var willEnterFullscreen: (() -> Unit)? = {}
override var willExitFullscreen: (() -> Unit)? = {}
override var willEnterPictureInPicture: (() -> Unit)? = {}
override var willExitPictureInPicture: (() -> Unit)? = {}
}
var onNitroIdChange: ((Int?) -> Unit)? = null
var playerView = createPlayerView()
var isInFullscreen: Boolean = false
set(value) {
field = value
events.onFullscreenChange?.let { it(value) }
}
var isInPictureInPicture: Boolean = false
set(value) {
field = value
events.onPictureInPictureChange?.let { it(value) }
}
private var rootContentViews: List<View> = listOf()
private var pictureInPictureHelperTag: String? = null
private var fullscreenFragmentTag: String? = null
private var movedToRootForPiP: Boolean = false
val applicationContent: ReactApplicationContext
get() {
return NitroModules.applicationContext ?: throw LibraryError.ApplicationContextNotFound
}
init {
addView(playerView)
setupFullscreenButton()
applyResizeMode()
}
private fun applyResizeMode() {
playerView.resizeMode = resizeMode.toAspectRatioFrameLayout()
}
@SuppressLint("InflateParams")
private fun createPlayerView(): PlayerView {
return when (surfaceType) {
SurfaceType.SURFACE -> LayoutInflater.from(context).inflate(player_view_surface, null) as PlayerView
SurfaceType.TEXTURE -> LayoutInflater.from(context).inflate(player_view_texture, null) as PlayerView
}.apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
setShutterBackgroundColor(Color.TRANSPARENT)
setShowSubtitleButton(true)
useController = false
// Apply optimizations based on video player size if needed
configureForSmallPlayer()
}
}
private val layoutRunnable = Runnable {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
layout(left, top, right, bottom)
// Additional layout fixes for small video players
applySmallPlayerLayoutFixes()
}
override fun requestLayout() {
super.requestLayout()
// https://github.com/facebook/react-native/blob/d19afc73f5048f81656d0b4424232ce6d69a6368/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbar.java#L166
// This fix issue where exoplayer views where wrong sizes
// Without it, controls, PictureInPicture, content fills, etc. don't work
post(layoutRunnable)
}
@SuppressLint("PrivateResource")
private fun setupFullscreenButton() {
playerView.setFullscreenButtonClickListener { _ ->
enterFullscreen()
}
playerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
?.setImageResource(androidx.media3.ui.R.drawable.exo_ic_fullscreen_enter)
}
fun enterFullscreen() {
if (isInFullscreen) {
return
}
val currentActivity = applicationContent.currentActivity
if (currentActivity !is FragmentActivity) {
Log.e("ReactNativeVideo", "Current activity is not a FragmentActivity, cannot enter fullscreen")
return
}
try {
events.willEnterFullscreen?.let { it() }
val fragment = FullscreenVideoFragment(this)
fullscreenFragmentTag = fragment.id
currentActivity.supportFragmentManager.beginTransaction()
.add(fragment, fragment.id)
.commitAllowingStateLoss()
isInFullscreen = true
} catch (err: Exception) {
val debugMessage = "Failed to start fullscreen fragment for nitroId: $nitroId"
Log.e("ReactNativeVideo", debugMessage, err)
}
}
@SuppressLint("PrivateResource")
fun exitFullscreen() {
if (!isInFullscreen) {
return
}
events.willExitFullscreen?.let { it() }
val currentActivity = applicationContent.currentActivity
fullscreenFragmentTag?.let { tag ->
(currentActivity as? FragmentActivity)?.let { activity ->
activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment ->
// The fragment will handle its own removal in exitFullscreen()
if (fragment is FullscreenVideoFragment) {
runOnMainThread {
fragment.exitFullscreen()
}
}
}
}
fullscreenFragmentTag = null
}
// Change fullscreen button icon back to enter fullscreen and update callback
setupFullscreenButton()
isInFullscreen = false
}
private fun setupPipHelper() {
if (!canEnterPictureInPicture()) {
return
}
val currentActivity = applicationContent.currentActivity
(currentActivity as? FragmentActivity)?.let {
val fragment = PictureInPictureHelperFragment(this)
pictureInPictureHelperTag = fragment.id
it.supportFragmentManager.beginTransaction()
.add(fragment, fragment.id)
.commitAllowingStateLoss()
}
}
private fun removePipHelper() {
val currentActivity = applicationContent.currentActivity
pictureInPictureHelperTag?.let { tag ->
(currentActivity as? FragmentActivity)?.let { activity ->
activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment ->
activity.supportFragmentManager.beginTransaction()
.remove(fragment)
.commitAllowingStateLoss()
}
}
pictureInPictureHelperTag = null
}
}
private fun removeFullscreenFragment() {
val currentActivity = applicationContent.currentActivity
fullscreenFragmentTag?.let { tag ->
(currentActivity as? FragmentActivity)?.let { activity ->
activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment ->
activity.supportFragmentManager.beginTransaction()
.remove(fragment)
.commitAllowingStateLoss()
}
}
fullscreenFragmentTag = null
}
}
fun hideRootContentViews() {
// Safety check: Only proceed if this is the designated PiP video
if (VideoManager.getCurrentPictureInPictureVideo() != this) {
Log.w("ReactNativeVideo", "hideRootContentViews called on non-PiP video nitroId: $nitroId - ignoring")
return
}
Log.d("ReactNativeVideo", "Hiding root content views for PiP video nitroId: $nitroId")
// Remove playerView from parent
// In PiP mode, we don't want to show the controller
// Controls are handled by System if we have MediaSession
playerView.useController = false
playerView.setBackgroundColor(Color.BLACK)
playerView.setShutterBackgroundColor(Color.BLACK)
// If we're already in fullscreen, the PlayerView is inside the fullscreen container
// and root content is already hidden. Avoid moving the PlayerView again.
if (isInFullscreen) {
Log.d("ReactNativeVideo", "PiP entered while in fullscreen - skipping reparent to root for nitroId: $nitroId")
movedToRootForPiP = false
return
}
(playerView.parent as? ViewGroup)?.removeView(playerView)
val currentActivity = applicationContent.currentActivity ?: return
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContentViews = (0 until rootContent.childCount)
.map { rootContent.getChildAt(it) }
.filter { it.isVisible }
rootContentViews.forEach { view -> view.visibility = GONE }
rootContent.addView(
playerView,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
)
movedToRootForPiP = true
Log.d("ReactNativeVideo", "Successfully moved player view to root content for PiP nitroId: $nitroId")
}
fun restoreRootContentViews() {
Log.d("ReactNativeVideo", "Restoring root content views for video nitroId: $nitroId")
// Reset PlayerView settings
playerView.useController = useController
playerView.setBackgroundColor(Color.BLACK)
playerView.setShutterBackgroundColor(Color.BLACK)
if (movedToRootForPiP) {
(playerView.parent as? ViewGroup)?.removeView(playerView)
val currentActivity = applicationContent.currentActivity ?: return
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContent.removeView(playerView)
// Restore root content views
rootContentViews.forEach { it.visibility = View.VISIBLE }
rootContentViews = listOf()
movedToRootForPiP = false
}
// Add PlayerView back to this VideoView only if not already attached
if (playerView.parent != this) {
addView(playerView)
}
Log.d("ReactNativeVideo", "Successfully restored root content views for video nitroId: $nitroId")
}
fun enterPictureInPicture() {
if (isInPictureInPicture || isInFullscreen || !pictureInPictureEnabled) {
return
}
if (!canEnterPictureInPicture()) {
throw VideoViewError.PictureInPictureNotSupported
}
// Use centralized PiP manager to handle multiple videos
VideoManager.requestPictureInPicture(this)
}
internal fun internalEnterPictureInPicture(): Boolean {
if (isInPictureInPicture || isInFullscreen || !pictureInPictureEnabled) {
return false
}
if (!canEnterPictureInPicture()) {
return false
}
val currentActivity = applicationContent.currentActivity ?: return false
return try {
events.willEnterPictureInPicture?.let { it() }
val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = createPictureInPictureParams(this)
safeEnterPictureInPictureMode(params)
} else {
try {
@Suppress("Deprecation")
currentActivity.enterPictureInPictureMode()
true
} catch (e: Exception) {
Log.e("ReactNativeVideo", "Failed to enter PiP mode (legacy)", e)
false
}
}
if (success) {
Log.d("ReactNativeVideo", "Successfully requested PiP mode for nitroId: $nitroId")
// Note: isInPictureInPicture will be set to true by the system callback in PictureInPictureHelperFragment
} else {
Log.w("ReactNativeVideo", "Failed to enter PiP mode for nitroId: $nitroId")
}
success
} catch (e: Exception) {
Log.e("ReactNativeVideo", "Exception in internalEnterPictureInPicture for nitroId: $nitroId", e)
false
}
}
fun exitPictureInPicture() {
if (!isInPictureInPicture) return
VideoManager.notifyPictureInPictureExited(this)
events.willExitPictureInPicture?.let { it() }
if (movedToRootForPiP) {
restoreRootContentViews()
} else {
Log.d("ReactNativeVideo", "Exiting PiP while in fullscreen - no reparent needed for nitroId: $nitroId")
}
isInPictureInPicture = false
}
internal fun forceExitPictureInPicture() {
if (!isInPictureInPicture) {
Log.d("ReactNativeVideo", "Force exit PiP skipped for nitroId: $nitroId (not in PiP)")
return
}
Log.d("ReactNativeVideo", "Force exiting PiP for nitroId: $nitroId")
try {
val currentActivity = applicationContent.currentActivity
if (currentActivity?.isInPictureInPictureMode == true) {
Log.d("ReactNativeVideo", "Activity is in PiP mode, preparing for transition")
}
events.willExitPictureInPicture?.let { it() }
if (movedToRootForPiP) {
restoreRootContentViews()
} else {
Log.d("ReactNativeVideo", "Force exit PiP while in fullscreen - no reparent needed for nitroId: $nitroId")
}
isInPictureInPicture = false
VideoManager.notifyPictureInPictureExited(this)
Log.d("ReactNativeVideo", "Successfully force exited PiP for nitroId: $nitroId")
} catch (e: Exception) {
Log.e("ReactNativeVideo", "Failed to force exit PiP mode for nitroId: $nitroId", e)
}
}
// -------- View Lifecycle Methods --------
override fun onDetachedFromWindow() {
removePipHelper()
removeFullscreenFragment()
VideoManager.unregisterView(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PictureInPictureUtils.safeSetPictureInPictureParams(
createDisabledPictureInPictureParams(this)
)
}
super.onDetachedFromWindow()
}
override fun onAttachedToWindow() {
hybridPlayer?.movePlayerToVideoView(this)
setupPipHelper()
super.onAttachedToWindow()
}
private fun PlayerView.configureForSmallPlayer() {
SmallVideoPlayerOptimizer.applyOptimizations(this, context, isFullscreen = false)
// Also apply after any layout changes
viewTreeObserver.addOnGlobalLayoutListener {
SmallVideoPlayerOptimizer.applyOptimizations(this, context, isFullscreen = false)
}
}
private fun applySmallPlayerLayoutFixes() {
SmallVideoPlayerOptimizer.applyOptimizations(playerView, context, isFullscreen = false)
}
}

View File

@@ -0,0 +1,14 @@
<androidx.media3.ui.PlayerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/player_view_surface"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
app:surface_type="surface_view"
app:scrubber_enabled_size="0dp"
app:scrubber_disabled_size="0dp"
app:scrubber_dragged_size="0dp"
app:touch_target_height="12dp"
app:bar_height="5dp" />

View File

@@ -0,0 +1,15 @@
<androidx.media3.ui.PlayerView
android:id="@+id/player_view_texture"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:surface_type="texture_view"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:clipChildren="false"
android:clipToPadding="false"
app:scrubber_enabled_size="0dp"
app:scrubber_disabled_size="0dp"
app:scrubber_dragged_size="0dp"
app:touch_target_height="12dp"
app:bar_height="5dp"
/>

View File

@@ -0,0 +1,32 @@
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GeneratePropsJavaDelegate.js
*/
package com.facebook.react.viewmanagers;
import android.view.View;
import androidx.annotation.Nullable;
import com.facebook.react.uimanager.BaseViewManager;
import com.facebook.react.uimanager.BaseViewManagerDelegate;
import com.facebook.react.uimanager.LayoutShadowNode;
public class RNCVideoViewManagerDelegate<T extends View, U extends BaseViewManager<T, ? extends LayoutShadowNode> & RNCVideoViewManagerInterface<T>> extends BaseViewManagerDelegate<T, U> {
public RNCVideoViewManagerDelegate(U viewManager) {
super(viewManager);
}
@Override
public void setProperty(T view, String propName, @Nullable Object value) {
switch (propName) {
case "nitroId":
mViewManager.setNitroId(view, value == null ? 0 : ((Double) value).intValue());
break;
default:
super.setProperty(view, propName, value);
}
}
}

View File

@@ -0,0 +1,16 @@
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GeneratePropsJavaInterface.js
*/
package com.facebook.react.viewmanagers;
import android.view.View;
public interface RNCVideoViewManagerInterface<T extends View> {
void setNitroId(T view, int value);
}

View File

@@ -0,0 +1,35 @@
package androidx.media3.exoplayer.dash;
import androidx.media3.common.MediaItem;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
class DashMediaSource {
@UnstableApi
class Factory(
factory: DataSource.Factory,
) : MediaSource.Factory {
// NOOP
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
throw UnsupportedOperationException("STUB")
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
throw UnsupportedOperationException("STUB")
}
override fun getSupportedTypes(): IntArray {
return intArrayOf()
}
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
throw UnsupportedOperationException("STUB")
}
}
}

View File

@@ -0,0 +1,11 @@
package androidx.media3.exoplayer.dash
import android.net.Uri
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.dash.manifest.DashManifest
object DashUtil {
fun loadManifest(ds: DataSource?, uri: Uri?): DashManifest? {
return null
}
}

View File

@@ -0,0 +1,9 @@
package androidx.media3.exoplayer.dash.manifest
import androidx.collection.CircularArray
class AdaptationSet {
var type: Int = 0
var representations: CircularArray<Representation?>? =
null
}

View File

@@ -0,0 +1,10 @@
package androidx.media3.exoplayer.dash.manifest
class DashManifest {
val periodCount: Int
get() = 0
fun getPeriod(index: Int): Period? {
return null
}
}

View File

@@ -0,0 +1,7 @@
package androidx.media3.exoplayer.dash.manifest
import androidx.collection.CircularArray
class Period {
var adaptationSets: CircularArray<AdaptationSet?>? = null
}

View File

@@ -0,0 +1,8 @@
package androidx.media3.exoplayer.dash.manifest
import androidx.media3.common.Format
class Representation {
var format: Format? = null
var presentationTimeOffsetUs: Long = 0
}

View File

@@ -0,0 +1,33 @@
package androidx.media3.exoplayer.hls
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
class HlsMediaSource {
@UnstableApi
class Factory(private val mediaDataSourceFactory: DataSource.Factory) : MediaSource.Factory {
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
throw UnsupportedOperationException("Not implemented")
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
throw UnsupportedOperationException("Not implemented")
}
override fun getSupportedTypes(): IntArray {
return intArrayOf()
}
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
throw UnsupportedOperationException("Not implemented")
}
fun setAllowChunklessPreparation(allowChunklessPreparation: Boolean): Factory {
return this
}
}
}

1
app.plugin.js Normal file
View File

@@ -0,0 +1 @@
module.exports = require('./lib/commonjs/expo-plugins/withReactNativeVideo');

5
babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
['module:react-native-builder-bob/babel-preset', { modules: 'commonjs' }],
],
};

View File

@@ -0,0 +1 @@
#import <React/RCTViewManager.h>

View File

@@ -0,0 +1,40 @@
//
// AVAsset+estimatedMemoryUsage.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/01/2025.
//
import AVFoundation
extension AVAsset {
var estimatedMemoryUsage: Int {
var size = 0
// Get enabled video tracks
let enabledVideoTrack = tracks(withMediaType: .video)
.filter { $0.isEnabled }
// Calculate memory usage for video tracks
for track in enabledVideoTrack {
let dimensions = track.naturalSize
let pixelCount = Int(dimensions.width * dimensions.height)
let frameSize = pixelCount * 4 // RGBA
let frames = 30 * 15 // 30 FPS * 15 seconds of buffer
size += frameSize * frames
}
// Get enabled audio tracks
let enabledAudioTrack = tracks(withMediaType: .audio)
.filter { $0.isEnabled }
// Estimate memory usage for audio tracks
for _ in enabledAudioTrack {
let frameSize = 44100 * 2 * 2 // 44.1kHz * 2 channels * 2 seconds
size += frameSize
}
return size
}
}

View File

@@ -0,0 +1,41 @@
//
// AVAssetTrack+orientation.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/01/2025.
//
import AVFoundation
extension AVAssetTrack {
var orientation: VideoOrientation {
let transform = preferredTransform
let size = naturalSize.applying(transform)
// Check if video is square
if size.width == size.height {
return .square
}
// Check if video is portrait or landscape
let isNaturalSizePortrait = size.width < size.height
// Calculate video rotation
let angle = atan2(Double(transform.b), Double(transform.a))
let degrees = angle * 180 / .pi
let rotation = degrees < 0 ? degrees + 360 : degrees
switch rotation {
case 0:
return isNaturalSizePortrait ? .portrait : .landscapeRight
case 90, -270:
return .portrait
case 180, -180:
return isNaturalSizePortrait ? .portraitUpsideDown : .landscapeLeft
case 270, -90:
return .portraitUpsideDown
default:
return isNaturalSizePortrait ? .portrait : .landscape
}
}
}

View File

@@ -0,0 +1,35 @@
//
// AVPlayerItem+externalSubtitles.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 08/05/2025.
//
import AVFoundation
import Foundation
extension AVPlayerItem {
static func withExternalSubtitles(for asset: AVURLAsset, config: NativeVideoConfig) async throws
-> AVPlayerItem
{
if config.externalSubtitles?.isEmpty != false {
return AVPlayerItem(asset: asset)
}
if asset.url.pathExtension == "m3u8" {
let supportedExternalSubtitles = config.externalSubtitles?.filter { subtitle in
ExternalSubtitlesUtils.isSubtitleTypeSupported(subtitle: subtitle)
}
if supportedExternalSubtitles?.isEmpty == true {
return AVPlayerItem(asset: asset)
} else {
return try await ExternalSubtitlesUtils.modifyStreamManifestWithExternalSubtitles(
for: asset, config: config)
}
}
return try await ExternalSubtitlesUtils.createCompositionWithExternalSubtitles(
for: asset, config: config)
}
}

View File

@@ -0,0 +1,34 @@
//
// AVPlayerItem+getBufferedPosition.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 06/05/2025.
//
import Foundation
import AVFoundation
extension AVPlayerItem {
// Duration that can be played using only the buffer (seconds)
func getbufferDuration() -> Double {
var effectiveTimeRange: CMTimeRange?
for value in loadedTimeRanges {
let timeRange: CMTimeRange = value.timeRangeValue
if CMTimeRangeContainsTime(timeRange, time: currentTime()) {
effectiveTimeRange = timeRange
break
}
}
if let effectiveTimeRange {
let playableDuration: Float64 = CMTimeGetSeconds(CMTimeRangeGetEnd(effectiveTimeRange))
if playableDuration > 0 {
return playableDuration
}
}
return 0
}
}

View File

@@ -0,0 +1,37 @@
//
// AVPlayerItem+setBufferConfig.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 13/09/2025.
//
import Foundation
import AVFoundation
extension AVPlayerItem {
func setBufferConfig(config: BufferConfig) {
if let forwardBufferDurationMs = config.preferredForwardBufferDurationMs {
preferredForwardBufferDuration = TimeInterval(forwardBufferDurationMs / 1000.0)
}
if let peakBitRate = config.preferredPeakBitRate {
preferredPeakBitRate = Double(peakBitRate)
}
if let maximumResolution = config.preferredMaximumResolution {
preferredMaximumResolution = CGSize(width: maximumResolution.width, height: maximumResolution.height)
}
if let peakBitRateForExpensiveNetworks = config.preferredPeakBitRateForExpensiveNetworks {
preferredPeakBitRateForExpensiveNetworks = Double(peakBitRateForExpensiveNetworks)
}
if let maximumResolutionForExpensiveNetworks = config.preferredMaximumResolutionForExpensiveNetworks {
preferredMaximumResolutionForExpensiveNetworks = CGSize(width: maximumResolutionForExpensiveNetworks.width, height: maximumResolutionForExpensiveNetworks.height)
}
if let liveTargetOffsetMs = config.livePlayback?.targetOffsetMs {
configuredTimeOffsetFromLive = CMTime(seconds: Double(liveTargetOffsetMs) / 1000.0, preferredTimescale: 1000)
}
}
}

View File

@@ -0,0 +1,21 @@
//
// AVPlayerViewController+Fullscreen.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 27/04/2025.
//
import Foundation
import AVKit
extension AVPlayerViewController {
// https://stackoverflow.com/a/64466924
func enterFullscreen(animated: Bool) {
performIfResponds(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
}
func exitFullscreen(animated: Bool) {
performIfResponds(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
}
}

View File

@@ -0,0 +1,24 @@
//
// AVPlayerViewController+PictureInPicture.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 27/04/2025.
//
import Foundation
import AVKit
extension AVPlayerViewController {
// https://github.com/expo/expo/blob/d37ae17df23c58011a3c5b9f5dedd563bf8e6521/packages/expo-video/ios/VideoView.swift#L110
func startPictureInPicture() throws {
guard AVPictureInPictureController.isPictureInPictureSupported() else {
throw VideoViewError.pictureInPictureNotSupported.error()
}
performIfResponds(NSSelectorFromString("startPictureInPicture"))
}
func stopPictureInPicture() {
performIfResponds(NSSelectorFromString("stopPictureInPicture"))
}
}

View File

@@ -0,0 +1,68 @@
import AVFoundation
extension AVURLAsset {
func getAssetInformation() async throws -> VideoInformation {
// Initialize with default values
var videoInformation = VideoInformation(
bitrate: Double.nan,
width: Double.nan,
height: Double.nan,
duration: -1,
fileSize: -1,
isHDR: false,
isLive: false,
orientation: .unknown
)
videoInformation.fileSize = try await VideoFileHelper.getFileSize(for: url)
// Check if asset is live stream
if duration.flags.contains(.indefinite) {
videoInformation.duration = -1
videoInformation.isLive = true
} else {
videoInformation.duration = Int64(CMTimeGetSeconds(duration))
videoInformation.isLive = false
}
if let videoTrack = tracks(withMediaType: .video).first {
let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
videoInformation.width = size.width
videoInformation.height = size.height
videoInformation.bitrate = Double(videoTrack.estimatedDataRate)
videoInformation.orientation = videoTrack.orientation
if #available(iOS 14.0, tvOS 14.0, visionOS 1.0, *) {
videoInformation.isHDR = videoTrack.hasMediaCharacteristic(.containsHDRVideo)
}
} else if url.pathExtension == "m3u8" {
// For HLS streams, we cannot get video track information directly
// So we download manifest and try to extract video information from it
let manifestContent = try await HLSManifestParser.downloadManifest(from: url)
let manifestInfo = try HLSManifestParser.parseM3U8Manifest(manifestContent)
if let videoStream = manifestInfo.streams.first {
videoInformation.width = Double(videoStream.width ?? Int(Double.nan))
videoInformation.height = Double(videoStream.height ?? Int(Double.nan))
videoInformation.bitrate = Double(videoStream.bandwidth ?? Int(Double.nan))
}
if videoInformation.width > 0 && videoInformation.height > 0 {
if videoInformation.width == videoInformation.height {
videoInformation.orientation = .square
} else if videoInformation.width > videoInformation.height {
videoInformation.orientation = .landscapeRight
} else if videoInformation.width < videoInformation.height {
videoInformation.orientation = .portrait
} else {
videoInformation.orientation = .unknown
}
}
}
return videoInformation
}
}

View File

@@ -0,0 +1,30 @@
//
// NSObject+PerformIfResponds.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 27/04/2025.
//
import Foundation
extension NSObject {
// Safe perform methods for Objective-C selectors
func performIfResponds(_ selector: Selector) {
if self.responds(to: selector) {
self.perform(selector)
}
}
func performIfResponds(_ selector: Selector, with object: Any?) {
if self.responds(to: selector) {
self.perform(selector, with: object)
}
}
func performIfResponds(_ selector: Selector, with object1: Any?, with object2: Any?) {
if self.responds(to: selector) {
self.perform(selector, with: object1, with: object2)
}
}
}

View File

@@ -0,0 +1,24 @@
//
// ResizeMode.swift
// ReactNativeVideo
//
// Created for resizeMode feature
//
import Foundation
import AVFoundation
public extension ResizeMode {
func toVideoGravity() -> AVLayerVideoGravity {
switch self {
case .contain:
return .resizeAspect
case .cover:
return .resizeAspectFill
case .stretch:
return .resize
case .none:
return .resizeAspect // Default to aspect ratio if none specified
}
}
}

View File

@@ -0,0 +1,381 @@
import AVFoundation
import Foundation
import NitroModules
class HLSSubtitleInjector: NSObject {
private let originalManifestUrl: URL
private let externalSubtitles: [NativeExternalSubtitle]
private var modifiedManifestContent: String?
private static let customScheme = "rnv-hls"
private static let subtitleScheme = "rnv-hls-subtitles"
private static let subtitleGroupID = "rnv-subs"
private static let resourceLoaderQueue = DispatchQueue(
label: "com.nitro.HLSSubtitleInjector.resourceLoaderQueue",
qos: .userInitiated
)
init(manifestUrl: URL, externalSubtitles: [NativeExternalSubtitle]) {
self.originalManifestUrl = manifestUrl
self.externalSubtitles = externalSubtitles
super.init()
}
func createModifiedAsset() -> AVURLAsset {
let customURL = createCustomURL(from: originalManifestUrl)
let asset = AVURLAsset(url: customURL)
asset.resourceLoader.setDelegate(
self,
queue: Self.resourceLoaderQueue
)
return asset
}
private func createCustomURL(from originalURL: URL) -> URL {
var components = URLComponents(
url: originalURL,
resolvingAgainstBaseURL: false
)!
components.scheme = Self.customScheme
return components.url!
}
private func getModifiedManifestContent() async throws -> String {
if let cached = modifiedManifestContent {
return cached
}
let originalContent = try await HLSManifestParser.downloadManifest(from: originalManifestUrl)
let modifiedContent = try modifyM3U8Content(
originalContent,
with: externalSubtitles
)
modifiedManifestContent = modifiedContent
return modifiedContent
}
private func modifyM3U8Content(
_ originalContent: String,
with externalSubtitles: [NativeExternalSubtitle]
) throws -> String {
let lines = originalContent.components(separatedBy: .newlines)
var modifiedLines: [String] = []
var foundExtM3U = false
var isAfterVersionOrM3U = false
var hasSubtitleGroup = false
let baseURL = originalManifestUrl.deletingLastPathComponent()
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine.hasPrefix("#EXTM3U") {
foundExtM3U = true
modifiedLines.append(line)
isAfterVersionOrM3U = true
continue
}
if isAfterVersionOrM3U && !hasSubtitleGroup
&& shouldInsertSubtitlesHere(line: trimmedLine)
{
for (index, subtitle) in externalSubtitles.enumerated() {
let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index)
modifiedLines.append(subtitleTrack)
}
hasSubtitleGroup = true
isAfterVersionOrM3U = false
}
let processedLine = HLSManifestParser.convertRelativeURLsToAbsolute(
line: line,
baseURL: baseURL
)
// Handle existing subtitle groups and stream info lines
if trimmedLine.hasPrefix("#EXT-X-MEDIA:") && trimmedLine.contains("TYPE=SUBTITLES") {
let modifiedMediaLine = replaceSubtitleGroupInMediaLine(processedLine)
modifiedLines.append(modifiedMediaLine)
} else if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") {
let modifiedStreamLine = replaceSubtitleGroupInStreamInf(
processedLine, hasSubtitleGroup: hasSubtitleGroup)
modifiedLines.append(modifiedStreamLine)
} else {
modifiedLines.append(processedLine)
}
}
if foundExtM3U && !hasSubtitleGroup {
var finalLines: [String] = []
var insertedSubtitles = false
for line in modifiedLines {
finalLines.append(line)
if !insertedSubtitles
&& (line.hasPrefix("#EXTM3U") || line.hasPrefix("#EXT-X-VERSION"))
{
for (index, subtitle) in externalSubtitles.enumerated() {
let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index)
finalLines.append(subtitleTrack)
}
insertedSubtitles = true
}
}
modifiedLines = finalLines
}
if !foundExtM3U {
throw SourceError.invalidUri(uri: originalManifestUrl.absoluteString)
.error()
}
// Post-process: ensure every variant stream references our subtitle group if we injected it
if hasSubtitleGroup {
modifiedLines = modifiedLines.map { line in
if line.hasPrefix("#EXT-X-STREAM-INF:") && !line.contains("SUBTITLES=") {
if line.hasSuffix(",") {
return line + "SUBTITLES=\"\(Self.subtitleGroupID)\""
} else {
return line + ",SUBTITLES=\"\(Self.subtitleGroupID)\""
}
}
return line
}
}
return modifiedLines.joined(separator: "\n")
}
private func shouldInsertSubtitlesHere(line: String) -> Bool {
return line.hasPrefix("#EXT-X-STREAM-INF:")
|| line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:")
|| line.hasPrefix("#EXT-X-MEDIA:")
|| line.hasPrefix("#EXTINF:")
|| line.hasPrefix("#EXT-X-BYTERANGE:")
|| (!line.hasPrefix("#") && !line.isEmpty
&& !line.hasPrefix("#EXT-X-VERSION"))
}
private func replaceSubtitleGroupInMediaLine(_ line: String) -> String {
// Find and replace GROUP-ID in subtitle media lines
let groupIdPattern = #"GROUP-ID="[^"]*""#
if let regex = try? NSRegularExpression(pattern: groupIdPattern, options: []) {
let range = NSRange(location: 0, length: line.utf16.count)
let replacement = "GROUP-ID=\"\(Self.subtitleGroupID)\""
return regex.stringByReplacingMatches(
in: line, options: [], range: range, withTemplate: replacement)
}
return line
}
private func replaceSubtitleGroupInStreamInf(_ line: String, hasSubtitleGroup: Bool) -> String {
// First, handle existing SUBTITLES= references
let subtitlesPattern = #"SUBTITLES="[^"]*""#
var modifiedLine = line
if let regex = try? NSRegularExpression(pattern: subtitlesPattern, options: []) {
let range = NSRange(location: 0, length: line.utf16.count)
let replacement = "SUBTITLES=\"\(Self.subtitleGroupID)\""
modifiedLine = regex.stringByReplacingMatches(
in: line, options: [], range: range, withTemplate: replacement)
} else if hasSubtitleGroup && !line.contains("SUBTITLES=") {
// Add subtitle group reference if we have subtitles but no existing reference
if line.hasSuffix(",") {
modifiedLine = line + "SUBTITLES=\"\(Self.subtitleGroupID)\""
} else {
modifiedLine = line + ",SUBTITLES=\"\(Self.subtitleGroupID)\""
}
}
return modifiedLine
}
private func createSubtitleTrackEntry(for subtitle: NativeExternalSubtitle, index: Int)
-> String
{
let subtitleM3U8URI = "\(Self.subtitleScheme)://\(index)/subtitle.m3u8"
return
"#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(Self.subtitleGroupID)\",NAME=\"\(subtitle.label)\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"\(subtitle.language)\",URI=\"\(subtitleM3U8URI)\""
}
}
// MARK: - AVAssetResourceLoaderDelegate
extension HLSSubtitleInjector: AVAssetResourceLoaderDelegate {
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest:
AVAssetResourceLoadingRequest
) -> Bool {
guard let url = loadingRequest.request.url else {
return false
}
switch url.scheme {
case Self.customScheme:
return handleMainManifest(url: url, loadingRequest: loadingRequest)
case Self.subtitleScheme:
return handleSubtitleM3U8(url: url, loadingRequest: loadingRequest)
default:
return false
}
}
private func handleMainManifest(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard url.path.hasSuffix(".m3u8") else {
return false
}
Task {
do {
let modifiedContent = try await getModifiedManifestContent()
guard let data = modifiedContent.data(using: .utf8) else {
throw SourceError.invalidUri(uri: "Failed to encode manifest content")
.error()
}
if let contentRequest = loadingRequest.contentInformationRequest {
contentRequest.contentType = "application/x-mpegURL"
contentRequest.contentLength = Int64(data.count)
contentRequest.isByteRangeAccessSupported = true
}
if let dataRequest = loadingRequest.dataRequest {
let requestedData: Data
if dataRequest.requestedOffset > 0 || dataRequest.requestedLength > 0 {
let offset = Int(dataRequest.requestedOffset)
let length =
dataRequest.requestedLength > 0
? min(Int(dataRequest.requestedLength), data.count - offset)
: data.count - offset
if offset < data.count && length > 0 {
requestedData = data.subdata(in: offset..<(offset + length))
} else {
requestedData = Data()
}
} else {
requestedData = data
}
dataRequest.respond(with: requestedData)
}
loadingRequest.finishLoading()
} catch {
loadingRequest.finishLoading(with: error)
}
}
return true
}
private func handleSubtitleM3U8(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let indexString = url.host, let index = Int(indexString) else {
return false
}
guard index < externalSubtitles.count else {
return false
}
let subtitle = externalSubtitles[index]
Task {
do {
guard let subtitleURL = URL(string: subtitle.uri) else {
throw SourceError.invalidUri(uri: "Invalid subtitle URI: \(subtitle.uri)").error()
}
let (vttData, response) = try await URLSession.shared.data(from: subtitleURL)
guard let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode
else {
throw SourceError.invalidUri(uri: "Subtitle request failed with status: \(response)")
.error()
}
guard let vttString = String(data: vttData, encoding: .utf8) else {
throw SourceError.invalidUri(uri: "Failed to decode VTT content").error()
}
let duration = extractDurationFromVTT(vttString)
let m3u8Wrapper = """
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-ALLOW-CACHE:NO
#EXT-X-TARGETDURATION:\(Int(duration))
#EXTINF:\(String(format: "%.3f", duration)), no desc
\(subtitle.uri)
#EXT-X-ENDLIST
"""
guard let m3u8Data = m3u8Wrapper.data(using: .utf8) else {
throw SourceError.invalidUri(uri: "Failed to create M3U8 wrapper").error()
}
if let contentRequest = loadingRequest.contentInformationRequest {
contentRequest.contentType = "application/x-mpegURL"
contentRequest.contentLength = Int64(m3u8Data.count)
contentRequest.isByteRangeAccessSupported = true
}
if let dataRequest = loadingRequest.dataRequest {
dataRequest.respond(with: m3u8Data)
}
loadingRequest.finishLoading()
} catch {
loadingRequest.finishLoading(with: error)
}
}
return true
}
private func extractDurationFromVTT(_ vttString: String) -> Double {
// Extract duration from VTT timestamps (similar to the PR approach)
let timestampPattern = #"(?:(\d+):)?(\d+):([\d\.]+)"#
guard let regex = try? NSRegularExpression(pattern: timestampPattern, options: []) else {
return 60.0 // Default fallback
}
let matches = regex.matches(
in: vttString,
options: [],
range: NSRange(location: 0, length: vttString.utf16.count)
)
guard let lastMatch = matches.last,
let range = Range(lastMatch.range, in: vttString)
else {
return 60.0 // Default fallback
}
let lastTimestampString = String(vttString[range])
let components = lastTimestampString.components(separatedBy: ":").reversed()
.compactMap { Double($0) }
.enumerated()
.map { pow(60.0, Double($0.offset)) * $0.element }
.reduce(0, +)
return max(components, 1.0) // Ensure at least 1 second
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest
) {
}
}

View File

@@ -0,0 +1,323 @@
import Foundation
import MediaPlayer
class NowPlayingInfoCenterManager {
static let shared = NowPlayingInfoCenterManager()
private let SEEK_INTERVAL_SECONDS: Double = 10
private weak var currentPlayer: AVPlayer?
private var players = NSHashTable<AVPlayer>.weakObjects()
private var observers: [Int: NSKeyValueObservation] = [:]
private var playbackObserver: Any?
private var playTarget: Any?
private var pauseTarget: Any?
private var skipForwardTarget: Any?
private var skipBackwardTarget: Any?
private var playbackPositionTarget: Any?
private var seekTarget: Any?
private var togglePlayPauseTarget: Any?
private let remoteCommandCenter = MPRemoteCommandCenter.shared()
var receivingRemoteControlEvents = false {
didSet {
if receivingRemoteControlEvents {
DispatchQueue.main.async {
VideoManager.shared.setRemoteControlEventsActive(true)
UIApplication.shared.beginReceivingRemoteControlEvents()
}
} else {
DispatchQueue.main.async {
UIApplication.shared.endReceivingRemoteControlEvents()
VideoManager.shared.setRemoteControlEventsActive(false)
}
}
}
}
deinit {
cleanup()
}
func registerPlayer(player: AVPlayer) {
if players.contains(player) {
return
}
if receivingRemoteControlEvents == false {
receivingRemoteControlEvents = true
}
if let oldObserver = observers[player.hashValue] {
oldObserver.invalidate()
}
observers[player.hashValue] = observePlayers(player: player)
players.add(player)
if currentPlayer == nil {
setCurrentPlayer(player: player)
}
}
func removePlayer(player: AVPlayer) {
if !players.contains(player) {
return
}
if let observer = observers[player.hashValue] {
observer.invalidate()
}
observers.removeValue(forKey: player.hashValue)
players.remove(player)
if currentPlayer == player {
currentPlayer = nil
updateNowPlayingInfo()
}
if players.allObjects.isEmpty {
cleanup()
}
}
public func cleanup() {
observers.removeAll()
players.removeAllObjects()
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
}
invalidateCommandTargets()
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
receivingRemoteControlEvents = false
}
private func setCurrentPlayer(player: AVPlayer) {
if player == currentPlayer {
return
}
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
}
currentPlayer = player
registerCommandTargets()
updateNowPlayingInfo()
playbackObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(value: 1, timescale: 4),
queue: .global(),
using: { [weak self] _ in
self?.updateNowPlayingInfo()
}
)
}
private func registerCommandTargets() {
invalidateCommandTargets()
playTarget = remoteCommandCenter.playCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate == 0 {
player.play()
}
return .success
}
pauseTarget = remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate != 0 {
player.pause()
}
return .success
}
skipBackwardTarget = remoteCommandCenter.skipBackwardCommand.addTarget {
[weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
let newTime =
player.currentTime()
- CMTime(seconds: self.SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
player.seek(to: newTime)
return .success
}
skipForwardTarget = remoteCommandCenter.skipForwardCommand.addTarget {
[weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
let newTime =
player.currentTime()
+ CMTime(seconds: self.SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
player.seek(to: newTime)
return .success
}
playbackPositionTarget = remoteCommandCenter.changePlaybackPositionCommand
.addTarget { [weak self] event in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if let event = event as? MPChangePlaybackPositionCommandEvent {
player.seek(
to: CMTime(seconds: event.positionTime, preferredTimescale: .max)
)
return .success
}
return .commandFailed
}
// Handler for togglePlayPauseCommand, sent by Apple's Earpods wired headphones
togglePlayPauseTarget = remoteCommandCenter.togglePlayPauseCommand.addTarget
{ [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate == 0 {
player.play()
} else {
player.pause()
}
return .success
}
}
private func invalidateCommandTargets() {
remoteCommandCenter.playCommand.removeTarget(playTarget)
remoteCommandCenter.pauseCommand.removeTarget(pauseTarget)
remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
remoteCommandCenter.changePlaybackPositionCommand.removeTarget(
playbackPositionTarget
)
remoteCommandCenter.togglePlayPauseCommand.removeTarget(
togglePlayPauseTarget
)
}
public func updateNowPlayingInfo() {
guard let player = currentPlayer, let currentItem = player.currentItem
else {
invalidateCommandTargets()
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
return
}
// commonMetadata is metadata from asset, externalMetadata is custom metadata set by user
// externalMetadata should override commonMetadata to allow override metadata from source
// When the metadata has the tag "iTunSMPB" or "iTunNORM" then the metadata is not converted correctly and comes [nil, nil, ...]
// This leads to a crash of the app
let metadata: [AVMetadataItem] = {
let common = processMetadataItems(currentItem.asset.commonMetadata)
let external = processMetadataItems(currentItem.externalMetadata)
return Array(common.merging(external) { _, new in new }.values)
}()
let titleItem =
AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierTitle
).first?.stringValue ?? ""
let artistItem =
AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierArtist
).first?.stringValue ?? ""
// I have some issue with this - setting artworkItem when it not set dont return nil but also is crashing application
// this is very hacky workaround for it
let imgData = AVMetadataItem.metadataItems(
from: metadata,
filteredByIdentifier: .commonIdentifierArtwork
).first?.dataValue
let image = imgData.flatMap { UIImage(data: $0) } ?? UIImage()
let artworkItem = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
let newNowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: titleItem,
MPMediaItemPropertyArtist: artistItem,
MPMediaItemPropertyArtwork: artworkItem,
MPMediaItemPropertyPlaybackDuration: currentItem.duration.seconds,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentItem.currentTime()
.seconds.rounded(),
MPNowPlayingInfoPropertyPlaybackRate: player.rate,
MPNowPlayingInfoPropertyIsLiveStream: CMTIME_IS_INDEFINITE(
currentItem.asset.duration
),
]
let currentNowPlayingInfo =
MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo =
currentNowPlayingInfo.merging(newNowPlayingInfo) { _, new in new }
}
private func findNewCurrentPlayer() {
if let newPlayer = players.allObjects.first(where: {
$0.rate != 0
}) {
setCurrentPlayer(player: newPlayer)
}
}
// We will observe players rate to find last active player that info will be displayed
private func observePlayers(player: AVPlayer) -> NSKeyValueObservation {
return player.observe(\.rate) { [weak self] player, change in
guard let self else { return }
let rate = change.newValue
// case where there is new player that is not paused
// In this case event is triggered by non currentPlayer
if rate != 0 && self.currentPlayer != player {
self.setCurrentPlayer(player: player)
return
}
// case where currentPlayer was paused
// In this case event is triggered by currentPlayer
if rate == 0 && self.currentPlayer == player {
self.findNewCurrentPlayer()
}
}
}
private func processMetadataItems(_ items: [AVMetadataItem]) -> [String:
AVMetadataItem]
{
var result = [String: AVMetadataItem]()
for item in items {
if let id = item.identifier?.rawValue, !id.isEmpty, result[id] == nil {
result[id] = item
}
}
return result
}
}

View File

@@ -0,0 +1,102 @@
//
// PluginRegistry.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 22/07/2025.
//
import AVFoundation
import Foundation
public final class PluginsRegistry {
public static let shared = PluginsRegistry()
// Plugin ID -> ReactNativeVideoPluginSpec
private var plugins: [String: ReactNativeVideoPluginSpec] = [:]
// MARK: - Public API
public func register(plugin: ReactNativeVideoPluginSpec) {
#if DEBUG
if hasPlugin(plugin: plugin) {
print(
"[ReactNativeVideo] Plugin \(plugin.name) (ID: \(plugin.id)) is already registered - overwriting."
)
} else {
print(
"[ReactNativeVideo] Registering plugin \(plugin.name) (ID: \(plugin.id))."
)
}
#endif
plugins.updateValue(plugin, forKey: plugin.id)
}
public func unregister(plugin: ReactNativeVideoPluginSpec) {
#if DEBUG
if !hasPlugin(plugin: plugin) {
print(
"[ReactNativeVideo] Plugin \(plugin.name) (ID: \(plugin.id)) is not registered - skipping."
)
} else {
print("[ReactNativeVideo] Unregistering plugin \(plugin.name) (ID: \(plugin.id)).")
}
#endif
plugins.removeValue(forKey: plugin.id)
}
// MARK: - Internal API
private func hasPlugin(plugin: ReactNativeVideoPluginSpec) -> Bool {
return plugins.contains { $0.value.id == plugin.id }
}
internal func getDrmManager(source: NativeVideoPlayerSource) throws -> DRMManagerSpec? {
for plugin in plugins.values {
if let drmManager = plugin.getDRMManager(source: source) {
return drmManager
}
}
throw LibraryError.DRMPluginNotFound.error()
}
internal func overrideSource(source: NativeVideoPlayerSource) async
-> NativeVideoPlayerSource
{
var overriddenSource = source
for plugin in plugins.values {
overriddenSource = await plugin.overrideSource(source: overriddenSource)
}
return overriddenSource
}
// MARK: - Notifications
internal func notifyPlayerCreated(player: NativeVideoPlayer) {
for plugin in plugins.values {
plugin.onPlayerCreated(player: Weak(value: player))
}
}
internal func notifyPlayerDestroyed(player: NativeVideoPlayer) {
for plugin in plugins.values {
plugin.onPlayerDestroyed(player: Weak(value: player))
}
}
internal func notifyVideoViewCreated(view: VideoComponentView) {
for plugin in plugins.values {
plugin.onVideoViewCreated(view: Weak(value: view))
}
}
internal func notifyVideoViewDestroyed(view: VideoComponentView) {
for plugin in plugins.values {
plugin.onVideoViewDestroyed(view: Weak(value: view))
}
}
}

View File

@@ -0,0 +1,91 @@
//
// ReactNativeVideoPlugin.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 22/07/2025.
//
import AVFoundation
import Foundation
public protocol ReactNativeVideoPluginSpec {
/**
* The ID of the plugin.
*/
var id: String { get }
/**
* The name of the plugin.
*/
var name: String { get }
/**
* Called when a player is created.
*
* @param player The weak reference to the player instance.
*/
func onPlayerCreated(player: Weak<NativeVideoPlayer>)
/**
* Called when a player is destroyed.
*
* @param player The weak reference to the player instance.
*/
func onPlayerDestroyed(player: Weak<NativeVideoPlayer>)
/**
* Called when a video view is created.
*
* @param view The weak reference to the video view instance.
*/
func onVideoViewCreated(view: Weak<VideoComponentView>)
/**
* Called when a video view is destroyed.
*
* @param view The weak reference to the video view instance.
*/
func onVideoViewDestroyed(view: Weak<VideoComponentView>)
/**
* Called when a source is overridden.
*
* @param source The source instance.
* @return The overridden source instance.
*/
func overrideSource(source: NativeVideoPlayerSource) async -> NativeVideoPlayerSource
/**
* Called when a DRM manager is requested.
*
* @param source The source instance.
* @return The DRM manager instance.
*/
func getDRMManager(source: NativeVideoPlayerSource) -> DRMManagerSpec?
}
open class ReactNativeVideoPlugin: ReactNativeVideoPluginSpec {
public let id: String
public let name: String
public init(name: String) {
self.name = name
self.id = "RNV_Plugin_\(name)"
PluginsRegistry.shared.register(plugin: self)
}
open func onPlayerCreated(player: Weak<NativeVideoPlayer>) { /* no-op */ }
open func onPlayerDestroyed(player: Weak<NativeVideoPlayer>) { /* no-op */ }
open func onVideoViewCreated(view: Weak<VideoComponentView>) { /* no-op */ }
open func onVideoViewDestroyed(view: Weak<VideoComponentView>) { /* no-op */ }
open func overrideSource(source: NativeVideoPlayerSource) async -> NativeVideoPlayerSource {
return source
}
open func getDRMManager(source: NativeVideoPlayerSource) -> DRMManagerSpec? {
return nil
}
}

View File

@@ -0,0 +1,14 @@
//
// DRMManagerSpec.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 05/08/2025.
//
import Foundation
import AVFoundation
public protocol DRMManagerSpec: AVContentKeySessionDelegate {
/// Creates a content key request for the given asset and DRM parameters.
func createContentKeyRequest(for asset: AVURLAsset, drmParams: NativeDrmParams) throws
}

View File

@@ -0,0 +1,36 @@
//
// NativeVideoPlayerSourceSpec.swift
// react-native-video
//
// Created by Krzysztof Moch on 23/07/2025.
//
import Foundation
import AVFoundation
// Helper alias that allow to represet player source outside of module
public typealias NativeVideoPlayerSource = NativeVideoPlayerSourceSpec & HybridVideoPlayerSourceSpec
public protocol NativeVideoPlayerSourceSpec {
// MARK: - Properties
/// The underlying AVURLAsset instance
var asset: AVURLAsset? { get set }
/// The URL of the video source
var url: URL { get }
/// The memory size used by the source
var memorySize: Int { get }
// MARK: - Methods
/// Initialize the asset asynchronously
func initializeAsset() async throws
/// Get non-null AVURLAsset instance (self.asset)
func getAsset() async throws -> AVURLAsset
/// Release the asset resources
func releaseAsset()
}

View File

@@ -0,0 +1,42 @@
//
// NativeVideoPlayerSpec.swift
// react-native-video
//
// Created by Krzysztof Moch on 09/10/2024.
//
import Foundation
import AVFoundation
// Helper alias that allow to represet player outside of module
public typealias NativeVideoPlayer = NativeVideoPlayerSpec & HybridVideoPlayerSpec
public protocol NativeVideoPlayerSpec {
// MARK: - Properties
/// The underlying AVPlayer instance (should not be used directly)
var player: AVPlayer { get set }
/// The current player item
var playerItem: AVPlayerItem? { get set }
/// The player observer for monitoring state changes
// var playerObserver: VideoPlayerObserver? { get set }
/// Whether the player was auto-paused
var wasAutoPaused: Bool { get set }
/// Whether the player is currently buffering
var isCurrentlyBuffering: Bool { get set }
/// The memory size used by the player
var memorySize: Int { get }
// MARK: - Methods
/// Release the player resources
func release()
/// Initialize the player item asynchronously
func initializePlayerItem() async throws -> AVPlayerItem
}

View File

@@ -0,0 +1,119 @@
import AVFoundation
import ObjectiveC
private var HLSSubtitleInjectorAssociatedKey: UInt8 = 0
enum ExternalSubtitlesUtils {
static func isSubtitleTypeSupported(subtitle: NativeExternalSubtitle) -> Bool {
if subtitle.type == .vtt {
return true
}
if let url = URL(string: subtitle.uri), url.pathExtension == "vtt" {
return true
}
return false
}
static func createCompositionWithExternalSubtitles(
for asset: AVURLAsset,
config: NativeVideoConfig
) async throws -> AVPlayerItem {
let subtitlesAssets = try config.externalSubtitles?.map { subtitle in
guard let url = URL(string: subtitle.uri) else {
throw PlayerError.invalidTrackUrl(url: subtitle.uri).error()
}
return AVURLAsset(url: url)
}
do {
let mainVideoTracks = asset.tracks(withMediaType: .video)
let mainAudioTracks = asset.tracks(withMediaType: .audio)
let textTracks =
subtitlesAssets?.flatMap { $0.tracks(withMediaType: .text) } ?? []
let composition = AVMutableComposition()
if let videoTrack = mainVideoTracks.first(where: { $0.mediaType == .video }){
if let compositionVideoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
) {
try compositionVideoTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: videoTrack.timeRange.duration),
of: videoTrack,
at: .zero
)
}
}
if let audioTrack = mainAudioTracks.first(where: { $0.mediaType == .audio }) {
if let compositionAudioTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
) {
try compositionAudioTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: audioTrack.timeRange.duration),
of: audioTrack,
at: .zero
)
}
}
for textTrack in textTracks {
if let compositionTextTrack = composition.addMutableTrack(
withMediaType: .text,
preferredTrackID: kCMPersistentTrackID_Invalid
) {
try compositionTextTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: textTrack.timeRange.duration),
of: textTrack,
at: .zero
)
compositionTextTrack.languageCode = textTrack.languageCode
compositionTextTrack.isEnabled = true
}
}
return await AVPlayerItem(asset: composition)
}
}
static func modifyStreamManifestWithExternalSubtitles(
for asset: AVURLAsset,
config: NativeVideoConfig
) async throws -> AVPlayerItem {
guard let externalSubtitles = config.externalSubtitles,
!externalSubtitles.isEmpty
else {
return AVPlayerItem(asset: asset)
}
let supportedSubtitles = externalSubtitles.filter { subtitle in
isSubtitleTypeSupported(subtitle: subtitle)
}
guard !supportedSubtitles.isEmpty else {
return AVPlayerItem(asset: asset)
}
let subtitleInjector = HLSSubtitleInjector(
manifestUrl: asset.url,
externalSubtitles: supportedSubtitles
)
let modifiedAsset = subtitleInjector.createModifiedAsset()
let playerItem = AVPlayerItem(asset: modifiedAsset)
objc_setAssociatedObject(
playerItem,
&HLSSubtitleInjectorAssociatedKey,
subtitleInjector,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
return playerItem
}
}

View File

@@ -0,0 +1,170 @@
import AVFoundation
import Foundation
import NitroModules
class HLSManifestParser {
/// Downloads manifest content from the given URL
static func downloadManifest(from url: URL) async throws -> String {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode
else {
throw SourceError.invalidUri(uri: url.absoluteString).error()
}
guard let manifestContent = String(data: data, encoding: .utf8) else {
throw SourceError.invalidUri(uri: url.absoluteString).error()
}
return manifestContent
}
/// Converts relative URLs in a manifest line to absolute URLs
static func convertRelativeURLsToAbsolute(line: String, baseURL: URL) -> String {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine.isEmpty {
return line
}
if trimmedLine.hasPrefix("#") {
if trimmedLine.contains("URI=") {
return convertURIParametersToAbsolute(line: line, baseURL: baseURL)
}
return line
}
if !trimmedLine.hasPrefix("http://") && !trimmedLine.hasPrefix("https://") {
let absoluteURL = baseURL.appendingPathComponent(trimmedLine)
return absoluteURL.absoluteString
}
return line
}
/// Converts URI parameters in manifest lines to absolute URLs
static func convertURIParametersToAbsolute(line: String, baseURL: URL) -> String {
var modifiedLine = line
let uriPattern = #"URI="([^"]+)""#
guard let regex = try? NSRegularExpression(pattern: uriPattern, options: [])
else {
return line
}
let nsLine = line as NSString
let matches = regex.matches(
in: line,
options: [],
range: NSRange(location: 0, length: nsLine.length)
)
for match in matches.reversed() {
if match.numberOfRanges >= 2 {
let uriRange = match.range(at: 1)
let uri = nsLine.substring(with: uriRange)
if !uri.hasPrefix("http://") && !uri.hasPrefix("https://") {
let absoluteURL = baseURL.appendingPathComponent(uri)
let fullRange = match.range(at: 0)
let replacement = "URI=\"\(absoluteURL.absoluteString)\""
modifiedLine = (modifiedLine as NSString).replacingCharacters(
in: fullRange,
with: replacement
)
}
}
}
return modifiedLine
}
/// Parses M3U8 manifest content and returns parsed information
static func parseM3U8Manifest(_ content: String) throws -> HLSManifestInfo {
let lines = content.components(separatedBy: .newlines)
var info = HLSManifestInfo()
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine.hasPrefix("#EXTM3U") {
info.isValid = true
}
// Parse version
if trimmedLine.hasPrefix("#EXT-X-VERSION:") {
let versionString = String(trimmedLine.dropFirst("#EXT-X-VERSION:".count))
info.version = Int(versionString)
}
// Parse stream info for resolution
if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") {
let streamInfo = parseStreamInf(trimmedLine)
info.streams.append(streamInfo)
}
}
if !info.isValid {
throw SourceError.invalidUri(uri: "Invalid M3U8 format").error()
}
return info
}
/// Parses EXT-X-STREAM-INF line to extract stream information
private static func parseStreamInf(_ line: String) -> HLSStreamInfo {
var streamInfo = HLSStreamInfo()
// Parse RESOLUTION
if let resolutionRange = line.range(of: "RESOLUTION=") {
let afterResolution = line[resolutionRange.upperBound...]
if let commaRange = afterResolution.range(of: ",") {
let resolutionValue = String(afterResolution[..<commaRange.lowerBound])
let components = resolutionValue.components(separatedBy: "x")
if components.count == 2 {
streamInfo.width = Int(components[0])
streamInfo.height = Int(components[1])
}
} else {
// Resolution is at the end of the line
let resolutionValue = String(afterResolution)
let components = resolutionValue.components(separatedBy: "x")
if components.count == 2 {
streamInfo.width = Int(components[0])
streamInfo.height = Int(components[1])
}
}
}
// Parse BANDWIDTH
if let bandwidthRange = line.range(of: "BANDWIDTH=") {
let afterBandwidth = line[bandwidthRange.upperBound...]
if let commaRange = afterBandwidth.range(of: ",") {
let bandwidthValue = String(afterBandwidth[..<commaRange.lowerBound])
streamInfo.bandwidth = Int(bandwidthValue)
} else {
// Bandwidth is at the end of the line
let bandwidthValue = String(afterBandwidth)
streamInfo.bandwidth = Int(bandwidthValue)
}
}
return streamInfo
}
}
// MARK: - Data Structures
struct HLSManifestInfo {
var isValid: Bool = false
var version: Int?
var streams: [HLSStreamInfo] = []
}
struct HLSStreamInfo {
var width: Int?
var height: Int?
var bandwidth: Int?
}

26
ios/core/Utils/Weak.swift Normal file
View File

@@ -0,0 +1,26 @@
//
// Weak.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 31/07/2025.
//
import Foundation
/// A generic class for managing weak references to objects.
///
/// The `Weak<T>` class is designed to hold a weak reference to an object of type `T`.
/// This is particularly useful in scenarios where strong references could lead to retain cycles
/// or memory leaks, such as in plugin systems or delegate patterns.
///
/// - Note: The `value` property provides access to the referenced object, or `nil` if the object has been deallocated.
public class Weak<T> {
private weak var _value: AnyObject?
public var value: T? {
return _value as? T
}
public init(value: T) {
_value = value as AnyObject
}
}

159
ios/core/VideoError.swift Normal file
View File

@@ -0,0 +1,159 @@
//
// VideoFileHelper.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 24/01/2025.
//
import Foundation
import NitroModules
// MARK: - LibraryError
enum LibraryError: VideoError {
case deallocated(objectName: String)
case DRMPluginNotFound
var code: String {
switch self {
case .deallocated:
return "library/deallocated"
case .DRMPluginNotFound:
return "library/drm-plugin-not-found"
}
}
var message: String {
switch self {
case let .deallocated(objectName: objectName):
return "Object \(objectName) has been deallocated"
case .DRMPluginNotFound:
return "No DRM plugin have been found, please add one to the project"
}
}
}
// MARK: - PlayerError
enum PlayerError: VideoError {
case notInitialized
case assetNotInitialized
case invalidSource
case invalidTrackUrl(url: String)
var code: String {
switch self {
case .notInitialized:
return "player/not-initialized"
case .assetNotInitialized:
return "player/asset-not-initialized"
case .invalidSource:
return "player/invalid-source"
case .invalidTrackUrl:
return "player/invalid-track-url"
}
}
var message: String {
switch self {
case .notInitialized:
return "Player has not been initialized (Or has been set to nil)"
case .assetNotInitialized:
return "Asset has not been initialized (Or has been set to nil)"
case .invalidSource:
return "Invalid source passed to player"
case let .invalidTrackUrl(url: url):
return "Invalid track URL: \(url)"
}
}
}
// MARK: - SourceError
enum SourceError: VideoError {
case invalidUri(uri: String)
case missingReadFilePermission(uri: String)
case fileDoesNotExist(uri: String)
case failedToInitializeAsset
case unsupportedContentType(uri: String)
var code: String {
switch self {
case .invalidUri:
return "source/invalid-uri"
case .missingReadFilePermission:
return "source/missing-read-file-permission"
case .fileDoesNotExist:
return "source/file-does-not-exist"
case .failedToInitializeAsset:
return "source/failed-to-initialize-asset"
case .unsupportedContentType:
return "source/unsupported-content-type"
}
}
var message: String {
switch self {
case let .invalidUri(uri: uri):
return "Invalid source file uri: \(uri)"
case let .missingReadFilePermission(uri: uri):
return "Missing read file permission for source file at \(uri)"
case let .fileDoesNotExist(uri: uri):
return "File does not exist at URI: \(uri)"
case .failedToInitializeAsset:
return "Failed to initialize asset"
case let .unsupportedContentType(uri: uri):
return "type of content (\(uri)) is not supported"
}
}
}
// MARK: - VideoViewError
enum VideoViewError: VideoError {
case viewNotFound(nitroId: Double)
case viewIsDeallocated
case pictureInPictureNotSupported
var code: String {
switch self {
case .viewNotFound:
return "view/not-found"
case .viewIsDeallocated:
return "view/deallocated"
case .pictureInPictureNotSupported:
return "view/picture-in-picture-not-supported"
}
}
var message: String {
switch self {
case let .viewNotFound(nitroId: nitroId):
return "View with nitroId \(nitroId) not found"
case .viewIsDeallocated:
return "Attempt to access a view, but it has been deallocated (or not initialized)"
case .pictureInPictureNotSupported:
return "Picture in picture is not supported on this device"
}
}
}
// MARK: - UnknownError
struct UnknownError: VideoError {
var code: String { "unknown/unknown" }
var message: String { "Unknown error" }
}
// MARK: - VideoError
protocol VideoError {
var code: String { get }
var message: String { get }
}
extension VideoError {
private func getMessage() -> String {
return "{%@\(code)::\(message)@%}"
}
func error() -> Error {
return RuntimeError.error(withMessage: getMessage())
}
}

View File

@@ -0,0 +1,48 @@
//
// VideoFileHelper.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/01/2025.
//
import Foundation
import NitroModules
enum VideoFileHelper {
private static let fileManager = FileManager.default
static func getFileSize(for url: URL) async throws -> Int64 {
if url.isFileURL {
return try getLocalFileSize(for: url)
}
return try await getRemoteFileSize(for: url)
}
static func validateReadPermission(for url: URL) throws {
guard url.isFileURL else { return }
if !fileManager.isReadableFile(atPath: url.path) {
throw SourceError.missingReadFilePermission(uri: url.path).error()
}
}
// MARK: - Private
private static func getLocalFileSize(for url: URL) throws -> Int64 {
Int64(try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? -1)
}
private static func getRemoteFileSize(for url: URL) async throws -> Int64 {
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
let contentLength = httpResponse.allHeaderFields["Content-Length"] as? String,
let size = Int64(contentLength) else {
return -1
}
return size
}
}

412
ios/core/VideoManager.swift Normal file
View File

@@ -0,0 +1,412 @@
//
// VideoManager.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 27/04/2025.
//
import Foundation
import AVFoundation
class VideoManager {
// MARK: - Singleton
static let shared = VideoManager()
private var players = NSHashTable<HybridVideoPlayer>.weakObjects()
private var videoView = NSHashTable<VideoComponentView>.weakObjects()
private var isAudioSessionActive = false
private var remoteControlEventsActive = false
// TODO: Create Global Config, and expose it there
private var isAudioSessionManagementDisabled: Bool = false
private init() {
// Subscribe to audio interruption notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
// Subscribe to route change notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioRouteChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillResignActive(notification:)),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(notification:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidEnterBackground(notification:)),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillEnterForeground(notification:)),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - public
func register(player: HybridVideoPlayer) {
players.add(player)
PluginsRegistry.shared.notifyPlayerCreated(player: player)
}
func unregister(player: HybridVideoPlayer) {
players.remove(player)
PluginsRegistry.shared.notifyPlayerDestroyed(player: player)
}
func register(view: VideoComponentView) {
videoView.add(view)
}
func unregister(view: VideoComponentView) {
videoView.remove(view)
}
func requestAudioSessionUpdate() {
updateAudioSessionConfiguration()
}
// MARK: - Remote Control Events
func setRemoteControlEventsActive(_ active: Bool) {
if isAudioSessionManagementDisabled || remoteControlEventsActive == active {
return
}
remoteControlEventsActive = active
requestAudioSessionUpdate()
}
// MARK: - Audio Session Management
private func activateAudioSession() {
if isAudioSessionActive {
return
}
do {
try AVAudioSession.sharedInstance().setActive(true)
isAudioSessionActive = true
} catch {
print("Failed to activate audio session: \(error.localizedDescription)")
}
}
private func deactivateAudioSession() {
if !isAudioSessionActive {
return
}
do {
try AVAudioSession.sharedInstance().setActive(
false, options: .notifyOthersOnDeactivation
)
isAudioSessionActive = false
} catch {
print("Failed to deactivate audio session: \(error.localizedDescription)")
}
}
private func updateAudioSessionConfiguration() {
let isAnyPlayerPlaying = players.allObjects.contains { hybridPlayer in
hybridPlayer.player.isMuted == false && hybridPlayer.player.rate != 0
}
let anyPlayerNeedsNotMixWithOthers = players.allObjects.contains { player in
player.mixAudioMode == .donotmix
}
let anyPlayerNeedsNotificationControls = players.allObjects.contains { player in
player.showNotificationControls
}
if isAnyPlayerPlaying || anyPlayerNeedsNotMixWithOthers || anyPlayerNeedsNotificationControls || remoteControlEventsActive {
activateAudioSession()
} else {
deactivateAudioSession()
}
configureAudioSession()
}
private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance()
var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = audioSession.categoryOptions
let anyViewNeedsPictureInPicture = videoView.allObjects.contains { view in
view.allowsPictureInPicturePlayback
}
let anyPlayerNeedsSilentSwitchObey = players.allObjects.contains { player in
player.ignoreSilentSwitchMode == .obey
}
let anyPlayerNeedsSilentSwitchIgnore = players.allObjects.contains { player in
player.ignoreSilentSwitchMode == .ignore
}
let anyPlayerNeedsBackgroundPlayback = players.allObjects.contains { player in
player.playInBackground
}
let anyPlayerNeedsNotificationControls = players.allObjects.contains { player in
player.showNotificationControls
}
if isAudioSessionManagementDisabled {
return
}
let category: AVAudioSession.Category = determineAudioCategory(
silentSwitchObey: anyPlayerNeedsSilentSwitchObey,
silentSwitchIgnore: anyPlayerNeedsSilentSwitchIgnore,
earpiece: false, // TODO: Pass actual value after we add prop
pip: anyViewNeedsPictureInPicture,
backgroundPlayback: anyPlayerNeedsBackgroundPlayback,
notificationControls: anyPlayerNeedsNotificationControls
)
let audioMixingMode = determineAudioMixingMode()
switch audioMixingMode {
case .mixwithothers:
audioSessionCategoryOptions.insert(.mixWithOthers)
case .donotmix:
audioSessionCategoryOptions.remove(.mixWithOthers)
case .duckothers:
audioSessionCategoryOptions.insert(.duckOthers)
case .auto:
audioSessionCategoryOptions.remove(.mixWithOthers)
audioSessionCategoryOptions.remove(.duckOthers)
}
do {
try audioSession.setCategory(category, mode: .moviePlayback, options: audioSessionCategoryOptions)
} catch {
print("ReactNativeVideo: Failed to set audio session category: \(error.localizedDescription)")
}
}
private func determineAudioCategory(
silentSwitchObey: Bool,
silentSwitchIgnore: Bool,
earpiece: Bool,
pip: Bool,
backgroundPlayback: Bool,
notificationControls: Bool
) -> AVAudioSession.Category {
// Handle conflicting settings
if silentSwitchObey && silentSwitchIgnore {
print(
"Warning: Conflicting ignoreSilentSwitch settings found (obey vs ignore) - defaulting to ignore"
)
return .playback
}
// PiP, background playback, or notification controls require playback category
if pip || backgroundPlayback || notificationControls || remoteControlEventsActive {
if silentSwitchObey {
print(
"Warning: ignoreSilentSwitch=obey cannot be used with PiP, backgroundPlayback, or notification controls - using playback category"
)
}
if earpiece {
print(
"Warning: audioOutput=earpiece cannot be used with PiP, backgroundPlayback, or notification controls - using playback category"
)
}
// Set up background playback policy if needed
if backgroundPlayback {
players.allObjects.forEach { player in
if player.playInBackground {
player.player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
} else {
player.player.audiovisualBackgroundPlaybackPolicy = .pauses
}
}
}
return .playback
}
// Earpiece requires playAndRecord
if earpiece {
if silentSwitchObey {
print(
"Warning: audioOutput=earpiece cannot be used with ignoreSilentSwitch=obey - using playAndRecord category"
)
}
return .playAndRecord
}
// Honor silent switch if requested
if silentSwitchObey {
return .ambient
}
// Default to playback for most cases
return .playback
}
func determineAudioMixingMode() -> MixAudioMode {
let activePlayers = players.allObjects.filter { player in
player.isPlaying && player.player.isMuted != true
}
if activePlayers.isEmpty {
return .mixwithothers
}
let anyPlayerNeedsMixWithOthers = activePlayers.contains { player in
player.mixAudioMode == .mixwithothers
}
let anyPlayerNeedsNotMixWithOthers = activePlayers.contains { player in
player.mixAudioMode == .donotmix
}
let anyPlayerNeedsDucksOthers = activePlayers.contains { player in
player.mixAudioMode == .duckothers
}
let anyPlayerHasAutoMixAudioMode = activePlayers.contains { player in
player.mixAudioMode == .auto
}
if anyPlayerNeedsNotMixWithOthers {
return .donotmix
}
if anyPlayerHasAutoMixAudioMode {
return .auto
}
if anyPlayerNeedsDucksOthers {
return .duckothers
}
return .mixwithothers
}
// MARK: - Notification Handlers
@objc
private func handleAudioSessionInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
switch type {
case .began:
// Audio session interrupted, nothing to do as players will pause automatically
break
case .ended:
// Interruption ended, check if we should resume audio session
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
updateAudioSessionConfiguration()
}
}
@unknown default:
break
}
}
@objc
private func handleAudioRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
return
}
switch reason {
case .categoryChange, .override, .wakeFromSleep, .newDeviceAvailable, .oldDeviceUnavailable:
// Reconfigure audio session when route changes
updateAudioSessionConfiguration()
default:
break
}
}
@objc func applicationWillResignActive(notification: Notification) {
// Pause all players when the app is about to become inactive
for player in players.allObjects {
if player.playInBackground || player.playWhenInactive || !player.isPlaying || player.player.isExternalPlaybackActive == true {
continue
}
try? player.pause()
player.wasAutoPaused = true
}
}
@objc func applicationDidBecomeActive(notification: Notification) {
// Resume all players when the app becomes active
for player in players.allObjects {
if player.wasAutoPaused {
try? player.play()
player.wasAutoPaused = false
}
}
}
@objc func applicationDidEnterBackground(notification: Notification) {
// Pause all players when the app enters background
for player in players.allObjects {
if player.playInBackground || player.player.isExternalPlaybackActive == true || !player.isPlaying {
continue
}
try? player.pause()
player.wasAutoPaused = true
}
}
@objc func applicationWillEnterForeground(notification: Notification) {
// Resume all players when the app enters foreground
for player in players.allObjects {
if player.wasAutoPaused {
try? player.play()
player.wasAutoPaused = false
}
}
}
}

View File

@@ -0,0 +1,294 @@
//
// VideoPlayerObserver.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 15/04/2025.
//
import Foundation
import AVFoundation
protocol VideoPlayerObserverDelegate: AnyObject {
func onPlayedToEnd(player: AVPlayer)
func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?)
func onPlayerItemWillChange(hasNewPlayerItem: Bool)
func onTextTrackDataChanged(texts: [NSAttributedString])
func onTimedMetadataChanged(timedMetadata: [AVMetadataItem])
func onRateChanged(rate: Float)
func onPlaybackBufferEmpty()
func onPlaybackLikelyToKeepUp()
func onVolumeChanged(volume: Float)
func onExternalPlaybackActiveChanged(isActive: Bool)
func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus)
func onPlayerStatusChanged(status: AVPlayer.Status)
func onPlayerItemStatusChanged(status: AVPlayerItem.Status)
func onBandwidthUpdate(bitrate: Double)
func onProgressUpdate(currentTime: Double, bufferDuration: Double)
}
extension VideoPlayerObserverDelegate {
func onPlayedToEnd(player: AVPlayer) {}
func onPlayerItemChange(player: AVPlayer, playerItem: AVPlayerItem?) {}
func onPlayerItemWillChange(hasNewPlayerItem: Bool) {}
func onTextTrackDataChanged(texts: [NSAttributedString]) {}
func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) {}
func onRateChanged(rate: Float) {}
func onPlaybackBufferEmpty() {}
func onPlaybackLikelyToKeepUp() {}
func onVolumeChanged(volume: Float) {}
func onExternalPlaybackActiveChanged(isActive: Bool) {}
func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) {}
func onPlayerStatusChanged(status: AVPlayer.Status) {}
func onPlayerItemStatusChanged(status: AVPlayerItem.Status) {}
func onBandwidthUpdate(bitrate: Double) {}
func onProgressUpdate(currentTime: Double, bufferDuration: Double) {}
}
class VideoPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPlayerItemLegibleOutputPushDelegate {
private weak var delegate: HybridVideoPlayer?
var player: AVPlayer? {
delegate?.player
}
// Player observers
var playerCurrentItemObserver: NSKeyValueObservation?
var playerRateObserver: NSKeyValueObservation?
var playerTimeControlStatusObserver: NSKeyValueObservation?
var playerExternalPlaybackActiveObserver: NSKeyValueObservation?
var playerVolumeObserver: NSKeyValueObservation?
var playerTimedMetadataObserver: NSKeyValueObservation?
var playerStatusObserver: NSKeyValueObservation?
var playerProgressPeriodicObserver: Any?
// Player item observers
var playbackEndedObserver: NSObjectProtocol?
var playbackBufferEmptyObserver: NSKeyValueObservation?
var playbackLikelyToKeepUpObserver: NSKeyValueObservation?
var playbackBufferFullObserver: NSKeyValueObservation?
var playerItemStatusObserver: NSKeyValueObservation?
var playerItemAccessLogObserver: NSObjectProtocol?
var metadataOutput: AVPlayerItemMetadataOutput?
var legibleOutput: AVPlayerItemLegibleOutput?
init(delegate: HybridVideoPlayer) {
self.delegate = delegate
}
deinit {
invalidatePlayerObservers()
invalidatePlayerItemObservers()
}
public func updatePlayerObservers() {
invalidatePlayerItemObservers()
invalidatePlayerObservers()
initializePlayerObservers()
}
func initializePlayerObservers() {
guard let player else {
return
}
playerCurrentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] _, change in
self?.onPlayerCurrentItemChanged(player: player, change: change)
}
playerRateObserver = player.observe(\.rate, options: [.new]) { [weak self] _, change in
guard let rate = change.newValue else { return }
self?.delegate?.onRateChanged(rate: rate)
}
playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, change in
guard let status = change.newValue else { return }
self?.delegate?.onTimeControlStatusChanged(status: status)
}
playerExternalPlaybackActiveObserver = player.observe(\.isExternalPlaybackActive, options: [.new]) { [weak self] _, change in
guard let isActive = change.newValue else { return }
self?.delegate?.onExternalPlaybackActiveChanged(isActive: isActive)
}
playerVolumeObserver = player.observe(\.volume, options: [.new]) { [weak self] _, change in
guard let volume = change.newValue else { return }
self?.delegate?.onVolumeChanged(volume: volume)
}
playerStatusObserver = player.observe(\.status, options: [.new]) { [weak self] _, change in
guard let status = change.newValue else { return }
self?.delegate?.onPlayerStatusChanged(status: status)
}
// 500ms interval TODO: Make this configurable
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
playerProgressPeriodicObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
guard let self, let player = self.player, let delegate = self.delegate else { return }
delegate.onProgressUpdate(currentTime: player.currentTime().seconds, bufferDuration: player.currentItem?.getbufferDuration() ?? 0)
}
}
private func initializePlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
playbackEndedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: playerItem,
queue: nil
) { [weak self] notification in
self?.delegate?.onPlayedToEnd(player: player)
}
playerItemAccessLogObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewAccessLogEntry,
object: playerItem,
queue: nil
) { [weak self] notification in
self?.onPlayerAccessLog(playerItem: playerItem)
}
setupBufferObservers(for: playerItem)
playerItemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] _, change in
self?.delegate?.onPlayerItemStatusChanged(status: playerItem.status)
}
let metadataOutput = AVPlayerItemMetadataOutput()
playerItem.add(metadataOutput)
metadataOutput.setDelegate(self, queue: .global(qos: .userInteractive))
let legibleOutput = AVPlayerItemLegibleOutput()
playerItem.add(legibleOutput)
metadataOutput.setDelegate(self, queue: .global(qos: .userInteractive))
}
private func invalidatePlayerItemObservers() {
// Remove NotificationCenter observers
if let playbackEndedObserver = playbackEndedObserver {
NotificationCenter.default.removeObserver(playbackEndedObserver)
self.playbackEndedObserver = nil
}
if let playerItemAccessLogObserver = playerItemAccessLogObserver {
NotificationCenter.default.removeObserver(playerItemAccessLogObserver)
self.playerItemAccessLogObserver = nil
}
// Invalidate KVO observers
clearBufferObservers()
playerItemStatusObserver?.invalidate()
playerItemStatusObserver = nil
// Remove outputs if needed
// (Assumes outputs are not shared between items)
if let playerItem = player?.currentItem {
if let metadataOutput = metadataOutput {
playerItem.remove(metadataOutput)
}
if let legibleOutput = legibleOutput {
playerItem.remove(legibleOutput)
}
}
metadataOutput = nil
legibleOutput = nil
}
func invalidatePlayerObservers() {
// Invalidate KVO observers
playerCurrentItemObserver?.invalidate()
playerCurrentItemObserver = nil
playerRateObserver?.invalidate()
playerRateObserver = nil
playerTimeControlStatusObserver?.invalidate()
playerTimeControlStatusObserver = nil
playerExternalPlaybackActiveObserver?.invalidate()
playerExternalPlaybackActiveObserver = nil
playerVolumeObserver?.invalidate()
playerVolumeObserver = nil
playerStatusObserver?.invalidate()
playerStatusObserver = nil
// Remove periodic time observer from player
if let player = player, let periodicObserver = playerProgressPeriodicObserver {
player.removeTimeObserver(periodicObserver)
playerProgressPeriodicObserver = nil
}
}
// MARK: - AVPlayerItemLegibleOutputPushDelegate
public func legibleOutput(_: AVPlayerItemLegibleOutput,
didOutputAttributedStrings strings: [NSAttributedString],
nativeSampleBuffers _: [Any],
forItemTime _: CMTime) {
delegate?.onTextTrackDataChanged(texts: strings)
}
// MARK: - AVPlayerItemMetadataOutputPushDelegate
public func metadataOutput(_: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from _: AVPlayerItemTrack?) {
for metadataGroup in groups {
delegate?.onTimedMetadataChanged(timedMetadata: metadataGroup.items)
}
}
// MARK: - AVPlayer Observers
func onPlayerCurrentItemChanged(player: AVPlayer, change: NSKeyValueObservedChange<AVPlayerItem?>) {
let newPlayerItem = change.newValue?.flatMap { $0 }
// Remove observers for old player item
invalidatePlayerItemObservers()
// Notify delegate about player item state change
delegate?.onPlayerItemWillChange(hasNewPlayerItem: newPlayerItem != nil)
if let playerItem = newPlayerItem {
// Initialize observers for new player item
initializePlayerItemObservers(player: player, playerItem: playerItem)
delegate?.onPlayerItemChange(player: player, playerItem: playerItem)
}
}
// MARK: - AVPlayerItem Observers
func onPlayerAccessLog(playerItem: AVPlayerItem) {
guard let accessLog = playerItem.accessLog() else { return }
guard let lastEvent = accessLog.events.last else { return }
delegate?.onBandwidthUpdate(bitrate: lastEvent.indicatedBitrate)
}
// MARK: - Buffer State Management
func setupBufferObservers(for playerItem: AVPlayerItem) {
clearBufferObservers()
// Observe buffer empty - this indicates definite buffering
playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new, .initial]) { [weak self] playerItem, change in
let isEmpty = change.newValue ?? playerItem.isPlaybackBufferEmpty
if isEmpty {
self?.delegate?.onPlaybackBufferEmpty()
}
}
// Observe likely to keep up - this indicates that buffering has finished
playbackLikelyToKeepUpObserver = playerItem.observe(\.isPlaybackLikelyToKeepUp, options: [.new, .initial]) { [weak self] playerItem, change in
let isLikelyToKeepUp = change.newValue ?? playerItem.isPlaybackLikelyToKeepUp
if isLikelyToKeepUp {
self?.delegate?.onPlaybackLikelyToKeepUp()
}
}
// Observe buffer full as an additional signal
playbackBufferFullObserver = playerItem.observe(\.isPlaybackBufferFull, options: [.new, .initial]) { [weak self] playerItem, change in
let isFull = change.newValue ?? playerItem.isPlaybackBufferFull
if isFull {
self?.delegate?.onPlaybackLikelyToKeepUp()
}
}
}
func clearBufferObservers() {
playbackBufferEmptyObserver?.invalidate()
playbackBufferFullObserver?.invalidate()
playbackLikelyToKeepUpObserver?.invalidate()
playbackBufferEmptyObserver = nil
playbackBufferFullObserver = nil
playbackLikelyToKeepUpObserver = nil
}
}

View File

@@ -0,0 +1,184 @@
//
// HybridVideoPlayer+Events.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 02/05/2025.
//
import Foundation
import AVFoundation
extension HybridVideoPlayer: VideoPlayerObserverDelegate {
// MARK: - VideoPlayerObserverDelegate
func onPlayedToEnd(player: AVPlayer) {
eventEmitter.onEnd()
if loop {
currentTime = 0
try? play()
}
}
func onRateChanged(rate: Float) {
eventEmitter.onPlaybackRateChange(Double(rate))
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
updateAndEmitPlaybackState()
}
func onVolumeChanged(volume: Float) {
eventEmitter.onVolumeChange(onVolumeChangeData(
volume: Double(volume),
muted: muted
))
}
func onPlaybackBufferEmpty() {
isCurrentlyBuffering = true
status = .loading
updateAndEmitPlaybackState()
}
func onProgressUpdate(currentTime: Double, bufferDuration: Double) {
eventEmitter.onProgress(.init(currentTime: currentTime, bufferDuration: bufferDuration))
}
func onPlaybackLikelyToKeepUp() {
isCurrentlyBuffering = false
if player.timeControlStatus == .playing {
status = .readytoplay
}
updateAndEmitPlaybackState()
}
func onExternalPlaybackActiveChanged(isActive: Bool) {
eventEmitter.onExternalPlaybackChange(isActive)
}
func onTimeControlStatusChanged(status: AVPlayer.TimeControlStatus) {
if player.status == .failed || playerItem?.status == .failed {
self.status = .error
isCurrentlyBuffering = false
eventEmitter.onPlaybackStateChange(.init(isPlaying: false, isBuffering: false))
return
}
switch status {
case .waitingToPlayAtSpecifiedRate:
isCurrentlyBuffering = true
self.status = .loading
break
case .playing:
isCurrentlyBuffering = false
self.status = .readytoplay
break
case .paused:
isCurrentlyBuffering = false
self.status = .readytoplay
break
@unknown default:
break
}
updateAndEmitPlaybackState()
}
func onPlayerStatusChanged(status: AVPlayer.Status) {
if status == .failed || playerItem?.status == .failed {
self.status = .error
isCurrentlyBuffering = false
updateAndEmitPlaybackState()
}
}
func onPlayerItemStatusChanged(status: AVPlayerItem.Status) {
if status == .failed {
self.status = .error
isCurrentlyBuffering = false
updateAndEmitPlaybackState()
return
}
switch status {
case .unknown:
isCurrentlyBuffering = true
self.status = .loading
// Set initial buffering state when we have a playerItem
if let playerItem = self.playerItem {
if playerItem.isPlaybackBufferEmpty {
isCurrentlyBuffering = true
}
}
case .readyToPlay:
guard let playerItem else { return }
let height = playerItem.presentationSize.height
let width = playerItem.presentationSize.width
let orientation: VideoOrientation = playerItem.asset.tracks.first(where: { $0.mediaType == .video })?.orientation ?? .unknown
eventEmitter.onLoad(
.init(currentTime, duration, height, width, orientation)
)
if playerItem.isPlaybackLikelyToKeepUp && !playerItem.isPlaybackBufferEmpty {
isCurrentlyBuffering = false
self.status = .readytoplay
}
case .failed:
self.status = .error
isCurrentlyBuffering = false
@unknown default:
break
}
updateAndEmitPlaybackState()
}
func onTextTrackDataChanged(texts: [NSAttributedString]) {
eventEmitter.onTextTrackDataChanged(texts.map { $0.string })
}
func onTimedMetadataChanged(timedMetadata: [AVMetadataItem]) {
var metadata: [TimedMetadataObject] = []
for item in timedMetadata {
let value = item.value as? String
let identifier = item.identifier?.rawValue
if let value, let identifier {
metadata.append(.init(value: value, identifier: identifier))
}
}
eventEmitter.onTimedMetadata(.init(metadata: metadata))
}
func onBandwidthUpdate(bitrate: Double) {
eventEmitter.onBandwidthUpdate(.init(bitrate: bitrate, width: nil, height: nil))
}
func onPlayerItemWillChange(hasNewPlayerItem: Bool) {
if hasNewPlayerItem {
// Set initial buffering state when playerItem is assigned
isCurrentlyBuffering = true
status = .loading
updateAndEmitPlaybackState()
} else {
// Clean up state when playerItem is cleared
isCurrentlyBuffering = false
}
}
func updateAndEmitPlaybackState() {
let isPlaying = player.rate > 0 && !isCurrentlyBuffering
eventEmitter.onPlaybackStateChange(.init(isPlaying: isPlaying, isBuffering: isCurrentlyBuffering))
eventEmitter.onBuffer(isCurrentlyBuffering)
}
}

View File

@@ -0,0 +1,485 @@
//
// HybridVideoPlayer.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 09/10/2024.
//
import AVFoundation
import Foundation
import NitroModules
class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec {
/**
* Player instance for video playback
*/
var player: AVPlayer {
didSet {
playerObserver?.initializePlayerObservers()
}
willSet {
playerObserver?.invalidatePlayerObservers()
}
}
var playerItem: AVPlayerItem? {
didSet {
if let bufferConfig = source.config.bufferConfig {
playerItem?.setBufferConfig(config: bufferConfig)
}
}
}
var playerObserver: VideoPlayerObserver?
init(source: (any HybridVideoPlayerSourceSpec)) throws {
self.source = source
self.eventEmitter = HybridVideoPlayerEventEmitter()
// Initialize AVPlayer with empty item
self.player = AVPlayer()
super.init()
self.playerObserver = VideoPlayerObserver(delegate: self)
self.playerObserver?.initializePlayerObservers()
Task {
if source.config.initializeOnCreation == true {
self.playerItem = try await initializePlayerItem()
self.player.replaceCurrentItem(with: self.playerItem)
}
}
VideoManager.shared.register(player: self)
}
deinit {
release()
}
// MARK: - Hybrid Impl
var source: any HybridVideoPlayerSourceSpec
var status: VideoPlayerStatus = .idle {
didSet {
if status != oldValue {
eventEmitter.onStatusChange(status)
}
}
}
var eventEmitter: HybridVideoPlayerEventEmitterSpec
var volume: Double {
set {
player.volume = Float(newValue)
}
get {
return Double(player.volume)
}
}
var muted: Bool {
set {
player.isMuted = newValue
eventEmitter.onVolumeChange(
onVolumeChangeData(
volume: Double(player.volume),
muted: muted
)
)
}
get {
return player.isMuted
}
}
var currentTime: Double {
set {
eventEmitter.onSeek(newValue)
player.seek(
to: CMTime(seconds: newValue, preferredTimescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
get {
player.currentTime().seconds
}
}
var duration: Double {
Double(player.currentItem?.duration.seconds ?? Double.nan)
}
var rate: Double {
set {
if #available(iOS 16.0, tvOS 16.0, *) {
player.defaultRate = Float(newValue)
}
player.rate = Float(newValue)
}
get {
return Double(player.rate)
}
}
var loop: Bool = false
var mixAudioMode: MixAudioMode = .auto {
didSet {
VideoManager.shared.requestAudioSessionUpdate()
}
}
var ignoreSilentSwitchMode: IgnoreSilentSwitchMode = .auto {
didSet {
VideoManager.shared.requestAudioSessionUpdate()
}
}
var playInBackground: Bool = false {
didSet {
VideoManager.shared.requestAudioSessionUpdate()
}
}
var playWhenInactive: Bool = false
var wasAutoPaused: Bool = false
// Text track selection state
private var selectedExternalTrackIndex: Int? = nil
var isCurrentlyBuffering: Bool = false
var isPlaying: Bool {
return player.rate != 0
}
var showNotificationControls: Bool = false {
didSet {
if showNotificationControls {
NowPlayingInfoCenterManager.shared.registerPlayer(player: player)
} else {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
}
}
func initialize() throws -> Promise<Void> {
return Promise.async { [weak self] in
guard let self else {
throw LibraryError.deallocated(objectName: "HybridVideoPlayer").error()
}
if self.playerItem != nil {
return
}
self.playerItem = try await self.initializePlayerItem()
self.player.replaceCurrentItem(with: self.playerItem)
}
}
func release() {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
self.player.replaceCurrentItem(with: nil)
self.playerItem = nil
if let source = self.source as? HybridVideoPlayerSource {
source.releaseAsset()
}
// Clear player observer
self.playerObserver = nil
status = .idle
VideoManager.shared.unregister(player: self)
}
func preload() throws -> NitroModules.Promise<Void> {
let promise = Promise<Void>()
if status != .idle {
promise.resolve(withResult: ())
return promise
}
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else {
promise.reject(
withError: LibraryError.deallocated(objectName: "HybridVideoPlayer")
.error()
)
return
}
do {
let playerItem = try await self.initializePlayerItem()
self.playerItem = playerItem
self.player.replaceCurrentItem(with: playerItem)
promise.resolve(withResult: ())
} catch {
promise.reject(withError: error)
}
}
return promise
}
func play() throws {
player.play()
}
func pause() throws {
player.pause()
}
func seekBy(time: Double) throws {
guard let currentItem = player.currentItem else {
throw PlayerError.notInitialized.error()
}
let currentItemTime = currentItem.currentTime()
// Duration is NaN for live streams
let fixedDurration = duration.isNaN ? Double.infinity : duration
// Clap by <0, duration>
let newTime = max(0, min(currentItemTime.seconds + time, fixedDurration))
currentTime = newTime
}
func seekTo(time: Double) {
currentTime = time
}
func replaceSourceAsync(source: (any HybridVideoPlayerSourceSpec)?) throws
-> Promise<Void>
{
let promise = Promise<Void>()
guard let source else {
release()
promise.resolve(withResult: ())
return promise
}
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else {
promise.reject(
withError: LibraryError.deallocated(objectName: "HybridVideoPlayer")
.error()
)
return
}
self.source = source
self.playerItem = try await self.initializePlayerItem()
self.player.replaceCurrentItem(with: self.playerItem)
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
promise.resolve(withResult: ())
}
return promise
}
// MARK: - Methods
func initializePlayerItem() async throws -> AVPlayerItem {
// Ensure the source is a valid HybridVideoPlayerSource
guard let _hybridSource = source as? HybridVideoPlayerSource else {
status = .error
throw PlayerError.invalidSource.error()
}
// (maybe) Override source with plugins
let _source = await PluginsRegistry.shared.overrideSource(
source: _hybridSource
)
let isNetworkSource = _source.url.isFileURL == false
eventEmitter.onLoadStart(
.init(sourceType: isNetworkSource ? .network : .local, source: _source)
)
let asset = try await _source.getAsset()
let playerItem: AVPlayerItem
if let externalSubtitles = source.config.externalSubtitles,
externalSubtitles.isEmpty == false
{
playerItem = try await AVPlayerItem.withExternalSubtitles(
for: asset,
config: source.config
)
} else {
playerItem = AVPlayerItem(asset: asset)
}
return playerItem
}
// MARK: - Text Track Management
func getAvailableTextTracks() throws -> [TextTrack] {
guard let currentItem = player.currentItem else {
return []
}
var tracks: [TextTrack] = []
if let mediaSelection = currentItem.asset.mediaSelectionGroup(
forMediaCharacteristic: .legible
) {
for (index, option) in mediaSelection.options.enumerated() {
let isSelected =
currentItem.currentMediaSelection.selectedMediaOption(
in: mediaSelection
) == option
let name =
option.commonMetadata.first(where: { $0.commonKey == .commonKeyTitle }
)?.stringValue
?? option.displayName
let isExternal =
source.config.externalSubtitles?.contains { subtitle in
name.contains(subtitle.label)
} ?? false
let trackId =
isExternal
? "external-\(index)"
: "builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")"
tracks.append(
TextTrack(
id: trackId,
label: option.displayName,
language: option.locale?.identifier,
selected: isSelected
)
)
}
}
return tracks
}
func selectTextTrack(textTrack: TextTrack?) throws {
guard let currentItem = player.currentItem else {
throw PlayerError.notInitialized.error()
}
guard
let mediaSelection = currentItem.asset.mediaSelectionGroup(
forMediaCharacteristic: .legible
)
else {
return
}
// If textTrack is nil, deselect any selected track
guard let textTrack = textTrack else {
currentItem.select(nil, in: mediaSelection)
selectedExternalTrackIndex = nil
eventEmitter.onTrackChange(nil)
return
}
// If textTrack id is empty, deselect any selected track
if textTrack.id.isEmpty {
currentItem.select(nil, in: mediaSelection)
selectedExternalTrackIndex = nil
eventEmitter.onTrackChange(nil)
return
}
if textTrack.id.hasPrefix("external-") {
let trackIndexStr = String(textTrack.id.dropFirst("external-".count))
if let trackIndex = Int(trackIndexStr),
trackIndex < mediaSelection.options.count
{
let option = mediaSelection.options[trackIndex]
currentItem.select(option, in: mediaSelection)
selectedExternalTrackIndex = trackIndex
eventEmitter.onTrackChange(textTrack)
}
} else if textTrack.id.hasPrefix("builtin-") {
for option in mediaSelection.options {
let optionId =
"builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")"
if optionId == textTrack.id {
currentItem.select(option, in: mediaSelection)
selectedExternalTrackIndex = nil
eventEmitter.onTrackChange(textTrack)
return
}
}
}
}
var selectedTrack: TextTrack? {
guard let currentItem = player.currentItem else {
return nil
}
guard
let mediaSelection = currentItem.asset.mediaSelectionGroup(
forMediaCharacteristic: .legible
)
else {
return nil
}
guard
let selectedOption = currentItem.currentMediaSelection
.selectedMediaOption(in: mediaSelection)
else {
return nil
}
guard let index = mediaSelection.options.firstIndex(of: selectedOption)
else {
return nil
}
let isExternal =
source.config.externalSubtitles?.contains { subtitle in
selectedOption.displayName.contains(subtitle.label)
} ?? false
let trackId =
isExternal
? "external-\(index)"
: "builtin-\(selectedOption.displayName)-\(selectedOption.locale?.identifier ?? "unknown")"
return TextTrack(
id: trackId,
label: selectedOption.displayName,
language: selectedOption.locale?.identifier,
selected: true
)
}
// MARK: - Memory Management
func dispose() {
release()
}
var memorySize: Int {
var size = 0
size += source.memorySize
size += playerItem?.asset.estimatedMemoryUsage ?? 0
return size
}
}

View File

@@ -0,0 +1,15 @@
//
// HybridVideoPlayerFactory.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 09/10/2024.
//
import Foundation
import NitroModules
class HybridVideoPlayerFactory: HybridVideoPlayerFactorySpec {
func createPlayer(source: HybridVideoPlayerSourceSpec) throws -> HybridVideoPlayerSpec {
return try HybridVideoPlayer(source: source)
}
}

View File

@@ -0,0 +1,49 @@
//
// HybridVideoPlayerEventEmitter.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 02/05/2025.
//
import Foundation
import NitroModules
class HybridVideoPlayerEventEmitter: HybridVideoPlayerEventEmitterSpec {
var onAudioBecomingNoisy: (() -> Void) = {}
var onAudioFocusChange: ((Bool) -> Void) = { _ in }
var onBandwidthUpdate: ((BandwidthData) -> Void) = { _ in }
var onBuffer: ((Bool) -> Void) = { _ in }
var onControlsVisibleChange: ((Bool) -> Void) = { _ in }
var onEnd: (() -> Void) = {}
var onExternalPlaybackChange: ((Bool) -> Void) = { _ in }
var onLoad: ((onLoadData) -> Void) = { _ in }
var onLoadStart: ((onLoadStartData) -> Void) = { _ in }
var onPlaybackStateChange: ((onPlaybackStateChangeData) -> Void) = { _ in }
var onPlaybackRateChange: ((Double) -> Void) = { _ in }
var onProgress: ((onProgressData) -> Void) = { _ in }
var onReadyToDisplay: (() -> Void) = {}
var onSeek: ((Double) -> Void) = { _ in }
var onStatusChange: (VideoPlayerStatus) -> Void = { _ in }
var onTimedMetadata: ((TimedMetadata) -> Void) = { _ in }
var onTextTrackDataChanged: ([String]) -> Void = { _ in }
var onTrackChange: ((TextTrack?) -> Void) = { _ in }
var onVolumeChange: ((onVolumeChangeData) -> Void) = { _ in }
}

View File

@@ -0,0 +1,137 @@
//
// HybridVideoPlayerSource.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/09/2024.
//
import Foundation
import AVFoundation
import NitroModules
class HybridVideoPlayerSource: HybridVideoPlayerSourceSpec, NativeVideoPlayerSourceSpec {
var asset: AVURLAsset?
var uri: String
var config: NativeVideoConfig
var drmManager: DRMManagerSpec?
let url: URL
init(config: NativeVideoConfig) throws {
self.uri = config.uri
self.config = config
guard let url = URL(string: uri) else {
throw SourceError.invalidUri(uri: uri).error()
}
self.url = url
super.init()
if config.drm != nil {
// Try to get the DRM manager
// If no DRM manager is found, it will throw an error
_ = try PluginsRegistry.shared.getDrmManager(source: self)
}
}
deinit {
releaseAsset()
}
func getAssetInformationAsync() -> Promise<VideoInformation> {
let promise = Promise<VideoInformation>()
Task.detached(priority: .utility) { [weak self] in
guard let self else {
promise.reject(withError: LibraryError.deallocated(objectName: "HybridVideoPlayerSource").error())
return
}
do {
if self.url.isFileURL {
try VideoFileHelper.validateReadPermission(for: self.url)
}
try await self.initializeAsset()
guard let asset = self.asset else {
throw PlayerError.assetNotInitialized.error()
}
let videoInformation = try await asset.getAssetInformation()
promise.resolve(withResult: videoInformation)
} catch {
promise.reject(withError: error)
}
}
return promise
}
func initializeAsset() async throws {
guard asset == nil else {
return
}
if let headers = config.headers {
let options = [
"AVURLAssetHTTPHeaderFieldsKey": headers
]
asset = AVURLAsset(url: url, options: options)
} else {
asset = AVURLAsset(url: url)
}
guard let asset else {
throw SourceError.failedToInitializeAsset.error()
}
if let drmParams = config.drm {
drmManager = try PluginsRegistry.shared.getDrmManager(source: self)
guard let drmManager else {
throw LibraryError.DRMPluginNotFound.error()
}
do {
try drmManager.createContentKeyRequest(for: asset, drmParams: drmParams)
} catch {
print("[ReactNativeVideo] Failed to create content key request for DRM: \(drmParams)")
}
}
// Code browned from expo-video https://github.com/expo/expo/blob/ea17c9b1ce5111e1454b089ba381f3feb93f33cc/packages/expo-video/ios/VideoPlayerItem.swift#L40C30-L40C73
// If we don't load those properties, they will be loaded on main thread causing lags
_ = try? await asset.load(.duration, .preferredTransform, .isPlayable) as Any
}
func getAsset() async throws -> AVURLAsset {
if let asset {
return asset
}
try await initializeAsset()
guard let asset else {
throw SourceError.failedToInitializeAsset.error()
}
return asset
}
func releaseAsset() {
asset = nil
}
var memorySize: Int {
var size = 0
size += asset?.estimatedMemoryUsage ?? 0
return size
}
}

View File

@@ -0,0 +1,29 @@
//
// HybridVideoPlayerSourceFactory.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/09/2024.
//
import Foundation
class HybridVideoPlayerSourceFactory: HybridVideoPlayerSourceFactorySpec {
func fromVideoConfig(config: NativeVideoConfig) throws
-> any HybridVideoPlayerSourceSpec
{
return try HybridVideoPlayerSource(config: config)
}
func fromUri(uri: String) throws -> HybridVideoPlayerSourceSpec {
let config = NativeVideoConfig(
uri: uri,
externalSubtitles: nil,
drm: nil,
headers: nil,
bufferConfig: nil,
metadata: nil,
initializeOnCreation: true
)
return try HybridVideoPlayerSource(config: config)
}
}

View File

@@ -0,0 +1,195 @@
//
// HybridVideoViewViewManager.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/09/2024.
//
import Foundation
import AVKit
import NitroModules
class HybridVideoViewViewManager: HybridVideoViewViewManagerSpec {
weak var view: VideoComponentView?
let DEALOCATED_WARNING = "ReactNativeVideo: VideoComponentView is no longer available. It is likely that the view was deallocated."
init(nitroId: Double) throws {
guard let view = VideoComponentView.globalViewsMap.object(forKey: NSNumber(value: nitroId)) else {
throw VideoViewError.viewNotFound(nitroId: nitroId).error()
}
self.view = view
super.init()
view.delegate = VideoViewDelegate(viewManager: self)
}
// MARK: - Properties
weak var player: (any HybridVideoPlayerSpec)? {
get {
guard let view = view else {
print(DEALOCATED_WARNING)
return nil
}
return view.player
}
set {
guard let view = view else {
print(DEALOCATED_WARNING)
return
}
view.player = newValue
}
}
var controls: Bool {
get {
guard let view else {
print(DEALOCATED_WARNING)
return false
}
return view.controls
}
set {
guard let view else {
print(DEALOCATED_WARNING)
return
}
view.controls = newValue
}
}
var pictureInPicture: Bool {
get {
guard let view else {
print(DEALOCATED_WARNING)
return false
}
return view.allowsPictureInPicturePlayback
}
set {
guard let view else {
print(DEALOCATED_WARNING)
return
}
view.allowsPictureInPicturePlayback = newValue
}
}
var autoEnterPictureInPicture: Bool {
get {
guard let view else {
print(DEALOCATED_WARNING)
return false
}
return view.autoEnterPictureInPicture
}
set {
guard let view else {
print(DEALOCATED_WARNING)
return
}
view.autoEnterPictureInPicture = newValue
}
}
var resizeMode: ResizeMode {
get {
guard let view else {
print(DEALOCATED_WARNING)
return .none
}
return view.resizeMode
}
set {
guard let view else {
print(DEALOCATED_WARNING)
return
}
view.resizeMode = newValue
}
}
var keepScreenAwake: Bool {
get {
guard let view else {
print(DEALOCATED_WARNING)
return false
}
return view.keepScreenAwake
}
set {
guard let view else {
print(DEALOCATED_WARNING)
return
}
view.keepScreenAwake = newValue
}
}
// Android only - no-op on iOS
var surfaceType: SurfaceType = .surface
func enterFullscreen() throws {
guard let view else {
throw VideoViewError.viewIsDeallocated.error()
}
try view.enterFullscreen()
}
func exitFullscreen() throws {
guard let view else {
throw VideoViewError.viewIsDeallocated.error()
}
try view.exitFullscreen()
}
func enterPictureInPicture() throws {
guard let view else {
throw VideoViewError.viewIsDeallocated.error()
}
try view.startPictureInPicture()
}
func exitPictureInPicture() throws {
guard let view else {
throw VideoViewError.viewIsDeallocated.error()
}
try view.stopPictureInPicture()
}
func canEnterPictureInPicture() -> Bool {
return AVPictureInPictureController.isPictureInPictureSupported()
}
// MARK: - Callbacks
var onPictureInPictureChange: ((Bool) -> Void)?
var onFullscreenChange: ((Bool) -> Void)?
var willEnterFullscreen: (() -> Void)?
var willExitFullscreen: (() -> Void)?
var willEnterPictureInPicture: (() -> Void)?
var willExitPictureInPicture: (() -> Void)?
var onReadyToDisplay: (() -> Void)?
}

View File

@@ -0,0 +1,14 @@
//
// HybridVideoViewViewManagerFactory.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 23/09/2024.
//
import Foundation
class HybridVideoViewViewManagerFactory: HybridVideoViewViewManagerFactorySpec {
func createViewManager(nitroId: Double) throws -> any HybridVideoViewViewManagerSpec {
return try HybridVideoViewViewManager(nitroId: nitroId)
}
}

View File

@@ -0,0 +1,261 @@
//
// VideoComponent.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 30/09/2024.
//
import Foundation
import UIKit
import AVFoundation
import AVKit
@objc public class VideoComponentView: UIView {
public weak var player: HybridVideoPlayerSpec? = nil {
didSet {
guard let player = player as? HybridVideoPlayer else { return }
configureAVPlayerViewController(with: player.player)
}
}
var delegate: VideoViewDelegate?
private var playerView: UIView? = nil
private var observer: VideoComponentViewObserver? {
didSet {
playerViewController?.delegate = observer
observer?.updatePlayerViewControllerObservers()
}
}
private var _keepScreenAwake: Bool = false
var keepScreenAwake: Bool {
get {
guard let player = player as? HybridVideoPlayer else { return false }
return player.player.preventsDisplaySleepDuringVideoPlayback
}
set {
guard let player = player as? HybridVideoPlayer else { return }
player.player.preventsDisplaySleepDuringVideoPlayback = newValue
_keepScreenAwake = newValue
}
}
var playerViewController: AVPlayerViewController? {
didSet {
guard let observer, let playerViewController else { return }
playerViewController.delegate = observer
observer.updatePlayerViewControllerObservers()
}
}
public var controls: Bool = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self, let playerViewController = self.playerViewController else { return }
playerViewController.showsPlaybackControls = self.controls
}
}
}
public var allowsPictureInPicturePlayback: Bool = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self, let playerViewController = self.playerViewController else { return }
VideoManager.shared.requestAudioSessionUpdate()
playerViewController.allowsPictureInPicturePlayback = self.allowsPictureInPicturePlayback
}
}
}
public var autoEnterPictureInPicture: Bool = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self, let playerViewController = self.playerViewController else { return }
VideoManager.shared.requestAudioSessionUpdate()
playerViewController.canStartPictureInPictureAutomaticallyFromInline = self.allowsPictureInPicturePlayback
}
}
}
public var resizeMode: ResizeMode = .none {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self, let playerViewController = self.playerViewController else { return }
playerViewController.videoGravity = resizeMode.toVideoGravity()
}
}
}
@objc public var nitroId: NSNumber = -1 {
didSet {
VideoComponentView.globalViewsMap.setObject(self, forKey: nitroId)
}
}
@objc public static var globalViewsMap: NSMapTable<NSNumber, VideoComponentView> = .strongToWeakObjects()
@objc public override init(frame: CGRect) {
super.init(frame: frame)
VideoManager.shared.register(view: self)
setupPlayerView()
observer = VideoComponentViewObserver(view: self)
}
deinit {
VideoManager.shared.unregister(view: self)
}
@objc public required init?(coder: NSCoder) {
super.init(coder: coder)
setupPlayerView()
}
func setNitroId(nitroId: NSNumber) -> Void {
self.nitroId = nitroId
}
private func setupPlayerView() {
// Create a UIView to hold the video player layer
playerView = UIView(frame: self.bounds)
playerView?.translatesAutoresizingMaskIntoConstraints = false
if let playerView = playerView {
addSubview(playerView)
NSLayoutConstraint.activate([
playerView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
playerView.topAnchor.constraint(equalTo: self.topAnchor),
playerView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
}
}
public func configureAVPlayerViewController(with player: AVPlayer) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let playerView = self.playerView else { return }
// Remove previous controller if any
self.playerViewController?.willMove(toParent: nil)
self.playerViewController?.view.removeFromSuperview()
self.playerViewController?.removeFromParent()
let controller = AVPlayerViewController()
controller.player = player
controller.showsPlaybackControls = controls
controller.videoGravity = self.resizeMode.toVideoGravity()
controller.view.frame = playerView.bounds
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
controller.view.backgroundColor = .clear
// We manage this manually in NowPlayingInfoCenterManager
controller.updatesNowPlayingInfoCenter = false
if #available(iOS 16.0, *) {
if let initialSpeed = controller.speeds.first(where: { $0.rate == player.rate }) {
controller.selectSpeed(initialSpeed)
}
}
// Find nearest UIViewController
if let parentVC = self.findViewController() {
parentVC.addChild(controller)
playerView.addSubview(controller.view)
controller.didMove(toParent: parentVC)
self.playerViewController = controller
}
}
}
// Helper to find nearest UIViewController
private func findViewController() -> UIViewController? {
var responder: UIResponder? = self
while let r = responder {
if let vc = r as? UIViewController {
return vc
}
responder = r.next
}
return nil
}
public override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
if newSuperview == nil {
PluginsRegistry.shared.notifyVideoViewDestroyed(view: self)
// We want to disable this when view is about to unmount
if keepScreenAwake {
keepScreenAwake = false
}
} else {
PluginsRegistry.shared.notifyVideoViewCreated(view: self)
// We want to restore keepScreenAwake after component remount
if _keepScreenAwake {
keepScreenAwake = true
}
}
}
public override func layoutSubviews() {
super.layoutSubviews()
// Update the frame of the playerViewController's view when the view's layout changes
playerViewController?.view.frame = playerView?.bounds ?? .zero
playerViewController?.contentOverlayView?.frame = playerView?.bounds ?? .zero
for subview in playerViewController?.contentOverlayView?.subviews ?? [] {
subview.frame = playerView?.bounds ?? .zero
}
}
public func enterFullscreen() throws {
guard let playerViewController else {
throw VideoViewError.viewIsDeallocated.error()
}
DispatchQueue.main.async {
playerViewController.enterFullscreen(animated: true)
}
}
public func exitFullscreen() throws {
guard let playerViewController else {
throw VideoViewError.viewIsDeallocated.error()
}
DispatchQueue.main.async {
playerViewController.exitFullscreen(animated: true)
}
}
public func startPictureInPicture() throws {
guard let playerViewController else {
throw VideoViewError.viewIsDeallocated.error()
}
guard AVPictureInPictureController.isPictureInPictureSupported() else {
throw VideoViewError.pictureInPictureNotSupported.error()
}
DispatchQueue.main.async {
// Here we skip error handling for simplicity
// We do check for PiP support earlier in the code
try? playerViewController.startPictureInPicture()
}
}
public func stopPictureInPicture() throws {
guard let playerViewController else {
throw VideoViewError.viewIsDeallocated.error()
}
DispatchQueue.main.async {
// Here we skip error handling for simplicity
// We do check for PiP support earlier in the code
playerViewController.stopPictureInPicture()
}
}
}

View File

@@ -0,0 +1,142 @@
//
// VideoComponentViewObserver.swift
// ReactNativeVideo
//
// Created by Krzysztof Moch on 06/05/2025.
//
import Foundation
import AVKit
import AVFoundation
protocol VideoComponentViewDelegate: AnyObject {
func onPictureInPictureChange(_ isActive: Bool)
func onFullscreenChange(_ isActive: Bool)
func willEnterFullscreen()
func willExitFullscreen()
func willEnterPictureInPicture()
func willExitPictureInPicture()
func onReadyToDisplay()
}
// Map delegate methods to view manager methods
final class VideoViewDelegate: NSObject, VideoComponentViewDelegate {
weak var viewManager: HybridVideoViewViewManager?
init(viewManager: HybridVideoViewViewManager) {
self.viewManager = viewManager
}
func onPictureInPictureChange(_ isActive: Bool) {
viewManager?.onPictureInPictureChange?(isActive)
}
func onFullscreenChange(_ isActive: Bool) {
viewManager?.onFullscreenChange?(isActive)
}
func willEnterFullscreen() {
viewManager?.willEnterFullscreen?()
}
func willExitFullscreen() {
viewManager?.willExitFullscreen?()
}
func willEnterPictureInPicture() {
viewManager?.willEnterPictureInPicture?()
}
func willExitPictureInPicture() {
viewManager?.willExitPictureInPicture?()
}
func onReadyToDisplay() {
viewManager?.player?.eventEmitter.onReadyToDisplay()
}
}
class VideoComponentViewObserver: NSObject, AVPlayerViewControllerDelegate {
private weak var view: VideoComponentView?
var delegate: VideoViewDelegate? {
get {
return view?.delegate
}
}
var playerViewController: AVPlayerViewController? {
return view?.playerViewController
}
// playerViewController observers
var onReadyToDisplayObserver: NSKeyValueObservation?
init(view: VideoComponentView) {
self.view = view
super.init()
}
func initializePlayerViewContorollerObservers() {
guard let playerViewController = playerViewController else {
return
}
onReadyToDisplayObserver = playerViewController.observe(\.isReadyForDisplay, options: [.new]) { [weak self] _, change in
guard let self = self else { return }
if change.newValue == true {
self.delegate?.onReadyToDisplay()
}
}
}
func removePlayerViewControllerObservers() {
onReadyToDisplayObserver?.invalidate()
onReadyToDisplayObserver = nil
}
func updatePlayerViewControllerObservers() {
removePlayerViewControllerObservers()
initializePlayerViewContorollerObservers()
}
func playerViewControllerDidStartPictureInPicture(_: AVPlayerViewController) {
delegate?.onPictureInPictureChange(true)
}
func playerViewControllerDidStopPictureInPicture(_: AVPlayerViewController) {
delegate?.onPictureInPictureChange(false)
}
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
delegate?.willEnterPictureInPicture()
}
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
delegate?.willExitPictureInPicture()
}
func playerViewController(
_: AVPlayerViewController,
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
) {
delegate?.willExitFullscreen()
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self = self else { return }
self.delegate?.onFullscreenChange(false)
}
}
func playerViewController(
_: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
) {
delegate?.willEnterFullscreen()
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self = self else { return }
self.delegate?.onFullscreenChange(true)
}
}
}

View File

@@ -0,0 +1,10 @@
#import <React/RCTViewComponentView.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface RCTVideoViewComponentView : RCTViewComponentView
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,94 @@
#import "RCTVideoViewComponentView.h"
#import <react/renderer/components/RNCVideoViewSpec/ComponentDescriptors.h>
#import <react/renderer/components/RNCVideoViewSpec/EventEmitters.h>
#import <react/renderer/components/RNCVideoViewSpec/Props.h>
#import <react/renderer/components/RNCVideoViewSpec/RCTComponentViewHelpers.h>
#import "RCTFabricComponentsPlugins.h"
#import "ReactNativeVideo-Swift-Cxx-Umbrella.hpp"
#if __has_include("ReactNativeVideo/ReactNativeVideo-Swift.h")
#import "ReactNativeVideo/ReactNativeVideo-Swift.h"
#else
#import "ReactNativeVideo-Swift.h"
#endif
using namespace facebook::react;
@interface RCTVideoViewComponentView () <RCTRNCVideoViewViewProtocol>
@end
@implementation RCTVideoViewComponentView {
VideoComponentView *_view;
int _nitroId;
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
static const auto defaultProps =
std::make_shared<const RNCVideoViewProps>();
_props = defaultProps;
_view = [[VideoComponentView alloc] initWithFrame:frame];
self.contentView = _view;
}
// -1 means that nitroId wasn't set yet
_nitroId = -1;
return self;
}
- (void)updateProps:(Props::Shared const &)props
oldProps:(Props::Shared const &)oldProps {
const auto &oldViewProps =
*std::static_pointer_cast<RNCVideoViewProps const>(_props);
const auto &newViewProps =
*std::static_pointer_cast<RNCVideoViewProps const>(props);
if (oldViewProps.nitroId != newViewProps.nitroId) {
[self setNitroId:newViewProps.nitroId];
}
[super updateProps:props oldProps:oldProps];
}
- (void)setNitroId:(int)nitroId {
_nitroId = nitroId;
[_view setNitroId:[NSNumber numberWithInt:nitroId]];
[self onNitroIdChange:nitroId];
}
+ (BOOL)shouldBeRecycled
{
return NO;
}
// Event emitter convenience method
- (void)onNitroIdChange:(int)nitroId {
auto eventEmitter =
std::dynamic_pointer_cast<const RNCVideoViewEventEmitter>(_eventEmitter);
if (!eventEmitter || nitroId == -1) {
return;
}
eventEmitter->onNitroIdChange({.nitroId = nitroId});
}
- (void)updateEventEmitter:(EventEmitter::Shared const &)eventEmitter {
[super updateEventEmitter:eventEmitter];
[self onNitroIdChange:_nitroId];
}
+ (ComponentDescriptorProvider)componentDescriptorProvider {
return concreteComponentDescriptorProvider<RNCVideoViewComponentDescriptor>();
}
Class<RCTComponentViewProtocol> RNCVideoViewCls(void) {
return RCTVideoViewComponentView.class;
}
@end

View File

@@ -0,0 +1,14 @@
#import <React/RCTViewManager.h>
#import <React/RCTUIManager.h>
#import "RCTBridge.h"
@interface RCTVideoViewViewManager : RCTViewManager
@end
@implementation RCTVideoViewViewManager
RCT_EXPORT_MODULE(RNCVideoView)
RCT_EXPORT_VIEW_PROPERTY(nitroId, NSNumber)
@end

View File

@@ -0,0 +1,9 @@
#import <React/RCTView.h>
@interface RCTVideoViewComponentView : RCTView
@property (nonatomic, copy) NSNumber *nitroId;
@property (nonatomic, copy) RCTDirectEventBlock onNitroIdChange;
@end

View File

@@ -0,0 +1,45 @@
#import "RCTVideoViewComponentView.h"
#import "ReactNativeVideo-Swift-Cxx-Umbrella.hpp"
#if __has_include("ReactNativeVideo/ReactNativeVideo-Swift.h")
#import "ReactNativeVideo/ReactNativeVideo-Swift.h"
#else
#import "ReactNativeVideo-Swift.h"
#endif
@implementation RCTVideoViewComponentView {
VideoComponentView *_view;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
// Initialize VideoComponentView with the given frame
_view = [[VideoComponentView alloc] initWithFrame:frame];
_view.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_view];
// Set up constraints to make VideoComponentView fill
// RCTVideoViewComponentView
[NSLayoutConstraint activateConstraints:@[
[_view.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[_view.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_view.topAnchor constraintEqualToAnchor:self.topAnchor],
[_view.bottomAnchor constraintEqualToAnchor:self.bottomAnchor]
]];
}
return self;
}
- (void)setNitroId:(NSNumber *)nitroId {
_nitroId = nitroId;
[_view setNitroId:nitroId];
// Emit the onNitroIdChange event when nitroId is updated
if (self.onNitroIdChange) {
self.onNitroIdChange(@{@"nitroId" : nitroId});
}
}
@end

Some files were not shown because too many files have changed in this diff Show More