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"/>
+
+
Media3 short-form content Demo
Open view pager activity
+ Open lazy column activity
Add view pager, please!
ViewPager activity
+ LazyColumn activity
How Many Players?