From 899e2f08b025aeb2dd54bfac10f15546ed69a60b Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 22 Apr 2026 19:34:11 +0200 Subject: [PATCH] refactor: Added MediaSegments to MediaContext to make it accesible during playback. Also refactored MediaTrackPreferences to load preference in the PlayerViewModel --- .../core/data/PlayableMediaRepository.kt | 2 + .../bbara/purefin/core/model/MediaSegment.kt | 18 +++++++++ .../core/player/manager/PlayerManager.kt | 34 +++-------------- .../core/player/manager/ProgressManager.kt | 3 ++ .../purefin/core/player/model/MediaContext.kt | 10 +++++ .../core/player/model/MetadataState.kt | 10 +++++ .../player/model/PlaybackProgressSnapshot.kt | 8 ++++ .../player/model/PlaybackStateSnapshot.kt | 9 +++++ .../core/player/preference/TrackPreference.kt | 1 + .../core/player/viewmodel/PlayerViewModel.kt | 16 +++++++- .../DefaultPlayableMediaRepository.kt | 38 +++++++++++++++++-- .../data/jellyfin/client/JellyfinApiClient.kt | 15 ++++++++ 12 files changed, 130 insertions(+), 34 deletions(-) create mode 100644 core/model/src/main/java/hu/bbara/purefin/core/model/MediaSegment.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/model/MediaContext.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/model/MetadataState.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackProgressSnapshot.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackStateSnapshot.kt diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/PlayableMediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/PlayableMediaRepository.kt index ebf035b6..7727d8de 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/PlayableMediaRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/PlayableMediaRepository.kt @@ -1,10 +1,12 @@ package hu.bbara.purefin.core.data import androidx.media3.common.MediaItem +import hu.bbara.purefin.core.model.MediaSegment import java.util.UUID interface PlayableMediaRepository { suspend fun getMediaItem(mediaId: UUID): Pair? + suspend fun getMediaSegments(mediaId: UUID): List suspend fun getNextUpMediaItems( episodeId: UUID, existingIds: Set, diff --git a/core/model/src/main/java/hu/bbara/purefin/core/model/MediaSegment.kt b/core/model/src/main/java/hu/bbara/purefin/core/model/MediaSegment.kt new file mode 100644 index 00000000..30613540 --- /dev/null +++ b/core/model/src/main/java/hu/bbara/purefin/core/model/MediaSegment.kt @@ -0,0 +1,18 @@ +package hu.bbara.purefin.core.model + +import java.util.UUID + +enum class SegmentType { + INTRO, + OUTRO, + MAIN_CONTENT, + PREVIEW, + RECAP; +} + +data class MediaSegment( + val id: UUID, + val type: SegmentType, + val startMs: Long, + val endMs: Long +) diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index 740aaf21..096b17c0 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -10,6 +10,10 @@ import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.PlaybackReportContext +import hu.bbara.purefin.core.player.model.MediaContext +import hu.bbara.purefin.core.player.model.MetadataState +import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot +import hu.bbara.purefin.core.player.model.PlaybackStateSnapshot import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackType @@ -25,7 +29,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -231,7 +234,7 @@ class PlayerManager @Inject constructor( // Save track preference if media context is available currentMediaContext?.let { context -> scope.launch { - saveTrackPreference(option, context.preferenceKey) + saveTrackPreference(option, context.mediaId) } } } @@ -263,7 +266,7 @@ class PlayerManager @Inject constructor( private suspend fun applyTrackPreferences() { val context = currentMediaContext ?: return - val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return + val preferences = context.preferences ?: return val currentTrackState = _tracks.value @@ -394,28 +397,3 @@ class PlayerManager @Inject constructor( } } -data class PlaybackStateSnapshot( - val isPlaying: Boolean = false, - val isBuffering: Boolean = false, - val isEnded: Boolean = false, - val error: String? = null -) - -data class PlaybackProgressSnapshot( - val durationMs: Long = 0L, - val positionMs: Long = 0L, - val bufferedMs: Long = 0L, - val isLive: Boolean = false -) - -data class MetadataState( - val mediaId: String? = null, - val title: String? = null, - val subtitle: String? = null, - val playbackReportContext: PlaybackReportContext? = null, -) - -data class MediaContext( - val mediaId: String, - val preferenceKey: String -) diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt index bc4ad425..d2f6dc6f 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt @@ -5,6 +5,9 @@ import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.MediaProgressWriter import hu.bbara.purefin.core.data.PlaybackProgressReporter import hu.bbara.purefin.core.data.PlaybackReportContext +import hu.bbara.purefin.core.player.model.MetadataState +import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot +import hu.bbara.purefin.core.player.model.PlaybackStateSnapshot import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/MediaContext.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/MediaContext.kt new file mode 100644 index 00000000..f4070e21 --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/MediaContext.kt @@ -0,0 +1,10 @@ +package hu.bbara.purefin.core.player.model + +import hu.bbara.purefin.core.model.MediaSegment +import hu.bbara.purefin.core.player.preference.MediaTrackPreferences + +data class MediaContext( + val mediaId: String, + val preferences: MediaTrackPreferences?, + val mediaSegments: List +) \ No newline at end of file diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/MetadataState.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/MetadataState.kt new file mode 100644 index 00000000..a25342e6 --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/MetadataState.kt @@ -0,0 +1,10 @@ +package hu.bbara.purefin.core.player.model + +import hu.bbara.purefin.core.data.PlaybackReportContext + +data class MetadataState( + val mediaId: String? = null, + val title: String? = null, + val subtitle: String? = null, + val playbackReportContext: PlaybackReportContext? = null, +) \ No newline at end of file diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackProgressSnapshot.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackProgressSnapshot.kt new file mode 100644 index 00000000..ef09734f --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackProgressSnapshot.kt @@ -0,0 +1,8 @@ +package hu.bbara.purefin.core.player.model + +data class PlaybackProgressSnapshot( + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val bufferedMs: Long = 0L, + val isLive: Boolean = false +) \ No newline at end of file diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackStateSnapshot.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackStateSnapshot.kt new file mode 100644 index 00000000..06721653 --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlaybackStateSnapshot.kt @@ -0,0 +1,9 @@ +package hu.bbara.purefin.core.player.model + +data class PlaybackStateSnapshot( + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val isEnded: Boolean = false, + val error: String? = null +) + diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/preference/TrackPreference.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/preference/TrackPreference.kt index df255e66..c5dda9dc 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/preference/TrackPreference.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/preference/TrackPreference.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.core.player.preference +import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.Serializable @Serializable diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index aea5fc4d..914d9cdd 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -6,18 +6,21 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.EpisodeSeriesLookup import hu.bbara.purefin.core.data.PlayableMediaRepository -import hu.bbara.purefin.core.player.manager.MediaContext import hu.bbara.purefin.core.player.manager.PlayerManager import hu.bbara.purefin.core.player.manager.ProgressManager +import hu.bbara.purefin.core.player.model.MediaContext import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.TrackOption +import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.InternalSerializationApi import java.util.UUID import javax.inject.Inject @@ -26,6 +29,7 @@ class PlayerViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val playerManager: PlayerManager, private val playableMediaRepository: PlayableMediaRepository, + private val trackPreferencesRepository: TrackPreferencesRepository, private val episodeSeriesLookup: EpisodeSeriesLookup, private val progressManager: ProgressManager, ) : ViewModel() { @@ -139,6 +143,7 @@ class PlayerViewModel @Inject constructor( loadMediaById(id) } + @OptIn(InternalSerializationApi::class) private fun loadMediaById(id: String) { val uuid = id.toUuidOrNull() if (uuid == null) { @@ -152,7 +157,14 @@ class PlayerViewModel @Inject constructor( val (mediaItem, resumePositionMs) = result val preferenceKey = episodeSeriesLookup.preferenceKeyFor(uuid) - val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey) + val preferences = trackPreferencesRepository.getMediaPreferences(preferenceKey).firstOrNull() + val mediaSegments = playableMediaRepository.getMediaSegments(uuid) + + val mediaContext = MediaContext( + mediaId = id, + preferences = preferences, + mediaSegments = mediaSegments + ) playerManager.play(mediaItem, mediaContext) diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index 1acd6ed7..0effbf0e 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -9,20 +9,27 @@ import androidx.media3.common.util.UnstableApi import hu.bbara.purefin.core.data.PlayableMediaRepository import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.core.image.ArtworkKind import hu.bbara.purefin.core.image.ImageUrlBuilder +import hu.bbara.purefin.core.model.MediaSegment +import hu.bbara.purefin.core.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 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 hu.bbara.purefin.core.image.ArtworkKind import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentType.INTRO +import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO +import org.jellyfin.sdk.model.api.MediaSegmentType.PREVIEW +import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP import org.jellyfin.sdk.model.api.MediaSourceInfo +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton @Singleton class DefaultPlayableMediaRepository @Inject constructor( @@ -52,6 +59,13 @@ class DefaultPlayableMediaRepository @Inject constructor( Pair(mediaItem, resumePositionMs) } + override suspend fun getMediaSegments(mediaId: UUID): List { + val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId) + return mediaSegments.mapNotNull { + it.toMediaSegment() + } + } + override suspend fun getNextUpMediaItems( episodeId: UUID, existingIds: Set, @@ -133,4 +147,20 @@ class DefaultPlayableMediaRepository @Inject constructor( val episodeNumber = item.indexNumber ?: return null return "S$seasonNumber:E$episodeNumber" } + + private fun MediaSegmentDto.toMediaSegment(): MediaSegment { + val segmentType = when (type) { + INTRO -> SegmentType.INTRO + PREVIEW -> SegmentType.PREVIEW + RECAP -> SegmentType.RECAP + OUTRO -> SegmentType.OUTRO + else -> SegmentType.MAIN_CONTENT + } + return MediaSegment( + id = itemId, + type = segmentType, + startMs = startTicks, + endMs = endTicks + ) + } } diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 5c37de12..d1b71e4f 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -13,6 +13,7 @@ import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi @@ -30,6 +31,8 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PlayMethod @@ -264,6 +267,18 @@ class JellyfinApiClient @Inject constructor( result.content.mediaSources } + suspend fun getMediaSegments(mediaId: UUID) : List = withContext(Dispatchers.IO) { + if (!ensureConfigured()) { + return@withContext emptyList() + } + val result = api.mediaSegmentsApi.getItemSegments( + itemId = mediaId, + includeSegmentTypes = listOf(MediaSegmentType.INTRO) + ) + Log.d("getMediaSegments", result.toString()) + result.content.items + } + suspend fun getPlaybackInfo( mediaId: UUID, deviceProfile: DeviceProfile,