refactor: convert PlayableMedia to sealed class with specific media types

This commit is contained in:
2026-04-25 21:25:11 +02:00
parent f9eaf0cf84
commit b15453ab01
5 changed files with 139 additions and 29 deletions

View File

@@ -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<MediaSegment>
)
sealed class PlayableMedia {
abstract val id: UUID
abstract val resumePositionMs: Long
abstract val mediaItem: MediaItem
abstract val preferences: MediaTrackPreferences
abstract val mediaSegments: List<MediaSegment>
data class Movie(
override val id: UUID,
override val resumePositionMs: Long,
override val mediaItem: MediaItem,
override val preferences: MediaTrackPreferences,
override val mediaSegments: List<MediaSegment>
) : PlayableMedia()
data class Series(
override val id: UUID,
override val resumePositionMs: Long,
override val mediaItem: MediaItem,
override val preferences: MediaTrackPreferences,
override val mediaSegments: List<MediaSegment>
) : PlayableMedia()
data class Episode(
override val id: UUID,
override val resumePositionMs: Long,
override val mediaItem: MediaItem,
override val preferences: MediaTrackPreferences,
override val mediaSegments: List<MediaSegment>
) : PlayableMedia()
}

View File

@@ -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)
}
}
}

View File

@@ -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()

View File

@@ -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<Movie?> {
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<Series?> {
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<Episode?> {
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) }
}

View File

@@ -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) {