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
import androidx.media3.common.MediaItem
import hu.bbara.purefin.core.model.MediaSegment
import java.util.UUID
interface PlayableMediaRepository {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>?
suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment>
suspend fun getNextUpMediaItems(
episodeId: UUID,
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 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
)

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

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
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
@Serializable

View File

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

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.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<MediaSegment> {
val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId)
return mediaSegments.mapNotNull {
it.toMediaSegment()
}
}
override suspend fun getNextUpMediaItems(
episodeId: UUID,
existingIds: Set<String>,
@@ -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
)
}
}

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.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<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(
mediaId: UUID,
deviceProfile: DeviceProfile,