From f7f9fc10588a69aa5c43d200050acdc00d566ff2 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 25 Apr 2026 18:08:23 +0200 Subject: [PATCH] refactor: centralize playable media state Move playback-related media state into shared model types and make the player consume a single PlayableMedia object instead of separately loading MediaItems, track preferences, resume positions, and media segments. This updates PlayableMediaRepository to return playable media bundles, seeds the player playlist with the current item, keeps next-up entries in the same model, restores resume/segment handling, and fixes current queue highlighting. The change also moves track preference models into core-model, centralizes Jellyfin BaseItemDto conversion, removes placeholder movie audio/subtitle fields, bumps the offline Room schema, and updates mobile/TV queue UI code for the playlist model. Verification: ./gradlew --no-daemon :core-model:compileDebugKotlin :core:compileDebugKotlin :data:compileDebugKotlin :app:compileDebugKotlin --- app-tv/build.gradle.kts | 2 +- .../components/TvPlayerControlsOverlayTest.kt | 12 +- .../ui/screen/player/TvPlayerScreen.kt | 2 +- .../player/components/TvPlayerQueuePanel.kt | 8 +- .../purefin/ui/screen/movie/MovieScreen.kt | 14 +- .../movie/components/MovieComponents.kt | 4 +- .../player/components/PlayerQueuePanel.kt | 6 +- core-model/build.gradle.kts | 35 +++-- .../main/java/hu/bbara/purefin/model/Movie.kt | 2 - .../hu/bbara/purefin/model/PlayableMedia.kt | 12 ++ .../bbara/purefin/model}/TrackPreference.kt | 2 +- .../purefin/data/CompositeMediaRepository.kt | 18 +++ .../bbara/purefin/data/MediaCatalogReader.kt | 3 + .../purefin/data/PlayableMediaRepository.kt | 14 +- .../purefin/player/manager/PlayerManager.kt | 119 ++++++++------- .../purefin/player/model/MediaContext.kt | 2 +- .../purefin/player/model/PlayerUiModels.kt | 5 +- .../purefin/player/preference/TrackMatcher.kt | 2 + .../preference/TrackPreferencesModule.kt | 1 + .../preference/TrackPreferencesRepository.kt | 4 + .../preference/TrackPreferencesSerializer.kt | 1 + .../player/viewmodel/PlayerViewModel.kt | 99 +++++-------- .../hu/bbara/purefin/ui/model/MediaUiModel.kt | 2 - .../catalog/InMemoryAppContentRepository.kt | 135 ++---------------- .../data/catalog/InMemoryMediaRepository.kt | 95 +++++------- .../data/catalog/OfflineMediaRepository.kt | 13 ++ .../data/converter/BaseItemDtoConverter.kt | 127 ++++++++++++++++ .../DefaultPlayableMediaRepository.kt | 57 ++++---- .../data/jellyfin/client/JellyfinApiClient.kt | 4 +- .../download/JellyfinDownloadMappers.kt | 83 ----------- .../JellyfinDownloadMediaSourceResolver.kt | 12 +- .../purefin/data/offline/cache/HomeCache.kt | 6 - .../data/offline/room/entity/MovieEntity.kt | 2 - .../room/offline/OfflineMediaDatabase.kt | 2 +- .../OfflineRoomMediaLocalDataSource.kt | 6 +- .../catalog/DefaultEpisodeSeriesLookupTest.kt | 78 ---------- 36 files changed, 440 insertions(+), 549 deletions(-) create mode 100644 core-model/src/main/java/hu/bbara/purefin/model/PlayableMedia.kt rename {core/src/main/java/hu/bbara/purefin/player/preference => core-model/src/main/java/hu/bbara/purefin/model}/TrackPreference.kt (95%) create mode 100644 data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt delete mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt delete mode 100644 data/src/test/java/hu/bbara/purefin/data/catalog/DefaultEpisodeSeriesLookupTest.kt 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(), - ) -}