From b15453ab01814e6afefd23b0712f3beac1f386df Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 25 Apr 2026 21:25:11 +0200 Subject: [PATCH] refactor: convert PlayableMedia to sealed class with specific media types --- .../hu/bbara/purefin/model/PlayableMedia.kt | 38 ++++++++-- .../purefin/player/manager/PlayerManager.kt | 8 +- .../player/viewmodel/PlayerViewModel.kt | 75 +++++++++++++++---- .../data/catalog/InMemoryMediaRepository.kt | 15 ++++ .../DefaultPlayableMediaRepository.kt | 32 ++++++-- 5 files changed, 139 insertions(+), 29 deletions(-) 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 index d1358522..4f009e6b 100644 --- a/core-model/src/main/java/hu/bbara/purefin/model/PlayableMedia.kt +++ b/core-model/src/main/java/hu/bbara/purefin/model/PlayableMedia.kt @@ -3,10 +3,34 @@ 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 +sealed class PlayableMedia { + abstract val id: UUID + abstract val resumePositionMs: Long + abstract val mediaItem: MediaItem + abstract val preferences: MediaTrackPreferences + abstract val mediaSegments: List + + data class Movie( + override val id: UUID, + override val resumePositionMs: Long, + override val mediaItem: MediaItem, + override val preferences: MediaTrackPreferences, + override val mediaSegments: List + ) : PlayableMedia() + + data class Series( + override val id: UUID, + override val resumePositionMs: Long, + override val mediaItem: MediaItem, + override val preferences: MediaTrackPreferences, + override val mediaSegments: List + ) : PlayableMedia() + + data class Episode( + override val id: UUID, + override val resumePositionMs: Long, + override val mediaItem: MediaItem, + override val preferences: MediaTrackPreferences, + override val mediaSegments: List + ) : PlayableMedia() +} 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 12d8674d..9fdcc261 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 @@ -102,7 +102,13 @@ class PlayerManager @Inject constructor( override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { _currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) } scope.launch { - updatePlaylist() + currentPlayableMedia.value?.let { currentMedia -> + // Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time. + if (currentMedia is PlayableMedia.Episode) { + updatePlaylist() + } + seekTo(currentMedia.resumePositionMs) + } } } 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 c42f8fdb..ec201c96 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 @@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.data.MediaCatalogReader import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.image.ImageUrlBuilder +import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.player.manager.PlayerManager import hu.bbara.purefin.player.manager.ProgressManager import hu.bbara.purefin.player.model.PlayerUiState @@ -122,22 +123,10 @@ class PlayerViewModel @Inject constructor( viewModelScope.launch { 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) { - Log.e("PlayerViewModel", "Episode not found for playlist: $playlist") - return@mapNotNull null - } - PlaylistElementUiModel( - id = episodeValue.id.toString(), - title = episodeValue.title, - artworkUrl = ImageUrlBuilder.finishImageUrl(prefixImageUrl = episodeValue.imageUrlPrefix, artworkKind = ArtworkKind.PRIMARY), - isCurrent = currentPlayableMedia.value?.id == episodeValue.id - ) + queue = playlist.mapNotNull { playableMedia -> + playableMedia.toPlaylistElementUiModel(currentPlayableMedia.value?.id) } ) } @@ -271,6 +260,64 @@ class PlayerViewModel @Inject constructor( _uiState.update { it.copy(error = null) } } + private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? { + return when (this) { + is PlayableMedia.Movie -> { + val movie = mediaCatalogReader.getMovie(id).first() + if (movie == null) { + Log.e("PlayerViewModel", "Movie not found for playlist item: $id") + null + } else { + PlaylistElementUiModel( + id = movie.id.toString(), + title = movie.title, + artworkUrl = ImageUrlBuilder.finishImageUrl( + prefixImageUrl = movie.imageUrlPrefix, + artworkKind = ArtworkKind.PRIMARY + ), + isCurrent = currentMediaId == movie.id + ) + } + } + + is PlayableMedia.Series -> { + val series = mediaCatalogReader.getSeries(id).first() + if (series == null) { + Log.e("PlayerViewModel", "Series not found for playlist item: $id") + null + } else { + PlaylistElementUiModel( + id = series.id.toString(), + title = series.name, + artworkUrl = ImageUrlBuilder.finishImageUrl( + prefixImageUrl = series.imageUrlPrefix, + artworkKind = ArtworkKind.PRIMARY + ), + isCurrent = currentMediaId == series.id + ) + } + } + + is PlayableMedia.Episode -> { + val episode = mediaCatalogReader.getEpisode(id).first() + if (episode == null) { + Log.e("PlayerViewModel", "Episode not found for playlist item: $id") + null + } else { + PlaylistElementUiModel( + id = episode.id.toString(), + title = episode.title, + artworkUrl = ImageUrlBuilder.finishImageUrl( + prefixImageUrl = episode.imageUrlPrefix, + artworkKind = ArtworkKind.PRIMARY + ), + isCurrent = currentMediaId == episode.id + ) + } + } + } + } + override fun onCleared() { super.onCleared() autoHideJob?.cancel() 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 5e392054..a63ec209 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 @@ -1,5 +1,6 @@ package hu.bbara.purefin.data.catalog +import android.util.Log import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.converter.toEpisode @@ -18,9 +19,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -47,6 +50,10 @@ class InMemoryMediaRepository @Inject constructor( override suspend fun getMovie(id: UUID): Flow { if (!moviesState.value.containsKey(id)) { jellyfinApiClient.getItemInfo(id)?.let { item -> + if (item.type != BaseItemKind.MOVIE) { + Log.d("InMemoryMediaRepository", "Item is not an movie: ${item.type}") + return flowOf(null) + } val movie = item.toMovie(serverUrl.first()) moviesState.update { current -> current + (movie.id to movie) } } @@ -57,6 +64,10 @@ class InMemoryMediaRepository @Inject constructor( override suspend fun getSeries(id: UUID): Flow { if (!seriesState.value.containsKey(id)) { jellyfinApiClient.getItemInfo(id)?.let { item -> + if (item.type != BaseItemKind.SERIES) { + Log.d("InMemoryMediaRepository", "Item is not an series: ${item.type}") + return flowOf(null) + } val series = item.toSeries(serverUrl.first()) seriesState.update { current -> current + (series.id to series) } } @@ -67,6 +78,10 @@ class InMemoryMediaRepository @Inject constructor( override suspend fun getEpisode(id: UUID): Flow { if (!episodesState.value.containsKey(id)) { jellyfinApiClient.getItemInfo(id)?.let { item -> + if (item.type != BaseItemKind.EPISODE) { + Log.d("InMemoryMediaRepository", "Item is not an episode: ${item.type}") + return flowOf(null) + } val episode = item.toEpisode(serverUrl.first()) episodesState.update { current -> current + (episode.id to episode) } } 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 dd9473f6..d76ab137 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 @@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType.INTRO import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO @@ -49,13 +50,30 @@ class DefaultPlayableMediaRepository @Inject constructor( 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 - ) + when (baseItem.type) { + BaseItemKind.MOVIE -> PlayableMedia.Movie( + id = mediaId, + mediaItem = mediaItem, + resumePositionMs = resumePositionMs ?: 0L, + preferences = mediaTrackPreferences, + mediaSegments = mediaSegments + ) + BaseItemKind.SERIES -> PlayableMedia.Series( + id = mediaId, + mediaItem = mediaItem, + resumePositionMs = resumePositionMs ?: 0L, + preferences = mediaTrackPreferences, + mediaSegments = mediaSegments + ) + BaseItemKind.EPISODE -> PlayableMedia.Episode( + id = mediaId, + mediaItem = mediaItem, + resumePositionMs = resumePositionMs ?: 0L, + preferences = mediaTrackPreferences, + mediaSegments = mediaSegments + ) + else -> null + } } private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {