mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Persist audio & subtitle track changes for series if in different language (#405)
This only applies if the user has set a preferred audio and/or subtitle language on the web UI! If the user chooses an audio/subtitle track that is a different language than their preferred, it will be remembered for that series. Playback of future episodes of the series will pick a matching language. The audio & subtitle selections are independent. You can make the choice before playback or during playback. Closes #378
This commit is contained in:
parent
033bdccaa2
commit
0f4254c6ec
21 changed files with 990 additions and 324 deletions
|
|
@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
|
|
@ -22,8 +23,15 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
|
||||
@Database(
|
||||
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
|
||||
version = 10,
|
||||
entities = [
|
||||
JellyfinServer::class,
|
||||
JellyfinUser::class,
|
||||
ItemPlayback::class,
|
||||
NavDrawerPinnedItem::class,
|
||||
LibraryDisplayInfo::class,
|
||||
PlaybackLanguageChoice::class,
|
||||
],
|
||||
version = 11,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -33,6 +41,7 @@ import java.util.UUID
|
|||
AutoMigration(7, 8),
|
||||
AutoMigration(8, 9),
|
||||
AutoMigration(9, 10),
|
||||
AutoMigration(10, 11),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -44,6 +53,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun serverPreferencesDao(): ServerPreferencesDao
|
||||
|
||||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||
|
||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,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.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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.serializer.toUUIDOrNull
|
||||
|
|
@ -20,47 +23,59 @@ class ItemPlaybackRepository
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
private val streamChoiceService: StreamChoiceService,
|
||||
) {
|
||||
fun getSelectedTracks(
|
||||
suspend fun getSelectedTracks(
|
||||
itemId: UUID,
|
||||
item: BaseItem,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||
if (itemPlayback != null) {
|
||||
Timber.v("Got itemPlayback for %s", itemId)
|
||||
getChosenItemFromPlayback(item, itemPlayback)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
||||
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
|
||||
}
|
||||
|
||||
fun getChosenItemFromPlayback(
|
||||
item: BaseItem,
|
||||
itemPlayback: ItemPlayback,
|
||||
itemPlayback: ItemPlayback?,
|
||||
plc: PlaybackLanguageChoice?,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? {
|
||||
val source =
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
||||
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
||||
if (source != null) {
|
||||
val audioStream =
|
||||
if (itemPlayback.audioIndexEnabled) {
|
||||
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
streamChoiceService.chooseAudioStream(
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.AUDIO }
|
||||
.orEmpty(),
|
||||
itemPlayback = itemPlayback,
|
||||
playbackLanguageChoice = plc,
|
||||
prefs = prefs,
|
||||
)
|
||||
val subtitleStream =
|
||||
if (itemPlayback.subtitleIndexEnabled) {
|
||||
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.orEmpty(),
|
||||
itemPlayback = itemPlayback,
|
||||
playbackLanguageChoice = plc,
|
||||
prefs = prefs,
|
||||
)
|
||||
return ChosenStreams(
|
||||
itemPlayback,
|
||||
item.id,
|
||||
source.id?.toUUIDOrNull(),
|
||||
audioStream,
|
||||
subtitleStream,
|
||||
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
itemId = item.id,
|
||||
source = source,
|
||||
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
||||
audioStream = audioStream,
|
||||
subtitleStream = subtitleStream,
|
||||
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
|
|
@ -89,22 +104,61 @@ class ItemPlaybackRepository
|
|||
itemPlayback: ItemPlayback?,
|
||||
trackIndex: Int,
|
||||
type: MediaStreamType,
|
||||
) = serverRepository.currentUser.value?.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
|
||||
): ItemPlayback =
|
||||
serverRepository.current.value!!.let { current ->
|
||||
val source =
|
||||
itemPlayback?.sourceId?.let { sourceId ->
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId }
|
||||
} ?: streamChoiceService.chooseSource(item.data, null)
|
||||
if (source == null) {
|
||||
Timber.w("Could not find media source for ${item.id}")
|
||||
throw IllegalArgumentException("Could not find media source for ${item.id}")
|
||||
}
|
||||
Timber.v("Saving track selection %s", toSave)
|
||||
saveItemPlayback(toSave)
|
||||
}
|
||||
var toSave =
|
||||
itemPlayback ?: ItemPlayback(
|
||||
userId = current.user.rowId,
|
||||
itemId = item.id,
|
||||
sourceId = source.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)
|
||||
toSave = saveItemPlayback(toSave)
|
||||
val seriesId = item.data.seriesId
|
||||
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
val audioLang = current.userDto.configuration?.audioLanguagePreference
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
if (stream?.language != null && stream.language != audioLang) {
|
||||
streamChoiceService.updateAudio(item.data, stream.language!!)
|
||||
}
|
||||
} else if (type == MediaStreamType.SUBTITLE) {
|
||||
if (trackIndex == TrackIndex.DISABLED) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
item.data,
|
||||
subtitleLang = null,
|
||||
subtitlesDisabled = true,
|
||||
)
|
||||
} else {
|
||||
val subtitleLang =
|
||||
current.userDto.configuration?.subtitleLanguagePreference
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
if (stream?.language != null && stream.language != subtitleLang) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
item.data,
|
||||
stream.language!!,
|
||||
subtitlesDisabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
toSave
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed
|
||||
|
|
@ -127,9 +181,11 @@ class ItemPlaybackRepository
|
|||
}
|
||||
|
||||
data class ChosenStreams(
|
||||
val itemPlayback: ItemPlayback,
|
||||
val itemPlayback: ItemPlayback?,
|
||||
val plc: PlaybackLanguageChoice?,
|
||||
val itemId: UUID,
|
||||
val sourceId: UUID?,
|
||||
val source: MediaSourceInfo,
|
||||
val videoStream: MediaStream?,
|
||||
val audioStream: MediaStream?,
|
||||
val subtitleStream: MediaStream?,
|
||||
val subtitlesDisabled: Boolean,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface PlaybackLanguageChoiceDao {
|
||||
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
|
||||
suspend fun get(
|
||||
userId: Int,
|
||||
seriesId: UUID,
|
||||
): PlaybackLanguageChoice?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun save(plc: PlaybackLanguageChoice): Long
|
||||
}
|
||||
|
|
@ -7,19 +7,10 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
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.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
|
|
@ -53,122 +44,6 @@ data class ItemPlayback(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = JellyfinUser::class,
|
||||
parentColumns = arrayOf("rowId"),
|
||||
childColumns = arrayOf("userId"),
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
onUpdate = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
primaryKeys = ["userId", "seriesId"],
|
||||
)
|
||||
@Serializable
|
||||
data class PlaybackLanguageChoice(
|
||||
val userId: Int,
|
||||
val seriesId: UUID,
|
||||
val itemId: UUID? = null,
|
||||
val audioLanguage: String? = null,
|
||||
val subtitleLanguage: String? = null,
|
||||
val subtitlesDisabled: Boolean? = null,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue