Support playing multiple file versions (#30)

Adds support for playing different versions of a media item.
Additionally, you can pick the audio or subtitle tracks for the chosen
version.

The file with the highest resolution is the default, but you can switch
versions or tracks in the "More" menu.

These choices are persisted across playbacks, just as in #26.
This commit is contained in:
damontecres 2025-10-18 14:11:06 -04:00 committed by GitHub
parent e2db5ab4f3
commit f57fd25f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 771 additions and 196 deletions

View file

@ -1,8 +1,13 @@
package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
@ -24,38 +29,93 @@ class ItemPlaybackRepository
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId)
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
if (source != null) {
val audioStream =
if (itemPlayback.audioIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
null
}
val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
null
}
ChosenStreams(
itemId,
audioStream,
subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
)
} else {
null
}
getChosenItemFromPlayback(item, itemPlayback)
} else {
null
}
}
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback,
): ChosenStreams? {
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
if (source != null) {
val audioStream =
if (itemPlayback.audioIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
null
}
val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
null
}
return ChosenStreams(
itemPlayback,
item.id,
source.id?.toUUIDOrNull(),
audioStream,
subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
)
} else {
return null
}
}
suspend fun savePlayVersion(
itemId: UUID,
sourceId: UUID,
): ItemPlayback? =
withContext(Dispatchers.IO) {
serverRepository.currentUser?.let { user ->
val itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = itemId,
sourceId = sourceId,
)
Timber.v("Saving play version %s", itemPlayback)
saveItemPlayback(itemPlayback)
}
}
suspend fun saveTrackSelection(
item: BaseItem,
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
) = serverRepository.currentUser?.let { user ->
var toSave =
itemPlayback ?: ItemPlayback(
userId = user.rowId,
itemId = item.id,
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
)
toSave =
when (type) {
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
else -> toSave
}
Timber.v("Saving track selection %s", toSave)
saveItemPlayback(toSave)
}
suspend fun saveItemPlayback(itemPlayback: ItemPlayback): ItemPlayback {
val rowId = itemPlaybackDao.saveItem(itemPlayback)
return itemPlayback.copy(rowId = rowId)
}
}
data class ChosenStreams(
val itemPlayback: ItemPlayback,
val itemId: UUID,
val sourceId: UUID?,
val audioStream: MediaStream?,
val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean,

View file

@ -7,9 +7,18 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.UuidSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@Entity(
@ -39,6 +48,119 @@ data class ItemPlayback(
@Transient val subtitleIndexEnabled = subtitleIndex >= 0
}
/**
* Returns the [MediaSourceInfo] with the highest video resolution
*/
fun chooseSource(sources: List<MediaSourceInfo>?) =
sources?.letNotEmpty { sources ->
val result =
sources.maxByOrNull { s ->
s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
(video.width ?: 0) * (video.height ?: 0)
} ?: 0
}
result
}
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
*/
fun chooseSource(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
fun chooseStream(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
type: MediaStreamType,
prefs: UserPreferences,
): MediaStream? {
val source = chooseSource(dto, itemPlayback)
return source?.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == type }
when (type) {
MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs)
MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs)
else -> candidates.firstOrNull()
}
}
}
fun chooseAudioStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): MediaStream? =
if (itemPlayback?.audioIndexEnabled == true) {
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
// TODO audio selection based on channel layout or preferences or default
val audioLanguage = prefs.userConfig.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
?: sorted.firstOrNull { it.language == audioLanguage }
?: sorted.firstOrNull { it.isDefault }
?: sorted.firstOrNull()
} else {
candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull()
}
}
fun chooseSubtitleStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference
return when (prefs.userConfig.subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED ->
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
SubtitlePlaybackMode.SMART -> {
val audioLanguage = prefs.userConfig.audioLanguagePreference
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
// TODO check for language?
(
candidates.firstOrNull { it.isDefault && it.isForced }
?: candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull { it.isForced }
)
}
SubtitlePlaybackMode.NONE -> null
}
}
}
object TrackIndex {
/**
* The user has not explicitly specified a track to use