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

This commit is contained in:
2025-01-21 17:45:59 -07:00
555 changed files with 12263 additions and 109934 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -7,4 +7,4 @@ public class DefaultDashChunkSource {
public Factory(DataSource.Factory mediaDataSourceFactory) {
}
}
}
}

View File

@@ -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;
}

View 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
}
}
}

View File

@@ -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 {

View 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
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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.
*

View File

@@ -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
}
}
}

View 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}")
}
}

View File

@@ -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);
}
}
}

View 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"
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>