mirror of
https://github.com/zoriya/react-native-video.git
synced 2025-12-06 07:16:12 +00:00
.
This commit is contained in:
8
.eslintrc.js
Normal file
8
.eslintrc.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: ["../../config/.eslintrc.js"],
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
1
.watchmanconfig
Normal file
1
.watchmanconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
59
ReactNativeVideo.podspec
Normal file
59
ReactNativeVideo.podspec
Normal 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
29
android/CMakeLists.txt
Normal 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
250
android/build.gradle
Normal 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
51
android/fix-prefab.gradle
Normal 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
13
android/gradle.properties
Normal 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
|
||||||
3
android/src/main/AndroidManifest.xml
Normal file
3
android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
3
android/src/main/AndroidManifestNew.xml
Normal file
3
android/src/main/AndroidManifestNew.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
6
android/src/main/cpp/cpp-adapter.cpp
Normal file
6
android/src/main/cpp/cpp-adapter.cpp
Normal 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);
|
||||||
|
}
|
||||||
233
android/src/main/java/com/twg/video/core/AudioFocusManager.kt
Normal file
233
android/src/main/java/com/twg/video/core/AudioFocusManager.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
95
android/src/main/java/com/twg/video/core/VideoError.kt
Normal file
95
android/src/main/java/com/twg/video/core/VideoError.kt
Normal 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")
|
||||||
284
android/src/main/java/com/twg/video/core/VideoManager.kt
Normal file
284
android/src/main/java/com/twg/video/core/VideoManager.kt
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
android/src/main/java/com/twg/video/core/utils/TextTrackUtils.kt
Normal file
175
android/src/main/java/com/twg/video/core/utils/TextTrackUtils.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
android/src/main/java/com/twg/video/core/utils/Threading.kt
Normal file
75
android/src/main/java/com/twg/video/core/utils/Threading.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 = {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)?
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
26
android/src/main/java/com/twg/video/react/VideoPackage.kt
Normal file
26
android/src/main/java/com/twg/video/react/VideoPackage.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
523
android/src/main/java/com/twg/video/view/VideoView.kt
Normal file
523
android/src/main/java/com/twg/video/view/VideoView.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
android/src/main/res/layout/player_view_surface.xml
Normal file
14
android/src/main/res/layout/player_view_surface.xml
Normal 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" />
|
||||||
15
android/src/main/res/layout/player_view_texture.xml
Normal file
15
android/src/main/res/layout/player_view_texture.xml
Normal 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"
|
||||||
|
/>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package androidx.media3.exoplayer.dash.manifest
|
||||||
|
|
||||||
|
class DashManifest {
|
||||||
|
val periodCount: Int
|
||||||
|
get() = 0
|
||||||
|
|
||||||
|
fun getPeriod(index: Int): Period? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package androidx.media3.exoplayer.dash.manifest
|
||||||
|
|
||||||
|
import androidx.collection.CircularArray
|
||||||
|
|
||||||
|
class Period {
|
||||||
|
var adaptationSets: CircularArray<AdaptationSet?>? = null
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
1
app.plugin.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./lib/commonjs/expo-plugins/withReactNativeVideo');
|
||||||
5
babel.config.js
Normal file
5
babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['module:react-native-builder-bob/babel-preset', { modules: 'commonjs' }],
|
||||||
|
],
|
||||||
|
};
|
||||||
1
ios/Video-Bridging-Header.h
Normal file
1
ios/Video-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import <React/RCTViewManager.h>
|
||||||
40
ios/core/Extensions/AVAsset+estimatedMemoryUsage.swift
Normal file
40
ios/core/Extensions/AVAsset+estimatedMemoryUsage.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ios/core/Extensions/AVAssetTrack+orientation.swift
Normal file
41
ios/core/Extensions/AVAssetTrack+orientation.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ios/core/Extensions/AVPlayerItem+externalSubtitles.swift
Normal file
35
ios/core/Extensions/AVPlayerItem+externalSubtitles.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ios/core/Extensions/AVPlayerItem+getBufferedDurration.swift
Normal file
34
ios/core/Extensions/AVPlayerItem+getBufferedDurration.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
37
ios/core/Extensions/AVPlayerItem+setBufferConfig.swift
Normal file
37
ios/core/Extensions/AVPlayerItem+setBufferConfig.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ios/core/Extensions/AVPlayerViewController+Fullscreen.swift
Normal file
21
ios/core/Extensions/AVPlayerViewController+Fullscreen.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
68
ios/core/Extensions/AVURLAsset+getAssetInformation.swift
Normal file
68
ios/core/Extensions/AVURLAsset+getAssetInformation.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
ios/core/Extensions/NSObject+PerformIfResponds.swift
Normal file
30
ios/core/Extensions/NSObject+PerformIfResponds.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ios/core/Extensions/ResizeMode+VideoGravity.swift
Normal file
24
ios/core/Extensions/ResizeMode+VideoGravity.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
381
ios/core/HLSSubtitleInjector.swift
Normal file
381
ios/core/HLSSubtitleInjector.swift
Normal 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
323
ios/core/NowPlayingInfoCenterManager.swift
Normal file
323
ios/core/NowPlayingInfoCenterManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
102
ios/core/Plugins/PluginsRegistry.swift
Normal file
102
ios/core/Plugins/PluginsRegistry.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
ios/core/Plugins/ReactNativeVideoPlugin.swift
Normal file
91
ios/core/Plugins/ReactNativeVideoPlugin.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
ios/core/Spec/DRMManagerSpec.swift
Normal file
14
ios/core/Spec/DRMManagerSpec.swift
Normal 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
|
||||||
|
}
|
||||||
36
ios/core/Spec/NativeVideoPlayerSourceSpec.swift
Normal file
36
ios/core/Spec/NativeVideoPlayerSourceSpec.swift
Normal 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()
|
||||||
|
}
|
||||||
42
ios/core/Spec/NativeVideoPlayerSpec.swift
Normal file
42
ios/core/Spec/NativeVideoPlayerSpec.swift
Normal 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
|
||||||
|
}
|
||||||
119
ios/core/Utils/ExternalSubtitlesUtils.swift
Normal file
119
ios/core/Utils/ExternalSubtitlesUtils.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
170
ios/core/Utils/HLSManifestParser.swift
Normal file
170
ios/core/Utils/HLSManifestParser.swift
Normal 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
26
ios/core/Utils/Weak.swift
Normal 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
159
ios/core/VideoError.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
48
ios/core/VideoFileHelper.swift
Normal file
48
ios/core/VideoFileHelper.swift
Normal 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
412
ios/core/VideoManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
ios/core/VideoPlayerObserver.swift
Normal file
294
ios/core/VideoPlayerObserver.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
184
ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift
Normal file
184
ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
485
ios/hybrids/VideoPlayer/HybridVideoPlayer.swift
Normal file
485
ios/hybrids/VideoPlayer/HybridVideoPlayer.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ios/hybrids/VideoPlayer/HybridVideoPlayerFactory.swift
Normal file
15
ios/hybrids/VideoPlayer/HybridVideoPlayerFactory.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
137
ios/hybrids/VideoPlayerSource/HybridVideoPlayerSource.swift
Normal file
137
ios/hybrids/VideoPlayerSource/HybridVideoPlayerSource.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
261
ios/view/VideoComponentView.swift
Normal file
261
ios/view/VideoComponentView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
ios/view/VideoComponentViewObserver.swift
Normal file
142
ios/view/VideoComponentViewObserver.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/view/fabric/RCTVideoViewComponentView.h
Normal file
10
ios/view/fabric/RCTVideoViewComponentView.h
Normal 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
|
||||||
94
ios/view/fabric/RCTVideoViewComponentView.mm
Normal file
94
ios/view/fabric/RCTVideoViewComponentView.mm
Normal 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
|
||||||
14
ios/view/fabric/RCTVideoViewViewManager.mm
Normal file
14
ios/view/fabric/RCTVideoViewViewManager.mm
Normal 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
|
||||||
9
ios/view/paper/RCTVideoViewComponentView.h
Normal file
9
ios/view/paper/RCTVideoViewComponentView.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#import <React/RCTView.h>
|
||||||
|
|
||||||
|
@interface RCTVideoViewComponentView : RCTView
|
||||||
|
|
||||||
|
@property (nonatomic, copy) NSNumber *nitroId;
|
||||||
|
@property (nonatomic, copy) RCTDirectEventBlock onNitroIdChange;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
45
ios/view/paper/RCTVideoViewComponentView.mm
Normal file
45
ios/view/paper/RCTVideoViewComponentView.mm
Normal 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
Reference in New Issue
Block a user