Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ReelPlayerController @Inject constructor(
/** Observable playback state for the UI. */
val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()

private var boundMediaId: String? = null
private var loadedReelIds: List<String> = emptyList()

init {
player.addListener(
Expand All @@ -49,19 +49,26 @@ class ReelPlayerController @Inject constructor(
}

/**
* Bind the shared player to [reel] and start playback. No-op if it is already the
* current item, which makes repeated calls on configuration changes cheap.
* Load the whole feed as a single playlist on the shared player. ExoPlayer then pre-buffers the
* neighbouring items, so swiping to the next reel starts much faster. No-op if the same feed is
* already loaded, which makes repeated calls on recomposition/config-change cheap.
*/
fun bind(reel: Reel) {
if (boundMediaId == reel.id) {
player.playWhenReady = true
return
}
boundMediaId = reel.id
player.setMediaItem(reel.toMediaItem())
fun setReels(reels: List<Reel>) {
val ids = reels.map { it.id }
if (ids == loadedReelIds) return
loadedReelIds = ids
player.setMediaItems(reels.map { it.toMediaItem() })
player.prepare()
}

/** Make [index] the current reel and start playback. */
fun playAt(index: Int) {
if (index !in loadedReelIds.indices) return
if (player.currentMediaItemIndex != index) {
player.seekToDefaultPosition(index)
}
player.playWhenReady = true
_playbackState.value = _playbackState.value.copy(currentMediaId = reel.id)
_playbackState.value = _playbackState.value.copy(currentMediaId = loadedReelIds[index])
}

/** Resume playback of the currently bound reel. */
Expand All @@ -85,7 +92,7 @@ class ReelPlayerController @Inject constructor(
* singleton outlives any screen.
*/
fun release() {
boundMediaId = null
loadedReelIds = emptyList()
player.release()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,72 @@
package dev.composereels.core.media.di

import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
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.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import javax.inject.Singleton

/**
* Provides the app's playback dependencies.
*
* Per the project's hard requirement, exactly one [ExoPlayer] instance exists for the whole
* process. Reels play one video at a time, so reusing a single decoder pipeline keeps memory
* and battery low; pages swap the media item on the shared player instead of creating new ones.
* process. Reels are loaded as a single playlist on that one player so ExoPlayer can pre-buffer the
* next item; a media cache makes already-watched reels start instantly on re-watch.
*/
@Module
@InstallIn(SingletonComponent::class)
object MediaModule {

private const val MAX_CACHE_BYTES = 256L * 1024 * 1024 // 256 MB LRU media cache.

@Provides
@Singleton
@OptIn(UnstableApi::class)
fun providesPlayerCache(@ApplicationContext context: Context): SimpleCache = SimpleCache(
File(context.cacheDir, "reel-media"),
LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES),
StandaloneDatabaseProvider(context),
)

@Provides
@Singleton
@OptIn(UnstableApi::class)
fun providesMediaSourceFactory(
@ApplicationContext context: Context,
cache: SimpleCache,
): MediaSource.Factory {
// Read through the cache, falling back to the network; ignore cache on read errors.
val cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(DefaultDataSource.Factory(context))
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
return DefaultMediaSourceFactory(cacheDataSourceFactory)
}

@Provides
@Singleton
@OptIn(UnstableApi::class)
fun providesExoPlayer(
@ApplicationContext context: Context,
mediaSourceFactory: MediaSource.Factory,
): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
// Pause automatically when headphones are unplugged.
.setHandleAudioBecomingNoisy(true)
// Request audio focus and duck/pause around other apps.
Expand All @@ -40,7 +79,7 @@ object MediaModule {
)
.build()
.apply {
// Reels loop until the user swipes away.
// Loop the current reel; the user swipes to advance.
repeatMode = Player.REPEAT_MODE_ONE
playWhenReady = false
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// The generated @Serializable code references kotlinx.serialization's internal GeneratedSerializer.
// The build handles this opt-in automatically, but some IDE analyzers flag it; opt in at file level
// to keep the editor clean. Harmless to the build.
@file:OptIn(InternalSerializationApi::class)

package dev.composereels.core.network.model

import dev.composereels.core.model.Reel
import dev.composereels.core.model.ReelSource
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,27 @@ class ReelsViewModel @Inject constructor(
private fun bindPage(page: Int) {
val state = _uiState.value
if (state !is ReelsUiState.Success) return
val reel = state.reels.getOrNull(page) ?: return
playerController.bind(reel)
if (page !in state.reels.indices) return
playerController.playAt(page)
_uiState.update { (it as? ReelsUiState.Success)?.copy(currentPage = page) ?: it }
}

private fun loadReels() {
viewModelScope.launch {
reelsRepository.getReels().asResult().collect { result ->
_uiState.update {
when (result) {
Result.Loading -> ReelsUiState.Loading
is Result.Success -> ReelsUiState.Success(reels = result.data)
.also { state -> bindFirstReel(state) }
is Result.Error -> ReelsUiState.Error(
message = result.throwable.message ?: "Failed to load reels.",
)
when (result) {
Result.Loading -> _uiState.value = ReelsUiState.Loading
is Result.Success -> {
// Load the whole feed as one playlist, then auto-play the top reel.
playerController.setReels(result.data)
playerController.playAt(0)
_uiState.value = ReelsUiState.Success(reels = result.data)
}
is Result.Error -> _uiState.value = ReelsUiState.Error(
message = result.throwable.message ?: "Failed to load reels.",
)
}
}
}
}

/** Auto-play the top reel as soon as the feed arrives. */
private fun bindFirstReel(state: ReelsUiState.Success) {
state.reels.firstOrNull()?.let(playerController::bind)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ class ReelsViewModelTest {
}

@Test
fun `binds the first reel as soon as the feed loads`() = runTest {
fun `loads the feed as a playlist and plays the first reel`() = runTest {
ReelsViewModel(FakeReelsRepository(testReels), playerController)

verify { playerController.bind(testReels.first()) }
verify { playerController.setReels(testReels) }
verify { playerController.playAt(0) }
}

@Test
fun `PageSettled binds the selected reel and updates currentPage`() = runTest {
fun `PageSettled plays the selected reel and updates currentPage`() = runTest {
val viewModel = ReelsViewModel(FakeReelsRepository(testReels), playerController)

viewModel.onEvent(ReelEvent.PageSettled(1))
Expand All @@ -49,7 +50,7 @@ class ReelsViewModelTest {
val state = awaitItem() as ReelsUiState.Success
assertEquals(1, state.currentPage)
}
verify { playerController.bind(testReels[1]) }
verify { playerController.playAt(1) }
}

@Test
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ coil = "2.7.0"
# Networking + serialization.
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
kotlinxSerialization = "1.8.0"
kotlinxCoroutines = "1.9.0"

# Testing.
Expand Down
Loading