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 androidx.media3.common.MediaItem
import java.util.UUID import java.util.UUID
data class PlayableMedia ( sealed class PlayableMedia {
val id: UUID, abstract val id: UUID
val resumePositionMs: Float, abstract val resumePositionMs: Long
val mediaItem: MediaItem, abstract val mediaItem: MediaItem
val preferences: MediaTrackPreferences, abstract val preferences: MediaTrackPreferences
val mediaSegments: List<MediaSegment> 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,8 +102,14 @@ class PlayerManager @Inject constructor(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
_currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) } _currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
scope.launch { scope.launch {
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() updatePlaylist()
} }
seekTo(currentMedia.resumePositionMs)
}
}
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {

View File

@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder 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.PlayerManager
import hu.bbara.purefin.player.manager.ProgressManager import hu.bbara.purefin.player.manager.ProgressManager
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.PlayerUiState
@@ -122,22 +123,10 @@ class PlayerViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val currentPlayableMedia = playerManager.currentPlayableMedia val currentPlayableMedia = playerManager.currentPlayableMedia
playerManager.playlist.collect { playlist -> playerManager.playlist.collect { playlist ->
val episodes =
playlist.map { playableMedia -> mediaCatalogReader.getEpisode(playableMedia.id) }
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
queue = episodes.mapNotNull { episode -> queue = playlist.mapNotNull { playableMedia ->
val episodeValue = episode.first() playableMedia.toPlaylistElementUiModel(currentPlayableMedia.value?.id)
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
)
} }
) )
} }
@@ -271,6 +260,64 @@ class PlayerViewModel @Inject constructor(
_uiState.update { it.copy(error = null) } _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() { override fun onCleared() {
super.onCleared() super.onCleared()
autoHideJob?.cancel() autoHideJob?.cancel()

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.data.catalog package hu.bbara.purefin.data.catalog
import android.util.Log
import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -47,6 +50,10 @@ class InMemoryMediaRepository @Inject constructor(
override suspend fun getMovie(id: UUID): Flow<Movie?> { override suspend fun getMovie(id: UUID): Flow<Movie?> {
if (!moviesState.value.containsKey(id)) { if (!moviesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item -> 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()) val movie = item.toMovie(serverUrl.first())
moviesState.update { current -> current + (movie.id to movie) } moviesState.update { current -> current + (movie.id to movie) }
} }
@@ -57,6 +64,10 @@ class InMemoryMediaRepository @Inject constructor(
override suspend fun getSeries(id: UUID): Flow<Series?> { override suspend fun getSeries(id: UUID): Flow<Series?> {
if (!seriesState.value.containsKey(id)) { if (!seriesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item -> 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()) val series = item.toSeries(serverUrl.first())
seriesState.update { current -> current + (series.id to series) } seriesState.update { current -> current + (series.id to series) }
} }
@@ -67,6 +78,10 @@ class InMemoryMediaRepository @Inject constructor(
override suspend fun getEpisode(id: UUID): Flow<Episode?> { override suspend fun getEpisode(id: UUID): Flow<Episode?> {
if (!episodesState.value.containsKey(id)) { if (!episodesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item -> 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()) val episode = item.toEpisode(serverUrl.first())
episodesState.update { current -> current + (episode.id to episode) } 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.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto 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.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType.INTRO import org.jellyfin.sdk.model.api.MediaSegmentType.INTRO
import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO
@@ -49,13 +50,30 @@ class DefaultPlayableMediaRepository @Inject constructor(
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(mediaId.toString()).first() val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(mediaId.toString()).first()
val mediaSegments = getMediaSegments(mediaId) val mediaSegments = getMediaSegments(mediaId)
PlayableMedia( when (baseItem.type) {
BaseItemKind.MOVIE -> PlayableMedia.Movie(
id = mediaId, id = mediaId,
mediaItem = mediaItem, mediaItem = mediaItem,
resumePositionMs = resumePositionMs?.toFloat() ?: 0f, resumePositionMs = resumePositionMs ?: 0L,
preferences = mediaTrackPreferences, preferences = mediaTrackPreferences,
mediaSegments = mediaSegments 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) { private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {