From b53862d2923c33792180a4938315a387f6976b9d Mon Sep 17 00:00:00 2001 From: Amud Date: Thu, 18 Sep 2025 12:47:19 +0530 Subject: [PATCH] Add LazyColumn shortform demo addressing single-player performance (#1853) Implements optimized single-player architecture for shortform content that addresses the performance issues described in #1853. Key optimizations: - Sliding window management (10 items) with dynamic loading - Aggressive preloading for adjacent items (3-5 second buffers) - Reactive cache status tracking prevents premature thumbnail loading - Optimized load control settings for quick start (500ms buffer) This provides a performant alternative to multi-player pools while maintaining lower memory overhead and simpler architecture. Fixes #1853 --- demos/shortform/build.gradle | 12 ++ demos/shortform/src/main/AndroidManifest.xml | 4 + .../media3/demo/shortform/MainActivity.kt | 7 + .../demo/shortform/MediaItemDatabase.kt | 2 + .../shortform/lazycolumn/BitmapProvider.kt | 29 +++ .../lazycolumn/ConcatMediaDataSource.kt | 74 +++++++ .../lazycolumn/LazyColumnActivity.kt | 73 +++++++ .../lazycolumn/LazyColumnPlayerManager.kt | 204 ++++++++++++++++++ .../LazyColumnTargetPreloadStatusControl.kt | 38 ++++ .../lazycolumn/SinglePlayerSetupHelper.kt | 62 ++++++ .../lazycolumn/SurfaceTextureListener.kt | 42 ++++ .../composable/LazyColumnMediaItem.kt | 114 ++++++++++ .../composable/ShortFormLazyColumn.kt | 103 +++++++++ .../lazycolumn/composable/VideoThumbnail.kt | 116 ++++++++++ .../src/main/res/layout/activity_main.xml | 10 + .../shortform/src/main/res/values/strings.xml | 2 + 16 files changed, 892 insertions(+) create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/BitmapProvider.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/ConcatMediaDataSource.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnActivity.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnPlayerManager.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnTargetPreloadStatusControl.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SinglePlayerSetupHelper.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SurfaceTextureListener.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/LazyColumnMediaItem.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/ShortFormLazyColumn.kt create mode 100644 demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/VideoThumbnail.kt diff --git a/demos/shortform/build.gradle b/demos/shortform/build.gradle index aa89367c08a..8613b6ffa8f 100644 --- a/demos/shortform/build.gradle +++ b/demos/shortform/build.gradle @@ -14,6 +14,7 @@ apply from: '../../constants.gradle' apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { namespace 'androidx.media3.demo.shortform' @@ -57,6 +58,7 @@ android { } buildFeatures { viewBinding true + compose true } sourceSets { main { @@ -80,4 +82,14 @@ dependencies { implementation project(modulePrefix + 'lib-exoplayer-dash') implementation project(modulePrefix + 'lib-exoplayer-hls') implementation project(modulePrefix + 'lib-ui') + + // Compose dependencies + def composeBom = platform('androidx.compose:compose-bom:2024.12.01') + implementation composeBom + implementation 'androidx.activity:activity-compose:1.9.0' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.runtime:runtime' + debugImplementation 'androidx.compose.ui:ui-tooling' } diff --git a/demos/shortform/src/main/AndroidManifest.xml b/demos/shortform/src/main/AndroidManifest.xml index 9a8a12d4529..d9520bf1610 100644 --- a/demos/shortform/src/main/AndroidManifest.xml +++ b/demos/shortform/src/main/AndroidManifest.xml @@ -34,6 +34,10 @@ android:exported="false" android:label="@string/title_activity_view_pager" android:name=".viewpager.ViewPagerActivity"/> + 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 index 06a8434e42c..156eec2e5ca 100644 --- a/demos/shortform/src/main/java/androidx/media3/demo/shortform/MainActivity.kt +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MainActivity.kt @@ -23,6 +23,7 @@ import android.view.View import android.widget.EditText import androidx.appcompat.app.AppCompatActivity import androidx.media3.common.util.UnstableApi +import androidx.media3.demo.shortform.lazycolumn.LazyColumnActivity import androidx.media3.demo.shortform.viewpager.ViewPagerActivity import java.lang.Integer.max import java.lang.Integer.min @@ -56,6 +57,12 @@ class MainActivity : AppCompatActivity() { Intent(this, ViewPagerActivity::class.java).putExtra(NUM_PLAYERS_EXTRA, numberOfPlayers) ) } + + findViewById(R.id.lazy_column_button).setOnClickListener { + startActivity( + Intent(this, LazyColumnActivity::class.java).putExtra(NUM_PLAYERS_EXTRA, numberOfPlayers) + ) + } } companion object { 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 index 19577d6fe2b..db32b7c97f2 100644 --- a/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaItemDatabase.kt +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/MediaItemDatabase.kt @@ -32,4 +32,6 @@ class MediaItemDatabase { val uri = mediaUris[index.mod(mediaUris.size)] return MediaItem.Builder().setUri(uri).setMediaId(index.toString()).build() } + + fun size(): Int = mediaUris.size } diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/BitmapProvider.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/BitmapProvider.kt new file mode 100644 index 00000000000..876d1b35eb2 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/BitmapProvider.kt @@ -0,0 +1,29 @@ +package androidx.media3.demo.shortform.lazycolumn + +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.os.Build + +class BitmapProvider { + companion object { + private const val THUMB_EXTRACT_TIME = 1 * 1000L * 1000L + } + + fun getBitmap( + ids: List?, + metadataRetriever: MediaMetadataRetriever, + ): Bitmap? { + if (ids.isNullOrEmpty()) return null + if (ids.size > 1) { + val customDataSource = ConcatMediaDataSource(ids) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + metadataRetriever.setDataSource(customDataSource) + } + } else { + metadataRetriever.setDataSource(ids.getOrNull(0)) + } + return metadataRetriever.getFrameAtTime(0) ?: metadataRetriever.getFrameAtTime( + THUMB_EXTRACT_TIME + ) + } +} diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/ConcatMediaDataSource.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/ConcatMediaDataSource.kt new file mode 100644 index 00000000000..4c8bab25301 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/ConcatMediaDataSource.kt @@ -0,0 +1,74 @@ +package androidx.media3.demo.shortform.lazycolumn + +import android.annotation.SuppressLint +import android.media.MediaDataSource +import java.io.ByteArrayOutputStream +import java.io.FileInputStream +import java.io.SequenceInputStream +import java.util.Enumeration +import kotlin.math.min + +@SuppressLint("NewApi") +class ConcatMediaDataSource(ids: List) : MediaDataSource() { + + private var data: ByteArray? = null + private val mergedInputStream: SequenceInputStream by lazy { getInputStream(ids) } + + private fun ensureDataBuffered() { + synchronized(this) { + if (data != null) return + ByteArrayOutputStream().use { buffer -> + mergedInputStream.use { input -> + input.copyTo(buffer) + } + data = buffer.toByteArray() + } + } + } + + override fun readAt( + position: Long, + buffer: ByteArray, + offset: Int, + size: Int, + ): Int { + synchronized(this) { + ensureDataBuffered() + data?.let { + if (position > it.size) return -1 + val actualSize = min(size, it.size - position.toInt()) + System.arraycopy(it, position.toInt(), buffer, offset, actualSize) + return actualSize + } ?: run { + return -1 + } + } + } + + override fun getSize(): Long { + synchronized(this) { + return (data?.size?.toLong() ?: -1) + } + } + + override fun close() { + synchronized(this) { + mergedInputStream.close() + data = null + } + } + + private fun getInputStream(files: List): SequenceInputStream { + val inputStreams = files.map { FileInputStream(it) } + val allInputStreams = listOf(*inputStreams.toTypedArray()) + return SequenceInputStream(allInputStreams.iterator().asEnumeration()) + } +} + +fun Iterator.asEnumeration(): Enumeration { + return object : Enumeration { + override fun hasMoreElements(): Boolean = this@asEnumeration.hasNext() + + override fun nextElement(): T = this@asEnumeration.next() + } +} diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnActivity.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnActivity.kt new file mode 100644 index 00000000000..857c8e4accf --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnActivity.kt @@ -0,0 +1,73 @@ +/* + * 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.lazycolumn + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.util.UnstableApi +import androidx.media3.demo.shortform.lazycolumn.composable.ShortFormLazyColumn + +@UnstableApi +class LazyColumnActivity : ComponentActivity() { + companion object { + const val LOAD_CONTROL_MIN_BUFFER_MS = 3_000 + const val LOAD_CONTROL_MAX_BUFFER_MS = 20_000 + const val LOAD_CONTROL_BUFFER_FOR_PLAYBACK_MS = 500 + const val PLAYER_CACHE_DIRECTORY = "exo_player" + } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background + ) { + ShortFormContent() + } + } + } + } +} + +@UnstableApi +@Composable +fun ShortFormContent() { + val context = LocalContext.current + val playerManager = remember { + LazyColumnPlayerManager(context) + } + + DisposableEffect(Unit) { + onDispose { + playerManager.release() + } + } + + ShortFormLazyColumn( + playerManager = playerManager + ) +} \ No newline at end of file diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnPlayerManager.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnPlayerManager.kt new file mode 100644 index 00000000000..16425c8dd57 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnPlayerManager.kt @@ -0,0 +1,204 @@ +/* + * 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.lazycolumn + +import android.content.Context +import android.view.Surface +import androidx.compose.runtime.mutableStateMapOf +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.demo.shortform.MediaItemDatabase +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager +import androidx.media3.exoplayer.source.preload.PreloadManagerListener + +/** + * Manages media playback for LazyColumn with window-based preloading and caching. + * + * This class implements a sliding window approach where only a subset of media items + * are kept in memory at any time, with intelligent preloading of adjacent items + * to ensure smooth playback transitions. + */ +@UnstableApi +class LazyColumnPlayerManager(appContext: Context) { + + companion object { + /** Number of media items to keep in the sliding window */ + private const val MANAGED_ITEM_COUNT = 10 + /** Number of items to add/remove when adjusting the window */ + private const val ITEM_ADD_REMOVE_COUNT = 4 + } + + private val singlePlayerSetupHelper by lazy { SinglePlayerSetupHelper(appContext) } + private val player = singlePlayerSetupHelper.player + private val preloadManager: DefaultPreloadManager = singlePlayerSetupHelper.preloadManager + private var targetPreloadStatusControl: LazyColumnTargetPreloadStatusControl = + singlePlayerSetupHelper.targetPreloadStatusControl + private val mediaItemDatabase: MediaItemDatabase = singlePlayerSetupHelper.mediaItemDatabase + private val cache: SimpleCache = singlePlayerSetupHelper.cache + private var currentPlayingIndex = -1 + private val currentMediaItemsAndIndexes: ArrayDeque> = ArrayDeque() + var onFirstFrame: ((Int) -> Unit)? = null + + /** + * Reactive map tracking which media items have been successfully cached. + * Key: media item index, Value: true if cached, false otherwise + */ + val cachedStatus = mutableStateMapOf() + + init { + player.addAnalyticsListener(object : AnalyticsListener { + override fun onRenderedFirstFrame( + eventTime: AnalyticsListener.EventTime, + output: Any, + renderTimeMs: Long + ) { + onFirstFrame?.invoke(currentPlayingIndex) + } + }) + + preloadManager.addListener(object : PreloadManagerListener { + override fun onCompleted(mediaItem: MediaItem) { + cachedStatus[mediaItem.mediaId.toInt()] = true + super.onCompleted(mediaItem) + } + }) + + initializeAllItems() + } + + private fun initializeAllItems() { + for (i in 0 until mediaItemDatabase.size()) { + val mediaItem = mediaItemDatabase.get(i) + preloadManager.add(mediaItem, i) + preloadManager.getMediaSource(mediaItem)?.let { + player.addMediaSource(i, it) + } + currentMediaItemsAndIndexes.addLast(Pair(mediaItem, i)) + } + preloadManager.invalidate() + } + + /** + * Retrieves cached file paths for generating video thumbnails. + * + * @param url The media URL to get cached paths for + * @return List of cached file paths if available, null otherwise + */ + fun getThumbnailPaths(url: String): List? { + val cachedSpans = cache.getCachedSpans(url) + val cachedPaths = + cachedSpans + .filter { it.isCached && (it.file?.length() ?: 0) > 0 } + .mapNotNull { it.file?.path } + return cachedPaths.ifEmpty { null } + } + + fun getUrl(index: Int): String { + return mediaItemDatabase.get(index).localConfiguration?.uri.toString() + } + + /** + * Updates the current playing index and manages the sliding window of media items. + * + * When the new index approaches the edges of the current window (within 2 items), + * this method automatically adds new items in the direction of movement and removes + * items from the opposite end to maintain optimal memory usage. + * + * @param newIndex The index of the media item to play + */ + fun updateCurrentIndex(newIndex: Int) { + val previousIndex = currentPlayingIndex + currentPlayingIndex = newIndex + if (newIndex != previousIndex) { + if (!currentMediaItemsAndIndexes.isEmpty()) { + val leftMostIndex = currentMediaItemsAndIndexes.first().second + val rightMostIndex = currentMediaItemsAndIndexes.last().second + + if (rightMostIndex - newIndex <= 2) { + for (i in 1..ITEM_ADD_REMOVE_COUNT) { + addMediaItem(index = rightMostIndex + i, isAddingToRightEnd = true) + removeMediaItem(isRemovingFromRightEnd = false) + } + } + else if (newIndex - leftMostIndex <= 2 && leftMostIndex > 0) { + for (i in 1..ITEM_ADD_REMOVE_COUNT) { + if (leftMostIndex - i >= 0) { + addMediaItem(index = leftMostIndex - i, isAddingToRightEnd = false) + removeMediaItem(isRemovingFromRightEnd = true) + } + } + } + } + + player.seekTo(newIndex, C.TIME_UNSET) + player.playWhenReady = true + targetPreloadStatusControl.currentPlayingIndex = newIndex + preloadManager.setCurrentPlayingIndex(newIndex) + preloadManager.invalidate() + } + } + + fun play() { + player.playWhenReady = true + } + + fun pause() { + player.playWhenReady = false + } + + fun release() { + preloadManager.release() + player.release() + } + + fun setVideoSurface(surface: Surface?) { + player.setVideoSurface(null) + player.setVideoSurface(surface) + } + + private fun addMediaItem(index: Int, isAddingToRightEnd: Boolean) { + if (index < 0) { + return + } + val mediaItem = mediaItemDatabase.get(index) + preloadManager.add(mediaItem, index) + if (isAddingToRightEnd) { + currentMediaItemsAndIndexes.addLast(Pair(mediaItem, index)) + } else { + currentMediaItemsAndIndexes.addFirst(Pair(mediaItem, index)) + } + } + + private fun removeMediaItem(isRemovingFromRightEnd: Boolean) { + if (currentMediaItemsAndIndexes.size <= MANAGED_ITEM_COUNT) { + return + } + val itemAndIndex = if (isRemovingFromRightEnd) { + currentMediaItemsAndIndexes.removeLast() + } else { + currentMediaItemsAndIndexes.removeFirst() + } + preloadManager.remove(itemAndIndex.first) + } + + fun getMediaCount(): Int { + return mediaItemDatabase.size() + } +} + diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnTargetPreloadStatusControl.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnTargetPreloadStatusControl.kt new file mode 100644 index 00000000000..4bc3e02a535 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/LazyColumnTargetPreloadStatusControl.kt @@ -0,0 +1,38 @@ +/* + * 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.lazycolumn + +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.demo.shortform.lazycolumn.LazyColumnActivity.Companion.LOAD_CONTROL_MIN_BUFFER_MS +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager +import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl +import kotlin.math.abs + +@UnstableApi +class LazyColumnTargetPreloadStatusControl( + var currentPlayingIndex: Int = C.INDEX_UNSET +) : TargetPreloadStatusControl { + + override fun getTargetPreloadStatus(rankingData: Int): DefaultPreloadManager.PreloadStatus? { + if (abs(rankingData - currentPlayingIndex) == 2) { + return DefaultPreloadManager.PreloadStatus.specifiedRangeLoaded(/* durationMs= */ LOAD_CONTROL_MIN_BUFFER_MS.toLong()) + } else if (abs(rankingData - currentPlayingIndex) == 1) { + return DefaultPreloadManager.PreloadStatus.specifiedRangeLoaded(/* durationMs= */ LOAD_CONTROL_MIN_BUFFER_MS.toLong()) + } + return null + } +} \ No newline at end of file diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SinglePlayerSetupHelper.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SinglePlayerSetupHelper.kt new file mode 100644 index 00000000000..fd0c03bd5ea --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SinglePlayerSetupHelper.kt @@ -0,0 +1,62 @@ +package androidx.media3.demo.shortform.lazycolumn + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.demo.shortform.MediaItemDatabase +import androidx.media3.demo.shortform.lazycolumn.LazyColumnActivity.Companion.LOAD_CONTROL_BUFFER_FOR_PLAYBACK_MS +import androidx.media3.demo.shortform.lazycolumn.LazyColumnActivity.Companion.LOAD_CONTROL_MAX_BUFFER_MS +import androidx.media3.demo.shortform.lazycolumn.LazyColumnActivity.Companion.LOAD_CONTROL_MIN_BUFFER_MS +import androidx.media3.demo.shortform.lazycolumn.LazyColumnActivity.Companion.PLAYER_CACHE_DIRECTORY +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager +import java.io.File + +@OptIn(UnstableApi::class) +class SinglePlayerSetupHelper(appContext: Context) { + + var preloadManager: DefaultPreloadManager + val targetPreloadStatusControl = LazyColumnTargetPreloadStatusControl() + val mediaItemDatabase = MediaItemDatabase() + val cache: SimpleCache + + var player: ExoPlayer + + + init { + val loadControl = DefaultLoadControl.Builder().setBufferDurationsMs( + LOAD_CONTROL_MIN_BUFFER_MS, + LOAD_CONTROL_MAX_BUFFER_MS, + LOAD_CONTROL_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + ).setPrioritizeTimeOverSizeThresholds(true).build() + + val databaseProvider = StandaloneDatabaseProvider(appContext) + val cacheEvictor = LeastRecentlyUsedCacheEvictor(256L * 1024 * 1024) + cache = SimpleCache( + File(appContext.cacheDir, PLAYER_CACHE_DIRECTORY), cacheEvictor, databaseProvider + ) + val upstreamFactory = DefaultDataSource.Factory(appContext) + val cacheDataSourceFactory = + CacheDataSource.Factory().setCache(cache).setUpstreamDataSourceFactory(upstreamFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + + val preloadManagerBuilder = + DefaultPreloadManager.Builder(appContext, targetPreloadStatusControl) + .setLoadControl(loadControl) + .setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory)) + preloadManager = preloadManagerBuilder.build() + player = preloadManagerBuilder.buildExoPlayer().apply { + prepare() + pauseAtEndOfMediaItems = true + } + } + +} \ No newline at end of file diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SurfaceTextureListener.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SurfaceTextureListener.kt new file mode 100644 index 00000000000..131eb2a2381 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/SurfaceTextureListener.kt @@ -0,0 +1,42 @@ +package androidx.media3.demo.shortform.lazycolumn + +import android.graphics.SurfaceTexture +import android.view.Surface +import android.view.TextureView + +class SurfaceTextureListener { + + private val releasableSurfaceTextures by lazy { mutableListOf() } + + fun get( + onSurfaceUpdate: (Surface) -> Unit, + ) = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable( + surface: SurfaceTexture, + width: Int, + height: Int, + ) { + onSurfaceUpdate(Surface(surface)) + } + + override fun onSurfaceTextureSizeChanged( + surface: SurfaceTexture, + width: Int, + height: Int, + ) { + onSurfaceUpdate(Surface(surface)) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + releasableSurfaceTextures.add(surface) + return false + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} + } + + fun release() { + releasableSurfaceTextures.forEach { it.release() } + releasableSurfaceTextures.clear() + } +} diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/LazyColumnMediaItem.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/LazyColumnMediaItem.kt new file mode 100644 index 00000000000..b5449a68aa5 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/LazyColumnMediaItem.kt @@ -0,0 +1,114 @@ +/* + * 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.lazycolumn.composable + +import android.view.Surface +import android.view.TextureView +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.util.UnstableApi +import androidx.media3.demo.shortform.lazycolumn.LazyColumnPlayerManager +import androidx.media3.demo.shortform.lazycolumn.SurfaceTextureListener + +@UnstableApi +@Composable +fun LazyColumnMediaItem( + playerManager: LazyColumnPlayerManager, + index: Int, + shouldPlay: Boolean, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val textureView = remember { mutableStateOf(TextureView(context)) } + val surfaceTextureListener = remember { SurfaceTextureListener() } + val surfaceWrapper = remember { mutableStateOf(null) } + val isCached = playerManager.cachedStatus[index] == true + val showThumbnail = remember { mutableStateOf(isCached) } + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + playerManager.pause() + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + surfaceTextureListener.release() + surfaceWrapper.value?.release() + surfaceWrapper.value = null + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + + Box(modifier) { + AndroidView( + factory = { textureView.value }, + update = { + it.surfaceTextureListener = surfaceTextureListener.get( + ) { surface -> + surfaceWrapper.value = surface + } + }, + ) + + VideoThumbnail( + id = playerManager.getUrl(index), + index = index, + showThumbnail = showThumbnail.value, + playerManager, + ) + } + + MediaViewSurface( + surface = surfaceWrapper.value, + shouldPlay = shouldPlay, + playerManager = playerManager, + onFirstFrame = { + showThumbnail.value = false + } + ) +} + + +@OptIn(UnstableApi::class) +@Composable +private fun MediaViewSurface( + surface: Surface?, + shouldPlay: Boolean, + playerManager: LazyColumnPlayerManager, + onFirstFrame: (Int) -> Unit = { }, +) { + if (shouldPlay && surface != null) { + LaunchedEffect(surface) { + playerManager.onFirstFrame = onFirstFrame + playerManager.setVideoSurface(surface) + } + } +} \ No newline at end of file diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/ShortFormLazyColumn.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/ShortFormLazyColumn.kt new file mode 100644 index 00000000000..773b7c396f1 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/ShortFormLazyColumn.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.lazycolumn.composable + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.snapFlingBehavior +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.distinctUntilChanged +import androidx.media3.common.util.UnstableApi +import androidx.media3.demo.shortform.lazycolumn.LazyColumnPlayerManager + + +@OptIn(ExperimentalFoundationApi::class) +@UnstableApi +@Composable +fun ShortFormLazyColumn( + playerManager: LazyColumnPlayerManager +) { + val lazyListState = rememberLazyListState() + var currentPlayingIndex by remember { mutableIntStateOf(0) } + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + + LaunchedEffect(lazyListState) { + snapshotFlow { lazyListState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { currentIndex -> + currentPlayingIndex = currentIndex + playerManager.updateCurrentIndex(currentIndex) + } + } + + val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState) + + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + flingBehavior = snapFlingBehavior + ) { + items( + count = playerManager.getMediaCount(), + key = { index -> index } + ) { index -> + val shouldPlay = index == currentPlayingIndex + + LazyColumnMediaItem( + playerManager = playerManager, + index = index, + shouldPlay = shouldPlay, + modifier = Modifier + .fillMaxWidth() + .height(screenHeight) + .background(Color.Black) + ) + } + } +} + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun rememberSnapFlingBehavior(listState: LazyListState): FlingBehavior { + return remember(listState) { + snapFlingBehavior( + snapLayoutInfoProvider = SnapLayoutInfoProvider( + lazyListState = listState, + snapPosition = SnapPosition.Start + ), + decayAnimationSpec = exponentialDecay(1.5f), + snapAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) + } +} \ No newline at end of file diff --git a/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/VideoThumbnail.kt b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/VideoThumbnail.kt new file mode 100644 index 00000000000..4e104dfd8f6 --- /dev/null +++ b/demos/shortform/src/main/java/androidx/media3/demo/shortform/lazycolumn/composable/VideoThumbnail.kt @@ -0,0 +1,116 @@ +package androidx.media3.demo.shortform.lazycolumn.composable + +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.media3.common.util.UnstableApi +import androidx.media3.demo.shortform.lazycolumn.BitmapProvider +import androidx.media3.demo.shortform.lazycolumn.LazyColumnPlayerManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +@OptIn(UnstableApi::class) +@Composable +internal fun VideoThumbnail( + id: String, + index: Int, + showThumbnail: Boolean, + lazyColumnPlayerManager: LazyColumnPlayerManager, + thumbnailLoaded: (Boolean) -> Unit = {}, +) { + val metadataRetriever = mediaDataRetrieverProvider() + val cachedPath = cachedPathProvider(id, index, lazyColumnPlayerManager, showThumbnail) + val bitmapProvider = remember { BitmapProvider() } + val cachedBitmap = remember { mutableStateOf(null) } + + AnimatedVisibility( + visible = showThumbnail, + enter = fadeIn(tween(0)), + exit = fadeOut(tween(100)), + ) { + cachedBitmap.value?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentScale = ContentScale.FillBounds, + contentDescription = "video_thumbnail", + colorFilter = null, + ) + } + } + + LaunchedEffect(key1 = cachedPath, key2 = metadataRetriever) { + if (metadataRetriever != null) { + withContext(coroutineContext + Dispatchers.IO) { + if (cachedBitmap.value == null) { + bitmapRetriever(bitmapProvider, cachedPath, metadataRetriever)?.run { + cachedBitmap.value = this + thumbnailLoaded(true) + } + } + } + } + } + + DisposableEffect(key1 = Unit) { + onDispose { + cachedBitmap.value?.recycle() + cachedBitmap.value = null + metadataRetriever?.release() + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun cachedPathProvider( + id: String, + index: Int, + lazyColumnPlayerManager: LazyColumnPlayerManager, + showThumbnail: Boolean +): List? { + val result = remember { mutableStateOf?>(null) } + val isCached = lazyColumnPlayerManager.cachedStatus[index] == true + + LaunchedEffect(showThumbnail, isCached) { + if (showThumbnail && isCached) { + withContext(coroutineContext + Dispatchers.IO) { + delay(50) + result.value = lazyColumnPlayerManager.getThumbnailPaths(id) + } + } + } + return result.value +} + +@Composable +private fun mediaDataRetrieverProvider(): MediaMetadataRetriever? { + return produceState(initialValue = null) { + withContext(coroutineContext + Dispatchers.IO) { value = MediaMetadataRetriever() } + }.value +} + +@ReadOnlyComposable +private fun bitmapRetriever( + bitmapProvider: BitmapProvider, + cachedPath: List?, + metadataRetriever: MediaMetadataRetriever +): Bitmap? = try { + bitmapProvider.getBitmap(cachedPath, metadataRetriever) +} catch (_: Exception) { + null +} diff --git a/demos/shortform/src/main/res/layout/activity_main.xml b/demos/shortform/src/main/res/layout/activity_main.xml index 65585180aef..a91d16e5012 100644 --- a/demos/shortform/src/main/res/layout/activity_main.xml +++ b/demos/shortform/src/main/res/layout/activity_main.xml @@ -29,6 +29,16 @@ android:layout_marginEnd="12dp" android:layout_marginRight="12dp"/> +