diff --git a/app-tv/build.gradle.kts b/app-tv/build.gradle.kts index ecf354ad..16c3278b 100644 --- a/app-tv/build.gradle.kts +++ b/app-tv/build.gradle.kts @@ -51,8 +51,8 @@ kotlin { } dependencies { - implementation(project(":data")) implementation(project(":core")) + implementation(project(":core-model")) implementation(project(":core-ui")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt index 733b8c1c..33b56327 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.pressKey import androidx.compose.ui.unit.dp import hu.bbara.purefin.player.model.PlayerUiState -import hu.bbara.purefin.player.model.QueueItemUi +import hu.bbara.purefin.player.model.PlaylistElementUiModel import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackType import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS @@ -247,14 +247,14 @@ class TvPlayerControlsOverlayTest { OverlayHost( uiState = samplePlayerState().copy( queue = listOf( - QueueItemUi( + PlaylistElementUiModel( id = "first", title = "Episode 1", subtitle = "Fallback entry", artworkUrl = null, isCurrent = false ), - QueueItemUi( + PlaylistElementUiModel( id = "second", title = "Episode 2", subtitle = "Later", @@ -871,21 +871,21 @@ private fun samplePlayerState(): PlayerUiState = PlayerUiState( positionMs = 120_000, bufferedMs = 240_000, queue = listOf( - QueueItemUi( + PlaylistElementUiModel( id = "played", title = "Already Played", subtitle = "Episode 0", artworkUrl = null, isCurrent = false ), - QueueItemUi( + PlaylistElementUiModel( id = "current", title = "Currently Playing", subtitle = "Episode 1", artworkUrl = null, isCurrent = true ), - QueueItemUi( + PlaylistElementUiModel( id = "next", title = "Episode 2", subtitle = "Up next", diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt index e7acba83..6995a16a 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt @@ -276,7 +276,7 @@ fun TvPlayerScreen( onExpandPlaylist = expandPlaylist, onCollapsePlaylist = collapsePlaylistToControls, onSelectQueueItem = { id -> - viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS) + viewModel.playQueueItem(id) collapsePlaylistToControls() }, qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt index 7cbe9801..94a88d5a 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerQueuePanel.kt @@ -20,11 +20,11 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester @@ -41,9 +41,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import hu.bbara.purefin.player.model.PlayerUiState -import hu.bbara.purefin.player.model.QueueItemUi import coil3.compose.AsyncImage +import hu.bbara.purefin.player.model.PlayerUiState +import hu.bbara.purefin.player.model.PlaylistElementUiModel internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row" internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item" @@ -159,7 +159,7 @@ internal fun TvPlayerQueuePanel( @Composable private fun TvQueueRowCard( - item: QueueItemUi, + item: PlaylistElementUiModel, isCurrent: Boolean, isFirst: Boolean, isLast: Boolean, diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt index 8090a3e8..472bdb9f 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt @@ -30,19 +30,19 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen -import hu.bbara.purefin.ui.common.media.MediaHero -import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow import hu.bbara.purefin.download.DownloadState +import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel +import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.image.ImageUrlBuilder -import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.Movie -import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel +import hu.bbara.purefin.navigation.MovieDto +import hu.bbara.purefin.ui.common.media.MediaHero +import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow import hu.bbara.purefin.ui.screen.movie.components.MovieDetails import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar +import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.theme.AppTheme -import hu.bbara.purefin.image.ArtworkKind import java.util.UUID @Composable @@ -202,8 +202,6 @@ private fun previewMovie(): Movie = format = "Dolby Vision", synopsis = "A new blade runner uncovers a buried secret that forces him to trace the vanished footsteps of Rick Deckard.", imageUrlPrefix = "https://images.unsplash.com/photo-1519608487953-e999c86e7455", - audioTrack = "English 5.1", - subtitles = "English CC", cast = listOf( CastMember("Ryan Gosling", "K", null), CastMember("Ana de Armas", "Joi", null), diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt index 19930576..1deb0f96 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt @@ -125,8 +125,8 @@ internal fun MovieDetails( MediaPlaybackSettings( backgroundColor = MaterialTheme.colorScheme.surface, foregroundColor = MaterialTheme.colorScheme.onSurface, - audioTrack = movie.audioTrack, - subtitles = movie.subtitles + audioTrack = "ENG", + subtitles = "ENG" ) if (movie.cast.isNotEmpty()) { diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerQueuePanel.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerQueuePanel.kt index bb0897ac..c0c1d634 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerQueuePanel.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerQueuePanel.kt @@ -1,5 +1,4 @@ package hu.bbara.purefin.ui.screen.player.components - import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -30,8 +29,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.player.model.PlayerUiState +import hu.bbara.purefin.ui.common.image.PurefinAsyncImage @Composable fun PlayerQueuePanel( @@ -83,7 +82,6 @@ fun PlayerQueuePanel( items(uiState.queue, key = { item -> item.id }) { item -> QueueRow( title = item.title, - subtitle = item.subtitle, artworkUrl = item.artworkUrl, isCurrent = item.isCurrent, onClick = { onSelect(item.id) } @@ -105,7 +103,7 @@ fun PlayerQueuePanel( @Composable private fun QueueRow( title: String, - subtitle: String?, + subtitle: String? = null, artworkUrl: String?, isCurrent: Boolean, onClick: () -> Unit diff --git a/core-model/build.gradle.kts b/core-model/build.gradle.kts index a95692d3..79c9c721 100644 --- a/core-model/build.gradle.kts +++ b/core-model/build.gradle.kts @@ -1,13 +1,30 @@ plugins { - id("java-library") - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) } -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + +android { + namespace = "hu.bbara.purefin.model" + compileSdk = 36 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } } + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + implementation(libs.media3.common) + implementation(libs.kotlinx.serialization.json) +} diff --git a/core-model/src/main/java/hu/bbara/purefin/model/Movie.kt b/core-model/src/main/java/hu/bbara/purefin/model/Movie.kt index aafd31d9..41d0df92 100644 --- a/core-model/src/main/java/hu/bbara/purefin/model/Movie.kt +++ b/core-model/src/main/java/hu/bbara/purefin/model/Movie.kt @@ -14,7 +14,5 @@ data class Movie( val format: String, val synopsis: String, val imageUrlPrefix: String, - val audioTrack: String, - val subtitles: String, val cast: List ) diff --git a/core-model/src/main/java/hu/bbara/purefin/model/PlayableMedia.kt b/core-model/src/main/java/hu/bbara/purefin/model/PlayableMedia.kt new file mode 100644 index 00000000..d1358522 --- /dev/null +++ b/core-model/src/main/java/hu/bbara/purefin/model/PlayableMedia.kt @@ -0,0 +1,12 @@ +package hu.bbara.purefin.model + +import androidx.media3.common.MediaItem +import java.util.UUID + +data class PlayableMedia ( + val id: UUID, + val resumePositionMs: Float, + val mediaItem: MediaItem, + val preferences: MediaTrackPreferences, + val mediaSegments: List +) \ No newline at end of file diff --git a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt b/core-model/src/main/java/hu/bbara/purefin/model/TrackPreference.kt similarity index 95% rename from core/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt rename to core-model/src/main/java/hu/bbara/purefin/model/TrackPreference.kt index bdd9d281..19f78bd6 100644 --- a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreference.kt +++ b/core-model/src/main/java/hu/bbara/purefin/model/TrackPreference.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.player.preference +package hu.bbara.purefin.model import kotlinx.serialization.Serializable diff --git a/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt index d9425501..06864947 100644 --- a/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt @@ -43,6 +43,24 @@ class CompositeMediaRepository @Inject constructor( .flatMapLatest { it.episodes } .stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) + override suspend fun getMovie(id: UUID): Flow { + return activeRepository + .flatMapLatest { it.getMovie(id) } + .stateIn(scope, SharingStarted.Companion.Eagerly, null) + } + + override suspend fun getSeries(id: UUID): Flow { + return activeRepository + .flatMapLatest { it.getSeries(id) } + .stateIn(scope, SharingStarted.Companion.Eagerly, null) + } + + override suspend fun getEpisode(id: UUID): Flow { + return activeRepository + .flatMapLatest { it.getEpisode(id) } + .stateIn(scope, SharingStarted.Companion.Eagerly, null) + } + override fun observeSeriesWithContent(seriesId: UUID): Flow { return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) } } diff --git a/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt b/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt index 73160c61..d5c27c68 100644 --- a/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt +++ b/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt @@ -12,5 +12,8 @@ interface MediaCatalogReader { val movies: StateFlow> val series: StateFlow> val episodes: StateFlow> + suspend fun getMovie(id: UUID): Flow + suspend fun getSeries(id: UUID): Flow + suspend fun getEpisode(id: UUID): Flow fun observeSeriesWithContent(seriesId: UUID): Flow } diff --git a/core/src/main/java/hu/bbara/purefin/data/PlayableMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/data/PlayableMediaRepository.kt index 87cc12f2..b1686975 100644 --- a/core/src/main/java/hu/bbara/purefin/data/PlayableMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/data/PlayableMediaRepository.kt @@ -1,15 +1,13 @@ package hu.bbara.purefin.data -import androidx.media3.common.MediaItem -import hu.bbara.purefin.model.MediaSegment +import hu.bbara.purefin.model.PlayableMedia import java.util.UUID interface PlayableMediaRepository { - suspend fun getMediaItem(mediaId: UUID): Pair? - suspend fun getMediaSegments(mediaId: UUID): List - suspend fun getNextUpMediaItems( + suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? + suspend fun getNextUpPlayableMedias( episodeId: UUID, - existingIds: Set, - count: Int = 9, - ): List + existingIds: Set, + count: Int, + ): List } diff --git a/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt b/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt index 981005de..99296969 100644 --- a/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt +++ b/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt @@ -9,17 +9,17 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import dagger.hilt.android.scopes.ViewModelScoped +import hu.bbara.purefin.data.PlayableMediaRepository import hu.bbara.purefin.data.PlaybackReportContext +import hu.bbara.purefin.model.AudioTrackProperties import hu.bbara.purefin.model.MediaSegment -import hu.bbara.purefin.player.model.MediaContext +import hu.bbara.purefin.model.PlayableMedia +import hu.bbara.purefin.model.SubtitleTrackProperties import hu.bbara.purefin.player.model.MetadataState import hu.bbara.purefin.player.model.PlaybackProgressSnapshot import hu.bbara.purefin.player.model.PlaybackStateSnapshot -import hu.bbara.purefin.player.model.QueueItemUi import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackType -import hu.bbara.purefin.player.preference.AudioTrackProperties -import hu.bbara.purefin.player.preference.SubtitleTrackProperties import hu.bbara.purefin.player.preference.TrackMatcher import hu.bbara.purefin.player.preference.TrackPreferencesRepository import kotlinx.coroutines.CoroutineScope @@ -28,11 +28,15 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject import kotlin.math.abs @@ -44,6 +48,7 @@ import kotlin.math.abs class PlayerManager @Inject constructor( val player: Player, private val trackMapper: TrackMapper, + private val playableMediaRepository: PlayableMediaRepository, private val trackPreferencesRepository: TrackPreferencesRepository, private val trackMatcher: TrackMatcher ) { @@ -55,7 +60,13 @@ class PlayerManager @Inject constructor( private val mediaSegmentManager = MediaSegmentManager(player as ExoPlayer) - private var currentMediaContext: MediaContext? = null + private val _currentMediaId = MutableStateFlow(null) + val currentPlayableMedia: StateFlow by lazy { + combine(_currentMediaId, _playlist) { mediaId, playlist -> + playlist.firstOrNull { it.id == mediaId } + }.stateIn(scope, SharingStarted.Eagerly, null) + } + private var pendingSeekPositionMs: Long? = null private val _playbackState = MutableStateFlow(PlaybackStateSnapshot()) @@ -70,8 +81,8 @@ class PlayerManager @Inject constructor( private val _tracks = MutableStateFlow(TrackSelectionState()) val tracks: StateFlow = _tracks.asStateFlow() - private val _queue = MutableStateFlow>(emptyList()) - val queue: StateFlow> = _queue.asStateFlow() + private val _playlist = MutableStateFlow(emptyList()) + val playlist: StateFlow> = _playlist.asStateFlow() private val listener = object : Player.Listener { override fun onPositionDiscontinuity( @@ -89,9 +100,9 @@ class PlayerManager @Inject constructor( } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - clearPendingSeek() - currentMediaContext?.let { - installMediaSegments(it.mediaSegments) + _currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) } + scope.launch { + updatePlaylist() } } @@ -111,16 +122,49 @@ class PlayerManager @Inject constructor( startProgressLoop() } - fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) { - currentMediaContext = mediaContext - clearPendingSeek() - player.setMediaItem(mediaItem) + fun play(mediaId: UUID) { + scope.launch { + if (_playlist.value.map { it.id }.contains(mediaId)) { + playFromPlaylist(mediaId) + } else { + playNewMedia(mediaId) + } + } + } + + private fun playFromPlaylist(mediaId: UUID) { + val index = _playlist.value.indexOfFirst { it.id == mediaId } + if (index != -1) { + player.seekToDefaultPosition(index) + player.playWhenReady = true + } else { + _playbackState.update { it.copy(error = "Media not found in playlist") } + } + } + + private suspend fun playNewMedia(mediaId: UUID) { + val playableMedia = playableMediaRepository.getPlayableMedia(mediaId) + if (playableMedia == null) { + _playbackState.update { it.copy(error = "Media not found") } + return + } + player.setMediaItem(playableMedia.mediaItem) player.prepare() player.playWhenReady = true } - fun addToQueue(mediaItem: MediaItem) { - player.addMediaItem(mediaItem) + private suspend fun updatePlaylist() { + val nextUpPlayableMedias = playableMediaRepository.getNextUpPlayableMedias( + episodeId = _currentMediaId.value ?: return, + existingIds = _playlist.value.map { it.id }.toSet(), + count = 5 + ) + addToQueue(nextUpPlayableMedias) + } + + private fun addToQueue(playableMedias: List) { + _playlist.update { it + playableMedias } + player.addMediaItems(playableMedias.map { it.mediaItem }) } fun togglePlayPause() { @@ -215,10 +259,10 @@ class PlayerManager @Inject constructor( } player.trackSelectionParameters = builder.build() - // Save track preference if media context is available - currentMediaContext?.let { context -> + // Save track preference if media id is available + _currentMediaId.value?.let { mediaId -> scope.launch { - saveTrackPreference(option, context.mediaId) + saveTrackPreference(option, mediaId) } } } @@ -235,23 +279,12 @@ class PlayerManager @Inject constructor( player.playWhenReady = true } - fun playQueueItem(id: String) { - val items = _queue.value - val targetIndex = items.indexOfFirst { it.id == id } - if (targetIndex >= 0) { - clearPendingSeek() - player.seekToDefaultPosition(targetIndex) - player.playWhenReady = true - } - } - fun clearError() { _playbackState.update { it.copy(error = null) } } private fun applyTrackPreferences() { - val context = currentMediaContext ?: return - val preferences = context.preferences + val preferences = currentPlayableMedia.value?.preferences ?: return val currentTrackState = _tracks.value @@ -274,7 +307,7 @@ class PlayerManager @Inject constructor( } } - private suspend fun saveTrackPreference(option: TrackOption, preferenceKey: String) { + private suspend fun saveTrackPreference(option: TrackOption, mediaId: UUID) { when (option.type) { TrackType.AUDIO -> { val properties = AudioTrackProperties( @@ -282,7 +315,7 @@ class PlayerManager @Inject constructor( channelCount = option.channelCount, label = option.label ) - trackPreferencesRepository.saveAudioPreference(preferenceKey, properties) + trackPreferencesRepository.saveAudioPreference(mediaId.toString(), properties) } TrackType.TEXT -> { @@ -292,7 +325,7 @@ class PlayerManager @Inject constructor( label = option.label, isOff = option.isOff ) - trackPreferencesRepository.saveSubtitlePreference(preferenceKey, properties) + trackPreferencesRepository.saveSubtitlePreference(mediaId.toString(), properties) } TrackType.VIDEO -> { @@ -346,24 +379,6 @@ class PlayerManager @Inject constructor( playbackReportContext = playbackReportContext, ) _tracks.value = trackMapper.map(player.currentTracks) - _queue.value = buildQueueSnapshot(player) - } - - private fun buildQueueSnapshot(player: Player): List { - val items = mutableListOf() - for (i in 0 until player.mediaItemCount) { - val mediaItem = player.getMediaItemAt(i) - items.add( - QueueItemUi( - id = mediaItem.mediaId.ifEmpty { i.toString() }, - title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}", - subtitle = mediaItem.mediaMetadata.subtitle?.toString(), - artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(), - isCurrent = i == player.currentMediaItemIndex - ) - ) - } - return items } private fun clampSeekPosition(positionMs: Long): Long { diff --git a/core/src/main/java/hu/bbara/purefin/player/model/MediaContext.kt b/core/src/main/java/hu/bbara/purefin/player/model/MediaContext.kt index ca01fec2..37617b05 100644 --- a/core/src/main/java/hu/bbara/purefin/player/model/MediaContext.kt +++ b/core/src/main/java/hu/bbara/purefin/player/model/MediaContext.kt @@ -1,7 +1,7 @@ package hu.bbara.purefin.player.model import hu.bbara.purefin.model.MediaSegment -import hu.bbara.purefin.player.preference.MediaTrackPreferences +import hu.bbara.purefin.model.MediaTrackPreferences data class MediaContext( val mediaId: String, diff --git a/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt b/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt index c96592d9..1650a4c8 100644 --- a/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt +++ b/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt @@ -14,7 +14,7 @@ data class PlayerUiState( val playbackSpeed: Float = 1f, val chapters: List = emptyList(), val ads: List = emptyList(), - val queue: List = emptyList(), + val queue: List = emptyList(), val audioTracks: List = emptyList(), val textTracks: List = emptyList(), val qualityTracks: List = emptyList(), @@ -47,10 +47,9 @@ data class TimedMarker( enum class MarkerType { CHAPTER, AD } -data class QueueItemUi( +data class PlaylistElementUiModel( val id: String, val title: String, - val subtitle: String?, val artworkUrl: String?, val isCurrent: Boolean ) diff --git a/core/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt b/core/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt index 1def1b08..cdad2788 100644 --- a/core/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt +++ b/core/src/main/java/hu/bbara/purefin/player/preference/TrackMatcher.kt @@ -1,5 +1,7 @@ package hu.bbara.purefin.player.preference +import hu.bbara.purefin.model.AudioTrackProperties +import hu.bbara.purefin.model.SubtitleTrackProperties import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackType import javax.inject.Inject diff --git a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt b/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt index 7f46b71b..0dfa5805 100644 --- a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt +++ b/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesModule.kt @@ -10,6 +10,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.model.TrackPreferences import javax.inject.Singleton @Module diff --git a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt b/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt index 0e562050..66424ef6 100644 --- a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesRepository.kt @@ -1,6 +1,10 @@ package hu.bbara.purefin.player.preference import androidx.datastore.core.DataStore +import hu.bbara.purefin.model.AudioTrackProperties +import hu.bbara.purefin.model.MediaTrackPreferences +import hu.bbara.purefin.model.SubtitleTrackProperties +import hu.bbara.purefin.model.TrackPreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject diff --git a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt b/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt index 6e58bc13..09b881af 100644 --- a/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt +++ b/core/src/main/java/hu/bbara/purefin/player/preference/TrackPreferencesSerializer.kt @@ -2,6 +2,7 @@ package hu.bbara.purefin.player.preference import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import hu.bbara.purefin.model.TrackPreferences import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.io.InputStream diff --git a/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index 9cf04c83..37a1f36e 100644 --- a/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -4,13 +4,12 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import hu.bbara.purefin.data.MediaCatalogReader import hu.bbara.purefin.player.manager.PlayerManager import hu.bbara.purefin.player.manager.ProgressManager -import hu.bbara.purefin.player.model.MediaContext import hu.bbara.purefin.player.model.PlayerUiState +import hu.bbara.purefin.player.model.PlaylistElementUiModel import hu.bbara.purefin.player.model.TrackOption -import hu.bbara.purefin.player.preference.TrackPreferencesRepository -import hu.bbara.purefin.data.PlayableMediaRepository import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -27,8 +26,7 @@ import javax.inject.Inject class PlayerViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val playerManager: PlayerManager, - private val playableMediaRepository: PlayableMediaRepository, - private val trackPreferencesRepository: TrackPreferencesRepository, + private val mediaCatalogReader: MediaCatalogReader, private val progressManager: ProgressManager, ) : ViewModel() { companion object { @@ -47,7 +45,6 @@ class PlayerViewModel @Inject constructor( private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS) private var autoHideJob: Job? = null - private var lastNextUpMediaId: String? = null private var dataErrorMessage: String? = null init { @@ -101,11 +98,6 @@ class PlayerViewModel @Inject constructor( subtitle = metadata.subtitle ) } - val currentMediaId = metadata.mediaId - if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) { - lastNextUpMediaId = currentMediaId - loadNextUp(currentMediaId) - } } } @@ -125,76 +117,63 @@ class PlayerViewModel @Inject constructor( } viewModelScope.launch { - playerManager.queue.collect { queue -> - _uiState.update { it.copy(queue = queue) } + val currentPlayableMedia = playerManager.currentPlayableMedia + playerManager.playlist.collect { playlist -> + val episodes = + playlist.map { playableMedia -> mediaCatalogReader.getEpisode(playableMedia.id) } + _uiState.update { state -> + state.copy( + queue = episodes.mapNotNull { episode -> + val episodeValue = episode.first() + if (episodeValue == null) { + throw IllegalStateException("Episode not found for media id: $episodeValue") + } + PlaylistElementUiModel( + id = episodeValue.id.toString(), + title = episodeValue.title, + artworkUrl = episodeValue.imageUrlPrefix, + isCurrent = currentPlayableMedia.value?.id == episodeValue.id + ) + } + ) + } } } } private fun loadInitialMedia() { val id = mediaId ?: return - loadMediaById(id) + viewModelScope.launch { + loadMediaById(id) + } } fun loadMedia(id: String) { if (mediaId != null) return // Already loading from SavedStateHandle - loadMediaById(id) + viewModelScope.launch { + loadMediaById(id) + } } @OptIn(InternalSerializationApi::class) - private fun loadMediaById(id: String) { + private suspend fun loadMediaById(id: String) { val uuid = id.toUuidOrNull() if (uuid == null) { dataErrorMessage = "Invalid media id" _uiState.update { it.copy(error = dataErrorMessage) } return } + //TODO hack to preload the series media + mediaCatalogReader.getEpisode(UUID.fromString(id)) viewModelScope.launch { - val result = playableMediaRepository.getMediaItem(uuid) - if (result != null) { - val (mediaItem, resumePositionMs) = result - - - // TODO use CatalogReader for this instead of episodeSeriesLookup -// val preferenceKey = episodeSeriesLookup.preferenceKeyFor(uuid) - val preferenceKey = uuid.toString() - val preferences = trackPreferencesRepository.getMediaPreferences(preferenceKey).first() - val mediaSegments = playableMediaRepository.getMediaSegments(uuid) - - val mediaContext = MediaContext( - mediaId = id, - preferences = preferences, - mediaSegments = mediaSegments - ) - - playerManager.play(mediaItem, mediaContext) - - // Seek to resume position after play() is called - resumePositionMs?.let { playerManager.seekTo(it) } - - if (dataErrorMessage != null) { - dataErrorMessage = null - _uiState.update { it.copy(error = null) } - } - } else { - dataErrorMessage = "Unable to load media" - _uiState.update { it.copy(error = dataErrorMessage) } + runCatching { + playerManager.play(uuid) + }.onFailure { e -> + _uiState.update { it.copy(error = e.message) } } } } - private fun loadNextUp(currentMediaId: String) { - val uuid = currentMediaId.toUuidOrNull() ?: return - viewModelScope.launch { - val queuedIds = uiState.value.queue.map { it.id }.toSet() - val items = playableMediaRepository.getNextUpMediaItems( - episodeId = uuid, - existingIds = queuedIds, - ) - items.forEach { playerManager.addToQueue(it) } - } - } - fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { playerManager.togglePlayPause() showControls(autoHideDelayMs) @@ -277,9 +256,9 @@ class PlayerViewModel @Inject constructor( playerManager.retry() } - fun playQueueItem(id: String, autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { - playerManager.playQueueItem(id) - showControls(autoHideDelayMs) + fun playQueueItem(id: String) { + playerManager.play(id.toUuidOrNull() ?: return) + showControls() } fun clearError() { diff --git a/core/src/main/java/hu/bbara/purefin/ui/model/MediaUiModel.kt b/core/src/main/java/hu/bbara/purefin/ui/model/MediaUiModel.kt index f0960af3..feb9754f 100644 --- a/core/src/main/java/hu/bbara/purefin/ui/model/MediaUiModel.kt +++ b/core/src/main/java/hu/bbara/purefin/ui/model/MediaUiModel.kt @@ -66,8 +66,6 @@ class MovieUiModel: MediaUiModel { format = "", synopsis = "", imageUrlPrefix = "", - audioTrack = "", - subtitles = "", cast = emptyList() ) ) diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index d0619972..039370d9 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -5,15 +5,11 @@ import androidx.datastore.core.DataStore import hu.bbara.purefin.data.HomeRepository import hu.bbara.purefin.data.NetworkMonitor import hu.bbara.purefin.data.UserSessionRepository -import hu.bbara.purefin.image.ArtworkKind -import hu.bbara.purefin.image.ImageUrlBuilder -import hu.bbara.purefin.model.Episode -import hu.bbara.purefin.model.Library -import hu.bbara.purefin.model.LibraryKind -import hu.bbara.purefin.model.Media -import hu.bbara.purefin.model.Movie -import hu.bbara.purefin.model.Season -import hu.bbara.purefin.model.Series +import hu.bbara.purefin.data.converter.toEpisode +import hu.bbara.purefin.data.converter.toLibrary +import hu.bbara.purefin.data.converter.toMovie +import hu.bbara.purefin.data.converter.toSeason +import hu.bbara.purefin.data.converter.toSeries import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.offline.cache.HomeCache import hu.bbara.purefin.data.offline.cache.toCachedEpisode @@ -26,6 +22,9 @@ import hu.bbara.purefin.data.offline.cache.toLibrary import hu.bbara.purefin.data.offline.cache.toMedia import hu.bbara.purefin.data.offline.cache.toMovie import hu.bbara.purefin.data.offline.cache.toSeries +import hu.bbara.purefin.model.Library +import hu.bbara.purefin.model.LibraryKind +import hu.bbara.purefin.model.Media import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -35,14 +34,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale import java.util.UUID -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.atomics.AtomicBoolean @@ -235,10 +229,10 @@ class InMemoryAppContentRepository @Inject constructor( } return when (library.type) { LibraryKind.MOVIES -> library.copy( - movies = contentItem.map { it.toMovie(serverUrl(), library.id) }, + movies = contentItem.map { it.toMovie(serverUrl()) }, ) LibraryKind.SERIES -> library.copy( - series = contentItem.map { it.toSeries(serverUrl(), library.id) }, + series = contentItem.map { it.toSeries(serverUrl()) }, ) } } @@ -317,13 +311,13 @@ class InMemoryAppContentRepository @Inject constructor( } library.id to when (library.collectionType) { CollectionType.MOVIES -> latestFromLibrary.map { - val movie = it.toMovie(serverUrl(), library.id) + val movie = it.toMovie(serverUrl()) Media.MovieMedia(movieId = movie.id) } CollectionType.TVSHOWS -> latestFromLibrary.map { when (it.type) { BaseItemKind.SERIES -> { - val series = it.toSeries(serverUrl(), library.id) + val series = it.toSeries(serverUrl()) Media.SeriesMedia(seriesId = series.id) } BaseItemKind.SEASON -> { @@ -347,111 +341,6 @@ class InMemoryAppContentRepository @Inject constructor( return userSessionRepository.serverUrl.first() } - private fun BaseItemDto.toLibrary(serverUrl: String): Library { - return when (collectionType) { - CollectionType.MOVIES -> Library( - id = id, - name = name!!, - posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY), - type = LibraryKind.MOVIES, - movies = emptyList(), - ) - CollectionType.TVSHOWS -> Library( - id = id, - name = name!!, - posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY), - type = LibraryKind.SERIES, - series = emptyList(), - ) - else -> throw UnsupportedOperationException("Unsupported library type: $collectionType") - } - } - - private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie { - return Movie( - id = id, - libraryId = libraryId, - title = name ?: "Unknown title", - progress = userData!!.playedPercentage, - watched = userData!!.played, - year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - synopsis = overview ?: "No synopsis available", - format = container?.uppercase() ?: "VIDEO", - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id), - subtitles = "ENG", - audioTrack = "ENG", - cast = emptyList(), - ) - } - - private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series { - return Series( - id = id, - libraryId = libraryId, - name = name ?: "Unknown", - synopsis = overview ?: "No synopsis available", - year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id), - unwatchedEpisodeCount = userData!!.unplayedItemCount!!, - seasonCount = childCount!!, - seasons = emptyList(), - cast = emptyList(), - ) - } - - private fun BaseItemDto.toSeason(): Season { - return Season( - id = id, - seriesId = seriesId!!, - name = name ?: "Unknown", - index = indexNumber ?: 0, - unwatchedEpisodeCount = userData!!.unplayedItemCount!!, - episodeCount = childCount!!, - episodes = emptyList(), - ) - } - - private fun BaseItemDto.toEpisode(serverUrl: String): Episode { - val releaseDate = formatReleaseDate(premiereDate, productionYear) - val imageUrlPrefix = id?.let { itemId -> - ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId) - } ?: "" - return Episode( - id = id, - seriesId = seriesId!!, - seasonId = parentId!!, - title = name ?: "Unknown title", - index = indexNumber!!, - releaseDate = releaseDate, - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - progress = userData!!.playedPercentage, - watched = userData!!.played, - format = container?.uppercase() ?: "VIDEO", - synopsis = overview ?: "No synopsis available.", - imageUrlPrefix = imageUrlPrefix, - cast = emptyList(), - ) - } - - private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String { - if (date == null) { - return fallbackYear?.toString() ?: "—" - } - val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) - return date.toLocalDate().format(formatter) - } - - private fun formatRuntime(ticks: Long?): String { - if (ticks == null || ticks <= 0) return "—" - val totalSeconds = ticks / 10_000_000 - val hours = TimeUnit.SECONDS.toHours(totalSeconds) - val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 - return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" - } - companion object { private const val TAG = "InMemoryAppContentRepo" } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt index 5420c89d..5e392054 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt @@ -2,11 +2,13 @@ package hu.bbara.purefin.data.catalog import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.UserSessionRepository +import hu.bbara.purefin.data.converter.toEpisode +import hu.bbara.purefin.data.converter.toMovie +import hu.bbara.purefin.data.converter.toSeason +import hu.bbara.purefin.data.converter.toSeries import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient -import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Movie -import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Series import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,12 +21,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale import java.util.UUID -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -36,6 +33,8 @@ class InMemoryMediaRepository @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val serverUrl = userSessionRepository.serverUrl + private val moviesState = MutableStateFlow>(emptyMap()) override val movies: StateFlow> = moviesState.asStateFlow() @@ -45,6 +44,39 @@ class InMemoryMediaRepository @Inject constructor( private val episodesState = MutableStateFlow>(emptyMap()) override val episodes: StateFlow> = episodesState.asStateFlow() + override suspend fun getMovie(id: UUID): Flow { + if (!moviesState.value.containsKey(id)) { + jellyfinApiClient.getItemInfo(id)?.let { item -> + val movie = item.toMovie(serverUrl.first()) + moviesState.update { current -> current + (movie.id to movie) } + } + } + return moviesState.map { it[id] } + } + + override suspend fun getSeries(id: UUID): Flow { + if (!seriesState.value.containsKey(id)) { + jellyfinApiClient.getItemInfo(id)?.let { item -> + val series = item.toSeries(serverUrl.first()) + seriesState.update { current -> current + (series.id to series) } + } + } + return seriesState.map { it[id] } + } + + override suspend fun getEpisode(id: UUID): Flow { + if (!episodesState.value.containsKey(id)) { + jellyfinApiClient.getItemInfo(id)?.let { item -> + val episode = item.toEpisode(serverUrl.first()) + episodesState.update { current -> current + (episode.id to episode) } + } + } + val episodeFlow = episodesState.map { it[id] } + val seriesId = episodeFlow.first()!!.seriesId + observeSeriesWithContent(seriesId = seriesId) + return episodeFlow + } + fun upsertMovies(movies: List) { moviesState.update { current -> current + movies.associateBy { it.id } } } @@ -102,53 +134,4 @@ class InMemoryMediaRepository @Inject constructor( val allEpisodes = filledSeasons.flatMap { it.episodes } episodesState.update { current -> current + allEpisodes.associateBy { it.id } } } - - private fun BaseItemDto.toSeason(): Season { - return Season( - id = id, - seriesId = seriesId!!, - name = name ?: "Unknown", - index = indexNumber ?: 0, - unwatchedEpisodeCount = userData!!.unplayedItemCount!!, - episodeCount = childCount!!, - episodes = emptyList(), - ) - } - - private fun BaseItemDto.toEpisode(serverUrl: String): Episode { - val releaseDate = formatReleaseDate(premiereDate, productionYear) - val imageUrlPrefix = id?.let { itemId -> - ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId) - } ?: "" - return Episode( - id = id, - seriesId = seriesId!!, - seasonId = parentId!!, - title = name ?: "Unknown title", - index = indexNumber!!, - releaseDate = releaseDate, - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - progress = userData!!.playedPercentage, - watched = userData!!.played, - format = container?.uppercase() ?: "VIDEO", - synopsis = overview ?: "No synopsis available.", - imageUrlPrefix = imageUrlPrefix, - cast = emptyList(), - ) - } - - private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String { - if (date == null) return fallbackYear?.toString() ?: "—" - val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) - return date.toLocalDate().format(formatter) - } - - private fun formatRuntime(ticks: Long?): String { - if (ticks == null || ticks <= 0) return "—" - val totalSeconds = ticks / 10_000_000 - val hours = TimeUnit.SECONDS.toHours(totalSeconds) - val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 - return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" - } } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt index e255c973..71d9ddb7 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import java.util.UUID import javax.inject.Inject @@ -31,6 +32,18 @@ class OfflineMediaRepository @Inject constructor( override val episodes: StateFlow> = localDataSource.episodesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + override suspend fun getMovie(id: UUID): Flow { + return movies.map { it[id] } + } + + override suspend fun getSeries(id: UUID): Flow { + return series.map { it[id] } + } + + override suspend fun getEpisode(id: UUID): Flow { + return episodes.map { it[id] } + } + override fun observeSeriesWithContent(seriesId: UUID): Flow { return localDataSource.observeSeriesWithContent(seriesId) } diff --git a/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt b/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt new file mode 100644 index 00000000..f8e233a3 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt @@ -0,0 +1,127 @@ +package hu.bbara.purefin.data.converter + +import hu.bbara.purefin.image.ArtworkKind +import hu.bbara.purefin.image.ImageUrlBuilder +import hu.bbara.purefin.model.Episode +import hu.bbara.purefin.model.Library +import hu.bbara.purefin.model.LibraryKind +import hu.bbara.purefin.model.Movie +import hu.bbara.purefin.model.Season +import hu.bbara.purefin.model.Series +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.CollectionType +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.concurrent.TimeUnit + +fun BaseItemDto.toLibrary(serverUrl: String): Library { + return when (collectionType) { + CollectionType.MOVIES -> Library( + id = id, + name = name!!, + posterUrl = ImageUrlBuilder.toImageUrl( + url = serverUrl, + itemId = id, + artworkKind = ArtworkKind.PRIMARY + ), + type = LibraryKind.MOVIES, + movies = emptyList(), + ) + CollectionType.TVSHOWS -> Library( + id = id, + name = name!!, + posterUrl = ImageUrlBuilder.toImageUrl( + url = serverUrl, + itemId = id, + artworkKind = ArtworkKind.PRIMARY + ), + type = LibraryKind.SERIES, + series = emptyList(), + ) + else -> throw UnsupportedOperationException("Unsupported library type: $collectionType") + } +} + +fun BaseItemDto.toMovie(serverUrl: String): Movie { + return Movie( + id = id, + libraryId = parentId!!, + title = name ?: "Unknown title", + progress = userData!!.playedPercentage, + watched = userData!!.played, + year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), + rating = officialRating ?: "NR", + runtime = formatRuntime(runTimeTicks), + synopsis = overview ?: "No synopsis available", + format = container?.uppercase() ?: "VIDEO", + imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id), + cast = emptyList(), + ) +} + +fun BaseItemDto.toSeries(serverUrl: String): Series { + return Series( + id = id, + libraryId = parentId!!, + name = name ?: "Unknown", + synopsis = overview ?: "No synopsis available", + year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), + imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id), + unwatchedEpisodeCount = userData!!.unplayedItemCount!!, + seasonCount = childCount!!, + seasons = emptyList(), + cast = emptyList(), + ) +} + +fun BaseItemDto.toSeason(): Season { + return Season( + id = id, + seriesId = seriesId!!, + name = name ?: "Unknown", + index = indexNumber ?: 0, + unwatchedEpisodeCount = userData!!.unplayedItemCount!!, + episodeCount = childCount!!, + episodes = emptyList(), + ) +} + +fun BaseItemDto.toEpisode(serverUrl: String): Episode { + val releaseDate = formatReleaseDate(premiereDate, productionYear) + val imageUrlPrefix = id?.let { itemId -> + ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId) + } ?: "" + return Episode( + id = id, + seriesId = seriesId!!, + seasonId = parentId!!, + title = name ?: "Unknown title", + index = indexNumber!!, + releaseDate = releaseDate, + rating = officialRating ?: "NR", + runtime = formatRuntime(runTimeTicks), + progress = userData!!.playedPercentage, + watched = userData!!.played, + format = container?.uppercase() ?: "VIDEO", + synopsis = overview ?: "No synopsis available.", + imageUrlPrefix = imageUrlPrefix, + cast = emptyList(), + ) +} + +fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String { + if (date == null) { + return fallbackYear?.toString() ?: "—" + } + val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) + return date.toLocalDate().format(formatter) +} + +private fun formatRuntime(ticks: Long?): String { + if (ticks == null || ticks <= 0) return "—" + val totalSeconds = ticks / 10_000_000 + val hours = TimeUnit.SECONDS.toHours(totalSeconds) + val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" +} \ No newline at end of file diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index 97073d20..dd9473f6 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -9,14 +9,16 @@ import androidx.media3.common.util.UnstableApi import hu.bbara.purefin.data.PlayableMediaRepository import hu.bbara.purefin.data.PlaybackReportContext import hu.bbara.purefin.data.UserSessionRepository -import hu.bbara.purefin.image.ArtworkKind -import hu.bbara.purefin.image.ImageUrlBuilder -import hu.bbara.purefin.model.MediaSegment -import hu.bbara.purefin.model.SegmentType import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey +import hu.bbara.purefin.image.ArtworkKind +import hu.bbara.purefin.image.ImageUrlBuilder +import hu.bbara.purefin.model.MediaSegment +import hu.bbara.purefin.model.PlayableMedia +import hu.bbara.purefin.model.SegmentType +import hu.bbara.purefin.player.preference.TrackPreferencesRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -35,14 +37,30 @@ import javax.inject.Singleton class DefaultPlayableMediaRepository @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, private val jellyfinPlaybackResolver: JellyfinPlaybackResolver, + private val trackPreferencesRepository: TrackPreferencesRepository, private val userSessionRepository: UserSessionRepository, ) : PlayableMediaRepository { - override suspend fun getMediaItem(mediaId: UUID): Pair? = withContext(Dispatchers.IO) { + override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) { + val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return@withContext null val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null - val baseItem = jellyfinApiClient.getItemInfo(mediaId) + val mediaItem = getMediaItem(baseItem, playbackDecision) val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) + val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(mediaId.toString()).first() + val mediaSegments = getMediaSegments(mediaId) + PlayableMedia( + id = mediaId, + mediaItem = mediaItem, + resumePositionMs = resumePositionMs?.toFloat() ?: 0f, + preferences = mediaTrackPreferences, + mediaSegments = mediaSegments + ) + } + + private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) { + val mediaId = baseItem.id + val baseItem = jellyfinApiClient.getItemInfo(mediaId) val serverUrl = userSessionRepository.serverUrl.first() val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY) @@ -50,46 +68,35 @@ class DefaultPlayableMediaRepository @Inject constructor( val mediaItem = createMediaItem( mediaId = mediaId.toString(), playbackDecision = playbackDecision, - title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null, + title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown", subtitle = seasonEpisodeLabel(baseItem), artworkUrl = artworkUrl, playbackReportContext = playbackDecision.reportContext, ) - Pair(mediaItem, resumePositionMs) + return@withContext mediaItem } - override suspend fun getMediaSegments(mediaId: UUID): List { + private suspend fun getMediaSegments(mediaId: UUID): List { val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId) return mediaSegments.mapNotNull { it.toMediaSegment() } } - override suspend fun getNextUpMediaItems( + override suspend fun getNextUpPlayableMedias( episodeId: UUID, - existingIds: Set, + existingIds: Set, count: Int, - ): List = withContext(Dispatchers.IO) { + ): List = withContext(Dispatchers.IO) { runCatching { - val serverUrl = userSessionRepository.serverUrl.first() val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) episodes.mapNotNull { episode -> val id = episode.id ?: return@mapNotNull null - val stringId = id.toString() - if (existingIds.contains(stringId)) { + if (existingIds.contains(id)) { return@mapNotNull null } - val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(id) ?: return@mapNotNull null - val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, id, ArtworkKind.PRIMARY) - createMediaItem( - mediaId = stringId, - playbackDecision = playbackDecision, - title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null, - subtitle = seasonEpisodeLabel(episode), - artworkUrl = artworkUrl, - playbackReportContext = playbackDecision.reportContext, - ) + getPlayableMedia(id) } }.getOrElse { error -> Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error) diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index a34abae7..7ac9b0eb 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -244,9 +244,9 @@ class JellyfinApiClient @Inject constructor( seriesId = seriesId, enableUserData = true, startItemId = episodeId, - limit = count + 1, + limit = count, ) - val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1) + val nextUpEpisodes = nextUpEpisodesResult.content.items Log.d("getNextEpisodes", nextUpEpisodes.toString()) nextUpEpisodes } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt deleted file mode 100644 index 88307c22..00000000 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt +++ /dev/null @@ -1,83 +0,0 @@ -package hu.bbara.purefin.data.jellyfin.download - -import hu.bbara.purefin.image.ImageUrlBuilder -import hu.bbara.purefin.model.Episode -import hu.bbara.purefin.model.Movie -import hu.bbara.purefin.model.Season -import hu.bbara.purefin.model.Series -import java.util.UUID -import java.util.concurrent.TimeUnit -import org.jellyfin.sdk.model.api.BaseItemDto - -internal fun BaseItemDto.toMovie(serverUrl: String): Movie { - return Movie( - id = id, - libraryId = parentId ?: UUID.randomUUID(), - title = name ?: "Unknown title", - progress = userData?.playedPercentage, - watched = userData?.played ?: false, - year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - format = container?.uppercase() ?: "VIDEO", - synopsis = overview ?: "No synopsis available", - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), - audioTrack = "ENG", - subtitles = "ENG", - cast = emptyList(), - ) -} - -internal fun BaseItemDto.toEpisode(serverUrl: String): Episode { - return Episode( - id = id, - seriesId = seriesId ?: UUID.randomUUID(), - seasonId = parentId ?: UUID.randomUUID(), - title = name ?: "Unknown title", - index = indexNumber ?: 0, - synopsis = overview ?: "No synopsis available.", - releaseDate = productionYear?.toString() ?: "—", - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - progress = userData?.playedPercentage, - watched = userData?.played ?: false, - format = container?.uppercase() ?: "VIDEO", - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), - cast = emptyList(), - ) -} - -internal fun BaseItemDto.toSeries(serverUrl: String): Series { - return Series( - id = id, - libraryId = parentId ?: UUID.randomUUID(), - name = name ?: "Unknown", - synopsis = overview ?: "No synopsis available", - year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), - unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0, - seasonCount = childCount ?: 0, - seasons = emptyList(), - cast = emptyList(), - ) -} - -internal fun BaseItemDto.toSeason(seriesId: UUID): Season { - return Season( - id = id, - seriesId = this.seriesId ?: seriesId, - name = name ?: "Unknown", - index = indexNumber ?: 0, - unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0, - episodeCount = childCount ?: 0, - episodes = emptyList(), - ) -} - -private fun formatRuntime(ticks: Long?): String { - if (ticks == null || ticks <= 0) return "—" - val totalSeconds = ticks / 10_000_000 - val hours = TimeUnit.SECONDS.toHours(totalSeconds) - val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 - return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" -} diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt index efc15080..2e05abb5 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt @@ -5,16 +5,20 @@ import hu.bbara.purefin.data.EpisodeDownloadSource import hu.bbara.purefin.data.MovieDownloadSource import hu.bbara.purefin.data.PlaybackMethod import hu.bbara.purefin.data.UserSessionRepository +import hu.bbara.purefin.data.converter.toEpisode +import hu.bbara.purefin.data.converter.toMovie +import hu.bbara.purefin.data.converter.toSeason +import hu.bbara.purefin.data.converter.toSeries import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecisionResolver import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.MediaSourceInfo +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton @Singleton class JellyfinDownloadMediaSourceResolver @Inject constructor( @@ -49,7 +53,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor( val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null val episode = episodeDto.toEpisode(serverUrl) val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null - val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: return@withContext null + val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason() ?: return@withContext null EpisodeDownloadSource( episode = episode, diff --git a/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt b/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt index c08f69cd..a01d3479 100644 --- a/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt +++ b/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt @@ -30,8 +30,6 @@ data class CachedMovie( val format: String, val synopsis: String, val imageUrlPrefix: String, - val audioTrack: String, - val subtitles: String, val cast: List = emptyList() ) @@ -156,8 +154,6 @@ fun Movie.toCachedMovie() = CachedMovie( format = format, synopsis = synopsis, imageUrlPrefix = imageUrlPrefix, - audioTrack = audioTrack, - subtitles = subtitles, cast = cast.map { it.toCachedCastMember() }, ) @@ -176,8 +172,6 @@ fun CachedMovie.toMovie(): Movie? { format = format, synopsis = synopsis, imageUrlPrefix = imageUrlPrefix, - audioTrack = audioTrack, - subtitles = subtitles, cast = cast.map { it.toCastMember() }, ) } diff --git a/data/src/main/java/hu/bbara/purefin/data/offline/room/entity/MovieEntity.kt b/data/src/main/java/hu/bbara/purefin/data/offline/room/entity/MovieEntity.kt index 55b335c8..dac94ba5 100644 --- a/data/src/main/java/hu/bbara/purefin/data/offline/room/entity/MovieEntity.kt +++ b/data/src/main/java/hu/bbara/purefin/data/offline/room/entity/MovieEntity.kt @@ -21,6 +21,4 @@ data class MovieEntity( val format: String, val synopsis: String, val imageUrlPrefix: String, - val audioTrack: String, - val subtitles: String ) diff --git a/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineMediaDatabase.kt b/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineMediaDatabase.kt index 6a946f40..c27b4988 100644 --- a/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineMediaDatabase.kt +++ b/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineMediaDatabase.kt @@ -23,7 +23,7 @@ import hu.bbara.purefin.data.offline.room.entity.SmartDownloadEntity EpisodeEntity::class, SmartDownloadEntity::class, ], - version = 9, + version = 10, exportSchema = false ) @TypeConverters(UuidConverters::class) diff --git a/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt b/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt index 83f13c2c..61a70660 100644 --- a/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt +++ b/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt @@ -220,9 +220,7 @@ class OfflineRoomMediaLocalDataSource( runtime = runtime, format = format, synopsis = synopsis, - imageUrlPrefix = imageUrlPrefix, - audioTrack = audioTrack, - subtitles = subtitles + imageUrlPrefix = imageUrlPrefix ) private fun Series.toEntity() = SeriesEntity( @@ -273,8 +271,6 @@ class OfflineRoomMediaLocalDataSource( format = format, synopsis = synopsis, imageUrlPrefix = imageUrlPrefix, - audioTrack = audioTrack, - subtitles = subtitles, cast = emptyList() ) diff --git a/data/src/test/java/hu/bbara/purefin/data/catalog/DefaultEpisodeSeriesLookupTest.kt b/data/src/test/java/hu/bbara/purefin/data/catalog/DefaultEpisodeSeriesLookupTest.kt deleted file mode 100644 index a83a1310..00000000 --- a/data/src/test/java/hu/bbara/purefin/data/catalog/DefaultEpisodeSeriesLookupTest.kt +++ /dev/null @@ -1,78 +0,0 @@ -package hu.bbara.purefin.data.catalog - -import hu.bbara.purefin.data.MediaCatalogReader -import hu.bbara.purefin.model.Episode -import hu.bbara.purefin.model.Movie -import hu.bbara.purefin.model.Series -import java.util.UUID -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Test - -class DefaultEpisodeSeriesLookupTest { - - @Test - fun `returns series id for known episode`() = runBlocking { - val episodeId = UUID.randomUUID() - val seriesId = UUID.randomUUID() - val lookup = DefaultEpisodeSeriesLookup( - mediaCatalogReader = FakeMediaCatalogReader( - episodes = mapOf( - episodeId to episode( - id = episodeId, - seriesId = seriesId, - ) - ) - ) - ) - - val result = lookup.preferenceKeyFor(episodeId) - - assertEquals(seriesId.toString(), result) - } - - @Test - fun `falls back to media id when episode is missing`() = runBlocking { - val mediaId = UUID.randomUUID() - val lookup = DefaultEpisodeSeriesLookup( - mediaCatalogReader = FakeMediaCatalogReader() - ) - - val result = lookup.preferenceKeyFor(mediaId) - - assertEquals(mediaId.toString(), result) - } - - private class FakeMediaCatalogReader( - movies: Map = emptyMap(), - series: Map = emptyMap(), - episodes: Map = emptyMap(), - ) : MediaCatalogReader { - override val movies: StateFlow> = MutableStateFlow(movies) - override val series: StateFlow> = MutableStateFlow(series) - override val episodes: StateFlow> = MutableStateFlow(episodes) - - override fun observeSeriesWithContent(seriesId: UUID): Flow = flowOf(series.value[seriesId]) - } - - private fun episode(id: UUID, seriesId: UUID) = Episode( - id = id, - seriesId = seriesId, - seasonId = UUID.randomUUID(), - index = 1, - title = "Episode", - synopsis = "Synopsis", - releaseDate = "2026", - rating = "PG", - runtime = "42m", - progress = null, - watched = false, - format = "VIDEO", - imageUrlPrefix = "", - cast = emptyList(), - ) -}