From 43e6882fb4802a0d2dad946bc1c624fa7e802c9d Mon Sep 17 00:00:00 2001 From: tianyifeng Date: Wed, 18 Oct 2023 04:54:25 -0700 Subject: [PATCH] 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 --- RELEASENOTES.md | 9 + demos/shortform/README.md | 6 + demos/shortform/build.gradle | 96 ++ demos/shortform/proguard-rules.txt | 2 + demos/shortform/src/main/AndroidManifest.xml | 46 + .../media3/demo/shortform/MainActivity.kt | 103 ++ .../demo/shortform/MediaItemDatabase.kt | 66 + .../demo/shortform/MediaSourceManager.kt | 140 +++ .../media3/demo/shortform/PlayerPool.kt | 131 ++ .../shortform/viewpager/ViewPagerActivity.kt | 75 ++ .../viewpager/ViewPagerMediaAdapter.kt | 138 +++ .../viewpager/ViewPagerMediaHolder.kt | 97 ++ demos/shortform/src/main/proguard-rules.txt | 1 + .../src/main/res/drawable/placeholder.png | Bin 0 -> 37076 bytes .../src/main/res/layout/activity_main.xml | 78 ++ .../main/res/layout/activity_view_pager.xml | 29 + .../main/res/layout/media_item_view_pager.xml | 32 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3394 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2184 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4886 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7492 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10801 bytes .../shortform/src/main/res/values/colors.xml | 30 + .../shortform/src/main/res/values/strings.xml | 23 + .../shortform/src/main/res/values/themes.xml | 33 + .../exoplayer/source/PreloadMediaPeriod.java | 222 ++++ .../exoplayer/source/PreloadMediaSource.java | 434 +++++++ .../PreloadAndPlaybackCoordinationTest.java | 235 ++++ .../source/PreloadMediaPeriodTest.java | 474 +++++++ .../source/PreloadMediaSourceTest.java | 1093 +++++++++++++++++ 30 files changed, 3593 insertions(+) create mode 100644 demos/shortform/README.md create mode 100644 demos/shortform/build.gradle create mode 100644 demos/shortform/proguard-rules.txt create mode 100644 demos/shortform/src/main/AndroidManifest.xml create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/MainActivity.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaItemDatabase.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaSourceManager.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/PlayerPool.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerActivity.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaAdapter.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/viewpager/ViewPagerMediaHolder.kt create mode 120000 demos/shortform/src/main/proguard-rules.txt create mode 100644 demos/shortform/src/main/res/drawable/placeholder.png create mode 100644 demos/shortform/src/main/res/layout/activity_main.xml create mode 100644 demos/shortform/src/main/res/layout/activity_view_pager.xml create mode 100644 demos/shortform/src/main/res/layout/media_item_view_pager.xml create mode 100644 demos/shortform/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 demos/shortform/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 demos/shortform/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 demos/shortform/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 demos/shortform/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 demos/shortform/src/main/res/values/colors.xml create mode 100644 demos/shortform/src/main/res/values/strings.xml create mode 100644 demos/shortform/src/main/res/values/themes.xml create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaPeriod.java create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/PreloadMediaSource.java create mode 100644 libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/PreloadAndPlaybackCoordinationTest.java create mode 100644 libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/PreloadMediaPeriodTest.java create mode 100644 libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/PreloadMediaSourceTest.java 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 0000000000000000000000000000000000000000..8f943e3c35c8ce511ab80b5432606e67d8bf0e0d GIT binary patch literal 37076 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 literal 0 HcmV?d00001 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 @@ + + + + +