refactor: Added MediaSegments to MediaContext to make it accesible during playback. Also refactored MediaTrackPreferences to load preference in the PlayerViewModel

This commit is contained in:
2026-04-22 19:34:11 +02:00
parent 188efde982
commit 899e2f08b0
12 changed files with 130 additions and 34 deletions

View File

@@ -1,10 +1,12 @@
package hu.bbara.purefin.core.data package hu.bbara.purefin.core.data
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import hu.bbara.purefin.core.model.MediaSegment
import java.util.UUID import java.util.UUID
interface PlayableMediaRepository { interface PlayableMediaRepository {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>?
suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment>
suspend fun getNextUpMediaItems( suspend fun getNextUpMediaItems(
episodeId: UUID, episodeId: UUID,
existingIds: Set<String>, existingIds: Set<String>,

View File

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

View File

@@ -10,6 +10,10 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.PlaybackReportContext 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.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType 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.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.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -231,7 +234,7 @@ class PlayerManager @Inject constructor(
// Save track preference if media context is available // Save track preference if media context is available
currentMediaContext?.let { context -> currentMediaContext?.let { context ->
scope.launch { scope.launch {
saveTrackPreference(option, context.preferenceKey) saveTrackPreference(option, context.mediaId)
} }
} }
} }
@@ -263,7 +266,7 @@ class PlayerManager @Inject constructor(
private suspend fun applyTrackPreferences() { private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return val context = currentMediaContext ?: return
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return val preferences = context.preferences ?: return
val currentTrackState = _tracks.value 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
)

View File

@@ -5,6 +5,9 @@ import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.MediaProgressWriter import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.PlaybackProgressReporter import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.PlaybackReportContext 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.core.player.preference package hu.bbara.purefin.core.player.preference
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable

View File

@@ -6,18 +6,21 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.EpisodeSeriesLookup import hu.bbara.purefin.core.data.EpisodeSeriesLookup
import hu.bbara.purefin.core.data.PlayableMediaRepository 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.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager 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.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow 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.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.InternalSerializationApi
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -26,6 +29,7 @@ class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager, private val playerManager: PlayerManager,
private val playableMediaRepository: PlayableMediaRepository, private val playableMediaRepository: PlayableMediaRepository,
private val trackPreferencesRepository: TrackPreferencesRepository,
private val episodeSeriesLookup: EpisodeSeriesLookup, private val episodeSeriesLookup: EpisodeSeriesLookup,
private val progressManager: ProgressManager, private val progressManager: ProgressManager,
) : ViewModel() { ) : ViewModel() {
@@ -139,6 +143,7 @@ class PlayerViewModel @Inject constructor(
loadMediaById(id) loadMediaById(id)
} }
@OptIn(InternalSerializationApi::class)
private fun loadMediaById(id: String) { private fun loadMediaById(id: String) {
val uuid = id.toUuidOrNull() val uuid = id.toUuidOrNull()
if (uuid == null) { if (uuid == null) {
@@ -152,7 +157,14 @@ class PlayerViewModel @Inject constructor(
val (mediaItem, resumePositionMs) = result val (mediaItem, resumePositionMs) = result
val preferenceKey = episodeSeriesLookup.preferenceKeyFor(uuid) 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) playerManager.play(mediaItem, mediaContext)

View File

@@ -9,20 +9,27 @@ import androidx.media3.common.util.UnstableApi
import hu.bbara.purefin.core.data.PlayableMediaRepository import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.session.UserSessionRepository 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.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.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey 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.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import hu.bbara.purefin.core.image.ArtworkKind
import org.jellyfin.sdk.model.api.BaseItemDto 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 org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class DefaultPlayableMediaRepository @Inject constructor( class DefaultPlayableMediaRepository @Inject constructor(
@@ -52,6 +59,13 @@ class DefaultPlayableMediaRepository @Inject constructor(
Pair(mediaItem, resumePositionMs) Pair(mediaItem, resumePositionMs)
} }
override suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> {
val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId)
return mediaSegments.mapNotNull {
it.toMediaSegment()
}
}
override suspend fun getNextUpMediaItems( override suspend fun getNextUpMediaItems(
episodeId: UUID, episodeId: UUID,
existingIds: Set<String>, existingIds: Set<String>,
@@ -133,4 +147,20 @@ class DefaultPlayableMediaRepository @Inject constructor(
val episodeNumber = item.indexNumber ?: return null val episodeNumber = item.indexNumber ?: return null
return "S$seasonNumber:E$episodeNumber" 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
)
}
} }

View File

@@ -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.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi 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.playStateApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi 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.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.ItemFields 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
@@ -264,6 +267,18 @@ class JellyfinApiClient @Inject constructor(
result.content.mediaSources result.content.mediaSources
} }
suspend fun getMediaSegments(mediaId: UUID) : List<MediaSegmentDto> = 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( suspend fun getPlaybackInfo(
mediaId: UUID, mediaId: UUID,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,