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
This commit is contained in:
2026-04-25 18:08:23 +02:00
parent 2433ececac
commit f7f9fc1058
36 changed files with 440 additions and 549 deletions

View File

@@ -43,6 +43,24 @@ class CompositeMediaRepository @Inject constructor(
.flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository
.flatMapLatest { it.getMovie(id) }
.stateIn(scope, SharingStarted.Companion.Eagerly, null)
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return activeRepository
.flatMapLatest { it.getSeries(id) }
.stateIn(scope, SharingStarted.Companion.Eagerly, null)
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return activeRepository
.flatMapLatest { it.getEpisode(id) }
.stateIn(scope, SharingStarted.Companion.Eagerly, null)
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
}

View File

@@ -12,5 +12,8 @@ interface MediaCatalogReader {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
suspend fun getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
}

View File

@@ -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<MediaItem, Long?>?
suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment>
suspend fun getNextUpMediaItems(
suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia?
suspend fun getNextUpPlayableMedias(
episodeId: UUID,
existingIds: Set<String>,
count: Int = 9,
): List<MediaItem>
existingIds: Set<UUID>,
count: Int,
): List<PlayableMedia>
}

View File

@@ -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<UUID?>(null)
val currentPlayableMedia: StateFlow<PlayableMedia?> 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<TrackSelectionState> = _tracks.asStateFlow()
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList())
val queue: StateFlow<List<QueueItemUi>> = _queue.asStateFlow()
private val _playlist = MutableStateFlow(emptyList<PlayableMedia>())
val playlist: StateFlow<List<PlayableMedia>> = _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<PlayableMedia>) {
_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<QueueItemUi> {
val items = mutableListOf<QueueItemUi>()
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 {

View File

@@ -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,

View File

@@ -14,7 +14,7 @@ data class PlayerUiState(
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),
val queue: List<QueueItemUi> = emptyList(),
val queue: List<PlaylistElementUiModel> = emptyList(),
val audioTracks: List<TrackOption> = emptyList(),
val textTracks: List<TrackOption> = emptyList(),
val qualityTracks: List<TrackOption> = 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
)

View File

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

View File

@@ -1,36 +0,0 @@
package hu.bbara.purefin.player.preference
import kotlinx.serialization.Serializable
@Serializable
data class TrackPreferences(
val mediaPreferences: Map<String, MediaTrackPreferences> = emptyMap()
)
@Serializable
data class MediaTrackPreferences(
val mediaId: String,
val audioPreference: AudioTrackProperties? = null,
val subtitlePreference: SubtitleTrackProperties? = null
) {
companion object {
fun empty(mediaId: String): MediaTrackPreferences {
return MediaTrackPreferences(mediaId = mediaId)
}
}
}
@Serializable
data class AudioTrackProperties(
val language: String? = null,
val channelCount: Int? = null,
val label: String? = null
)
@Serializable
data class SubtitleTrackProperties(
val language: String? = null,
val forced: Boolean = false,
val label: String? = null,
val isOff: Boolean = false
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,8 +66,6 @@ class MovieUiModel: MediaUiModel {
format = "",
synopsis = "",
imageUrlPrefix = "",
audioTrack = "",
subtitles = "",
cast = emptyList()
)
)