I am using Exoplayer inside a HorizontalPager as I want to get a carousel of videos where the next one is always loaded to optimize the performance of loading videos. To manage the multiple instances I have a class called ExoplayersPool:
@SuppressLint("UnsafeOptInUsageError")
class ExoPlayersPool(
private val context: Context,
private val videos: List<String>,
) {
private val players = hashMapOf<Int, ExoPlayer?>()
fun getExoInstance(index: Int) = players[index].takeIf {
it != null
} ?: run {
val videoUrl = videos[index]
players[index]?.release()
val mediaSource = MediaItem.Builder()
.setUri(Uri.parse(videoUrl))
.setMimeType(getMimeTypeFromUrl(videoUrl))
.build()
val exoPlayer = ExoPlayer.Builder(context)
.build().apply {
repeatMode = Player.REPEAT_MODE_ALL
volume = 1f
seekTo(0)
setMediaItem(mediaSource)
prepare()
}
players[index] = exoPlayer
exoPlayer
}
fun releasePlayer(index: Int) {
val player = players[index]
player?.release()
players[index] = null
}
fun releaseAll() {
players.forEach {
it.value?.release()
}
players.clear()
}
private fun getMimeTypeFromUrl(videoUrl: String): String? {
val videoUri = Uri.parse(videoUrl)
val inferContentType = Util.inferContentType(
videoUri,
null
)
return Util.getAdaptiveMimeTypeForContentType(inferContentType)
.takeIf { it?.isNotEmpty() == true } ?: run {
val extension = MimeTypeMap.getFileExtensionFromUrl(
videoUri.encodedPath
)
MimeTypeMap.getSingleton().getMimeTypeFromExtension(
extension.lowercase(Locale.getDefault())
)
}
}
}
In this class I manage the creation of the different exoplayers that I need to show in the carousel. This carousel is managed by a HorizontalPager that scrolls to the next position automatically:
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun HomeScreen() {
val videos = listOf(
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4"
)
val pagerState = rememberPagerState(pageCount = { videos.size })
val coroutineScope = rememberCoroutineScope()
var selectedPage by remember { mutableIntStateOf(0) }
val onSwipe = {
coroutineScope.launch {
val targetPage = (selectedPage + 1).takeIf {
selectedPage < videos.size - 1
}.orZero()
pagerState.animateScrollToPage(targetPage)
if (targetPage == 0) {
pagerState.scrollToPage(0)
}
}
}
LaunchedEffect(key1 = pagerState) {
snapshotFlow {
pagerState.currentPage
}.collect { page ->
selectedPage = page
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
HeaderSection(
pagerState = pagerState,
videos = videos,
onSwipe = onSwipe
)
}
items(10) { index ->
FillSection(index)
}
}
}
@Composable
private fun FillSection(index: Int) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(
color = if (index % 2 == 0) {
Color.Gray
} else {
Color.Blue
}
)
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun HeaderSection(
pagerState: PagerState,
videos: List<String>,
onSwipe: () -> Job
) {
Box(modifier = Modifier.fillMaxSize()) {
val exoPlayersPool = rememberExoPlayersPool(
pages = videos,
)
HorizontalPager(
modifier = Modifier
.fillMaxWidth()
.height(350.dp),
state = pagerState,
beyondBoundsPageCount = 1
) { index ->
ExoplayerContent(videoUrl = videos[index],
getExoInstance = {
exoPlayersPool.getExoInstance(index)
}, onRelease = {
exoPlayersPool.releasePlayer(index)
}
)
}
HomeBannersPageIndicator(
pagerState = pagerState
) {
onSwipe()
}
}
}
In turn, as you can see in the code above, this is inside a LazyColumn and to load my ExoPlayer view I use an AndroidExternalSurface.
The problem is that the first load everything works fine, but the moment the carousel reaches the last video and goes back to the first one, the screen is black, although the sound of the video is still playing:
@Composable
fun ExoplayerContent(
videoUrl: String,
modifier: Modifier = Modifier,
getExoInstance: () -> ExoPlayer,
onRelease: () -> Unit,
onError: (Throwable) -> Unit = {}
) {
Box(
modifier = modifier.fillMaxSize()
) {
val isVideoUrl = MediaViewUtils.isVideo(url = videoUrl)
if (isVideoUrl) {
ExoPlayerView(
player = getExoInstance(),
modifier = modifier,
onRelease = onRelease,
onError = onError
)
}
}
}
@SuppressLint("RestrictedApi")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BoxScope.HomeBannersPageIndicator(
pagerState: PagerState,
onAnimationFinished: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(20.dp)
.align(Alignment.BottomStart),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
var currentPage by remember { mutableIntStateOf(0) }
var progress by remember { mutableFloatStateOf(0.0f) }
LaunchedEffect(
key1 = pagerState.currentPage,
key2 = pagerState.isScrollInProgress
) {
if (pagerState.currentPage != currentPage) {
currentPage = pagerState.currentPage
progress = 0.0f
}
while (progress <= 1.0f) {
if (pagerState.isScrollInProgress) break
progress += 0.01f
delay(50)
}
if (progress >= 1.0f) {
onAnimationFinished()
}
}
repeat(pagerState.pageCount) { index ->
LinearProgressIndicator(
modifier = Modifier
.weight(1f)
.height(2.dp),
progress = progress.takeIf {
currentPage == index
} ?: 0F,
color = Color.White,
trackColor = Color.Gray.copy(
alpha = 0.5f
)
)
}
}
}
@Composable
fun rememberExoPlayersPool(
pages: List<String>,
): ExoPlayersPool {
val context = LocalContext.current
val playersPool = remember {
ExoPlayersPool(
context = context,
videos = pages
)
}
DisposableEffect(Unit) {
onDispose {
playersPool.releaseAll()
}
}
return playersPool
}
Here are all the code snippets related to this problem, as I have tried to search for information and have not been able to find anything.