Add PreloadMediaSource and PreloadMediaPeriod

The `PreloadMediaSource` has below two new public methods that suppose to be called by the app:

* `preload(long)` allows the apps to preload the source at the passed start position before playback. The preload efforts include preparing the source for a `Timeline`, creating and caching a `MediaPeriod`, preparing the period, selecting tracks on the period and continuing loading the data on the period.
* `releasePreloadMediaSource()` allows the apps to release the preloaded progress.

The `PreloadMediaPeriod` is designed to facilitate the `PreloadMediaSource` for the preloading work. It has a new package-private method `selectTracksForPreloading` which will cache the `SampleStream` that corresponds to the track selection made during the preloading, and when the `PreloadMediaPeriod.selectTracks` is called for playback, it will uses the preloaded streams if the new selection is equal to the selection made during the preloading.

Also add a shortform demo module to demo the usage of `PreloadMediaSource` with the short-form content use case.

PiperOrigin-RevId: 574439529
This commit is contained in:
tianyifeng 2023-10-18 04:54:25 -07:00 committed by Copybara-Service
parent ff4ff76990
commit 43e6882fb4
30 changed files with 3593 additions and 0 deletions

View file

@ -6,6 +6,12 @@
* ExoPlayer:
* Add luma and chroma bitdepth to `ColorInfo`
[#491](https://github.com/androidx/media/pull/491).
* Add `PreloadMediaSource` and `PreloadMediaPeriod` that allows apps to
preload the media source at a specific start position before playback,
where the efforts include preparing the source for a `Timeline`,
preparing and caching the period, selecting tracks and loading the data
on the period. Apps are able to control the preload progress by
implementing `PreloadMediaSource.PreloadControl`.
* Transformer:
* Add support for flattening H.265/HEVC SEF slow motion videos.
* Track Selection:
@ -62,6 +68,9 @@
* Remove deprecated `DownloadNotificationHelper.buildProgressNotification`
method, use a non deprecated method that takes a `notMetRequirements`
parameter instead.
* Demo app:
* Add a shortform demo module to demo the usage of `PreloadMediaSource`
with the short-form content use case.
## 1.2

View file

@ -0,0 +1,6 @@
# Short form content demo
This app demonstrates usage of ExoPlayer in common short form content UI setups.
See the [demos README](../README.md) for instructions on how to build and run
this demo.

View file

@ -0,0 +1,96 @@
// Copyright 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
apply from: '../../constants.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace 'androidx.media3.demo.shortform'
compileSdkVersion project.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
defaultConfig {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.appTargetSdkVersion
multiDexEnabled true
}
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles = [
'proguard-rules.txt',
getDefaultProguardFile('proguard-android-optimize.txt')
]
signingConfig signingConfigs.debug
}
debug {
jniDebuggable = true
}
}
lintOptions {
// The demo app isn't indexed, and doesn't have translations.
disable 'GoogleAppIndexingWarning','MissingTranslation'
}
buildFeatures {
viewBinding true
}
sourceSets {
main {
java {
srcDirs 'src/main/java'
}
}
test {
java {
srcDirs 'src/test/java'
}
}
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:' + androidxCoreVersion
implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
implementation 'com.google.android.material:material:' + androidxMaterialVersion
implementation project(modulePrefix + 'lib-exoplayer')
implementation project(modulePrefix + 'lib-exoplayer-dash')
implementation project(modulePrefix + 'lib-exoplayer-hls')
implementation project(modulePrefix + 'lib-ui')
testImplementation 'androidx.test:core:' + androidxTestCoreVersion
testImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
testImplementation 'junit:junit:' + junitVersion
testImplementation 'com.google.truth:truth:' + truthVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}

View file

@ -0,0 +1,2 @@
# Proguard rules specific to the media3 short form content demo app.

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="androidx.media3.demo.shortform">
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name="androidx.multidex.MultiDexApplication"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:taskAffinity=""
tools:replace="android:name">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:exported="false"
android:label="@string/title_activity_view_pager"
android:name=".viewpager.ViewPagerActivity"/>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-sdk />
</manifest>

View file

@ -0,0 +1,103 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.util.UnstableApi
import androidx.media3.demo.shortform.viewpager.ViewPagerActivity
import java.lang.Integer.max
import java.lang.Integer.min
class MainActivity : AppCompatActivity() {
@androidx.annotation.OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
var numberOfPlayers = 3
val numPlayersFieldView = findViewById<EditText>(R.id.num_players_field)
numPlayersFieldView.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable) {
val newText = numPlayersFieldView.text.toString()
if (newText != "") {
numberOfPlayers = max(1, min(newText.toInt(), 5))
}
}
}
)
var mediaItemsBackwardCacheSize = 2
val mediaItemsBCacheSizeView = findViewById<EditText>(R.id.media_items_b_cache_size)
mediaItemsBCacheSizeView.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable) {
val newText = mediaItemsBCacheSizeView.text.toString()
if (newText != "") {
mediaItemsBackwardCacheSize = max(1, min(newText.toInt(), 20))
}
}
}
)
var mediaItemsForwardCacheSize = 3
val mediaItemsFCacheSizeView = findViewById<EditText>(R.id.media_items_f_cache_size)
mediaItemsFCacheSizeView.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable) {
val newText = mediaItemsFCacheSizeView.text.toString()
if (newText != "") {
mediaItemsForwardCacheSize = max(1, min(newText.toInt(), 20))
}
}
}
)
findViewById<View>(R.id.view_pager_button).setOnClickListener {
startActivity(
Intent(this, ViewPagerActivity::class.java)
.putExtra(NUM_PLAYERS_EXTRA, numberOfPlayers)
.putExtra(MEDIA_ITEMS_BACKWARD_CACHE_SIZE, mediaItemsBackwardCacheSize)
.putExtra(MEDIA_ITEMS_FORWARD_CACHE_SIZE, mediaItemsForwardCacheSize)
)
}
}
companion object {
const val MEDIA_ITEMS_BACKWARD_CACHE_SIZE = "media_items_backward_cache_size"
const val MEDIA_ITEMS_FORWARD_CACHE_SIZE = "media_items_forward_cache_size"
const val NUM_PLAYERS_EXTRA = "number_of_players"
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform
import androidx.media3.common.MediaItem
import androidx.media3.common.util.Log
import androidx.media3.common.util.UnstableApi
@UnstableApi
class MediaItemDatabase() {
var lCacheSize: Int = 2
var rCacheSize: Int = 7
private val mediaItems =
mutableListOf(
MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/shortform_1.mp4"),
MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/shortform_2.mp4"),
MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/shortform_3.mp4"),
MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/shortform_4.mp4"),
MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/shortform_5.mp4")
)
// Effective sliding window of size = lCacheSize + 1 + rCacheSize
private val slidingWindowCache = HashMap<Int, MediaItem>()
private fun getRaw(index: Int): MediaItem {
return mediaItems[index.mod(mediaItems.size)]
}
private fun getCached(index: Int): MediaItem {
var mediaItem = slidingWindowCache[index]
if (mediaItem == null) {
mediaItem = getRaw(index)
slidingWindowCache[index] = mediaItem
Log.d("viewpager", "Put URL ${mediaItem.localConfiguration?.uri} into sliding cache")
slidingWindowCache.remove(index - lCacheSize - 1)
slidingWindowCache.remove(index + rCacheSize + 1)
}
return mediaItem
}
fun get(index: Int): MediaItem {
return getCached(index)
}
fun get(fromIndex: Int, toIndex: Int): List<MediaItem> {
val result: MutableList<MediaItem> = mutableListOf()
for (i in fromIndex..toIndex) {
result.add(get(i))
}
return result
}
}

View file

@ -0,0 +1,140 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.os.Process
import androidx.media3.common.MediaItem
import androidx.media3.common.Metadata
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.RendererCapabilities
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.PreloadMediaSource
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.upstream.Allocator
import androidx.media3.exoplayer.upstream.BandwidthMeter
import androidx.media3.exoplayer.video.VideoRendererEventListener
@UnstableApi
class MediaSourceManager(
mediaSourceFactory: MediaSource.Factory,
preloadLooper: Looper,
allocator: Allocator,
renderersFactory: RenderersFactory,
trackSelector: TrackSelector,
bandwidthMeter: BandwidthMeter,
) {
private val mediaSourcesThread = HandlerThread("playback-thread", Process.THREAD_PRIORITY_AUDIO)
private var handler: Handler
private var sourceMap: MutableMap<MediaItem, PreloadMediaSource> = HashMap()
private var preloadMediaSourceFactory: PreloadMediaSource.Factory
init {
mediaSourcesThread.start()
handler = Handler(mediaSourcesThread.looper)
trackSelector.init({}, bandwidthMeter)
preloadMediaSourceFactory =
PreloadMediaSource.Factory(
mediaSourceFactory,
PlayerId.UNSET,
PreloadControlImpl(targetPreloadPositionUs = 5_000_000L),
trackSelector,
bandwidthMeter,
getRendererCapabilities(renderersFactory = renderersFactory),
allocator,
preloadLooper
)
}
fun add(mediaItem: MediaItem) {
if (!sourceMap.containsKey(mediaItem)) {
val preloadMediaSource = preloadMediaSourceFactory.createMediaSource(mediaItem)
sourceMap[mediaItem] = preloadMediaSource
handler.post { preloadMediaSource.preload(/* startPositionUs= */ 0L) }
}
}
fun addAll(mediaItems: List<MediaItem>) {
mediaItems.forEach {
if (!sourceMap.containsKey(it)) {
add(it)
}
}
}
operator fun get(mediaItem: MediaItem): MediaSource {
if (!sourceMap.containsKey(mediaItem)) {
add(mediaItem)
}
return sourceMap[mediaItem]!!
}
/** Releases the instance. The instance can't be used after being released. */
fun release() {
sourceMap.keys.forEach { sourceMap[it]!!.releasePreloadMediaSource() }
handler.removeCallbacksAndMessages(null)
mediaSourcesThread.quit()
}
@UnstableApi
private fun getRendererCapabilities(
renderersFactory: RenderersFactory
): Array<RendererCapabilities> {
val renderers =
renderersFactory.createRenderers(
Util.createHandlerForCurrentOrMainLooper(),
object : VideoRendererEventListener {},
object : AudioRendererEventListener {},
{ _: CueGroup? -> }
) { _: Metadata ->
}
val capabilities = ArrayList<RendererCapabilities>()
for (i in renderers.indices) {
capabilities.add(renderers[i].capabilities)
}
return capabilities.toTypedArray()
}
companion object {
private const val TAG = "MSManager"
}
private class PreloadControlImpl(private val targetPreloadPositionUs: Long) :
PreloadMediaSource.PreloadControl {
override fun onTimelineRefreshed(mediaSource: PreloadMediaSource): Boolean {
return true
}
override fun onPrepared(mediaSource: PreloadMediaSource): Boolean {
return true
}
override fun onContinueLoadingRequested(
mediaSource: PreloadMediaSource,
bufferedPositionUs: Long
): Boolean {
return bufferedPositionUs < targetPreloadPositionUs
}
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.upstream.BandwidthMeter
import androidx.media3.exoplayer.util.EventLogger
import com.google.common.collect.BiMap
import com.google.common.collect.HashBiMap
import com.google.common.collect.Maps
import java.util.Collections
import java.util.LinkedList
import java.util.Queue
class PlayerPool(
private val numberOfPlayers: Int,
context: Context,
playbackLooper: Looper,
loadControl: LoadControl,
renderersFactory: RenderersFactory,
bandwidthMeter: BandwidthMeter
) {
/** Creates a player instance to be used by the pool. */
interface PlayerFactory {
/** Creates an [ExoPlayer] instance. */
fun createPlayer(): ExoPlayer
}
private val availablePlayerQueue: Queue<Int> = LinkedList()
private val playerMap: BiMap<Int, ExoPlayer> = Maps.synchronizedBiMap(HashBiMap.create())
private val playerRequestTokenSet: MutableSet<Int> = Collections.synchronizedSet(HashSet<Int>())
private val playerFactory: PlayerFactory =
DefaultPlayerFactory(context, playbackLooper, loadControl, renderersFactory, bandwidthMeter)
fun acquirePlayer(token: Int, callback: (ExoPlayer) -> Unit) {
synchronized(playerMap) {
if (playerMap.size < numberOfPlayers) {
val player = playerFactory.createPlayer()
playerMap[playerMap.size] = player
callback.invoke(player)
return
}
// Add token to set of views requesting players
playerRequestTokenSet.add(token)
acquirePlayerInternal(token, callback)
}
}
private fun acquirePlayerInternal(token: Int, callback: (ExoPlayer) -> Unit) {
synchronized(playerMap) {
if (!availablePlayerQueue.isEmpty()) {
val playerNumber = availablePlayerQueue.remove()
playerMap[playerNumber]?.let { callback.invoke(it) }
playerRequestTokenSet.remove(token)
return
} else if (playerRequestTokenSet.contains(token)) {
Handler(Looper.getMainLooper()).postDelayed({ acquirePlayerInternal(token, callback) }, 500)
}
}
}
fun releasePlayer(token: Int, player: ExoPlayer?) {
synchronized(playerMap) {
// Remove token from set of views requesting players & remove potential callbacks
// trying to grab the player
playerRequestTokenSet.remove(token)
// Stop the player and release into the pool for reusing, do not player.release()
player?.stop()
player?.clearMediaItems()
if (player != null) {
val playerNumber = playerMap.inverse()[player]
availablePlayerQueue.add(playerNumber)
}
}
}
fun destroyPlayers() {
synchronized(playerMap) {
for (i in 0 until playerMap.size) {
playerMap[i]?.release()
playerMap.remove(i)
}
}
}
@OptIn(UnstableApi::class)
private class DefaultPlayerFactory(
private val context: Context,
private val playbackLooper: Looper,
private val loadControl: LoadControl,
private val renderersFactory: RenderersFactory,
private val bandwidthMeter: BandwidthMeter
) : PlayerFactory {
private var playerCounter = 0
override fun createPlayer(): ExoPlayer {
val player =
ExoPlayer.Builder(context)
.setPlaybackLooper(playbackLooper)
.setLoadControl(loadControl)
.setRenderersFactory(renderersFactory)
.setBandwidthMeter(bandwidthMeter)
.build()
player.addAnalyticsListener(EventLogger("player-$playerCounter"))
playerCounter++
player.repeatMode = ExoPlayer.REPEAT_MODE_ONE
return player
}
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform.viewpager
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.util.UnstableApi
import androidx.media3.demo.shortform.MainActivity
import androidx.media3.demo.shortform.MediaItemDatabase
import androidx.media3.demo.shortform.R
import androidx.viewpager2.widget.ViewPager2
@UnstableApi
class ViewPagerActivity : AppCompatActivity() {
private lateinit var viewPagerView: ViewPager2
private lateinit var adapter: ViewPagerMediaAdapter
private var numberOfPlayers = 3
private var mediaItemDatabase = MediaItemDatabase()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_view_pager)
numberOfPlayers = intent.getIntExtra(MainActivity.NUM_PLAYERS_EXTRA, numberOfPlayers)
mediaItemDatabase.lCacheSize =
intent.getIntExtra(MainActivity.MEDIA_ITEMS_BACKWARD_CACHE_SIZE, mediaItemDatabase.lCacheSize)
mediaItemDatabase.rCacheSize =
intent.getIntExtra(MainActivity.MEDIA_ITEMS_FORWARD_CACHE_SIZE, mediaItemDatabase.rCacheSize)
Log.d("viewpager", "Using a pool of $numberOfPlayers players")
Log.d("viewpager", "Backward cache is of size: ${mediaItemDatabase.lCacheSize}")
Log.d("viewpager", "Forward cache is of size: ${mediaItemDatabase.rCacheSize}")
viewPagerView = findViewById(R.id.viewPager)
viewPagerView.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {}
override fun onPageScrollStateChanged(state: Int) {
Log.d("viewpager", "onPageScrollStateChanged: state=$state")
}
override fun onPageSelected(position: Int) {
Log.d("viewpager", "onPageSelected: position=$position")
}
}
)
}
override fun onStart() {
super.onStart()
adapter = ViewPagerMediaAdapter(mediaItemDatabase, numberOfPlayers, this)
viewPagerView.adapter = adapter
}
override fun onStop() {
adapter.onDestroy()
super.onStop()
}
}

View file

@ -0,0 +1,138 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform.viewpager
import android.content.Context
import android.os.HandlerThread
import android.os.Process
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.demo.shortform.MediaItemDatabase
import androidx.media3.demo.shortform.MediaSourceManager
import androidx.media3.demo.shortform.PlayerPool
import androidx.media3.demo.shortform.R
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
import androidx.media3.exoplayer.util.EventLogger
import androidx.recyclerview.widget.RecyclerView
@UnstableApi
class ViewPagerMediaAdapter(
private val mediaItemDatabase: MediaItemDatabase,
numberOfPlayers: Int,
private val context: Context
) : RecyclerView.Adapter<ViewPagerMediaHolder>() {
private val playbackThread: HandlerThread =
HandlerThread("playback-thread", Process.THREAD_PRIORITY_AUDIO)
private val mediaSourceManager: MediaSourceManager
private var viewCounter = 0
private var playerPool: PlayerPool
init {
playbackThread.start()
val loadControl = DefaultLoadControl()
val renderersFactory = DefaultRenderersFactory(context)
playerPool =
PlayerPool(
numberOfPlayers,
context,
playbackThread.looper,
loadControl,
renderersFactory,
DefaultBandwidthMeter.getSingletonInstance(context)
)
mediaSourceManager =
MediaSourceManager(
ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)),
playbackThread.looper,
loadControl.allocator,
renderersFactory,
DefaultTrackSelector(context),
DefaultBandwidthMeter.getSingletonInstance(context)
)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewPagerMediaHolder {
Log.d("viewpager", "onCreateViewHolder: $viewCounter")
val view =
LayoutInflater.from(parent.context).inflate(R.layout.media_item_view_pager, parent, false)
val holder = ViewPagerMediaHolder(view, viewCounter++, playerPool)
view.addOnAttachStateChangeListener(holder)
return holder
}
override fun onBindViewHolder(holder: ViewPagerMediaHolder, position: Int) {
// TODO could give more information to the database about which item to supply
// e.g. based on how long the previous item was in view (i.e. "popularity" of content)
// need to measure how long it's been since the last onBindViewHolder call
val mediaItem = mediaItemDatabase.get(position)
Log.d("viewpager", "onBindViewHolder: Getting item at position $position")
holder.bindData(position, mediaSourceManager[mediaItem])
// We are moving to <position>, so should prepare the next couple of items
// Potentially most of those are already cached on the database side because of the sliding
// window and we would only require one more item at index=mediaItemHorizon
val mediaItemHorizon = position + mediaItemDatabase.rCacheSize
val reachableMediaItems =
mediaItemDatabase.get(fromIndex = position + 1, toIndex = mediaItemHorizon)
// Same as with the data retrieval, most items will have been converted to MediaSources and
// prepared already, but not on the first swipe
mediaSourceManager.addAll(reachableMediaItems)
}
override fun getItemCount(): Int {
// Effectively infinite scroll
return Int.MAX_VALUE
}
override fun onViewRecycled(holder: ViewPagerMediaHolder) {
super.onViewRecycled(holder)
Log.d("viewpager", "Recycling the view")
}
fun onDestroy() {
playbackThread.quit()
playerPool.destroyPlayers()
mediaSourceManager.release()
}
inner class Factory : PlayerPool.PlayerFactory {
private var playerCounter = 0
override fun createPlayer(): ExoPlayer {
val loadControl =
DefaultLoadControl.Builder()
.setBufferDurationsMs(
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
/* bufferForPlaybackMs= */ 0,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
val player = ExoPlayer.Builder(context).setLoadControl(loadControl).build()
player.addAnalyticsListener(EventLogger("player-$playerCounter"))
playerCounter++
player.repeatMode = ExoPlayer.REPEAT_MODE_ONE
return player
}
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.demo.shortform.viewpager
import android.util.Log
import android.view.View
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.demo.shortform.PlayerPool
import androidx.media3.demo.shortform.R
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.ui.PlayerView
import androidx.recyclerview.widget.RecyclerView
class ViewPagerMediaHolder(
itemView: View,
private val viewCounter: Int,
private val playerPool: PlayerPool
) : RecyclerView.ViewHolder(itemView), View.OnAttachStateChangeListener {
private val playerView: PlayerView = itemView.findViewById(R.id.player_view)
private var player: ExoPlayer? = null
private var isInView: Boolean = false
private var token: Int = -1
private lateinit var mediaSource: MediaSource
init {
// Define click listener for the ViewHolder's View
playerView.findViewById<PlayerView>(R.id.player_view).setOnClickListener {
if (it is PlayerView) {
it.player?.run { playWhenReady = !playWhenReady }
}
}
}
@OptIn(UnstableApi::class)
override fun onViewAttachedToWindow(view: View) {
Log.d("viewpager", "onViewAttachedToWindow: $viewCounter")
isInView = true
if (player == null) {
playerPool.acquirePlayer(token, ::setupPlayer)
}
}
override fun onViewDetachedFromWindow(view: View) {
Log.d("viewpager", "onViewDetachedFromWindow: $viewCounter")
isInView = false
releasePlayer(player)
}
fun bindData(token: Int, mediaSource: MediaSource) {
this.mediaSource = mediaSource
this.token = token
}
@OptIn(UnstableApi::class)
fun releasePlayer(player: ExoPlayer?) {
playerPool.releasePlayer(token, player ?: this.player)
this.player = null
playerView.player = null
}
@OptIn(UnstableApi::class)
fun setupPlayer(player: ExoPlayer) {
if (!isInView) {
releasePlayer(player)
} else {
if (player != this.player) {
releasePlayer(this.player)
}
player.run {
repeatMode = ExoPlayer.REPEAT_MODE_ONE
setMediaSource(mediaSource)
seekTo(currentPosition)
playWhenReady = true
this@ViewPagerMediaHolder.player = player
player.prepare()
playerView.player = player
}
}
}
}

View file

@ -0,0 +1 @@
../../proguard-rules.txt

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<Button android:id="@+id/view_pager_button"
android:text="@string/open_view_pager_activity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginRight="12dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/num_players_field"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="150dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_gravity="center_horizontal"
android:background="@color/purple_700"
android:gravity="center"
android:hint="@string/num_of_players"
android:inputType="numberDecimal"
android:textColorHint="@color/grey" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/media_items_b_cache_size"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="20dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_gravity="center_horizontal"
android:background="@color/purple_700"
android:gravity="center"
android:hint="@string/how_many_previous_videos_cached"
android:inputType="numberDecimal"
android:textColorHint="@color/grey" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/media_items_f_cache_size"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="20dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_gravity="center_horizontal"
android:background="@color/purple_700"
android:gravity="center"
android:hint="@string/how_many_future_videos_cached"
android:inputType="numberDecimal"
android:textColorHint="@color/grey" />
</LinearLayout>

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".viewpager.ViewPagerActivity">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".viewpager.ViewPagerActivity">
<androidx.media3.ui.PlayerView android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:use_controller="false"
app:resize_mode="fill"
app:show_shuffle_button="true"
app:show_subtitle_button="true"/>
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2021 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="grey">#FF999999</color>
<color name="background">#292929</color>
<color name="player_background">#1c1c1c</color>
<color name="playlist_item_background">#363434</color>
<color name="playlist_item_foreground">#635E5E</color>
<color name="divider">#646464</color>
</resources>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2021 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<string name="app_name">Media3 short-form content Demo</string>
<string name="open_view_pager_activity">Open view pager activity</string>
<string name="add_view_pager">Add view pager, please!</string>
<string name="title_activity_view_pager">ViewPager activity</string>
<string name="num_of_players">How Many Players?</string>
<string name="how_many_previous_videos_cached">How Many Previous Videos Cached</string>
<string name="how_many_future_videos_cached">How Many Future Videos Cached</string>
</resources>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2021 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Media3Demo" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">
?attr/colorPrimaryVariant
</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,222 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer.source;
import static androidx.media3.common.util.Assertions.checkNotNull;
import androidx.annotation.Nullable;
import androidx.media3.common.util.NullableType;
import androidx.media3.exoplayer.LoadingInfo;
import androidx.media3.exoplayer.SeekParameters;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.exoplayer.trackselection.TrackSelectorResult;
import java.io.IOException;
import java.util.Arrays;
/** A {@link MediaPeriod} that has data preloaded before playback. */
/* package */ final class PreloadMediaPeriod implements MediaPeriod {
public final MediaPeriod mediaPeriod;
private boolean prepareInternalCalled;
private boolean prepared;
@Nullable private Callback callback;
@Nullable private PreloadTrackSelectionHolder preloadTrackSelectionHolder;
/**
* Creates the {@link PreloadMediaPeriod}.
*
* @param mediaPeriod The wrapped {@link MediaPeriod}.
*/
public PreloadMediaPeriod(MediaPeriod mediaPeriod) {
this.mediaPeriod = mediaPeriod;
}
/* package */ void preload(Callback callback, long positionUs) {
this.callback = callback;
if (prepared) {
callback.onPrepared(PreloadMediaPeriod.this);
}
if (!prepareInternalCalled) {
prepareInternal(positionUs);
}
}
@Override
public void prepare(Callback callback, long positionUs) {
this.callback = callback;
if (prepared) {
callback.onPrepared(PreloadMediaPeriod.this);
return;
}
if (!prepareInternalCalled) {
prepareInternal(positionUs);
}
}
private void prepareInternal(long positionUs) {
prepareInternalCalled = true;
mediaPeriod.prepare(
new Callback() {
@Override
public void onContinueLoadingRequested(MediaPeriod mediaPeriod) {
checkNotNull(callback).onContinueLoadingRequested(PreloadMediaPeriod.this);
}
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
prepared = true;
checkNotNull(callback).onPrepared(PreloadMediaPeriod.this);
}
},
positionUs);
}
@Override
public void maybeThrowPrepareError() throws IOException {
mediaPeriod.maybeThrowPrepareError();
}
@Override
public TrackGroupArray getTrackGroups() {
return mediaPeriod.getTrackGroups();
}
@Override
public long selectTracks(
@NullableType ExoTrackSelection[] selections,
boolean[] mayRetainStreamFlags,
@NullableType SampleStream[] streams,
boolean[] streamResetFlags,
long positionUs) {
long trackSelectionPositionUs;
if (preloadTrackSelectionHolder != null
&& Arrays.equals(selections, preloadTrackSelectionHolder.trackSelectorResult.selections)
&& positionUs == preloadTrackSelectionHolder.trackSelectionPositionUs) {
trackSelectionPositionUs = preloadTrackSelectionHolder.trackSelectionPositionUs;
System.arraycopy(
preloadTrackSelectionHolder.streams,
0,
streams,
0,
preloadTrackSelectionHolder.streams.length);
System.arraycopy(
preloadTrackSelectionHolder.streamResetFlags,
0,
streamResetFlags,
0,
preloadTrackSelectionHolder.streamResetFlags.length);
} else {
trackSelectionPositionUs =
mediaPeriod.selectTracks(
selections, mayRetainStreamFlags, streams, streamResetFlags, positionUs);
}
preloadTrackSelectionHolder = null;
return trackSelectionPositionUs;
}
/* package */ long selectTracksForPreloading(
TrackSelectorResult trackSelectorResult, long positionUs) {
@NullableType ExoTrackSelection[] selections = trackSelectorResult.selections;
@NullableType SampleStream[] preloadedSampleStreams = new SampleStream[selections.length];
boolean[] preloadedStreamResetFlags = new boolean[selections.length];
boolean[] mayRetainStreamFlags = new boolean[selections.length];
if (preloadTrackSelectionHolder != null) {
for (int i = 0; i < trackSelectorResult.length; i++) {
mayRetainStreamFlags[i] =
trackSelectorResult.isEquivalent(
checkNotNull(preloadTrackSelectionHolder).trackSelectorResult, i);
}
}
long trackSelectionPositionUs =
mediaPeriod.selectTracks(
selections,
mayRetainStreamFlags,
preloadedSampleStreams,
preloadedStreamResetFlags,
positionUs);
preloadTrackSelectionHolder =
new PreloadTrackSelectionHolder(
trackSelectorResult,
preloadedSampleStreams,
preloadedStreamResetFlags,
trackSelectionPositionUs);
return trackSelectionPositionUs;
}
@Override
public void discardBuffer(long positionUs, boolean toKeyframe) {
mediaPeriod.discardBuffer(positionUs, toKeyframe);
}
@Override
public long readDiscontinuity() {
return mediaPeriod.readDiscontinuity();
}
@Override
public long seekToUs(long positionUs) {
return mediaPeriod.seekToUs(positionUs);
}
@Override
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
return mediaPeriod.getAdjustedSeekPositionUs(positionUs, seekParameters);
}
@Override
public long getBufferedPositionUs() {
return mediaPeriod.getBufferedPositionUs();
}
@Override
public long getNextLoadPositionUs() {
return mediaPeriod.getNextLoadPositionUs();
}
@Override
public boolean continueLoading(LoadingInfo loadingInfo) {
return mediaPeriod.continueLoading(loadingInfo);
}
@Override
public boolean isLoading() {
return mediaPeriod.isLoading();
}
@Override
public void reevaluateBuffer(long positionUs) {
mediaPeriod.reevaluateBuffer(positionUs);
}
private static class PreloadTrackSelectionHolder {
public final TrackSelectorResult trackSelectorResult;
public final @NullableType SampleStream[] streams;
public final boolean[] streamResetFlags;
public final long trackSelectionPositionUs;
public PreloadTrackSelectionHolder(
TrackSelectorResult trackSelectorResult,
@NullableType SampleStream[] streams,
boolean[] streamResetFlags,
long trackSelectionPositionUs) {
this.trackSelectorResult = trackSelectorResult;
this.streams = streams;
this.streamResetFlags = streamResetFlags;
this.trackSelectionPositionUs = trackSelectionPositionUs;
}
}
}

View file

@ -0,0 +1,434 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer.source;
import static androidx.media3.common.util.Assertions.checkNotNull;
import android.os.Handler;
import android.os.Looper;
import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.LoadControl;
import androidx.media3.exoplayer.LoadingInfo;
import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
import androidx.media3.exoplayer.trackselection.TrackSelector;
import androidx.media3.exoplayer.trackselection.TrackSelectorResult;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import androidx.media3.exoplayer.upstream.CmcdConfiguration;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import java.util.Arrays;
/**
* Preloads a {@link MediaSource} and provides a {@link MediaPeriod} that has data loaded before
* playback.
*/
@UnstableApi
public final class PreloadMediaSource extends WrappingMediaSource {
/**
* Controls preloading of {@link PreloadMediaSource}.
*
* <p>The methods are called on the {@link Looper} that is passed when constructing the {@link
* PreloadMediaSource.Factory}.
*/
public interface PreloadControl {
/**
* Called from {@link PreloadMediaSource} when the {@link Timeline} is refreshed.
*
* @param mediaSource The {@link PreloadMediaSource} that has its {@link Timeline} refreshed.
* @return True if the {@code mediaSource} should continue preloading, false otherwise.
*/
boolean onTimelineRefreshed(PreloadMediaSource mediaSource);
/**
* Called from {@link PreloadMediaSource} when it is prepared.
*
* @param mediaSource The {@link PreloadMediaSource} it is prepared.
* @return True if the {@code mediaSource} should continue preloading, false otherwise.
*/
boolean onPrepared(PreloadMediaSource mediaSource);
/**
* Called from {@link PreloadMediaSource} when it requests to continue loading.
*
* @param mediaSource The {@link PreloadMediaSource} that requests to continue loading.
* @param bufferedPositionUs An estimate of the absolute position in microseconds up to which
* data is buffered, or {@link C#TIME_END_OF_SOURCE} if the track is fully buffered.
*/
boolean onContinueLoadingRequested(PreloadMediaSource mediaSource, long bufferedPositionUs);
}
/** Factory for {@link PreloadMediaSource}. */
public static final class Factory implements MediaSource.Factory {
private final MediaSource.Factory mediaSourceFactory;
private final PlayerId playerId;
private final Looper preloadLooper;
private final Allocator allocator;
private final TrackSelector trackSelector;
private final BandwidthMeter bandwidthMeter;
private final RendererCapabilities[] rendererCapabilities;
private final PreloadControl preloadControl;
/**
* Creates a new factory for {@link PreloadMediaSource}.
*
* @param mediaSourceFactory The underlying {@link MediaSource.Factory}.
* @param playerId The {@link PlayerId} of the {@link ExoPlayer} that will play the created
* {@link PreloadMediaSource} instances.
* @param preloadControl The {@link PreloadControl} that will control the progress of preloading
* the created {@link PreloadMediaSource} instances.
* @param trackSelector The {@link TrackSelector}. The instance passed should be {@link
* TrackSelector#init(TrackSelector.InvalidationListener, BandwidthMeter) initialized}.
* @param bandwidthMeter The {@link BandwidthMeter}. It should be the same bandwidth meter of
* the {@link ExoPlayer} that is injected by {@link
* ExoPlayer.Builder#setBandwidthMeter(BandwidthMeter)}.
* @param rendererCapabilities The array of {@link RendererCapabilities}. It should be derived
* from the same {@link RenderersFactory} of the {@link ExoPlayer} that is injected by
* {@link ExoPlayer.Builder#setRenderersFactory(RenderersFactory)}.
* @param allocator The {@link Allocator}. It should be the same allocator of the {@link
* ExoPlayer} that is injected by {@link ExoPlayer.Builder#setLoadControl(LoadControl)}.
* @param preloadLooper The {@link Looper} that will be used for preloading. It should be the
* same looper with {@link ExoPlayer.Builder#setPlaybackLooper(Looper)} that will play the
* created {@link PreloadMediaSource} instances.
*/
public Factory(
MediaSource.Factory mediaSourceFactory,
PlayerId playerId,
PreloadControl preloadControl,
TrackSelector trackSelector,
BandwidthMeter bandwidthMeter,
RendererCapabilities[] rendererCapabilities,
Allocator allocator,
Looper preloadLooper) {
this.mediaSourceFactory = mediaSourceFactory;
this.playerId = playerId;
this.preloadControl = preloadControl;
this.trackSelector = trackSelector;
this.bandwidthMeter = bandwidthMeter;
this.rendererCapabilities = Arrays.copyOf(rendererCapabilities, rendererCapabilities.length);
this.allocator = allocator;
this.preloadLooper = preloadLooper;
}
@Override
public Factory setCmcdConfigurationFactory(CmcdConfiguration.Factory cmcdConfigurationFactory) {
this.mediaSourceFactory.setCmcdConfigurationFactory(cmcdConfigurationFactory);
return this;
}
@Override
public Factory setDrmSessionManagerProvider(
DrmSessionManagerProvider drmSessionManagerProvider) {
this.mediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
return this;
}
@Override
public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
this.mediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy);
return this;
}
@Override
public int[] getSupportedTypes() {
return this.mediaSourceFactory.getSupportedTypes();
}
@Override
public PreloadMediaSource createMediaSource(MediaItem mediaItem) {
return new PreloadMediaSource(
mediaSourceFactory.createMediaSource(mediaItem),
playerId,
preloadControl,
trackSelector,
bandwidthMeter,
rendererCapabilities,
allocator,
preloadLooper);
}
}
private static final String TAG = "PreloadMediaSource";
private final PreloadControl preloadControl;
private final TrackSelector trackSelector;
private final BandwidthMeter bandwidthMeter;
private final RendererCapabilities[] rendererCapabilities;
private final Allocator allocator;
private final Handler preloadHandler;
private boolean preloadCalled;
private boolean prepareChildSourceCalled;
private long startPositionUs;
@Nullable private Timeline timeline;
@Nullable private Pair<PreloadMediaPeriod, MediaPeriodKey> preloadingMediaPeriodAndKey;
@Nullable private Pair<PreloadMediaPeriod, MediaPeriodId> playingPreloadedMediaPeriodAndId;
private PreloadMediaSource(
MediaSource mediaSource,
PlayerId playerId,
PreloadControl preloadControl,
TrackSelector trackSelector,
BandwidthMeter bandwidthMeter,
RendererCapabilities[] rendererCapabilities,
Allocator allocator,
Looper preloadLooper) {
super(mediaSource);
this.preloadControl = preloadControl;
this.trackSelector = trackSelector;
this.bandwidthMeter = bandwidthMeter;
this.rendererCapabilities = rendererCapabilities;
this.allocator = allocator;
preloadHandler = Util.createHandler(preloadLooper, /* callback= */ null);
startPositionUs = C.TIME_UNSET;
setPlayerId(playerId);
}
/**
* Preloads the {@link PreloadMediaSource} for an expected start position {@code startPositionUs}.
*
* <p>Can be called from any thread.
*
* @param startPositionUs The expected starting position in microseconds, or {@link C#TIME_UNSET}
* to indicate the default start position.
*/
public void preload(long startPositionUs) {
preloadHandler.post(
() -> {
preloadCalled = true;
this.startPositionUs = startPositionUs;
if (!isUsedByPlayer()) {
prepareSourceInternal(bandwidthMeter.getTransferListener());
}
});
}
@Override
protected void prepareSourceInternal() {
if (timeline != null) {
onChildSourceInfoRefreshed(timeline);
} else if (!prepareChildSourceCalled) {
prepareChildSourceCalled = true;
prepareChildSource();
}
}
@Override
protected void onChildSourceInfoRefreshed(Timeline newTimeline) {
this.timeline = newTimeline;
refreshSourceInfo(newTimeline);
if (isUsedByPlayer() || !preloadControl.onTimelineRefreshed(PreloadMediaSource.this)) {
return;
}
Pair<Object, Long> periodPosition =
newTimeline.getPeriodPositionUs(
new Timeline.Window(),
new Timeline.Period(),
/* windowIndex= */ 0,
/* windowPositionUs= */ startPositionUs);
MediaPeriodId mediaPeriodId = new MediaPeriodId(periodPosition.first);
PreloadMediaPeriod mediaPeriod =
PreloadMediaSource.this.createPeriod(mediaPeriodId, allocator, periodPosition.second);
mediaPeriod.preload(
new PreloadMediaPeriodCallback(periodPosition.second),
/* positionUs= */ periodPosition.second);
}
@Override
public PreloadMediaPeriod createPeriod(
MediaPeriodId id, Allocator allocator, long startPositionUs) {
MediaPeriodKey key = new MediaPeriodKey(id, startPositionUs);
if (preloadingMediaPeriodAndKey != null && key.equals(preloadingMediaPeriodAndKey.second)) {
PreloadMediaPeriod mediaPeriod = checkNotNull(preloadingMediaPeriodAndKey).first;
if (isUsedByPlayer()) {
preloadingMediaPeriodAndKey = null;
playingPreloadedMediaPeriodAndId = new Pair<>(mediaPeriod, id);
}
return mediaPeriod;
} else if (preloadingMediaPeriodAndKey != null) {
mediaSource.releasePeriod(checkNotNull(preloadingMediaPeriodAndKey).first.mediaPeriod);
preloadingMediaPeriodAndKey = null;
}
PreloadMediaPeriod mediaPeriod =
new PreloadMediaPeriod(mediaSource.createPeriod(id, allocator, startPositionUs));
if (!isUsedByPlayer()) {
preloadingMediaPeriodAndKey = new Pair<>(mediaPeriod, key);
}
return mediaPeriod;
}
@Override
protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId(MediaPeriodId mediaPeriodId) {
if (playingPreloadedMediaPeriodAndId != null
&& mediaPeriodIdEqualsWithoutWindowSequenceNumber(
mediaPeriodId, checkNotNull(playingPreloadedMediaPeriodAndId).second)) {
return checkNotNull(playingPreloadedMediaPeriodAndId).second;
}
return mediaPeriodId;
}
@Override
public void releasePeriod(MediaPeriod mediaPeriod) {
PreloadMediaPeriod preloadMediaPeriod = ((PreloadMediaPeriod) mediaPeriod);
if (preloadingMediaPeriodAndKey != null
&& preloadMediaPeriod == checkNotNull(preloadingMediaPeriodAndKey).first) {
preloadingMediaPeriodAndKey = null;
} else if (playingPreloadedMediaPeriodAndId != null
&& preloadMediaPeriod == checkNotNull(playingPreloadedMediaPeriodAndId).first) {
playingPreloadedMediaPeriodAndId = null;
}
MediaPeriod periodToRelease = preloadMediaPeriod.mediaPeriod;
mediaSource.releasePeriod(periodToRelease);
}
@Override
protected void releaseSourceInternal() {
if (!preloadCalled && !isUsedByPlayer()) {
timeline = null;
prepareChildSourceCalled = false;
super.releaseSourceInternal();
}
}
/**
* Releases the preloaded resources in {@link PreloadMediaSource}.
*
* <p>Can be called from any thread.
*/
public void releasePreloadMediaSource() {
preloadHandler.post(
() -> {
preloadCalled = false;
startPositionUs = C.TIME_UNSET;
if (preloadingMediaPeriodAndKey != null) {
mediaSource.releasePeriod(preloadingMediaPeriodAndKey.first.mediaPeriod);
preloadingMediaPeriodAndKey = null;
}
releaseSourceInternal();
preloadHandler.removeCallbacksAndMessages(null);
});
}
private class PreloadMediaPeriodCallback implements MediaPeriod.Callback {
private final long periodStartPositionUs;
private boolean prepared;
public PreloadMediaPeriodCallback(long periodStartPositionUs) {
this.periodStartPositionUs = periodStartPositionUs;
}
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
prepared = true;
PreloadMediaPeriod preloadMediaPeriod = (PreloadMediaPeriod) mediaPeriod;
TrackGroupArray trackGroups = preloadMediaPeriod.getTrackGroups();
@Nullable TrackSelectorResult trackSelectorResult = null;
MediaPeriodKey key = checkNotNull(preloadingMediaPeriodAndKey).second;
try {
trackSelectorResult =
trackSelector.selectTracks(
rendererCapabilities, trackGroups, key.mediaPeriodId, checkNotNull(timeline));
} catch (ExoPlaybackException e) {
Log.e(TAG, "Failed to select tracks", e);
}
if (trackSelectorResult != null) {
preloadMediaPeriod.selectTracksForPreloading(trackSelectorResult, periodStartPositionUs);
if (preloadControl.onPrepared(PreloadMediaSource.this)) {
preloadMediaPeriod.continueLoading(
new LoadingInfo.Builder().setPlaybackPositionUs(periodStartPositionUs).build());
}
}
}
@Override
public void onContinueLoadingRequested(MediaPeriod mediaPeriod) {
PreloadMediaPeriod preloadMediaPeriod = (PreloadMediaPeriod) mediaPeriod;
if (!prepared
|| preloadControl.onContinueLoadingRequested(
PreloadMediaSource.this, preloadMediaPeriod.getBufferedPositionUs())) {
preloadMediaPeriod.continueLoading(
new LoadingInfo.Builder().setPlaybackPositionUs(periodStartPositionUs).build());
}
}
}
private boolean isUsedByPlayer() {
return prepareSourceCalled();
}
private static boolean mediaPeriodIdEqualsWithoutWindowSequenceNumber(
MediaPeriodId firstPeriodId, MediaPeriodId secondPeriodId) {
return firstPeriodId.periodUid.equals(secondPeriodId.periodUid)
&& firstPeriodId.adGroupIndex == secondPeriodId.adGroupIndex
&& firstPeriodId.adIndexInAdGroup == secondPeriodId.adIndexInAdGroup
&& firstPeriodId.nextAdGroupIndex == secondPeriodId.nextAdGroupIndex;
}
private static class MediaPeriodKey {
public final MediaSource.MediaPeriodId mediaPeriodId;
private final Long startPositionUs;
public MediaPeriodKey(MediaSource.MediaPeriodId mediaPeriodId, long startPositionUs) {
this.mediaPeriodId = mediaPeriodId;
this.startPositionUs = startPositionUs;
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof MediaPeriodKey)) {
return false;
}
MediaPeriodKey mediaPeriodKey = (MediaPeriodKey) other;
// The MediaPeriodId.windowSequenceNumber is intentionally left out of equals to ensure we
// detect the "same" media even if it's used with a different sequence number.
return mediaPeriodIdEqualsWithoutWindowSequenceNumber(
this.mediaPeriodId, mediaPeriodKey.mediaPeriodId)
&& startPositionUs.equals(mediaPeriodKey.startPositionUs);
}
@Override
public int hashCode() {
// The MediaPeriodId.windowSequenceNumber is intentionally left out of hashCode to ensure we
// detect the "same" media even if it's used with a different sequence number.
int result = 17;
result = 31 * result + mediaPeriodId.periodUid.hashCode();
result = 31 * result + mediaPeriodId.adGroupIndex;
result = 31 * result + mediaPeriodId.adIndexInAdGroup;
result = 31 * result + mediaPeriodId.nextAdGroupIndex;
result = 31 * result + startPositionUs.intValue();
return result;
}
}
}

View file

@ -0,0 +1,235 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer.source;
import static com.google.common.truth.Truth.assertThat;
import android.content.Context;
import android.util.Pair;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.SystemClock;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.trackselection.TrackSelector;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import androidx.media3.exoplayer.upstream.DefaultAllocator;
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter;
import androidx.media3.exoplayer.video.VideoRendererEventListener;
import androidx.media3.test.utils.FakeAudioRenderer;
import androidx.media3.test.utils.FakeMediaPeriod;
import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeMediaSourceFactory;
import androidx.media3.test.utils.FakeVideoRenderer;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.shadows.ShadowLooper;
/**
* Tests the coordination behaviours when the player starts to prepare the {@link
* PreloadMediaSource} while it is in the different preload stages. For example, as long as the
* player calls {@link PreloadMediaSource#prepareSource(MediaSource.MediaSourceCaller,
* TransferListener, PlayerId)}, the rest of the preload logic shouldn't proceed.
*/
@RunWith(AndroidJUnit4.class)
public class PreloadAndPlaybackCoordinationTest {
private final PlayerId playerId;
private final BandwidthMeter bandwidthMeter;
private final PreloadMediaSource preloadMediaSource;
private final FakeMediaSource wrappedMediaSource;
private final MediaSource.MediaSourceCaller playbackMediaSourceCaller;
private final AtomicBoolean preloadControlOnSourceInfoRefreshedCalled;
private final AtomicBoolean preloadControlOnPreparedCalled;
private final AtomicBoolean playbackSourceCallerOnSourceInfoRefreshedCalled;
private final AtomicBoolean playbackPeriodCallbackOnPreparedCalled;
public PreloadAndPlaybackCoordinationTest() {
playerId = new PlayerId();
Context context = ApplicationProvider.getApplicationContext();
bandwidthMeter = new DefaultBandwidthMeter.Builder(context).build();
FakeMediaSourceFactory mediaSourceFactory = new FakeMediaSourceFactory();
Allocator allocator =
new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
TrackSelector trackSelector = new DefaultTrackSelector(context);
trackSelector.init(() -> {}, bandwidthMeter);
RenderersFactory renderersFactory =
(handler, videoListener, audioListener, textOutput, metadataOutput) ->
new Renderer[] {
new FakeVideoRenderer(
SystemClock.DEFAULT.createHandler(handler.getLooper(), /* callback= */ null),
videoListener),
new FakeAudioRenderer(
SystemClock.DEFAULT.createHandler(handler.getLooper(), /* callback= */ null),
audioListener)
};
preloadControlOnSourceInfoRefreshedCalled = new AtomicBoolean();
preloadControlOnPreparedCalled = new AtomicBoolean();
playbackSourceCallerOnSourceInfoRefreshedCalled = new AtomicBoolean();
playbackPeriodCallbackOnPreparedCalled = new AtomicBoolean();
PreloadMediaSource.PreloadControl preloadControl =
new PreloadMediaSource.PreloadControl() {
@Override
public boolean onTimelineRefreshed(PreloadMediaSource mediaSource) {
preloadControlOnSourceInfoRefreshedCalled.set(true);
return true;
}
@Override
public boolean onPrepared(PreloadMediaSource mediaSource) {
preloadControlOnPreparedCalled.set(true);
return true;
}
@Override
public boolean onContinueLoadingRequested(
PreloadMediaSource mediaSource, long bufferedPositionUs) {
return true;
}
};
PreloadMediaSource.Factory preloadMediaSourceFactory =
new PreloadMediaSource.Factory(
mediaSourceFactory,
playerId,
preloadControl,
trackSelector,
bandwidthMeter,
getRendererCapabilities(renderersFactory),
allocator,
/* preloadLooper= */ Util.getCurrentOrMainLooper());
preloadMediaSource =
preloadMediaSourceFactory.createMediaSource(
MediaItem.fromUri("asset://android_asset/media/mp4/sample.mp4"));
wrappedMediaSource = mediaSourceFactory.getLastCreatedSource();
MediaPeriod.Callback playbackMediaPeriodCallback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
playbackPeriodCallbackOnPreparedCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
playbackMediaSourceCaller =
(source, timeline) -> {
playbackSourceCallerOnSourceInfoRefreshedCalled.set(true);
Pair<Object, Long> periodPosition =
timeline.getPeriodPositionUs(
new Timeline.Window(),
new Timeline.Period(),
/* windowIndex= */ 0,
/* windowPositionUs= */ 0L);
MediaSource.MediaPeriodId mediaPeriodId =
new MediaSource.MediaPeriodId(periodPosition.first);
MediaPeriod mediaPeriod =
source.createPeriod(mediaPeriodId, allocator, periodPosition.second);
mediaPeriod.prepare(playbackMediaPeriodCallback, /* positionUs= */ 0L);
};
}
@Test
public void playbackPrepareSource_beforePreloadStart() {
preloadMediaSource.prepareSource(
playbackMediaSourceCaller, bandwidthMeter.getTransferListener(), playerId);
preloadMediaSource.preload(/* startPositionUs= */ 0L);
ShadowLooper.idleMainLooper();
assertThat(preloadControlOnSourceInfoRefreshedCalled.get()).isFalse();
assertThat(preloadControlOnPreparedCalled.get()).isFalse();
assertThat(playbackSourceCallerOnSourceInfoRefreshedCalled.get()).isTrue();
assertThat(playbackPeriodCallbackOnPreparedCalled.get()).isTrue();
}
@Test
public void playbackPrepareSource_betweenPreloadStartAndTimelineInfoRefreshed() {
wrappedMediaSource.setAllowPreparation(false);
preloadMediaSource.preload(/* startPositionUs= */ 0L);
ShadowLooper.idleMainLooper();
preloadMediaSource.prepareSource(
playbackMediaSourceCaller, bandwidthMeter.getTransferListener(), playerId);
wrappedMediaSource.setAllowPreparation(true);
ShadowLooper.idleMainLooper();
assertThat(preloadControlOnSourceInfoRefreshedCalled.get()).isFalse();
assertThat(preloadControlOnPreparedCalled.get()).isFalse();
assertThat(playbackSourceCallerOnSourceInfoRefreshedCalled.get()).isTrue();
assertThat(playbackPeriodCallbackOnPreparedCalled.get()).isTrue();
}
@Test
public void playbackPrepareSource_betweenTimelineInfoRefreshedAndPeriodPrepared() {
wrappedMediaSource.setPeriodDefersOnPreparedCallback(true);
preloadMediaSource.preload(/* startPositionUs= */ 0L);
ShadowLooper.idleMainLooper();
preloadMediaSource.prepareSource(
playbackMediaSourceCaller, bandwidthMeter.getTransferListener(), playerId);
FakeMediaPeriod lastCreatedActiveMediaPeriod =
(FakeMediaPeriod) wrappedMediaSource.getLastCreatedActiveMediaPeriod();
lastCreatedActiveMediaPeriod.setPreparationComplete();
ShadowLooper.idleMainLooper();
assertThat(preloadControlOnSourceInfoRefreshedCalled.get()).isTrue();
assertThat(preloadControlOnPreparedCalled.get()).isFalse();
assertThat(playbackSourceCallerOnSourceInfoRefreshedCalled.get()).isTrue();
assertThat(playbackPeriodCallbackOnPreparedCalled.get()).isTrue();
}
@Test
public void playbackPrepareSource_afterPeriodContinueLoading() {
preloadMediaSource.preload(/* startPositionUs= */ 0L);
ShadowLooper.idleMainLooper();
preloadMediaSource.prepareSource(
playbackMediaSourceCaller, bandwidthMeter.getTransferListener(), playerId);
ShadowLooper.idleMainLooper();
assertThat(preloadControlOnSourceInfoRefreshedCalled.get()).isTrue();
assertThat(preloadControlOnPreparedCalled.get()).isTrue();
assertThat(playbackSourceCallerOnSourceInfoRefreshedCalled.get()).isTrue();
assertThat(playbackPeriodCallbackOnPreparedCalled.get()).isTrue();
}
private static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) {
Renderer[] renderers =
renderersFactory.createRenderers(
Util.createHandlerForCurrentLooper(),
new VideoRendererEventListener() {},
new AudioRendererEventListener() {},
cueGroup -> {},
metadata -> {});
RendererCapabilities[] rendererCapabilities = new RendererCapabilities[renderers.length];
for (int i = 0; i < renderers.length; i++) {
rendererCapabilities[i] = renderers[i].getCapabilities();
}
return rendererCapabilities;
}
}

View file

@ -0,0 +1,474 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer.source;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;
import android.os.Looper;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.Tracks;
import androidx.media3.exoplayer.RendererConfiguration;
import androidx.media3.exoplayer.drm.DrmSessionEventListener;
import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.exoplayer.trackselection.FixedTrackSelection;
import androidx.media3.exoplayer.trackselection.TrackSelectorResult;
import androidx.media3.exoplayer.upstream.DefaultAllocator;
import androidx.media3.test.utils.FakeMediaPeriod;
import androidx.media3.test.utils.FakeTimeline;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link PreloadMediaPeriod}. */
@RunWith(AndroidJUnit4.class)
public final class PreloadMediaPeriodTest {
@Test
public void preload_prepareWrappedPeriodAndInvokeCallbackOnPrepared() {
MediaSource.MediaPeriodId mediaPeriodId =
new MediaSource.MediaPeriodId(/* periodUid= */ new Object());
FakeMediaPeriod wrappedMediaPeriod =
new FakeMediaPeriod(
TrackGroupArray.EMPTY,
new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE),
FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US,
new MediaSourceEventListener.EventDispatcher()
.withParameters(/* windowIndex= */ 0, mediaPeriodId));
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
AtomicBoolean onPreparedCallbackCalled = new AtomicBoolean();
AtomicReference<MediaPeriod> mediaPeriodReference = new AtomicReference<>();
preloadMediaPeriod.preload(
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
mediaPeriodReference.set(mediaPeriod);
onPreparedCallbackCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
},
/* positionUs= */ 0L);
// Wrapped media period is called `prepare` for once.
assertThat(onPreparedCallbackCalled.get()).isTrue();
assertThat(mediaPeriodReference.get()).isSameInstanceAs(preloadMediaPeriod);
}
@Test
public void prepareBeforePreload_prepareWrappedPeriodAndInvokeCallbackOnPrepared() {
MediaSource.MediaPeriodId mediaPeriodId =
new MediaSource.MediaPeriodId(/* periodUid= */ new Object());
FakeMediaPeriod wrappedMediaPeriod =
new FakeMediaPeriod(
TrackGroupArray.EMPTY,
new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE),
FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US,
new MediaSourceEventListener.EventDispatcher()
.withParameters(/* windowIndex= */ 0, mediaPeriodId));
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
AtomicBoolean onPreparedCallbackCalled = new AtomicBoolean();
AtomicReference<MediaPeriod> mediaPeriodReference = new AtomicReference<>();
preloadMediaPeriod.prepare(
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
mediaPeriodReference.set(mediaPeriod);
onPreparedCallbackCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
},
/* positionUs= */ 0L);
assertThat(onPreparedCallbackCalled.get()).isTrue();
assertThat(mediaPeriodReference.get()).isSameInstanceAs(preloadMediaPeriod);
}
@Test
public void prepareBeforeWrappedPeriodPreparedByPreloading_invokeLatestCallbackOnPrepared() {
MediaSource.MediaPeriodId mediaPeriodId =
new MediaSource.MediaPeriodId(/* periodUid= */ new Object());
FakeMediaPeriod wrappedMediaPeriod =
new FakeMediaPeriod(
TrackGroupArray.EMPTY,
new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE),
FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US,
new MediaSourceEventListener.EventDispatcher()
.withParameters(/* windowIndex= */ 0, mediaPeriodId),
DrmSessionManager.DRM_UNSUPPORTED,
new DrmSessionEventListener.EventDispatcher()
.withParameters(/* windowIndex= */ 0, mediaPeriodId),
/* deferOnPrepared= */ true);
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
AtomicBoolean onPreparedOfPreloadCallbackCalled = new AtomicBoolean();
AtomicBoolean onPreparedOfPrepareCallbackCalled = new AtomicBoolean();
AtomicReference<MediaPeriod> mediaPeriodReference = new AtomicReference<>();
MediaPeriod.Callback preloadCallback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
onPreparedOfPreloadCallbackCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
MediaPeriod.Callback prepareCallback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
mediaPeriodReference.set(mediaPeriod);
onPreparedOfPrepareCallbackCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
preloadMediaPeriod.preload(preloadCallback, /* positionUs= */ 0L);
preloadMediaPeriod.prepare(prepareCallback, /* positionUs= */ 0L);
wrappedMediaPeriod.setPreparationComplete();
shadowOf(Looper.getMainLooper()).idle();
// Should only invoke the latest callback.
assertThat(onPreparedOfPreloadCallbackCalled.get()).isFalse();
assertThat(onPreparedOfPrepareCallbackCalled.get()).isTrue();
assertThat(mediaPeriodReference.get()).isSameInstanceAs(preloadMediaPeriod);
}
@Test
public void prepareAfterWrappedPeriodPreparedByPreloading_immediatelyInvokeCallbackOnPrepared() {
MediaSource.MediaPeriodId mediaPeriodId =
new MediaSource.MediaPeriodId(/* periodUid= */ new Object());
FakeMediaPeriod wrappedMediaPeriod =
new FakeMediaPeriod(
TrackGroupArray.EMPTY,
new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE),
FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US,
new MediaSourceEventListener.EventDispatcher()
.withParameters(/* windowIndex= */ 0, mediaPeriodId));
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
AtomicBoolean onPreparedOfPreloadCallbackCalled = new AtomicBoolean();
AtomicBoolean onPreparedOfPrepareCallbackCalled = new AtomicBoolean();
AtomicReference<MediaPeriod> mediaPeriodReference = new AtomicReference<>();
MediaPeriod.Callback preloadCallback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
onPreparedOfPreloadCallbackCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
MediaPeriod.Callback prepareCallback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
mediaPeriodReference.set(mediaPeriod);
onPreparedOfPrepareCallbackCalled.set(true);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
preloadMediaPeriod.preload(preloadCallback, /* positionUs= */ 0L);
preloadMediaPeriod.prepare(prepareCallback, /* positionUs= */ 0L);
assertThat(onPreparedOfPreloadCallbackCalled.get()).isTrue();
assertThat(onPreparedOfPrepareCallbackCalled.get()).isTrue();
assertThat(mediaPeriodReference.get()).isSameInstanceAs(preloadMediaPeriod);
}
@Test
public void selectTracks_afterPreloadingForSameSelections_usePreloadedResults() {
MediaPeriod wrappedMediaPeriod = mock(MediaPeriod.class);
ExoTrackSelection[] trackSelections =
new ExoTrackSelection[] {
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build()),
/* track= */ 0),
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()),
/* track= */ 0)
};
TrackSelectorResult trackSelectorResult =
new TrackSelectorResult(
new RendererConfiguration[] {
RendererConfiguration.DEFAULT, RendererConfiguration.DEFAULT
},
trackSelections,
Tracks.EMPTY,
/* info= */ null);
SampleStream[] preloadedStreams =
new SampleStream[] {new EmptySampleStream(), new EmptySampleStream()};
when(wrappedMediaPeriod.selectTracks(
eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L)))
.thenAnswer(
invocation -> {
SampleStream[] streams = invocation.getArgument(2);
boolean[] streamResetFlags = invocation.getArgument(3);
for (int i = 0; i < streams.length; i++) {
streams[i] = preloadedStreams[i];
streamResetFlags[i] = true;
}
return 0L;
});
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
MediaPeriod.Callback callback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
preloadMediaPeriod.prepare(callback, /* positionUs= */ 0L);
// Select tracks for preloading.
long preloadTrackSelectionStartPositionUs =
preloadMediaPeriod.selectTracksForPreloading(trackSelectorResult, /* positionUs= */ 0L);
SampleStream[] streams = new SampleStream[2];
boolean[] streamResetFlags = new boolean[2];
// Select tracks based on the same track selections.
long trackSelectionStartPositionUs =
preloadMediaPeriod.selectTracks(
trackSelections, new boolean[2], streams, streamResetFlags, /* positionUs= */ 0L);
verify(wrappedMediaPeriod)
.selectTracks(eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L));
assertThat(trackSelectionStartPositionUs).isEqualTo(preloadTrackSelectionStartPositionUs);
assertThat(streams).isEqualTo(preloadedStreams);
assertThat(streamResetFlags).hasLength(2);
assertThat(streamResetFlags[0]).isTrue();
assertThat(streamResetFlags[1]).isTrue();
}
@Test
public void selectTracks_afterPreloadingButForDifferentSelections_callOnWrappedPeriod() {
MediaPeriod wrappedMediaPeriod = mock(MediaPeriod.class);
ExoTrackSelection[] trackSelections =
new ExoTrackSelection[] {
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build()),
/* track= */ 0),
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()),
/* track= */ 0)
};
TrackSelectorResult trackSelectorResult =
new TrackSelectorResult(
new RendererConfiguration[] {
RendererConfiguration.DEFAULT, RendererConfiguration.DEFAULT
},
trackSelections,
Tracks.EMPTY,
/* info= */ null);
SampleStream[] preloadedStreams =
new SampleStream[] {new EmptySampleStream(), new EmptySampleStream()};
when(wrappedMediaPeriod.selectTracks(
eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L)))
.thenAnswer(
invocation -> {
SampleStream[] streams = invocation.getArgument(2);
boolean[] streamResetFlags = invocation.getArgument(3);
for (int i = 0; i < streams.length; i++) {
streams[i] = preloadedStreams[i];
streamResetFlags[i] = true;
}
return 0L;
});
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
MediaPeriod.Callback callback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
preloadMediaPeriod.prepare(callback, /* positionUs= */ 0L);
// Select tracks for preloading.
preloadMediaPeriod.selectTracksForPreloading(trackSelectorResult, /* positionUs= */ 0L);
verify(wrappedMediaPeriod)
.selectTracks(eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L));
// Create a new track selections.
ExoTrackSelection[] newTrackSelections =
new ExoTrackSelection[] {
null,
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()),
/* track= */ 0)
};
SampleStream[] newSampleStreams = new SampleStream[] {new EmptySampleStream()};
when(wrappedMediaPeriod.selectTracks(
eq(newTrackSelections), any(), any(), any(), /* positionUs= */ eq(0L)))
.thenAnswer(
invocation -> {
SampleStream[] streams = invocation.getArgument(2);
boolean[] streamResetFlags = invocation.getArgument(3);
for (int i = 0; i < streams.length; i++) {
streams[i] = newSampleStreams[i];
streamResetFlags[i] = true;
}
return 0L;
});
SampleStream[] streams = new SampleStream[1];
boolean[] streamResetFlags = new boolean[1];
// Select tracks based on the new track selections.
long trackSelectionStartPositionUs =
preloadMediaPeriod.selectTracks(
newTrackSelections, new boolean[1], streams, streamResetFlags, /* positionUs= */ 0L);
verify(wrappedMediaPeriod)
.selectTracks(eq(newTrackSelections), any(), same(streams), same(streamResetFlags), eq(0L));
assertThat(trackSelectionStartPositionUs).isEqualTo(0L);
// Use newSampleStreams instead of preloadedSampleStreams
assertThat(streams).isEqualTo(newSampleStreams);
assertThat(streamResetFlags).hasLength(1);
assertThat(streamResetFlags[0]).isTrue();
}
@Test
public void
selectTracks_afterPreloadingForSameSelectionsButAtDifferentPosition_callOnWrappedPeriod() {
MediaPeriod wrappedMediaPeriod = mock(MediaPeriod.class);
ExoTrackSelection[] trackSelections =
new ExoTrackSelection[] {
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build()),
/* track= */ 0),
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()),
/* track= */ 0)
};
TrackSelectorResult trackSelectorResult =
new TrackSelectorResult(
new RendererConfiguration[] {
RendererConfiguration.DEFAULT, RendererConfiguration.DEFAULT
},
trackSelections,
Tracks.EMPTY,
/* info= */ null);
when(wrappedMediaPeriod.selectTracks(eq(trackSelections), any(), any(), any(), anyLong()))
.thenAnswer(invocation -> invocation.getArgument(4, Long.class));
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
MediaPeriod.Callback callback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
preloadMediaPeriod.prepare(callback, /* positionUs= */ 0L);
// Select tracks for preloading.
preloadMediaPeriod.selectTracksForPreloading(trackSelectorResult, /* positionUs= */ 0L);
verify(wrappedMediaPeriod)
.selectTracks(eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L));
SampleStream[] streams = new SampleStream[2];
boolean[] streamResetFlags = new boolean[2];
// Select tracks based on the same track selections but at a different position.
long trackSelectionStartPositionUs =
preloadMediaPeriod.selectTracks(
trackSelections, new boolean[2], streams, streamResetFlags, /* positionUs= */ 10L);
verify(wrappedMediaPeriod)
.selectTracks(
eq(trackSelections),
any(),
same(streams),
same(streamResetFlags),
/* positionUs= */ eq(10L));
assertThat(trackSelectionStartPositionUs).isEqualTo(10L);
}
@Test
public void selectTracks_theSecondCallAfterPreloading_callOnWrappedPeriod() {
MediaPeriod wrappedMediaPeriod = mock(MediaPeriod.class);
ExoTrackSelection[] trackSelections =
new ExoTrackSelection[] {
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build()),
/* track= */ 0),
new FixedTrackSelection(
new TrackGroup(new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()),
/* track= */ 0)
};
TrackSelectorResult trackSelectorResult =
new TrackSelectorResult(
new RendererConfiguration[] {
RendererConfiguration.DEFAULT, RendererConfiguration.DEFAULT
},
trackSelections,
Tracks.EMPTY,
/* info= */ null);
when(wrappedMediaPeriod.selectTracks(
eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L)))
.thenReturn(0L);
PreloadMediaPeriod preloadMediaPeriod = new PreloadMediaPeriod(wrappedMediaPeriod);
MediaPeriod.Callback callback =
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
};
preloadMediaPeriod.prepare(callback, /* positionUs= */ 0L);
verify(wrappedMediaPeriod).prepare(any(), /* positionUs= */ eq(0L));
// Select tracks for preloading.
preloadMediaPeriod.selectTracksForPreloading(trackSelectorResult, /* positionUs= */ 0L);
verify(wrappedMediaPeriod)
.selectTracks(eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L));
SampleStream[] streams = new SampleStream[2];
boolean[] streamResetFlags = new boolean[2];
// First `selectTracks` call based on the same track selections at the same position.
preloadMediaPeriod.selectTracks(
trackSelections, new boolean[2], streams, streamResetFlags, /* positionUs= */ 0L);
// Second `selectTracks` call based on the same track selections at the same position.
long trackSelectionStartPositionUs =
preloadMediaPeriod.selectTracks(
trackSelections, new boolean[2], streams, streamResetFlags, /* positionUs= */ 0L);
verify(wrappedMediaPeriod, times(2))
.selectTracks(eq(trackSelections), any(), any(), any(), /* positionUs= */ eq(0L));
assertThat(trackSelectionStartPositionUs).isEqualTo(0L);
}
}