Merge remote-tracking branch 'upstream/master'
Some checks failed
Build Android / Build Android Example App (push) Has been cancelled
Build Android / Build Android Example App With Ads (push) Has been cancelled
Build iOS / Build iOS Example App (push) Has been cancelled
Build iOS / Build iOS Example App With Ads (push) Has been cancelled
Build iOS / Build iOS Example App With Caching (push) Has been cancelled
Check Android / Kotlin-Lint (push) Has been cancelled
Check CLang / CLang-Format (push) Has been cancelled
Check iOS / Swift-Lint (push) Has been cancelled
Check iOS / Swift-Format (push) Has been cancelled
Check JS / Check TS (tsc) (push) Has been cancelled
Check JS / Lint JS (eslint, prettier) (push) Has been cancelled
deploy docs / deploy-docs (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
Some checks failed
Build Android / Build Android Example App (push) Has been cancelled
Build Android / Build Android Example App With Ads (push) Has been cancelled
Build iOS / Build iOS Example App (push) Has been cancelled
Build iOS / Build iOS Example App With Ads (push) Has been cancelled
Build iOS / Build iOS Example App With Caching (push) Has been cancelled
Check Android / Kotlin-Lint (push) Has been cancelled
Check CLang / CLang-Format (push) Has been cancelled
Check iOS / Swift-Lint (push) Has been cancelled
Check iOS / Swift-Format (push) Has been cancelled
Check JS / Check TS (tsc) (push) Has been cancelled
Check JS / Lint JS (eslint, prettier) (push) Has been cancelled
deploy docs / deploy-docs (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
This commit is contained in:
@@ -91,9 +91,10 @@ def configStringPath = ExoplayerDependencies
|
||||
.concat("buildFromSource:$media3_buildFromSource")
|
||||
.md5()
|
||||
|
||||
if (isNewArchitectureEnabled()) {
|
||||
apply plugin: "com.facebook.react"
|
||||
}
|
||||
// commented as new architecture not yet fully supported
|
||||
// if (isNewArchitectureEnabled()) {
|
||||
// apply plugin: "com.facebook.react"
|
||||
// }
|
||||
|
||||
android {
|
||||
if (supportsNamespace()) {
|
||||
@@ -216,7 +217,7 @@ dependencies {
|
||||
//noinspection GradleDynamicVersion
|
||||
implementation "com.facebook.react:react-native:+"
|
||||
|
||||
implementation "androidx.core:core:$androidxCore_version"
|
||||
implementation "androidx.core:core-ktx:$androidxCore_version"
|
||||
implementation "androidx.activity:activity-ktx:$androidxActivity_version"
|
||||
|
||||
// For media playback using ExoPlayer
|
||||
|
||||
@@ -4,12 +4,12 @@ RNVideo_targetSdkVersion=34
|
||||
RNVideo_compileSdkVersion=34
|
||||
RNVideo_ndkversion=26.1.10909125
|
||||
RNVideo_buildToolsVersion=34.0.0
|
||||
RNVideo_media3Version=1.3.1
|
||||
RNVideo_media3Version=1.4.1
|
||||
RNVideo_useExoplayerIMA=false
|
||||
RNVideo_useExoplayerRtsp=false
|
||||
RNVideo_useExoplayerSmoothStreaming=true
|
||||
RNVideo_useExoplayerDash=true
|
||||
RNVideo_useExoplayerHls=true
|
||||
RNVideo_androidxCoreVersion=1.9.0
|
||||
RNVideo_androidxActivityVersion=1.7.0
|
||||
RNVideo_androidxCoreVersion=1.13.1
|
||||
RNVideo_androidxActivityVersion=1.9.3
|
||||
RNVideo_buildFromMedia3Source=false
|
||||
|
||||
@@ -7,4 +7,4 @@ public class DefaultDashChunkSource {
|
||||
public Factory(DataSource.Factory mediaDataSourceFactory) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,17 @@ import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.source.ads.AdsLoader;
|
||||
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
|
||||
|
||||
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ImaAdsLoader implements AdsLoader {
|
||||
private final ImaSdkSettings imaSdkSettings;
|
||||
|
||||
public ImaAdsLoader(ImaSdkSettings imaSdkSettings) {
|
||||
this.imaSdkSettings = imaSdkSettings;
|
||||
}
|
||||
|
||||
public void setPlayer(ExoPlayer ignoredPlayer) {
|
||||
}
|
||||
|
||||
@@ -45,6 +53,7 @@ public class ImaAdsLoader implements AdsLoader {
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private ImaSdkSettings imaSdkSettings;
|
||||
public Builder(Context ignoredThemedReactContext) {
|
||||
}
|
||||
|
||||
@@ -56,6 +65,11 @@ public class ImaAdsLoader implements AdsLoader {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
|
||||
this.imaSdkSettings = imaSdkSettings;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ImaAdsLoader build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
43
android/src/main/java/com/brentvatne/common/api/AdsProps.kt
Normal file
43
android/src/main/java/com/brentvatne/common/api/AdsProps.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
class AdsProps {
|
||||
var adTagUrl: Uri? = null
|
||||
var adLanguage: String? = null
|
||||
|
||||
/** return true if this and src are equals */
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other !is AdsProps) return false
|
||||
return (
|
||||
adTagUrl == other.adTagUrl &&
|
||||
adLanguage == other.adLanguage
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PROP_AD_TAG_URL = "adTagUrl"
|
||||
private const val PROP_AD_LANGUAGE = "adLanguage"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): AdsProps {
|
||||
val adsProps = AdsProps()
|
||||
if (src != null) {
|
||||
val uriString = ReactBridgeUtils.safeGetString(src, PROP_AD_TAG_URL)
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
adsProps.adTagUrl = null
|
||||
} else {
|
||||
adsProps.adTagUrl = Uri.parse(uriString)
|
||||
}
|
||||
val languageString = ReactBridgeUtils.safeGetString(src, PROP_AD_LANGUAGE)
|
||||
if (!TextUtils.isEmpty(languageString)) {
|
||||
adsProps.adLanguage = languageString
|
||||
}
|
||||
}
|
||||
return adsProps
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,23 @@ class BufferConfig {
|
||||
|
||||
var live: Live = Live()
|
||||
|
||||
/** return true if this and src are equals */
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other !is BufferConfig) return false
|
||||
return (
|
||||
cacheSize == other.cacheSize &&
|
||||
minBufferMs == other.minBufferMs &&
|
||||
maxBufferMs == other.maxBufferMs &&
|
||||
bufferForPlaybackMs == other.bufferForPlaybackMs &&
|
||||
bufferForPlaybackAfterRebufferMs == other.bufferForPlaybackAfterRebufferMs &&
|
||||
backBufferDurationMs == other.backBufferDurationMs &&
|
||||
maxHeapAllocationPercent == other.maxHeapAllocationPercent &&
|
||||
minBackBufferMemoryReservePercent == other.minBackBufferMemoryReservePercent &&
|
||||
minBufferMemoryReservePercent == other.minBufferMemoryReservePercent &&
|
||||
live == other.live
|
||||
)
|
||||
}
|
||||
|
||||
class Live {
|
||||
var maxPlaybackSpeed: Float = BufferConfigPropUnsetDouble.toFloat()
|
||||
var minPlaybackSpeed: Float = BufferConfigPropUnsetDouble.toFloat()
|
||||
@@ -30,12 +47,23 @@ class BufferConfig {
|
||||
var minOffsetMs: Long = BufferConfigPropUnsetInt.toLong()
|
||||
var targetOffsetMs: Long = BufferConfigPropUnsetInt.toLong()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other !is Live) return false
|
||||
return (
|
||||
maxPlaybackSpeed == other.maxPlaybackSpeed &&
|
||||
minPlaybackSpeed == other.minPlaybackSpeed &&
|
||||
maxOffsetMs == other.maxOffsetMs &&
|
||||
minOffsetMs == other.minOffsetMs &&
|
||||
targetOffsetMs == other.targetOffsetMs
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PROP_BUFFER_CONFIG_LIVE_MAX_PLAYBACK_SPEED = "maxPlaybackSpeed"
|
||||
private val PROP_BUFFER_CONFIG_LIVE_MIN_PLAYBACK_SPEED = "minPlaybackSpeed"
|
||||
private val PROP_BUFFER_CONFIG_LIVE_MAX_OFFSET_MS = "maxOffsetMs"
|
||||
private val PROP_BUFFER_CONFIG_LIVE_MIN_OFFSET_MS = "minOffsetMs"
|
||||
private val PROP_BUFFER_CONFIG_LIVE_TARGET_OFFSET_MS = "targetOffsetMs"
|
||||
private const val PROP_BUFFER_CONFIG_LIVE_MAX_PLAYBACK_SPEED = "maxPlaybackSpeed"
|
||||
private const val PROP_BUFFER_CONFIG_LIVE_MIN_PLAYBACK_SPEED = "minPlaybackSpeed"
|
||||
private const val PROP_BUFFER_CONFIG_LIVE_MAX_OFFSET_MS = "maxOffsetMs"
|
||||
private const val PROP_BUFFER_CONFIG_LIVE_MIN_OFFSET_MS = "minOffsetMs"
|
||||
private const val PROP_BUFFER_CONFIG_LIVE_TARGET_OFFSET_MS = "targetOffsetMs"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): Live {
|
||||
@@ -54,16 +82,16 @@ class BufferConfig {
|
||||
val BufferConfigPropUnsetInt = -1
|
||||
val BufferConfigPropUnsetDouble = -1.0
|
||||
|
||||
private val PROP_BUFFER_CONFIG_CACHE_SIZE = "cacheSizeMB"
|
||||
private val PROP_BUFFER_CONFIG_MIN_BUFFER_MS = "minBufferMs"
|
||||
private val PROP_BUFFER_CONFIG_MAX_BUFFER_MS = "maxBufferMs"
|
||||
private val PROP_BUFFER_CONFIG_BUFFER_FOR_PLAYBACK_MS = "bufferForPlaybackMs"
|
||||
private val PROP_BUFFER_CONFIG_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = "bufferForPlaybackAfterRebufferMs"
|
||||
private val PROP_BUFFER_CONFIG_MAX_HEAP_ALLOCATION_PERCENT = "maxHeapAllocationPercent"
|
||||
private val PROP_BUFFER_CONFIG_MIN_BACK_BUFFER_MEMORY_RESERVE_PERCENT = "minBackBufferMemoryReservePercent"
|
||||
private val PROP_BUFFER_CONFIG_MIN_BUFFER_MEMORY_RESERVE_PERCENT = "minBufferMemoryReservePercent"
|
||||
private val PROP_BUFFER_CONFIG_BACK_BUFFER_DURATION_MS = "backBufferDurationMs"
|
||||
private val PROP_BUFFER_CONFIG_LIVE = "live"
|
||||
private const val PROP_BUFFER_CONFIG_CACHE_SIZE = "cacheSizeMB"
|
||||
private const val PROP_BUFFER_CONFIG_MIN_BUFFER_MS = "minBufferMs"
|
||||
private const val PROP_BUFFER_CONFIG_MAX_BUFFER_MS = "maxBufferMs"
|
||||
private const val PROP_BUFFER_CONFIG_BUFFER_FOR_PLAYBACK_MS = "bufferForPlaybackMs"
|
||||
private const val PROP_BUFFER_CONFIG_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = "bufferForPlaybackAfterRebufferMs"
|
||||
private const val PROP_BUFFER_CONFIG_MAX_HEAP_ALLOCATION_PERCENT = "maxHeapAllocationPercent"
|
||||
private const val PROP_BUFFER_CONFIG_MIN_BACK_BUFFER_MEMORY_RESERVE_PERCENT = "minBackBufferMemoryReservePercent"
|
||||
private const val PROP_BUFFER_CONFIG_MIN_BUFFER_MEMORY_RESERVE_PERCENT = "minBufferMemoryReservePercent"
|
||||
private const val PROP_BUFFER_CONFIG_BACK_BUFFER_DURATION_MS = "backBufferDurationMs"
|
||||
private const val PROP_BUFFER_CONFIG_LIVE = "live"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): BufferConfig {
|
||||
|
||||
51
android/src/main/java/com/brentvatne/common/api/CMCDProps.kt
Normal file
51
android/src/main/java/com/brentvatne/common/api/CMCDProps.kt
Normal file
@@ -0,0 +1,51 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.ReadableType
|
||||
|
||||
data class CMCDProps(
|
||||
val cmcdObject: List<Pair<String, Any>> = emptyList(),
|
||||
val cmcdRequest: List<Pair<String, Any>> = emptyList(),
|
||||
val cmcdSession: List<Pair<String, Any>> = emptyList(),
|
||||
val cmcdStatus: List<Pair<String, Any>> = emptyList(),
|
||||
val mode: Int = 1
|
||||
) {
|
||||
companion object {
|
||||
private const val PROP_CMCD_OBJECT = "object"
|
||||
private const val PROP_CMCD_REQUEST = "request"
|
||||
private const val PROP_CMCD_SESSION = "session"
|
||||
private const val PROP_CMCD_STATUS = "status"
|
||||
private const val PROP_CMCD_MODE = "mode"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): CMCDProps? {
|
||||
if (src == null) return null
|
||||
|
||||
return CMCDProps(
|
||||
cmcdObject = parseKeyValuePairs(src.getArray(PROP_CMCD_OBJECT)),
|
||||
cmcdRequest = parseKeyValuePairs(src.getArray(PROP_CMCD_REQUEST)),
|
||||
cmcdSession = parseKeyValuePairs(src.getArray(PROP_CMCD_SESSION)),
|
||||
cmcdStatus = parseKeyValuePairs(src.getArray(PROP_CMCD_STATUS)),
|
||||
mode = safeGetInt(src, PROP_CMCD_MODE, 1)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseKeyValuePairs(array: ReadableArray?): List<Pair<String, Any>> {
|
||||
if (array == null) return emptyList()
|
||||
|
||||
return (0 until array.size()).mapNotNull { i ->
|
||||
val item = array.getMap(i)
|
||||
val key = item.getString("key")
|
||||
val value = when (item.getType("value")) {
|
||||
ReadableType.Number -> item.getDouble("value")
|
||||
ReadableType.String -> item.getString("value")
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (key != null && value != null) Pair(key, value) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,42 @@ import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
class ControlsConfig {
|
||||
var hideSeekBar: Boolean = false
|
||||
var hideDuration: Boolean = false
|
||||
var hidePosition: Boolean = false
|
||||
var hidePlayPause: Boolean = false
|
||||
var hideForward: Boolean = false
|
||||
var hideRewind: Boolean = false
|
||||
var hideNext: Boolean = false
|
||||
var hidePrevious: Boolean = false
|
||||
var hideFullscreen: Boolean = false
|
||||
var hideNavigationBarOnFullScreenMode: Boolean = true
|
||||
var hideNotificationBarOnFullScreenMode: Boolean = true
|
||||
var liveLabel: String? = null
|
||||
var hideSettingButton: Boolean = true
|
||||
|
||||
var seekIncrementMS: Int = 10000
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): ControlsConfig {
|
||||
fun parse(controlsConfig: ReadableMap?): ControlsConfig {
|
||||
val config = ControlsConfig()
|
||||
|
||||
if (src != null) {
|
||||
config.hideSeekBar = ReactBridgeUtils.safeGetBool(src, "hideSeekBar", false)
|
||||
config.seekIncrementMS = ReactBridgeUtils.safeGetInt(src, "seekIncrementMS", 10000)
|
||||
if (controlsConfig != null) {
|
||||
config.hideSeekBar = ReactBridgeUtils.safeGetBool(controlsConfig, "hideSeekBar", false)
|
||||
config.hideDuration = ReactBridgeUtils.safeGetBool(controlsConfig, "hideDuration", false)
|
||||
config.hidePosition = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePosition", false)
|
||||
config.hidePlayPause = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePlayPause", false)
|
||||
config.hideForward = ReactBridgeUtils.safeGetBool(controlsConfig, "hideForward", false)
|
||||
config.hideRewind = ReactBridgeUtils.safeGetBool(controlsConfig, "hideRewind", false)
|
||||
config.hideNext = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNext", false)
|
||||
config.hidePrevious = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePrevious", false)
|
||||
config.hideFullscreen = ReactBridgeUtils.safeGetBool(controlsConfig, "hideFullscreen", false)
|
||||
config.seekIncrementMS = ReactBridgeUtils.safeGetInt(controlsConfig, "seekIncrementMS", 10000)
|
||||
config.hideNavigationBarOnFullScreenMode = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNavigationBarOnFullScreenMode", true)
|
||||
config.hideNotificationBarOnFullScreenMode = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNotificationBarOnFullScreenMode", true)
|
||||
config.liveLabel = ReactBridgeUtils.safeGetString(controlsConfig, "liveLabel", null)
|
||||
config.hideSettingButton = ReactBridgeUtils.safeGetBool(controlsConfig, "hideSettingButton", true)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
import androidx.annotation.IntDef
|
||||
import java.lang.annotation.Retention
|
||||
import java.lang.annotation.RetentionPolicy
|
||||
import kotlin.annotation.Retention
|
||||
|
||||
internal object ResizeMode {
|
||||
/**
|
||||
@@ -42,7 +41,7 @@ internal object ResizeMode {
|
||||
else -> RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@IntDef(
|
||||
RESIZE_MODE_FIT,
|
||||
RESIZE_MODE_FIXED_WIDTH,
|
||||
|
||||
@@ -11,12 +11,18 @@ import com.facebook.react.bridge.ReadableMap
|
||||
class SideLoadedTextTrackList {
|
||||
var tracks = ArrayList<SideLoadedTextTrack>()
|
||||
|
||||
/** return true if this and src are equals */
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other !is SideLoadedTextTrackList) return false
|
||||
return tracks == other.tracks
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(src: ReadableArray?): SideLoadedTextTrackList? {
|
||||
if (src == null) {
|
||||
return null
|
||||
}
|
||||
var sideLoadedTextTrackList = SideLoadedTextTrackList()
|
||||
val sideLoadedTextTrackList = SideLoadedTextTrackList()
|
||||
for (i in 0 until src.size()) {
|
||||
val textTrack: ReadableMap = src.getMap(i)
|
||||
sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack))
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetBool
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetMap
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetString
|
||||
import com.brentvatne.react.BuildConfig
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.util.Locale
|
||||
import java.util.Objects
|
||||
@@ -29,6 +30,12 @@ class Source {
|
||||
/** Parsed value of source to playback */
|
||||
var uri: Uri? = null
|
||||
|
||||
/** True if source is a local JS asset */
|
||||
var isLocalAssetFile: Boolean = false
|
||||
|
||||
/** True if source is a local file asset://, ... */
|
||||
var isAsset: Boolean = false
|
||||
|
||||
/** Start position of playback used to resume playback */
|
||||
var startPositionMs: Int = -1
|
||||
|
||||
@@ -38,12 +45,18 @@ class Source {
|
||||
/** Will crop content end at specified position */
|
||||
var cropEndMs: Int = -1
|
||||
|
||||
/** Will virtually consider that content before contentStartTime is a preroll ad */
|
||||
var contentStartTime: Int = -1
|
||||
|
||||
/** Allow to force stream content, necessary when uri doesn't contain content type (.mlp4, .m3u, ...) */
|
||||
var extension: String? = null
|
||||
|
||||
/** Metadata to display in notification */
|
||||
var metadata: Metadata? = null
|
||||
|
||||
/** Allowed reload before failure notification */
|
||||
var minLoadRetryCount = 3
|
||||
|
||||
/** http header list */
|
||||
val headers: MutableMap<String, String> = HashMap()
|
||||
|
||||
@@ -57,6 +70,26 @@ class Source {
|
||||
*/
|
||||
var textTracksAllowChunklessPreparation: Boolean = false
|
||||
|
||||
/**
|
||||
* CMCD properties linked to the source
|
||||
*/
|
||||
var cmcdProps: CMCDProps? = null
|
||||
|
||||
/**
|
||||
* Ads playback properties
|
||||
*/
|
||||
var adsProps: AdsProps? = null
|
||||
|
||||
/*
|
||||
* buffering configuration
|
||||
*/
|
||||
var bufferConfig = BufferConfig()
|
||||
|
||||
/**
|
||||
* The list of sideLoaded text tracks
|
||||
*/
|
||||
var sideLoadedTextTracks: SideLoadedTextTrackList? = null
|
||||
|
||||
override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers)
|
||||
|
||||
/** return true if this and src are equals */
|
||||
@@ -68,7 +101,15 @@ class Source {
|
||||
cropEndMs == other.cropEndMs &&
|
||||
startPositionMs == other.startPositionMs &&
|
||||
extension == other.extension &&
|
||||
drmProps == other.drmProps
|
||||
drmProps == other.drmProps &&
|
||||
contentStartTime == other.contentStartTime &&
|
||||
cmcdProps == other.cmcdProps &&
|
||||
sideLoadedTextTracks == other.sideLoadedTextTracks &&
|
||||
adsProps == other.adsProps &&
|
||||
minLoadRetryCount == other.minLoadRetryCount &&
|
||||
isLocalAssetFile == other.isLocalAssetFile &&
|
||||
isAsset == other.isAsset &&
|
||||
bufferConfig == other.bufferConfig
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,14 +165,22 @@ class Source {
|
||||
companion object {
|
||||
private const val TAG = "Source"
|
||||
private const val PROP_SRC_URI = "uri"
|
||||
private const val PROP_SRC_IS_LOCAL_ASSET_FILE = "isLocalAssetFile"
|
||||
private const val PROP_SRC_IS_ASSET = "isAsset"
|
||||
private const val PROP_SRC_START_POSITION = "startPosition"
|
||||
private const val PROP_SRC_CROP_START = "cropStart"
|
||||
private const val PROP_SRC_CROP_END = "cropEnd"
|
||||
private const val PROP_SRC_CONTENT_START_TIME = "contentStartTime"
|
||||
private const val PROP_SRC_TYPE = "type"
|
||||
private const val PROP_SRC_METADATA = "metadata"
|
||||
private const val PROP_SRC_HEADERS = "requestHeaders"
|
||||
private const val PROP_SRC_DRM = "drm"
|
||||
private const val PROP_SRC_CMCD = "cmcd"
|
||||
private const val PROP_SRC_ADS = "ad"
|
||||
private const val PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION = "textTracksAllowChunklessPreparation"
|
||||
private const val PROP_SRC_TEXT_TRACKS = "textTracks"
|
||||
private const val PROP_SRC_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount"
|
||||
private const val PROP_SRC_BUFFER_CONFIG = "bufferConfig"
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
private fun getUriFromAssetId(context: Context, uriString: String): Uri? {
|
||||
@@ -184,12 +233,22 @@ class Source {
|
||||
}
|
||||
source.uriString = uriString
|
||||
source.uri = uri
|
||||
source.isLocalAssetFile = safeGetBool(src, PROP_SRC_IS_LOCAL_ASSET_FILE, false)
|
||||
source.isAsset = safeGetBool(src, PROP_SRC_IS_ASSET, false)
|
||||
source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1)
|
||||
source.cropStartMs = safeGetInt(src, PROP_SRC_CROP_START, -1)
|
||||
source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1)
|
||||
source.contentStartTime = safeGetInt(src, PROP_SRC_CONTENT_START_TIME, -1)
|
||||
source.extension = safeGetString(src, PROP_SRC_TYPE, null)
|
||||
source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM))
|
||||
source.cmcdProps = CMCDProps.parse(safeGetMap(src, PROP_SRC_CMCD))
|
||||
if (BuildConfig.USE_EXOPLAYER_IMA) {
|
||||
source.adsProps = AdsProps.parse(safeGetMap(src, PROP_SRC_ADS))
|
||||
}
|
||||
source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true)
|
||||
source.sideLoadedTextTracks = SideLoadedTextTrackList.parse(safeGetArray(src, PROP_SRC_TEXT_TRACKS))
|
||||
source.minLoadRetryCount = safeGetInt(src, PROP_SRC_MIN_LOAD_RETRY_COUNT, 3)
|
||||
source.bufferConfig = BufferConfig.parse(safeGetMap(src, PROP_SRC_BUFFER_CONFIG))
|
||||
|
||||
val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS)
|
||||
if (propSrcHeadersArray != null) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
/**
|
||||
* Helper file to parse SubtitleStyle prop and build a dedicated class
|
||||
*/
|
||||
class SubtitleStyle private constructor() {
|
||||
class SubtitleStyle public constructor() {
|
||||
var fontSize = -1
|
||||
private set
|
||||
var paddingLeft = 0
|
||||
@@ -19,6 +19,8 @@ class SubtitleStyle private constructor() {
|
||||
private set
|
||||
var opacity = 1f
|
||||
private set
|
||||
var subtitlesFollowVideo = true
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private const val PROP_FONT_SIZE_TRACK = "fontSize"
|
||||
@@ -27,6 +29,7 @@ class SubtitleStyle private constructor() {
|
||||
private const val PROP_PADDING_LEFT = "paddingLeft"
|
||||
private const val PROP_PADDING_RIGHT = "paddingRight"
|
||||
private const val PROP_OPACITY = "opacity"
|
||||
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): SubtitleStyle {
|
||||
@@ -37,6 +40,7 @@ class SubtitleStyle private constructor() {
|
||||
subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0)
|
||||
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
|
||||
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
|
||||
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
|
||||
return subtitleStyle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ enum class EventTypes(val eventName: String) {
|
||||
|
||||
EVENT_TEXT_TRACK_DATA_CHANGED("onTextTrackDataChanged"),
|
||||
EVENT_VIDEO_TRACKS("onVideoTracks"),
|
||||
EVENT_ON_RECEIVE_AD_EVENT("onReceiveAdEvent");
|
||||
EVENT_ON_RECEIVE_AD_EVENT("onReceiveAdEvent"),
|
||||
EVENT_PICTURE_IN_PICTURE_STATUS_CHANGED("onPictureInPictureStatusChanged");
|
||||
|
||||
companion object {
|
||||
fun toMap() =
|
||||
@@ -65,11 +66,11 @@ class VideoEventEmitter {
|
||||
audioTracks: ArrayList<Track>,
|
||||
textTracks: ArrayList<Track>,
|
||||
videoTracks: ArrayList<VideoTrack>,
|
||||
trackId: String
|
||||
trackId: String?
|
||||
) -> Unit
|
||||
lateinit var onVideoError: (errorString: String, exception: Exception, errorCode: String) -> Unit
|
||||
lateinit var onVideoProgress: (currentPosition: Long, bufferedDuration: Long, seekableDuration: Long, currentPlaybackTime: Double) -> Unit
|
||||
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String) -> Unit
|
||||
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit
|
||||
lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
|
||||
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit
|
||||
lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit
|
||||
@@ -92,6 +93,7 @@ class VideoEventEmitter {
|
||||
lateinit var onVideoTracks: (videoTracks: ArrayList<VideoTrack>?) -> Unit
|
||||
lateinit var onTextTrackDataChanged: (textTrackData: String) -> Unit
|
||||
lateinit var onReceiveAdEvent: (adEvent: String, adData: Map<String?, String?>?) -> Unit
|
||||
lateinit var onPictureInPictureStatusChanged: (isActive: Boolean) -> Unit
|
||||
|
||||
fun addEventEmitters(reactContext: ThemedReactContext, view: ReactExoplayerView) {
|
||||
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
|
||||
@@ -110,7 +112,7 @@ class VideoEventEmitter {
|
||||
|
||||
val naturalSize: WritableMap = aspectRatioToNaturalSize(videoWidth, videoHeight)
|
||||
putMap("naturalSize", naturalSize)
|
||||
putString("trackId", trackId)
|
||||
trackId?.let { putString("trackId", it) }
|
||||
putArray("videoTracks", videoTracksToArray(videoTracks))
|
||||
putArray("audioTracks", audioTracksToArray(audioTracks))
|
||||
putArray("textTracks", textTracksToArray(textTracks))
|
||||
@@ -155,9 +157,13 @@ class VideoEventEmitter {
|
||||
onVideoBandwidthUpdate = { bitRateEstimate, height, width, trackId ->
|
||||
event.dispatch(EventTypes.EVENT_BANDWIDTH) {
|
||||
putDouble("bitrate", bitRateEstimate.toDouble())
|
||||
putInt("width", width)
|
||||
putInt("height", height)
|
||||
putString("trackId", trackId)
|
||||
if (width > 0) {
|
||||
putInt("width", width)
|
||||
}
|
||||
if (height > 0) {
|
||||
putInt("height", height)
|
||||
}
|
||||
trackId?.let { putString("trackId", it) }
|
||||
}
|
||||
}
|
||||
onVideoPlaybackStateChanged = { isPlaying, isSeeking ->
|
||||
@@ -216,7 +222,7 @@ class VideoEventEmitter {
|
||||
putArray(
|
||||
"metadata",
|
||||
Arguments.createArray().apply {
|
||||
metadataArrayList.forEachIndexed { i, metadata ->
|
||||
metadataArrayList.forEachIndexed { _, metadata ->
|
||||
pushMap(
|
||||
Arguments.createMap().apply {
|
||||
putString("identifier", metadata.identifier)
|
||||
@@ -281,6 +287,11 @@ class VideoEventEmitter {
|
||||
)
|
||||
}
|
||||
}
|
||||
onPictureInPictureStatusChanged = { isActive ->
|
||||
event.dispatch(EventTypes.EVENT_PICTURE_IN_PICTURE_STATUS_CHANGED) {
|
||||
putBoolean("isActive", isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +321,7 @@ class VideoEventEmitter {
|
||||
|
||||
private fun videoTracksToArray(videoTracks: java.util.ArrayList<VideoTrack>?): WritableArray =
|
||||
Arguments.createArray().apply {
|
||||
videoTracks?.forEachIndexed { i, vTrack ->
|
||||
videoTracks?.forEachIndexed { _, vTrack ->
|
||||
pushMap(
|
||||
Arguments.createMap().apply {
|
||||
putInt("width", vTrack.width)
|
||||
@@ -343,15 +354,19 @@ class VideoEventEmitter {
|
||||
|
||||
private fun aspectRatioToNaturalSize(videoWidth: Int, videoHeight: Int): WritableMap =
|
||||
Arguments.createMap().apply {
|
||||
putInt("width", videoWidth)
|
||||
putInt("height", videoHeight)
|
||||
val orientation = if (videoWidth > videoHeight) {
|
||||
"landscape"
|
||||
} else if (videoWidth < videoHeight) {
|
||||
"portrait"
|
||||
} else {
|
||||
"square"
|
||||
if (videoWidth > 0) {
|
||||
putInt("width", videoWidth)
|
||||
}
|
||||
if (videoHeight > 0) {
|
||||
putInt("height", videoHeight)
|
||||
}
|
||||
|
||||
val orientation = when {
|
||||
videoWidth > videoHeight -> "landscape"
|
||||
videoWidth < videoHeight -> "portrait"
|
||||
else -> "square"
|
||||
}
|
||||
|
||||
putString("orientation", orientation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.brentvatne.common.toolbox
|
||||
import com.facebook.react.bridge.Dynamic
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.util.HashMap
|
||||
|
||||
/*
|
||||
* Toolbox to safe parsing of <Video props
|
||||
@@ -54,6 +53,17 @@ object ReactBridgeUtils {
|
||||
|
||||
@JvmStatic fun safeGetFloat(map: ReadableMap?, key: String?): Float = safeGetFloat(map, key, 0.0f)
|
||||
|
||||
@JvmStatic fun safeParseInt(value: String?, default: Int): Int {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return try {
|
||||
value.toInt()
|
||||
} catch (e: java.lang.Exception) {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toStringMap converts a [ReadableMap] into a HashMap.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.brentvatne.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.FrameLayout
|
||||
import androidx.media3.common.Format
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -94,4 +95,12 @@ class AspectRatioFrameLayout(context: Context) : FrameLayout(context) {
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateAspectRatio(format: Format) {
|
||||
// There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it
|
||||
when (format.rotationDegrees) {
|
||||
90, 270 -> videoAspectRatio = if (format.width == 0) 1f else (format.height * format.pixelWidthHeightRatio) / format.width
|
||||
else -> videoAspectRatio = if (format.height == 0) 1f else (format.width * format.pixelWidthHeightRatio) / format.height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt
Normal file
54
android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.upstream.CmcdConfiguration
|
||||
import com.brentvatne.common.api.CMCDProps
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import com.google.common.collect.ImmutableListMultimap
|
||||
|
||||
class CMCDConfig(private val props: CMCDProps) {
|
||||
fun toCmcdConfigurationFactory(): CmcdConfiguration.Factory = CmcdConfiguration.Factory(::createCmcdConfiguration)
|
||||
|
||||
private fun createCmcdConfiguration(mediaItem: MediaItem): CmcdConfiguration =
|
||||
CmcdConfiguration(
|
||||
java.util.UUID.randomUUID().toString(),
|
||||
mediaItem.mediaId,
|
||||
object : CmcdConfiguration.RequestConfig {
|
||||
override fun getCustomData(): ImmutableListMultimap<String, String> = buildCustomData()
|
||||
},
|
||||
intToCmcdMode(props.mode)
|
||||
)
|
||||
|
||||
private fun intToCmcdMode(mode: Int): Int =
|
||||
when (mode) {
|
||||
0 -> CmcdConfiguration.MODE_REQUEST_HEADER
|
||||
|
||||
1 -> CmcdConfiguration.MODE_QUERY_PARAMETER
|
||||
|
||||
else -> {
|
||||
DebugLog.e("CMCDConfig", "Unsupported mode: $mode, fallback on MODE_REQUEST_HEADER")
|
||||
CmcdConfiguration.MODE_REQUEST_HEADER
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCustomData(): ImmutableListMultimap<String, String> =
|
||||
ImmutableListMultimap.builder<String, String>().apply {
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_OBJECT, props.cmcdObject)
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_REQUEST, props.cmcdRequest)
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_SESSION, props.cmcdSession)
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_STATUS, props.cmcdStatus)
|
||||
}.build()
|
||||
|
||||
private fun addFormattedData(builder: ImmutableListMultimap.Builder<String, String>, key: String, dataList: List<Pair<String, Any>>) {
|
||||
dataList.forEach { (dataKey, dataValue) ->
|
||||
builder.put(key, formatKeyValue(dataKey, dataValue))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatKeyValue(key: String, value: Any): String =
|
||||
when (value) {
|
||||
is String -> "$key=\"$value\""
|
||||
is Number -> "$key=$value"
|
||||
else -> throw IllegalArgumentException("Unsupported value type: ${value::class.java}")
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
package com.brentvatne.exoplayer;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media3.common.AdViewProvider;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.Tracks;
|
||||
import androidx.media3.common.VideoSize;
|
||||
import androidx.media3.common.text.Cue;
|
||||
import androidx.media3.common.util.Assertions;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.ui.SubtitleView;
|
||||
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.TextureView;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.brentvatne.common.api.ResizeMode;
|
||||
import com.brentvatne.common.api.SubtitleStyle;
|
||||
import com.brentvatne.common.api.ViewType;
|
||||
import com.brentvatne.common.toolbox.DebugLog;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
public final class ExoPlayerView extends FrameLayout implements AdViewProvider {
|
||||
private final static String TAG = "ExoPlayerView";
|
||||
private View surfaceView;
|
||||
private final View shutterView;
|
||||
private final SubtitleView subtitleLayout;
|
||||
private final AspectRatioFrameLayout layout;
|
||||
private final ComponentListener componentListener;
|
||||
private ExoPlayer player;
|
||||
private final Context context;
|
||||
private final ViewGroup.LayoutParams layoutParams;
|
||||
private final FrameLayout adOverlayFrameLayout;
|
||||
|
||||
private @ViewType.ViewType int viewType = ViewType.VIEW_TYPE_SURFACE;
|
||||
private boolean hideShutterView = false;
|
||||
|
||||
public ExoPlayerView(Context context) {
|
||||
super(context, null, 0);
|
||||
|
||||
this.context = context;
|
||||
|
||||
layoutParams = new ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT);
|
||||
|
||||
componentListener = new ComponentListener();
|
||||
|
||||
FrameLayout.LayoutParams aspectRatioParams = new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT);
|
||||
aspectRatioParams.gravity = Gravity.CENTER;
|
||||
layout = new AspectRatioFrameLayout(context);
|
||||
layout.setLayoutParams(aspectRatioParams);
|
||||
|
||||
shutterView = new View(getContext());
|
||||
shutterView.setLayoutParams(layoutParams);
|
||||
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black));
|
||||
|
||||
subtitleLayout = new SubtitleView(context);
|
||||
subtitleLayout.setLayoutParams(layoutParams);
|
||||
subtitleLayout.setUserDefaultStyle();
|
||||
subtitleLayout.setUserDefaultTextSize();
|
||||
|
||||
updateSurfaceView(viewType);
|
||||
|
||||
adOverlayFrameLayout = new FrameLayout(context);
|
||||
|
||||
layout.addView(shutterView, 1, layoutParams);
|
||||
layout.addView(adOverlayFrameLayout, 2, layoutParams);
|
||||
|
||||
addViewInLayout(layout, 0, aspectRatioParams);
|
||||
addViewInLayout(subtitleLayout, 1, layoutParams);
|
||||
}
|
||||
|
||||
private void clearVideoView() {
|
||||
if (surfaceView instanceof TextureView) {
|
||||
player.clearVideoTextureView((TextureView) surfaceView);
|
||||
} else if (surfaceView instanceof SurfaceView) {
|
||||
player.clearVideoSurfaceView((SurfaceView) surfaceView);
|
||||
}
|
||||
}
|
||||
|
||||
private void setVideoView() {
|
||||
if (surfaceView instanceof TextureView) {
|
||||
player.setVideoTextureView((TextureView) surfaceView);
|
||||
} else if (surfaceView instanceof SurfaceView) {
|
||||
player.setVideoSurfaceView((SurfaceView) surfaceView);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isPlaying() {
|
||||
return player != null && player.isPlaying();
|
||||
}
|
||||
|
||||
public void setSubtitleStyle(SubtitleStyle style) {
|
||||
// ensure we reset subtile style before reapplying it
|
||||
subtitleLayout.setUserDefaultStyle();
|
||||
subtitleLayout.setUserDefaultTextSize();
|
||||
|
||||
if (style.getFontSize() > 0) {
|
||||
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.getFontSize());
|
||||
}
|
||||
subtitleLayout.setPadding(style.getPaddingLeft(), style.getPaddingTop(), style.getPaddingRight(), style.getPaddingBottom());
|
||||
if (style.getOpacity() != 0) {
|
||||
subtitleLayout.setAlpha(style.getOpacity());
|
||||
subtitleLayout.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
subtitleLayout.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void setShutterColor(Integer color) {
|
||||
shutterView.setBackgroundColor(color);
|
||||
}
|
||||
|
||||
public void updateSurfaceView(@ViewType.ViewType int viewType) {
|
||||
this.viewType = viewType;
|
||||
boolean viewNeedRefresh = false;
|
||||
if (viewType == ViewType.VIEW_TYPE_SURFACE || viewType == ViewType.VIEW_TYPE_SURFACE_SECURE) {
|
||||
if (!(surfaceView instanceof SurfaceView)) {
|
||||
surfaceView = new SurfaceView(context);
|
||||
viewNeedRefresh = true;
|
||||
}
|
||||
((SurfaceView)surfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE);
|
||||
} else if (viewType == ViewType.VIEW_TYPE_TEXTURE) {
|
||||
if (!(surfaceView instanceof TextureView)) {
|
||||
surfaceView = new TextureView(context);
|
||||
viewNeedRefresh = true;
|
||||
}
|
||||
// Support opacity properly:
|
||||
((TextureView) surfaceView).setOpaque(false);
|
||||
} else {
|
||||
DebugLog.wtf(TAG, "wtf is this texture " + viewType);
|
||||
}
|
||||
if (viewNeedRefresh) {
|
||||
surfaceView.setLayoutParams(layoutParams);
|
||||
|
||||
if (layout.getChildAt(0) != null) {
|
||||
layout.removeViewAt(0);
|
||||
}
|
||||
layout.addView(surfaceView, 0, layoutParams);
|
||||
|
||||
if (this.player != null) {
|
||||
setVideoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateShutterViewVisibility() {
|
||||
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestLayout() {
|
||||
super.requestLayout();
|
||||
post(measureAndLayout);
|
||||
}
|
||||
|
||||
// AdsLoader.AdViewProvider implementation.
|
||||
|
||||
@Override
|
||||
public ViewGroup getAdViewGroup() {
|
||||
return Assertions.checkNotNull(adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
|
||||
* player will be called and previous
|
||||
* assignments are overridden.
|
||||
*
|
||||
* @param player The {@link ExoPlayer} to use.
|
||||
*/
|
||||
public void setPlayer(ExoPlayer player) {
|
||||
if (this.player == player) {
|
||||
return;
|
||||
}
|
||||
if (this.player != null) {
|
||||
this.player.removeListener(componentListener);
|
||||
clearVideoView();
|
||||
}
|
||||
this.player = player;
|
||||
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
|
||||
if (player != null) {
|
||||
setVideoView();
|
||||
player.addListener(componentListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
|
||||
*
|
||||
* @param resizeMode The resize mode.
|
||||
*/
|
||||
public void setResizeMode(@ResizeMode.Mode int resizeMode) {
|
||||
if (layout != null && layout.getResizeMode() != resizeMode) {
|
||||
layout.setResizeMode(resizeMode);
|
||||
post(measureAndLayout);
|
||||
}
|
||||
}
|
||||
|
||||
public void setHideShutterView(boolean hideShutterView) {
|
||||
this.hideShutterView = hideShutterView;
|
||||
updateShutterViewVisibility();
|
||||
}
|
||||
|
||||
private final Runnable measureAndLayout = () -> {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY));
|
||||
layout(getLeft(), getTop(), getRight(), getBottom());
|
||||
};
|
||||
|
||||
private void updateForCurrentTrackSelections(Tracks tracks) {
|
||||
if (tracks == null) {
|
||||
return;
|
||||
}
|
||||
ImmutableList<Tracks.Group> groups = tracks.getGroups();
|
||||
for (Tracks.Group group: groups) {
|
||||
if (group.getType() == C.TRACK_TYPE_VIDEO && group.length > 0) {
|
||||
// get the first track of the group to identify aspect ratio
|
||||
Format format = group.getTrackFormat(0);
|
||||
|
||||
// There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it
|
||||
switch (format.rotationDegrees) {
|
||||
// update aspect ratio !
|
||||
case 90:
|
||||
case 270:
|
||||
layout.setVideoAspectRatio(format.width == 0 ? 1 : (format.height * format.pixelWidthHeightRatio) / format.width);
|
||||
default:
|
||||
layout.setVideoAspectRatio(format.height == 0 ? 1 : (format.width * format.pixelWidthHeightRatio) / format.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// no video tracks, in that case refresh shutterView visibility
|
||||
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
|
||||
}
|
||||
|
||||
public void invalidateAspectRatio() {
|
||||
// Resetting aspect ratio will force layout refresh on next video size changed
|
||||
layout.invalidateAspectRatio();
|
||||
}
|
||||
|
||||
private final class ComponentListener implements Player.Listener {
|
||||
|
||||
@Override
|
||||
public void onCues(@NonNull List<Cue> cues) {
|
||||
subtitleLayout.setCues(cues);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoSizeChanged(VideoSize videoSize) {
|
||||
boolean isInitialRatio = layout.getVideoAspectRatio() == 0;
|
||||
if (videoSize.height == 0 || videoSize.width == 0) {
|
||||
// When changing video track we receive an ghost state with height / width = 0
|
||||
// No need to resize the view in that case
|
||||
return;
|
||||
}
|
||||
layout.setVideoAspectRatio((videoSize.width * videoSize.pixelWidthHeightRatio) / videoSize.height);
|
||||
|
||||
// React native workaround for measuring and layout on initial load.
|
||||
if (isInitialRatio) {
|
||||
post(measureAndLayout);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderedFirstFrame() {
|
||||
shutterView.setVisibility(INVISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTracksChanged(@NonNull Tracks tracks) {
|
||||
updateForCurrentTrackSelections(tracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
Normal file
335
android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
Normal file
@@ -0,0 +1,335 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AdViewProvider
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
import com.brentvatne.common.api.ViewType
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
|
||||
@UnstableApi
|
||||
class ExoPlayerView(private val context: Context) :
|
||||
FrameLayout(context, null, 0),
|
||||
AdViewProvider {
|
||||
|
||||
var surfaceView: View? = null
|
||||
private set
|
||||
private var shutterView: View
|
||||
private var subtitleLayout: SubtitleView
|
||||
private var layout: AspectRatioFrameLayout
|
||||
private var componentListener: ComponentListener
|
||||
private var player: ExoPlayer? = null
|
||||
private var layoutParams: ViewGroup.LayoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
private var adOverlayFrameLayout: FrameLayout? = null
|
||||
val isPlaying: Boolean
|
||||
get() = player != null && player?.isPlaying == true
|
||||
|
||||
@ViewType.ViewType
|
||||
private var viewType = ViewType.VIEW_TYPE_SURFACE
|
||||
private var hideShutterView = false
|
||||
|
||||
private var localStyle = SubtitleStyle()
|
||||
|
||||
init {
|
||||
componentListener = ComponentListener()
|
||||
|
||||
val aspectRatioParams = LayoutParams(
|
||||
LayoutParams.MATCH_PARENT,
|
||||
LayoutParams.MATCH_PARENT
|
||||
)
|
||||
aspectRatioParams.gravity = Gravity.CENTER
|
||||
layout = AspectRatioFrameLayout(context)
|
||||
layout.layoutParams = aspectRatioParams
|
||||
|
||||
shutterView = View(context)
|
||||
shutterView.layoutParams = layoutParams
|
||||
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black))
|
||||
|
||||
subtitleLayout = SubtitleView(context)
|
||||
subtitleLayout.layoutParams = layoutParams
|
||||
subtitleLayout.setUserDefaultStyle()
|
||||
subtitleLayout.setUserDefaultTextSize()
|
||||
|
||||
updateSurfaceView(viewType)
|
||||
|
||||
layout.addView(shutterView, 1, layoutParams)
|
||||
if (localStyle.subtitlesFollowVideo) {
|
||||
layout.addView(subtitleLayout, layoutParams)
|
||||
}
|
||||
|
||||
addViewInLayout(layout, 0, aspectRatioParams)
|
||||
if (!localStyle.subtitlesFollowVideo) {
|
||||
addViewInLayout(subtitleLayout, 1, layoutParams)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearVideoView() {
|
||||
when (val view = surfaceView) {
|
||||
is TextureView -> player?.clearVideoTextureView(view)
|
||||
|
||||
is SurfaceView -> player?.clearVideoSurfaceView(view)
|
||||
|
||||
else -> {
|
||||
Log.w(
|
||||
"clearVideoView",
|
||||
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setVideoView() {
|
||||
when (val view = surfaceView) {
|
||||
is TextureView -> player?.setVideoTextureView(view)
|
||||
|
||||
is SurfaceView -> player?.setVideoSurfaceView(view)
|
||||
|
||||
else -> {
|
||||
Log.w(
|
||||
"setVideoView",
|
||||
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
// ensure we reset subtitle style before reapplying it
|
||||
subtitleLayout.setUserDefaultStyle()
|
||||
subtitleLayout.setUserDefaultTextSize()
|
||||
|
||||
if (style.fontSize > 0) {
|
||||
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
|
||||
}
|
||||
subtitleLayout.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingTop,
|
||||
style.paddingBottom
|
||||
)
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleLayout.alpha = style.opacity
|
||||
subtitleLayout.visibility = View.VISIBLE
|
||||
} else {
|
||||
subtitleLayout.visibility = View.GONE
|
||||
}
|
||||
if (localStyle.subtitlesFollowVideo != style.subtitlesFollowVideo) {
|
||||
// No need to manipulate layout if value didn't change
|
||||
if (style.subtitlesFollowVideo) {
|
||||
removeViewInLayout(subtitleLayout)
|
||||
layout.addView(subtitleLayout, layoutParams)
|
||||
} else {
|
||||
layout.removeViewInLayout(subtitleLayout)
|
||||
addViewInLayout(subtitleLayout, 1, layoutParams, false)
|
||||
}
|
||||
requestLayout()
|
||||
}
|
||||
localStyle = style
|
||||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
shutterView.setBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
|
||||
this.viewType = viewType
|
||||
var viewNeedRefresh = false
|
||||
when (viewType) {
|
||||
ViewType.VIEW_TYPE_SURFACE, ViewType.VIEW_TYPE_SURFACE_SECURE -> {
|
||||
if (surfaceView !is SurfaceView) {
|
||||
surfaceView = SurfaceView(context)
|
||||
viewNeedRefresh = true
|
||||
}
|
||||
(surfaceView as SurfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE)
|
||||
}
|
||||
|
||||
ViewType.VIEW_TYPE_TEXTURE -> {
|
||||
if (surfaceView !is TextureView) {
|
||||
surfaceView = TextureView(context)
|
||||
viewNeedRefresh = true
|
||||
}
|
||||
// Support opacity properly:
|
||||
(surfaceView as TextureView).isOpaque = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
DebugLog.wtf(TAG, "Unexpected texture view type: $viewType")
|
||||
}
|
||||
}
|
||||
|
||||
if (viewNeedRefresh) {
|
||||
surfaceView?.layoutParams = layoutParams
|
||||
|
||||
if (layout.getChildAt(0) != null) {
|
||||
layout.removeViewAt(0)
|
||||
}
|
||||
layout.addView(surfaceView, 0, layoutParams)
|
||||
|
||||
if (this.player != null) {
|
||||
setVideoView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var adsShown = false
|
||||
fun showAds() {
|
||||
if (!adsShown) {
|
||||
adOverlayFrameLayout = FrameLayout(context)
|
||||
layout.addView(adOverlayFrameLayout, layoutParams)
|
||||
adsShown = true
|
||||
}
|
||||
}
|
||||
|
||||
fun hideAds() {
|
||||
if (adsShown) {
|
||||
layout.removeView(adOverlayFrameLayout)
|
||||
adOverlayFrameLayout = null
|
||||
adsShown = false
|
||||
}
|
||||
}
|
||||
|
||||
fun updateShutterViewVisibility() {
|
||||
shutterView.visibility = if (this.hideShutterView) {
|
||||
View.INVISIBLE
|
||||
} else {
|
||||
View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestLayout() {
|
||||
super.requestLayout()
|
||||
post(measureAndLayout)
|
||||
}
|
||||
|
||||
// AdsLoader.AdViewProvider implementation.
|
||||
override fun getAdViewGroup(): ViewGroup =
|
||||
Assertions.checkNotNull(
|
||||
adOverlayFrameLayout,
|
||||
"exo_ad_overlay must be present for ad playback"
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
|
||||
* player will be called and previous
|
||||
* assignments are overridden.
|
||||
*
|
||||
* @param player The {@link ExoPlayer} to use.
|
||||
*/
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
if (this.player == player) {
|
||||
return
|
||||
}
|
||||
if (this.player != null) {
|
||||
this.player!!.removeListener(componentListener)
|
||||
clearVideoView()
|
||||
}
|
||||
this.player = player
|
||||
|
||||
updateShutterViewVisibility()
|
||||
if (player != null) {
|
||||
setVideoView()
|
||||
player.addListener(componentListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
|
||||
*
|
||||
* @param resizeMode The resize mode.
|
||||
*/
|
||||
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
|
||||
if (layout.resizeMode != resizeMode) {
|
||||
layout.resizeMode = resizeMode
|
||||
post(measureAndLayout)
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideShutterView(hideShutterView: Boolean) {
|
||||
this.hideShutterView = hideShutterView
|
||||
updateShutterViewVisibility()
|
||||
}
|
||||
|
||||
private val measureAndLayout: Runnable = Runnable {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
layout(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private fun updateForCurrentTrackSelections(tracks: Tracks?) {
|
||||
if (tracks == null) {
|
||||
return
|
||||
}
|
||||
val groups = tracks.groups
|
||||
|
||||
for (group in groups) {
|
||||
if (group.type == C.TRACK_TYPE_VIDEO && group.length > 0) {
|
||||
// get the first track of the group to identify aspect ratio
|
||||
val format = group.getTrackFormat(0)
|
||||
layout.updateAspectRatio(format)
|
||||
return
|
||||
}
|
||||
}
|
||||
// no video tracks, in that case refresh shutterView visibility
|
||||
updateShutterViewVisibility()
|
||||
}
|
||||
|
||||
fun invalidateAspectRatio() {
|
||||
// Resetting aspect ratio will force layout refresh on next video size changed
|
||||
layout.invalidateAspectRatio()
|
||||
}
|
||||
|
||||
private inner class ComponentListener : Player.Listener {
|
||||
override fun onCues(cues: List<Cue>) {
|
||||
subtitleLayout.setCues(cues)
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.height == 0 || videoSize.width == 0) {
|
||||
// When changing video track we receive an ghost state with height / width = 0
|
||||
// No need to resize the view in that case
|
||||
return
|
||||
}
|
||||
// Here we use updateForCurrentTrackSelections to have a consistent behavior.
|
||||
// according to: https://github.com/androidx/media/issues/1207
|
||||
// sometimes media3 send bad Video size information
|
||||
player?.let {
|
||||
updateForCurrentTrackSelections(it.currentTracks)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
shutterView.visibility = INVISIBLE
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
updateForCurrentTrackSelections(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,19 @@ import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.media3.ui.LegacyPlayerControlView
|
||||
import com.brentvatne.common.api.ControlsConfig
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
@@ -20,14 +27,22 @@ class FullScreenPlayerView(
|
||||
private val exoPlayerView: ExoPlayerView,
|
||||
private val reactExoplayerView: ReactExoplayerView,
|
||||
private val playerControlView: LegacyPlayerControlView?,
|
||||
private val onBackPressedCallback: OnBackPressedCallback
|
||||
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen) {
|
||||
private val onBackPressedCallback: OnBackPressedCallback,
|
||||
private val controlsConfig: ControlsConfig
|
||||
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar) {
|
||||
|
||||
private var parent: ViewGroup? = null
|
||||
private val containerView = FrameLayout(context)
|
||||
private val mKeepScreenOnHandler = Handler(Looper.getMainLooper())
|
||||
private val mKeepScreenOnUpdater = KeepScreenOnUpdater(this)
|
||||
|
||||
// As this view is fullscreen we need to save initial state and restore it afterward
|
||||
// Following variables save UI state when open the view
|
||||
// restoreUIState, will reapply these values
|
||||
private var initialSystemBarsBehavior: Int? = null
|
||||
private var initialNavigationBarIsVisible: Boolean? = null
|
||||
private var initialNotificationBarIsVisible: Boolean? = null
|
||||
|
||||
private class KeepScreenOnUpdater(fullScreenPlayerView: FullScreenPlayerView) : Runnable {
|
||||
private val mFullscreenPlayer = WeakReference(fullScreenPlayerView)
|
||||
|
||||
@@ -59,10 +74,15 @@ class FullScreenPlayerView(
|
||||
|
||||
init {
|
||||
setContentView(containerView, generateDefaultLayoutParams())
|
||||
}
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
onBackPressedCallback.handleOnBackPressed()
|
||||
|
||||
window?.let {
|
||||
val inset = WindowInsetsControllerCompat(it, it.decorView)
|
||||
initialSystemBarsBehavior = inset.systemBarsBehavior
|
||||
initialNavigationBarIsVisible = ViewCompat.getRootWindowInsets(it.decorView)
|
||||
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
|
||||
initialNotificationBarIsVisible = ViewCompat.getRootWindowInsets(it.decorView)
|
||||
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -75,6 +95,7 @@ class FullScreenPlayerView(
|
||||
parent?.removeView(it)
|
||||
containerView.addView(it, generateDefaultLayoutParams())
|
||||
}
|
||||
updateNavigationBarVisibility()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
@@ -89,6 +110,28 @@ class FullScreenPlayerView(
|
||||
}
|
||||
parent?.requestLayout()
|
||||
parent = null
|
||||
onBackPressedCallback.handleOnBackPressed()
|
||||
restoreSystemUI()
|
||||
}
|
||||
|
||||
// restore system UI state
|
||||
private fun restoreSystemUI() {
|
||||
window?.let {
|
||||
updateNavigationBarVisibility(
|
||||
it,
|
||||
initialNavigationBarIsVisible,
|
||||
initialNotificationBarIsVisible,
|
||||
initialSystemBarsBehavior
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideWithoutPlayer() {
|
||||
for (i in 0 until containerView.childCount) {
|
||||
if (containerView.getChildAt(i) !== exoPlayerView) {
|
||||
containerView.getChildAt(i).visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFullscreenIconResource(isFullscreen: Boolean): Int =
|
||||
@@ -127,4 +170,69 @@ class FullScreenPlayerView(
|
||||
layoutParams.setMargins(0, 0, 0, 0)
|
||||
return layoutParams
|
||||
}
|
||||
|
||||
private fun updateBarVisibility(
|
||||
inset: WindowInsetsControllerCompat,
|
||||
type: Int,
|
||||
shouldHide: Boolean?,
|
||||
initialVisibility: Boolean?,
|
||||
systemBarsBehavior: Int? = null
|
||||
) {
|
||||
shouldHide?.takeIf { it != initialVisibility }?.let {
|
||||
if (it) {
|
||||
inset.hide(type)
|
||||
systemBarsBehavior?.let { behavior -> inset.systemBarsBehavior = behavior }
|
||||
} else {
|
||||
inset.show(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move the UI to fullscreen.
|
||||
// if you change this code, remember to check that the UI is well restored in restoreUIState
|
||||
private fun updateNavigationBarVisibility(
|
||||
window: Window,
|
||||
hideNavigationBarOnFullScreenMode: Boolean?,
|
||||
hideNotificationBarOnFullScreenMode: Boolean?,
|
||||
systemBarsBehavior: Int?
|
||||
) {
|
||||
// Configure the behavior of the hidden system bars.
|
||||
val inset = WindowInsetsControllerCompat(window, window.decorView)
|
||||
|
||||
// Update navigation bar visibility and apply systemBarsBehavior if hiding
|
||||
updateBarVisibility(
|
||||
inset,
|
||||
WindowInsetsCompat.Type.navigationBars(),
|
||||
hideNavigationBarOnFullScreenMode,
|
||||
initialNavigationBarIsVisible,
|
||||
systemBarsBehavior
|
||||
)
|
||||
|
||||
// Update notification bar visibility (no need for systemBarsBehavior here)
|
||||
updateBarVisibility(
|
||||
inset,
|
||||
WindowInsetsCompat.Type.statusBars(),
|
||||
hideNotificationBarOnFullScreenMode,
|
||||
initialNotificationBarIsVisible
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateNavigationBarVisibility() {
|
||||
window?.let {
|
||||
updateNavigationBarVisibility(
|
||||
it,
|
||||
controlsConfig.hideNavigationBarOnFullScreenMode,
|
||||
controlsConfig.hideNotificationBarOnFullScreenMode,
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
)
|
||||
}
|
||||
if (controlsConfig.hideNotificationBarOnFullScreenMode) {
|
||||
val liveContainer = playerControlView?.findViewById<LinearLayout?>(com.brentvatne.react.R.id.exo_live_container)
|
||||
liveContainer?.let {
|
||||
val layoutParams = it.layoutParams as LinearLayout.LayoutParams
|
||||
layoutParams.topMargin = 40
|
||||
it.layoutParams = layoutParams
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AppOpsManager
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.AppOpsManagerCompat
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import com.brentvatne.receiver.PictureInPictureReceiver
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
|
||||
internal fun Context.findActivity(): ComponentActivity {
|
||||
var context = this
|
||||
while (context is ContextWrapper) {
|
||||
if (context is ComponentActivity) return context
|
||||
context = context.baseContext
|
||||
}
|
||||
throw IllegalStateException("Picture in picture should be called in the context of an Activity")
|
||||
}
|
||||
|
||||
object PictureInPictureUtil {
|
||||
private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000
|
||||
private const val TAG = "PictureInPictureUtil"
|
||||
|
||||
@JvmStatic
|
||||
fun addLifecycleEventListener(context: ThemedReactContext, view: ReactExoplayerView): Runnable {
|
||||
val activity = context.findActivity()
|
||||
|
||||
val onPictureInPictureModeChanged: (info: PictureInPictureModeChangedInfo) -> Unit = { info: PictureInPictureModeChangedInfo ->
|
||||
view.setIsInPictureInPicture(info.isInPictureInPictureMode)
|
||||
if (!info.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.CREATED) {
|
||||
// when user click close button of PIP
|
||||
if (!view.playInBackground) view.setPausedModifier(true)
|
||||
}
|
||||
}
|
||||
|
||||
val onUserLeaveHintCallback = {
|
||||
if (view.enterPictureInPictureOnLeave) {
|
||||
view.enterPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
|
||||
activity.addOnPictureInPictureModeChangedListener(onPictureInPictureModeChanged)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
activity.addOnUserLeaveHintListener(onUserLeaveHintCallback)
|
||||
}
|
||||
|
||||
// @TODO convert to lambda when ReactExoplayerView migrated
|
||||
return object : Runnable {
|
||||
override fun run() {
|
||||
context.findActivity().removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChanged)
|
||||
context.findActivity().removeOnUserLeaveHintListener(onUserLeaveHintCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun enterPictureInPictureMode(context: ThemedReactContext, pictureInPictureParams: PictureInPictureParams?) {
|
||||
if (!isSupportPictureInPicture(context)) return
|
||||
if (isSupportPictureInPictureAction() && pictureInPictureParams != null) {
|
||||
try {
|
||||
context.findActivity().enterPictureInPictureMode(pictureInPictureParams)
|
||||
} catch (e: IllegalStateException) {
|
||||
DebugLog.e(TAG, e.toString())
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
context.findActivity().enterPictureInPictureMode()
|
||||
} catch (e: IllegalStateException) {
|
||||
DebugLog.e(TAG, e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun applyPlayingStatus(
|
||||
context: ThemedReactContext,
|
||||
pipParamsBuilder: PictureInPictureParams.Builder?,
|
||||
receiver: PictureInPictureReceiver,
|
||||
isPaused: Boolean
|
||||
) {
|
||||
if (pipParamsBuilder == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val actions = getPictureInPictureActions(context, isPaused, receiver)
|
||||
pipParamsBuilder.setActions(actions)
|
||||
updatePictureInPictureActions(context, pipParamsBuilder.build())
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun applyAutoEnterEnabled(context: ThemedReactContext, pipParamsBuilder: PictureInPictureParams.Builder?, autoEnterEnabled: Boolean) {
|
||||
if (pipParamsBuilder == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
pipParamsBuilder.setAutoEnterEnabled(autoEnterEnabled)
|
||||
updatePictureInPictureActions(context, pipParamsBuilder.build())
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun applySourceRectHint(context: ThemedReactContext, pipParamsBuilder: PictureInPictureParams.Builder?, playerView: ExoPlayerView) {
|
||||
if (pipParamsBuilder == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
pipParamsBuilder.setSourceRectHint(calcRectHint(playerView))
|
||||
updatePictureInPictureActions(context, pipParamsBuilder.build())
|
||||
}
|
||||
|
||||
private fun updatePictureInPictureActions(context: ThemedReactContext, pipParams: PictureInPictureParams) {
|
||||
if (!isSupportPictureInPictureAction()) return
|
||||
if (!isSupportPictureInPicture(context)) return
|
||||
try {
|
||||
context.findActivity().setPictureInPictureParams(pipParams)
|
||||
} catch (e: IllegalStateException) {
|
||||
DebugLog.e(TAG, e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun getPictureInPictureActions(context: ThemedReactContext, isPaused: Boolean, receiver: PictureInPictureReceiver): ArrayList<RemoteAction> {
|
||||
val intent = receiver.getPipActionIntent(isPaused)
|
||||
val resource =
|
||||
if (isPaused) androidx.media3.ui.R.drawable.exo_icon_play else androidx.media3.ui.R.drawable.exo_icon_pause
|
||||
val icon = Icon.createWithResource(context, resource)
|
||||
val title = if (isPaused) "play" else "pause"
|
||||
return arrayListOf(RemoteAction(icon, title, title, intent))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun calcRectHint(playerView: ExoPlayerView): Rect {
|
||||
val hint = Rect()
|
||||
playerView.surfaceView?.getGlobalVisibleRect(hint)
|
||||
val location = IntArray(2)
|
||||
playerView.surfaceView?.getLocationOnScreen(location)
|
||||
|
||||
val height = hint.bottom - hint.top
|
||||
hint.top = location[1]
|
||||
hint.bottom = hint.top + height
|
||||
return hint
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun calcPictureInPictureAspectRatio(player: ExoPlayer): Rational {
|
||||
var aspectRatio = Rational(player.videoSize.width, player.videoSize.height)
|
||||
// AspectRatio for the activity in picture-in-picture, must be between 2.39:1 and 1:2.39 (inclusive).
|
||||
// https://developer.android.com/reference/android/app/PictureInPictureParams.Builder#setAspectRatio(android.util.Rational)
|
||||
val maximumRatio = Rational(239, 100)
|
||||
val minimumRatio = Rational(100, 239)
|
||||
if (aspectRatio.toFloat() > maximumRatio.toFloat()) {
|
||||
aspectRatio = maximumRatio
|
||||
} else if (aspectRatio.toFloat() < minimumRatio.toFloat()) {
|
||||
aspectRatio = minimumRatio
|
||||
}
|
||||
return aspectRatio
|
||||
}
|
||||
|
||||
private fun isSupportPictureInPicture(context: ThemedReactContext): Boolean =
|
||||
checkIsApiSupport() && checkIsSystemSupportPIP(context) && checkIsUserAllowPIP(context)
|
||||
|
||||
private fun isSupportPictureInPictureAction(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
|
||||
private fun checkIsApiSupport(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun checkIsSystemSupportPIP(context: ThemedReactContext): Boolean {
|
||||
val activity = context.findActivity() ?: return false
|
||||
|
||||
val activityInfo = activity.packageManager.getActivityInfo(activity.componentName, PackageManager.GET_META_DATA)
|
||||
// detect current activity's android:supportsPictureInPicture value defined within AndroidManifest.xml
|
||||
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/content/pm/ActivityInfo.java;l=1090-1093;drc=7651f0a4c059a98f32b0ba30cd64500bf135385f
|
||||
val isActivitySupportPip = activityInfo.flags and FLAG_SUPPORTS_PICTURE_IN_PICTURE != 0
|
||||
|
||||
// PIP might be disabled on devices that have low RAM.
|
||||
val isPipAvailable = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
|
||||
return isActivitySupportPip && isPipAvailable
|
||||
}
|
||||
|
||||
private fun checkIsUserAllowPIP(context: ThemedReactContext): Boolean {
|
||||
val activity = context.currentActivity ?: return false
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@SuppressLint("InlinedApi")
|
||||
val result = AppOpsManagerCompat.noteOpNoThrow(
|
||||
activity,
|
||||
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
|
||||
Process.myUid(),
|
||||
activity.packageName
|
||||
)
|
||||
AppOpsManager.MODE_ALLOWED == result
|
||||
} else {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,10 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.brentvatne.common.api.BufferConfig
|
||||
import com.brentvatne.common.api.BufferingStrategy
|
||||
import com.brentvatne.common.api.ControlsConfig
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SideLoadedTextTrackList
|
||||
import com.brentvatne.common.api.Source
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
import com.brentvatne.common.api.ViewType
|
||||
@@ -16,7 +12,6 @@ import com.brentvatne.common.react.EventTypes
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.brentvatne.react.ReactNativeVideoManager
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.uimanager.ViewGroupManager
|
||||
@@ -28,7 +23,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
private const val TAG = "ExoViewManager"
|
||||
private const val REACT_CLASS = "RCTVideo"
|
||||
private const val PROP_SRC = "src"
|
||||
private const val PROP_AD_TAG_URL = "adTagUrl"
|
||||
private const val PROP_RESIZE_MODE = "resizeMode"
|
||||
private const val PROP_REPEAT = "repeat"
|
||||
private const val PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack"
|
||||
@@ -37,21 +31,18 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
private const val PROP_SELECTED_TEXT_TRACK = "selectedTextTrack"
|
||||
private const val PROP_SELECTED_TEXT_TRACK_TYPE = "type"
|
||||
private const val PROP_SELECTED_TEXT_TRACK_VALUE = "value"
|
||||
private const val PROP_TEXT_TRACKS = "textTracks"
|
||||
private const val PROP_PAUSED = "paused"
|
||||
private const val PROP_ENTER_PICTURE_IN_PICTURE_ON_LEAVE = "enterPictureInPictureOnLeave"
|
||||
private const val PROP_MUTED = "muted"
|
||||
private const val PROP_AUDIO_OUTPUT = "audioOutput"
|
||||
private const val PROP_VOLUME = "volume"
|
||||
private const val PROP_BUFFER_CONFIG = "bufferConfig"
|
||||
private const val PROP_PREVENTS_DISPLAY_SLEEP_DURING_VIDEO_PLAYBACK =
|
||||
"preventsDisplaySleepDuringVideoPlayback"
|
||||
private const val PROP_PROGRESS_UPDATE_INTERVAL = "progressUpdateInterval"
|
||||
private const val PROP_REPORT_BANDWIDTH = "reportBandwidth"
|
||||
private const val PROP_RATE = "rate"
|
||||
private const val PROP_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount"
|
||||
private const val PROP_MAXIMUM_BIT_RATE = "maxBitRate"
|
||||
private const val PROP_PLAY_IN_BACKGROUND = "playInBackground"
|
||||
private const val PROP_CONTENT_START_TIME = "contentStartTime"
|
||||
private const val PROP_DISABLE_FOCUS = "disableFocus"
|
||||
private const val PROP_BUFFERING_STRATEGY = "bufferingStrategy"
|
||||
private const val PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError"
|
||||
@@ -79,6 +70,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
|
||||
override fun onDropViewInstance(view: ReactExoplayerView) {
|
||||
view.cleanUpResources()
|
||||
view.exitPictureInPictureMode()
|
||||
ReactNativeVideoManager.getInstance().unregisterView(this)
|
||||
}
|
||||
|
||||
@@ -92,22 +84,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
@ReactProp(name = PROP_SRC)
|
||||
fun setSrc(videoView: ReactExoplayerView, src: ReadableMap?) {
|
||||
val context = videoView.context.applicationContext
|
||||
val source = Source.parse(src, context)
|
||||
if (source.uri == null) {
|
||||
videoView.clearSrc()
|
||||
} else {
|
||||
videoView.setSrc(source)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_AD_TAG_URL)
|
||||
fun setAdTagUrl(videoView: ReactExoplayerView, uriString: String?) {
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
videoView.setAdTagUrl(null)
|
||||
return
|
||||
}
|
||||
val adTagUrl = Uri.parse(uriString)
|
||||
videoView.setAdTagUrl(adTagUrl)
|
||||
videoView.setSrc(Source.parse(src, context))
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_RESIZE_MODE)
|
||||
@@ -169,12 +146,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setSelectedTextTrack(typeString, value)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_TEXT_TRACKS)
|
||||
fun setTextTracks(videoView: ReactExoplayerView, textTracks: ReadableArray?) {
|
||||
val sideLoadedTextTracks = SideLoadedTextTrackList.parse(textTracks)
|
||||
videoView.setTextTracks(sideLoadedTextTracks)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_PAUSED, defaultBoolean = false)
|
||||
fun setPaused(videoView: ReactExoplayerView, paused: Boolean) {
|
||||
videoView.setPausedModifier(paused)
|
||||
@@ -185,6 +156,11 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setMutedModifier(muted)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_ENTER_PICTURE_IN_PICTURE_ON_LEAVE, defaultBoolean = false)
|
||||
fun setEnterPictureInPictureOnLeave(videoView: ReactExoplayerView, enterPictureInPictureOnLeave: Boolean) {
|
||||
videoView.setEnterPictureInPictureOnLeave(enterPictureInPictureOnLeave)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_AUDIO_OUTPUT)
|
||||
fun setAudioOutput(videoView: ReactExoplayerView, audioOutput: String) {
|
||||
videoView.setAudioOutput(AudioOutput.get(audioOutput))
|
||||
@@ -215,11 +191,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setMaxBitRateModifier(maxBitRate.toInt())
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_MIN_LOAD_RETRY_COUNT)
|
||||
fun setMinLoadRetryCount(videoView: ReactExoplayerView, minLoadRetryCount: Int) {
|
||||
videoView.setMinLoadRetryCountModifier(minLoadRetryCount)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_PLAY_IN_BACKGROUND, defaultBoolean = false)
|
||||
fun setPlayInBackground(videoView: ReactExoplayerView, playInBackground: Boolean) {
|
||||
videoView.setPlayInBackground(playInBackground)
|
||||
@@ -235,11 +206,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setFocusable(focusable)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_CONTENT_START_TIME, defaultInt = -1)
|
||||
fun setContentStartTime(videoView: ReactExoplayerView, contentStartTime: Int) {
|
||||
videoView.setContentStartTime(contentStartTime)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_BUFFERING_STRATEGY)
|
||||
fun setBufferingStrategy(videoView: ReactExoplayerView, bufferingStrategy: String) {
|
||||
val strategy = BufferingStrategy.parse(bufferingStrategy)
|
||||
@@ -276,15 +242,9 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setSubtitleStyle(SubtitleStyle.parse(src))
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = 0)
|
||||
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = Color.BLACK)
|
||||
fun setShutterColor(videoView: ReactExoplayerView, color: Int) {
|
||||
videoView.setShutterColor(if (color == 0) Color.BLACK else color)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_BUFFER_CONFIG)
|
||||
fun setBufferConfig(videoView: ReactExoplayerView, bufferConfig: ReadableMap?) {
|
||||
val config = BufferConfig.parse(bufferConfig)
|
||||
videoView.setBufferConfig(config)
|
||||
videoView.setShutterColor(color)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_SHOW_NOTIFICATION_CONTROLS)
|
||||
|
||||
@@ -63,6 +63,7 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
|
||||
mediaSessionsList[player] = mediaSession
|
||||
addSession(mediaSession)
|
||||
startForeground(mediaSession.player.hashCode(), buildNotification(mediaSession))
|
||||
}
|
||||
|
||||
fun unregisterPlayer(player: ExoPlayer) {
|
||||
@@ -95,6 +96,10 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
cleanup()
|
||||
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
private fun buildNotification(session: MediaSession): Notification {
|
||||
val returnToPlayer = Intent(this, sourceActivity).apply {
|
||||
val returnToPlayer = Intent(this, sourceActivity ?: this.javaClass).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
@@ -179,17 +184,17 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
|
||||
// Add media control buttons that invoke intents in your media service
|
||||
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_back, "Seek Backward", seekBackwardPendingIntent) // #0
|
||||
.addAction(androidx.media3.session.R.drawable.media3_icon_rewind, "Seek Backward", seekBackwardPendingIntent) // #0
|
||||
.addAction(
|
||||
if (session.player.isPlaying) {
|
||||
androidx.media3.session.R.drawable.media3_notification_pause
|
||||
androidx.media3.session.R.drawable.media3_icon_pause
|
||||
} else {
|
||||
androidx.media3.session.R.drawable.media3_notification_play
|
||||
androidx.media3.session.R.drawable.media3_icon_play
|
||||
},
|
||||
"Toggle Play",
|
||||
togglePlayPendingIntent
|
||||
) // #1
|
||||
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_forward, "Seek Forward", seekForwardPendingIntent) // #2
|
||||
.addAction(androidx.media3.session.R.drawable.media3_icon_fast_forward, "Seek Forward", seekForwardPendingIntent) // #2
|
||||
// Apply the media style template
|
||||
.setStyle(MediaStyleNotificationHelper.MediaStyle(session).setShowActionsInCompactView(0, 1, 2))
|
||||
.setContentTitle(session.player.mediaMetadata.title)
|
||||
@@ -209,9 +214,6 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
private fun hideAllNotifications() {
|
||||
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancelAll()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.brentvatne.react
|
||||
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import com.brentvatne.exoplayer.ReactExoplayerViewManager
|
||||
|
||||
/**
|
||||
* ReactNativeVideoManager is a singleton class which allows to manipulate / the global state of the app
|
||||
@@ -23,13 +22,13 @@ class ReactNativeVideoManager : RNVPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private var instanceList: ArrayList<ReactExoplayerViewManager> = ArrayList()
|
||||
private var instanceList: ArrayList<Any> = ArrayList()
|
||||
private var pluginList: ArrayList<RNVPlugin> = ArrayList()
|
||||
|
||||
/**
|
||||
* register a new ReactExoplayerViewManager in the managed list
|
||||
*/
|
||||
fun registerView(newInstance: ReactExoplayerViewManager) {
|
||||
fun registerView(newInstance: Any) {
|
||||
if (instanceList.size > 2) {
|
||||
DebugLog.d(TAG, "multiple Video displayed ?")
|
||||
}
|
||||
@@ -39,7 +38,7 @@ class ReactNativeVideoManager : RNVPlugin {
|
||||
/**
|
||||
* unregister existing ReactExoplayerViewManager in the managed list
|
||||
*/
|
||||
fun unregisterView(newInstance: ReactExoplayerViewManager) {
|
||||
fun unregisterView(newInstance: Any) {
|
||||
instanceList.remove(newInstance)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.brentvatne.react
|
||||
|
||||
import com.brentvatne.common.api.Source
|
||||
import com.brentvatne.exoplayer.ReactExoplayerView
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.uimanager.UIManagerHelper
|
||||
import com.facebook.react.uimanager.common.UIManagerType
|
||||
@@ -42,6 +44,7 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
@Suppress("UNUSED_PARAMETER") // codegen compatibility
|
||||
fun seekCmd(reactTag: Int, time: Float, tolerance: Float) {
|
||||
performOnPlayerView(reactTag) {
|
||||
it?.seekTo((time * 1000f).roundToInt().toLong())
|
||||
@@ -62,6 +65,27 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun enterPictureInPictureCmd(reactTag: Int) {
|
||||
performOnPlayerView(reactTag) {
|
||||
it?.enterPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun exitPictureInPictureCmd(reactTag: Int) {
|
||||
performOnPlayerView(reactTag) {
|
||||
it?.exitPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setSourceCmd(reactTag: Int, source: ReadableMap?) {
|
||||
performOnPlayerView(reactTag) {
|
||||
it?.setSrc(Source.parse(source, reactApplicationContext))
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getCurrentPosition(reactTag: Int, promise: Promise) {
|
||||
performOnPlayerView(reactTag) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.brentvatne.receiver
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.brentvatne.exoplayer.ReactExoplayerView
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
|
||||
class PictureInPictureReceiver(private val view: ReactExoplayerView, private val context: ThemedReactContext) : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_MEDIA_CONTROL = "rnv_media_control"
|
||||
const val EXTRA_CONTROL_TYPE = "rnv_control_type"
|
||||
|
||||
// The request code for play action PendingIntent.
|
||||
const val REQUEST_PLAY = 1
|
||||
|
||||
// The request code for pause action PendingIntent.
|
||||
const val REQUEST_PAUSE = 2
|
||||
|
||||
// The intent extra value for play action.
|
||||
const val CONTROL_TYPE_PLAY = 1
|
||||
|
||||
// The intent extra value for pause action.
|
||||
const val CONTROL_TYPE_PAUSE = 2
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
intent ?: return
|
||||
if (intent.action == ACTION_MEDIA_CONTROL) {
|
||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
||||
CONTROL_TYPE_PLAY -> view.setPausedModifier(false)
|
||||
CONTROL_TYPE_PAUSE -> view.setPausedModifier(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setListener() {
|
||||
ContextCompat.registerReceiver(context, this, IntentFilter(ACTION_MEDIA_CONTROL), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
fun removeListener() {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (e: Exception) {
|
||||
// ignore if already unregistered
|
||||
}
|
||||
}
|
||||
|
||||
fun getPipActionIntent(isPaused: Boolean): PendingIntent {
|
||||
val requestCode = if (isPaused) REQUEST_PLAY else REQUEST_PAUSE
|
||||
val controlType = if (isPaused) CONTROL_TYPE_PLAY else CONTROL_TYPE_PAUSE
|
||||
val flag =
|
||||
if (Build.VERSION.SDK_INT >=
|
||||
Build.VERSION_CODES.M
|
||||
) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(
|
||||
EXTRA_CONTROL_TYPE,
|
||||
controlType
|
||||
)
|
||||
intent.setPackage(context.packageName)
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, flag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.google.ads.interactivemedia.v3.api;
|
||||
|
||||
public abstract class ImaSdkFactory {
|
||||
private static ImaSdkFactory instance;
|
||||
|
||||
public abstract ImaSdkSettings createImaSdkSettings();
|
||||
|
||||
public static ImaSdkFactory getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new ConcreteImaSdkFactory();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
class ConcreteImaSdkFactory extends ImaSdkFactory {
|
||||
|
||||
@Override
|
||||
public ImaSdkSettings createImaSdkSettings() {
|
||||
return new ConcreteImaSdkSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.google.ads.interactivemedia.v3.api;
|
||||
|
||||
import androidx.annotation.InspectableProperty;
|
||||
|
||||
public abstract class ImaSdkSettings {
|
||||
public abstract String getLanguage();
|
||||
public abstract void setLanguage(String language);
|
||||
}
|
||||
|
||||
// Concrete Implementation
|
||||
class ConcreteImaSdkSettings extends ImaSdkSettings {
|
||||
|
||||
private String language;
|
||||
|
||||
@Override
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLanguage(String language) {
|
||||
this.language = language;
|
||||
}
|
||||
}
|
||||
6
android/src/main/res/drawable/circle.xml
Normal file
6
android/src/main/res/drawable/circle.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/red"/>
|
||||
<size android:width="10dp" android:height="10dp"/>
|
||||
</shape>
|
||||
@@ -1,17 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_height="match_parent"
|
||||
android:layoutDirection="ltr"
|
||||
android:background="@color/midnight_black"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="@dimen/live_wrapper_margin_top"
|
||||
android:id="@+id/exo_live_container">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/exo_live_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="@dimen/position_duration_horizontal_padding"
|
||||
android:src="@drawable/circle" />
|
||||
|
||||
<TextView android:id="@+id/exo_live_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="@dimen/position_duration_text_size"
|
||||
android:textStyle="bold"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/white"/>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:paddingTop="@dimen/controller_wrapper_padding_top"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton android:id="@+id/exo_prev"
|
||||
@@ -70,6 +101,13 @@
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/silver_gray"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/exo_settings"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/ExoStyledControls.Button.Bottom.Settings"
|
||||
/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/exo_fullscreen"
|
||||
style="@style/ExoMediaButton.FullScreen"
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
<resources>
|
||||
<color name="silver_gray">#FFBEBEBE</color>
|
||||
<color name="midnight_black">#CC000000</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="red">#FF0000</color>
|
||||
</resources>
|
||||
@@ -3,6 +3,7 @@
|
||||
<!-- margin & padding-->
|
||||
<dimen name="controller_wrapper_padding_top">4dp</dimen>
|
||||
<dimen name="seekBar_wrapper_margin_top">4dp</dimen>
|
||||
<dimen name="live_wrapper_margin_top">12dp</dimen>
|
||||
<dimen name="position_duration_horizontal_padding">4dp</dimen>
|
||||
<dimen name="full_screen_margin">4dp</dimen>
|
||||
|
||||
|
||||
@@ -16,4 +16,10 @@
|
||||
<string name="error_drm_unsupported_scheme">This device does not support the required DRM scheme</string>
|
||||
|
||||
<string name="error_drm_unknown">An unknown DRM error occurred</string>
|
||||
|
||||
<string name="settings">Settings</string>
|
||||
|
||||
<string name="playback_speed">Playback Speed</string>
|
||||
|
||||
<string name="select_playback_speed">Select Playback Speed</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user