diff --git a/core/media/src/main/kotlin/dev/composereels/core/media/ReelPlayerController.kt b/core/media/src/main/kotlin/dev/composereels/core/media/ReelPlayerController.kt index a27ac50..894d14f 100644 --- a/core/media/src/main/kotlin/dev/composereels/core/media/ReelPlayerController.kt +++ b/core/media/src/main/kotlin/dev/composereels/core/media/ReelPlayerController.kt @@ -30,7 +30,7 @@ class ReelPlayerController @Inject constructor( /** Observable playback state for the UI. */ val playbackState: StateFlow = _playbackState.asStateFlow() - private var boundMediaId: String? = null + private var loadedReelIds: List = emptyList() init { player.addListener( @@ -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) { + 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. */ @@ -85,7 +92,7 @@ class ReelPlayerController @Inject constructor( * singleton outlives any screen. */ fun release() { - boundMediaId = null + loadedReelIds = emptyList() player.release() } } diff --git a/core/media/src/main/kotlin/dev/composereels/core/media/di/MediaModule.kt b/core/media/src/main/kotlin/dev/composereels/core/media/di/MediaModule.kt index 158d88b..3c0fd6b 100644 --- a/core/media/src/main/kotlin/dev/composereels/core/media/di/MediaModule.kt +++ b/core/media/src/main/kotlin/dev/composereels/core/media/di/MediaModule.kt @@ -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. @@ -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 } diff --git a/core/network/src/main/kotlin/dev/composereels/core/network/model/NetworkReel.kt b/core/network/src/main/kotlin/dev/composereels/core/network/model/NetworkReel.kt index 470550a..34476a6 100644 --- a/core/network/src/main/kotlin/dev/composereels/core/network/model/NetworkReel.kt +++ b/core/network/src/main/kotlin/dev/composereels/core/network/model/NetworkReel.kt @@ -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 diff --git a/feature/reels/src/main/kotlin/dev/composereels/feature/reels/ReelsViewModel.kt b/feature/reels/src/main/kotlin/dev/composereels/feature/reels/ReelsViewModel.kt index 289a99c..edb2798 100644 --- a/feature/reels/src/main/kotlin/dev/composereels/feature/reels/ReelsViewModel.kt +++ b/feature/reels/src/main/kotlin/dev/composereels/feature/reels/ReelsViewModel.kt @@ -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) - } } diff --git a/feature/reels/src/test/kotlin/dev/composereels/feature/reels/ReelsViewModelTest.kt b/feature/reels/src/test/kotlin/dev/composereels/feature/reels/ReelsViewModelTest.kt index e22fab7..ae572f7 100644 --- a/feature/reels/src/test/kotlin/dev/composereels/feature/reels/ReelsViewModelTest.kt +++ b/feature/reels/src/test/kotlin/dev/composereels/feature/reels/ReelsViewModelTest.kt @@ -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)) @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51dfdb7..ec2b4cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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.