Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add preload manager to exoplayer to improve short form playback experience #91

Merged
merged 9 commits into from
Sep 6, 2024
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2024 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 com.google.android.samples.socialite.data.utils

/**
*
* Sample list of short form video urls, used for preloading multiple videos in background especially used for enabling preload manager of exoplayer.
*
*/
class ShortsVideoList {
companion object {
val mediaUris =
mutableListOf(
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_1.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_2.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_3.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_4.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_5.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_6.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_7.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_8.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_9.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_10.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_11.mp4",
)
}

fun get(index: Int): String {
return mediaUris[index.mod(mediaUris.size)]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.google.android.samples.socialite.R
import com.google.android.samples.socialite.data.ChatDao
import com.google.android.samples.socialite.data.ContactDao
import com.google.android.samples.socialite.data.MessageDao
import com.google.android.samples.socialite.data.utils.ShortsVideoList
import com.google.android.samples.socialite.di.AppCoroutineScope
import com.google.android.samples.socialite.model.ChatDetail
import com.google.android.samples.socialite.model.Message
Expand Down Expand Up @@ -107,6 +108,12 @@ class ChatRepository @Inject internal constructor(
)

coroutineScope.launch {
// Special incoming message indicating to add shorts videos to try preload in exoplayer
if (text == "preload") {
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This experience for testing is okay, but if you want to merge this, it should probably be in the main text UI as "story" type blocks that open up this screen, as this just seems like a broken user experience.

preloadShortVideos(chatId, detail, PushReason.IncomingMessage)
return@launch
}

if (isBotEnabled.firstOrNull() == true) {
// Get the previous messages and them generative model chat
val pastMessages = getMessageHistory(chatId)
Expand Down Expand Up @@ -166,6 +173,26 @@ class ChatRepository @Inject internal constructor(
}
}

/**
* Add list of short form videos as sent messages to current chat history.This is used to test the preload manager of exoplayer
*/
private suspend fun preloadShortVideos(
chatId: Long,
detail: ChatDetail,
incomingMessage: PushReason,
) {
for ((index, uri) in ShortsVideoList.mediaUris.withIndex())
saveMessageAndNotify(
chatId,
"Shorts $index",
0L,
uri,
"video/mp4",
detail,
incomingMessage,
)
}

private suspend fun saveMessageAndNotify(
chatId: Long,
text: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package com.google.android.samples.socialite.ui.home.timeline
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.AndroidExternalSurface
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
Expand All @@ -28,7 +27,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
Expand All @@ -53,16 +51,20 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.google.android.samples.socialite.R
Expand All @@ -71,6 +73,7 @@ import kotlin.math.absoluteValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun Timeline(
contentPadding: PaddingValues,
Expand Down Expand Up @@ -106,17 +109,17 @@ fun TimelineVerticalPager(
player: Player?,
onInitializePlayer: () -> Unit = {},
onReleasePlayer: () -> Unit = {},
onChangePlayerItem: (uri: Uri?) -> Unit = {},
onChangePlayerItem: (uri: Uri?, page: Int) -> Unit = { uri: Uri?, i: Int -> },
videoRatio: Float?,
) {
val pagerState = rememberPagerState(pageCount = { mediaItems.count() })
LaunchedEffect(pagerState) {
// Collect from the a snapshotFlow reading the settledPage
snapshotFlow { pagerState.settledPage }.collect { page ->
if (mediaItems[page].type == TimelineMediaType.VIDEO) {
onChangePlayerItem(Uri.parse(mediaItems[page].uri))
onChangePlayerItem(Uri.parse(mediaItems[page].uri), pagerState.currentPage)
} else {
onChangePlayerItem(null)
onChangePlayerItem(null, pagerState.currentPage)
}
}
}
Expand Down Expand Up @@ -200,23 +203,18 @@ fun TimelinePage(
when (media.type) {
TimelineMediaType.VIDEO -> {
if (page == state.settledPage) {
// Use a default 1:1 ratio if the video size is unknown
val sanitizedRatio = videoRatio ?: 1f
AndroidExternalSurface(
modifier = modifier
.aspectRatio(sanitizedRatio, sanitizedRatio < 1f)
.background(Color.White),
) {
onSurface { surface, _, _ ->
player.setVideoSurface(surface)

// Cleanup when surface is destroyed
surface.onDestroyed {
player.clearVideoSurface(this)
release()
}
}
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
Box(modifier = modifier)
return
}
AndroidView(
factory = { PlayerView(it) },
update = { playerView ->
playerView.player = player
},
modifier = modifier.fillMaxSize(),
)
}
}
TimelineMediaType.PHOTO -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.google.android.samples.socialite.ui.home.timeline

import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand All @@ -31,12 +32,14 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import com.google.android.samples.socialite.repository.ChatRepository
import com.google.android.samples.socialite.ui.player.preloadmanager.PreloadManagerWrapper
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

@UnstableApi
@HiltViewModel
class TimelineViewModel @Inject constructor(
@ApplicationContext private val application: Context,
Expand All @@ -52,6 +55,10 @@ class TimelineViewModel @Inject constructor(
// Width/Height ratio of the current media item, used to properly size the Surface
var videoRatio by mutableStateOf<Float?>(null)

private val enablePreloadManager: Boolean = true
private lateinit var preloadManager: PreloadManagerWrapper
var timeToFirstFrame = 0L
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved

private val videoSizeListener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
videoRatio = if (videoSize.height > 0 && videoSize.width > 0) {
Expand All @@ -63,6 +70,14 @@ class TimelineViewModel @Inject constructor(
}
}

private val firstFrameListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
timeToFirstFrame = System.currentTimeMillis() - timeToFirstFrame
Log.d("PreloadManager", "\t\tTime to first Frame = $timeToFirstFrame ")
super.onRenderedFirstFrame()
}
}

init {
viewModelScope.launch {
val allChats = repository.getChats().first()
Expand Down Expand Up @@ -96,7 +111,8 @@ class TimelineViewModel @Inject constructor(

// Reduced buffer durations since the primary use-case is for short-form videos
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved
val loadControl =
DefaultLoadControl.Builder().setBufferDurationsMs(500, 1000, 0, 500).build()
DefaultLoadControl.Builder().setBufferDurationsMs(5_000, 20_000, 5_00, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
.setPrioritizeTimeOverSizeThresholds(true).build()
val newPlayer = ExoPlayer
.Builder(application.applicationContext)
.setLoadControl(loadControl)
Expand All @@ -105,31 +121,59 @@ class TimelineViewModel @Inject constructor(
it.repeatMode = ExoPlayer.REPEAT_MODE_ONE
it.playWhenReady = true
it.addListener(videoSizeListener)
it.addListener(firstFrameListener)
}

videoRatio = null
player = newPlayer

if (enablePreloadManager) {
initPreloadManager(loadControl)
}
}

private fun initPreloadManager(loadControl: DefaultLoadControl) {
preloadManager =
PreloadManagerWrapper.build(
(player as ExoPlayer).applicationLooper,
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved
loadControl,
application.applicationContext,
)
preloadManager.setPreloadWindowSize(5)

// Add videos to preload
if (media.isNotEmpty()) {
preloadManager.init(media)
}
}

fun releasePlayer() {
player?.apply {
removeListener(videoSizeListener)
release()
}

if (enablePreloadManager) {
preloadManager.release()
}
videoRatio = null
player = null
}

fun changePlayerItem(uri: Uri?) {
fun changePlayerItem(uri: Uri?, currentPlayingIndex: Int) {
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved
if (player == null) return

player?.apply {
stop()
videoRatio = null
if (uri != null) {
setMediaItem(MediaItem.fromUri(uri))
MayuriKhinvasara marked this conversation as resolved.
Show resolved Hide resolved
timeToFirstFrame = System.currentTimeMillis()
Log.d("PreloadManager", "Video Playing $uri ")
prepare()
if (enablePreloadManager) {
preloadManager.setCurrentPlayingIndex(currentPlayingIndex)
preloadManager.addMediaItem(MediaItem.fromUri(uri), currentPlayingIndex)
}
} else {
clearMediaItems()
}
Expand Down
Loading
Loading