mirror of
https://github.com/zoriya/react-native-video.git
synced 2025-12-05 23:06:14 +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