diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 4f8995591d..514522eb76 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -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
diff --git a/demos/shortform/README.md b/demos/shortform/README.md
new file mode 100644
index 0000000000..3f40270fc0
--- /dev/null
+++ b/demos/shortform/README.md
@@ -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.
diff --git a/demos/shortform/build.gradle b/demos/shortform/build.gradle
new file mode 100644
index 0000000000..0b96bdb2cf
--- /dev/null
+++ b/demos/shortform/build.gradle
@@ -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
+}
diff --git a/demos/shortform/proguard-rules.txt b/demos/shortform/proguard-rules.txt
new file mode 100644
index 0000000000..c1eeab406b
--- /dev/null
+++ b/demos/shortform/proguard-rules.txt
@@ -0,0 +1,2 @@
+# Proguard rules specific to the media3 short form content demo app.
+
diff --git a/demos/shortform/src/main/AndroidManifest.xml b/demos/shortform/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..a1d03af062
--- /dev/null
+++ b/demos/shortform/src/main/AndroidManifest.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/MainActivity.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MainActivity.kt
new file mode 100644
index 0000000000..4e842d09e3
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MainActivity.kt
@@ -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(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(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(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(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"
+ }
+}
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaItemDatabase.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaItemDatabase.kt
new file mode 100644
index 0000000000..4e525bd55b
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaItemDatabase.kt
@@ -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()
+
+ 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 {
+ val result: MutableList = mutableListOf()
+ for (i in fromIndex..toIndex) {
+ result.add(get(i))
+ }
+ return result
+ }
+}
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaSourceManager.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaSourceManager.kt
new file mode 100644
index 0000000000..63b17d7392
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaSourceManager.kt
@@ -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 = 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) {
+ 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 {
+ val renderers =
+ renderersFactory.createRenderers(
+ Util.createHandlerForCurrentOrMainLooper(),
+ object : VideoRendererEventListener {},
+ object : AudioRendererEventListener {},
+ { _: CueGroup? -> }
+ ) { _: Metadata ->
+ }
+ val capabilities = ArrayList()
+ 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
+ }
+ }
+}
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/PlayerPool.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/PlayerPool.kt
new file mode 100644
index 0000000000..8e2b35e4c5
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/PlayerPool.kt
@@ -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 = LinkedList()
+ private val playerMap: BiMap = Maps.synchronizedBiMap(HashBiMap.create())
+ private val playerRequestTokenSet: MutableSet = Collections.synchronizedSet(HashSet())
+ 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
+ }
+ }
+}
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerActivity.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerActivity.kt
new file mode 100644
index 0000000000..fd0c430191
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerActivity.kt
@@ -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()
+ }
+}
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaAdapter.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaAdapter.kt
new file mode 100644
index 0000000000..d01389d5f8
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaAdapter.kt
@@ -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() {
+ 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 , 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
+ }
+ }
+}
diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaHolder.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaHolder.kt
new file mode 100644
index 0000000000..307d6131ba
--- /dev/null
+++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaHolder.kt
@@ -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(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
+ }
+ }
+ }
+}
diff --git a/demos/shortform/src/main/proguard-rules.txt b/demos/shortform/src/main/proguard-rules.txt
new file mode 120000
index 0000000000..499fb08b36
--- /dev/null
+++ b/demos/shortform/src/main/proguard-rules.txt
@@ -0,0 +1 @@
+../../proguard-rules.txt
\ No newline at end of file
diff --git a/demos/shortform/src/main/res/drawable/placeholder.png b/demos/shortform/src/main/res/drawable/placeholder.png
new file mode 100644
index 0000000000..8f943e3c35
Binary files /dev/null and b/demos/shortform/src/main/res/drawable/placeholder.png differ
diff --git a/demos/shortform/src/main/res/layout/activity_main.xml b/demos/shortform/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000000..34d1dea9d3
--- /dev/null
+++ b/demos/shortform/src/main/res/layout/activity_main.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/shortform/src/main/res/layout/activity_view_pager.xml b/demos/shortform/src/main/res/layout/activity_view_pager.xml
new file mode 100644
index 0000000000..7be1ac0f3a
--- /dev/null
+++ b/demos/shortform/src/main/res/layout/activity_view_pager.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/demos/shortform/src/main/res/layout/media_item_view_pager.xml b/demos/shortform/src/main/res/layout/media_item_view_pager.xml
new file mode 100644
index 0000000000..f62e982360
--- /dev/null
+++ b/demos/shortform/src/main/res/layout/media_item_view_pager.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/demos/shortform/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/shortform/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/shortform/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/shortform/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/shortform/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/shortform/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/shortform/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/shortform/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/shortform/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/shortform/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/shortform/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/shortform/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/shortform/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/shortform/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/shortform/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/shortform/src/main/res/values/colors.xml b/demos/shortform/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..486f0bda87
--- /dev/null
+++ b/demos/shortform/src/main/res/values/colors.xml
@@ -0,0 +1,30 @@
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+ #FF999999
+ #292929
+ #1c1c1c
+ #363434
+ #635E5E
+ #646464
+
diff --git a/demos/shortform/src/main/res/values/strings.xml b/demos/shortform/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..0c68ec91a4
--- /dev/null
+++ b/demos/shortform/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+
+ Media3 short-form content Demo
+ Open view pager activity
+ Add view pager, please!
+ ViewPager activity
+ How Many Players?
+ How Many Previous Videos Cached
+ How Many Future Videos Cached
+
diff --git a/demos/shortform/src/main/res/values/themes.xml b/demos/shortform/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..2cf983ad81
--- /dev/null
+++ b/demos/shortform/src/main/res/values/themes.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaPeriod.java
new file mode 100644
index 0000000000..e57c0ba9e2
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaPeriod.java
@@ -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;
+ }
+ }
+}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaSource.java
new file mode 100644
index 0000000000..a226242af5
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaSource.java
@@ -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}.
+ *
+ *
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 preloadingMediaPeriodAndKey;
+ @Nullable private Pair 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}.
+ *
+ *
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