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