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:
damontecres 2025-12-11 10:16:45 -05:00 committed by GitHub
parent 033bdccaa2
commit 0f4254c6ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 990 additions and 324 deletions

View file

@ -0,0 +1,383 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "26bbe361923e3aac064c08c52e65264e",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '26bbe361923e3aac064c08c52e65264e')"
]
}
}

View file

@ -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.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem 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 com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -22,8 +23,15 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
@Database( @Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], entities = [
version = 10, JellyfinServer::class,
JellyfinUser::class,
ItemPlayback::class,
NavDrawerPinnedItem::class,
LibraryDisplayInfo::class,
PlaybackLanguageChoice::class,
],
version = 11,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -33,6 +41,7 @@ import java.util.UUID
AutoMigration(7, 8), AutoMigration(7, 8),
AutoMigration(8, 9), AutoMigration(8, 9),
AutoMigration(9, 10), AutoMigration(9, 10),
AutoMigration(10, 11),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -44,6 +53,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun serverPreferencesDao(): ServerPreferencesDao abstract fun serverPreferencesDao(): ServerPreferencesDao
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
} }
class Converters { class Converters {

View file

@ -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.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback 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.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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -20,47 +23,59 @@ class ItemPlaybackRepository
constructor( constructor(
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
private val streamChoiceService: StreamChoiceService,
) { ) {
fun getSelectedTracks( suspend fun getSelectedTracks(
itemId: UUID, itemId: UUID,
item: BaseItem, item: BaseItem,
prefs: UserPreferences,
): ChosenStreams? = ): ChosenStreams? =
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) { val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
Timber.v("Got itemPlayback for %s", itemId) Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
getChosenItemFromPlayback(item, itemPlayback) return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
} else {
null
}
} }
fun getChosenItemFromPlayback( fun getChosenItemFromPlayback(
item: BaseItem, item: BaseItem,
itemPlayback: ItemPlayback, itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): ChosenStreams? { ): ChosenStreams? {
val source = 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) { if (source != null) {
val audioStream = val audioStream =
if (itemPlayback.audioIndexEnabled) { streamChoiceService.chooseAudioStream(
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex } candidates =
} else { source.mediaStreams
null ?.filter { it.type == MediaStreamType.AUDIO }
} .orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
val subtitleStream = val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) { streamChoiceService.chooseSubtitleStream(
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex } candidates =
} else { source.mediaStreams
null ?.filter { it.type == MediaStreamType.SUBTITLE }
} .orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
return ChosenStreams( return ChosenStreams(
itemPlayback, itemPlayback = itemPlayback,
item.id, plc = plc,
source.id?.toUUIDOrNull(), itemId = item.id,
audioStream, source = source,
subtitleStream, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
itemPlayback.subtitleIndex == TrackIndex.DISABLED, audioStream = audioStream,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
) )
} else { } else {
return null return null
@ -89,12 +104,21 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
trackIndex: Int, trackIndex: Int,
type: MediaStreamType, type: MediaStreamType,
) = serverRepository.currentUser.value?.let { user -> ): 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}")
}
var toSave = var toSave =
itemPlayback ?: ItemPlayback( itemPlayback ?: ItemPlayback(
userId = user.rowId, userId = current.user.rowId,
itemId = item.id, itemId = item.id,
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(), sourceId = source.id?.toUUIDOrNull(),
) )
toSave = toSave =
when (type) { when (type) {
@ -103,7 +127,37 @@ class ItemPlaybackRepository
else -> toSave else -> toSave
} }
Timber.v("Saving track selection %s", toSave) Timber.v("Saving track selection %s", toSave)
saveItemPlayback(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
} }
/** /**
@ -127,9 +181,11 @@ class ItemPlaybackRepository
} }
data class ChosenStreams( data class ChosenStreams(
val itemPlayback: ItemPlayback, val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,
val itemId: UUID, val itemId: UUID,
val sourceId: UUID?, val source: MediaSourceInfo,
val videoStream: MediaStream?,
val audioStream: MediaStream?, val audioStream: MediaStream?,
val subtitleStream: MediaStream?, val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean, val subtitlesDisabled: Boolean,

View file

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

View file

@ -7,19 +7,10 @@ import androidx.room.ForeignKey
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey 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.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers 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.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
@Entity( @Entity(
@ -53,122 +44,6 @@ data class ItemPlayback(
val subtitleIndexEnabled = subtitleIndex >= 0 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 { object TrackIndex {
/** /**
* The user has not explicitly specified a track to use * The user has not explicitly specified a track to use

View file

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

View file

@ -0,0 +1,227 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
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 timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StreamChoiceService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
) {
suspend fun updateAudio(
dto: BaseItemDto,
audioLang: String,
) = update(dto) {
it.copy(
audioLanguage = audioLang,
)
}
suspend fun updateSubtitles(
dto: BaseItemDto,
subtitleLang: String?,
subtitlesDisabled: Boolean,
) = update(dto) {
it.copy(
subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
subtitlesDisabled = subtitlesDisabled,
)
}
suspend fun update(
dto: BaseItemDto,
update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
) {
val seriesId = dto.seriesId
if (seriesId != null) {
val userId = serverRepository.currentUser.value!!.rowId
val currentPlc =
playbackLanguageChoiceDao.get(userId, seriesId)
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
val newPlc = update.invoke(currentPlc)
Timber.v("Saving series PLC: %s", newPlc)
playbackLanguageChoiceDao.save(newPlc)
}
}
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
dto.seriesId?.let {
playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it)
}
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
*/
fun chooseSource(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
): MediaSourceInfo? =
itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
/**
* 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
}
suspend fun chooseStream(
source: MediaSourceInfo,
seriesId: UUID?,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
type: MediaStreamType,
prefs: UserPreferences,
): MediaStream? {
val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) }
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == type }
when (type) {
MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, plc, prefs)
MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, plc, prefs)
else -> candidates.firstOrNull()
}
}
}
fun chooseAudioStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?,
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 =
if (prefs.userConfig.audioLanguagePreference.isNotNullOrBlank()) {
// If the user has chosen a preferred language, but changed tracks on the series, use that
// Otherwise, use their preferred language
playbackLanguageChoice?.audioLanguage
?: prefs.userConfig.audioLanguagePreference
} else {
null
}
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?,
playbackLanguageChoice: PlaybackLanguageChoice?,
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 =
if (prefs.userConfig.subtitleLanguagePreference.isNotNullOrBlank()) {
// If the user has chosen a preferred language, but changed tracks on the series, use that
// Otherwise, use their preferred language
playbackLanguageChoice?.subtitleLanguage
?: prefs.userConfig.subtitleLanguagePreference
} else {
null
}
val subtitleMode =
when {
playbackLanguageChoice?.subtitlesDisabled == false &&
playbackLanguageChoice.subtitleLanguage != null &&
subtitleLanguage.isNotNullOrBlank() -> {
// User has a subtitle language preference, but has chosen a different language for the series
// So override their normal playback mode to always display subtitles
SubtitlePlaybackMode.ALWAYS
}
playbackLanguageChoice?.subtitlesDisabled == true -> {
// Series level settings disables subtitles
SubtitlePlaybackMode.NONE
}
else -> {
// Fallback to the user's preference
prefs.userConfig.subtitleMode
}
}
return when (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
}
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.github.damontecres.wholphin.services
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserPreferencesService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
) {
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,
userConfig ?: DefaultUserConfiguration,
)
}
}

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -56,6 +57,10 @@ object DatabaseModule {
@Singleton @Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
@Provides
@Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -26,12 +27,8 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.audioStreamCount
@ -40,16 +37,30 @@ import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.languageName import com.github.damontecres.wholphin.util.languageName
import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.Codec
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.api.VideoRangeType
@Composable @Composable
@NonRestartableComposable
fun VideoStreamDetails( fun VideoStreamDetails(
preferences: UserPreferences, chosenStreams: ChosenStreams?,
dto: BaseItemDto, modifier: Modifier = Modifier,
itemPlayback: ItemPlayback?, ) = VideoStreamDetails(
chosenStreams?.source,
chosenStreams?.videoStream,
chosenStreams?.audioStream,
chosenStreams?.subtitleStream,
modifier,
)
@Composable
fun VideoStreamDetails(
source: MediaSourceInfo?,
videoStream: MediaStream?,
audioStream: MediaStream?,
subtitleStream: MediaStream?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -57,17 +68,6 @@ fun VideoStreamDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) }
val videoStream =
remember(dto, itemPlayback) {
chooseStream(
dto,
itemPlayback,
MediaStreamType.VIDEO,
preferences,
)
}
val video = val video =
remember(videoStream) { remember(videoStream) {
videoStream videoStream
@ -95,15 +95,6 @@ fun VideoStreamDetails(
StreamLabel(it) StreamLabel(it)
} }
val audioStream =
remember(dto, itemPlayback) {
chooseStream(
dto,
itemPlayback,
MediaStreamType.AUDIO,
preferences,
)
}
val audioCount = remember(source) { source?.audioStreamCount ?: 0 } val audioCount = remember(source) { source?.audioStreamCount ?: 0 }
val audio = val audio =
remember(audioCount, audioStream) { remember(audioCount, audioStream) {
@ -124,23 +115,14 @@ fun VideoStreamDetails(
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier.widthIn(max = 200.dp),
) )
val subtitleStream =
remember(dto, itemPlayback) {
chooseStream(
dto,
itemPlayback,
MediaStreamType.SUBTITLE,
preferences,
)
}
val subtitleCount = val subtitleCount =
remember(source) { remember(source) {
(source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0) (source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0)
} }
var disabled by remember { mutableStateOf(false) } var disabled by remember { mutableStateOf(false) }
val subtitle = val subtitle =
remember(subtitleCount, subtitleStream, itemPlayback) { remember(subtitleCount, subtitleStream) {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { if (subtitleCount > 0 && subtitleStream == null) {
disabled = true disabled = true
context.getString(R.string.disabled) context.getString(R.string.disabled)
} else if (subtitleCount == 0 || subtitleStream == null) { } else if (subtitleCount == 0 || subtitleStream == null) {

View file

@ -30,7 +30,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
@ -57,6 +56,7 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -157,7 +157,7 @@ fun EpisodeDetails(
watched = ep.data.userData?.played ?: false, watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId, seriesId = ep.data.seriesId,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -174,7 +174,8 @@ fun EpisodeDetails(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
chooseSource( viewModel.streamChoiceService
.chooseSource(
ep.data, ep.data,
chosenStreams?.itemPlayback, chosenStreams?.itemPlayback,
)?.let { source -> )?.let { source ->

View file

@ -58,9 +58,7 @@ fun EpisodeDetailsHeader(
EpisodeQuickDetails(dto) EpisodeQuickDetails(dto)
VideoStreamDetails( VideoStreamDetails(
preferences = preferences, chosenStreams = chosenStreams,
dto = dto,
itemPlayback = chosenStreams?.itemPlayback,
modifier = Modifier.padding(bottom = padding), modifier = Modifier.padding(bottom = padding),
) )
dto.taglines?.firstOrNull()?.let { tagline -> dto.taglines?.firstOrNull()?.let { tagline ->

View file

@ -12,7 +12,9 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -44,8 +46,10 @@ class EpisodeViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -85,8 +89,9 @@ class EpisodeViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
val result = itemPlaybackRepository.getSelectedTracks(item.id, item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item this@EpisodeViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
@ -115,10 +120,12 @@ class EpisodeViewModel
sourceId: UUID, sourceId: UUID,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen
@ -133,6 +140,8 @@ class EpisodeViewModel
type: MediaStreamType, type: MediaStreamType,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -142,7 +151,7 @@ class EpisodeViewModel
) )
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen

View file

@ -45,7 +45,6 @@ import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
@ -83,6 +82,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -204,7 +204,7 @@ fun MovieDetails(
watched = movie.data.userData?.played ?: false, watched = movie.data.userData?.played ?: false,
favorite = movie.data.userData?.isFavorite ?: false, favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null, seriesId = null,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -221,7 +221,8 @@ fun MovieDetails(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
chooseSource( viewModel.streamChoiceService
.chooseSource(
movie.data, movie.data,
chosenStreams?.itemPlayback, chosenStreams?.itemPlayback,
)?.let { source -> )?.let { source ->

View file

@ -72,9 +72,7 @@ fun MovieDetailsHeader(
} }
VideoStreamDetails( VideoStreamDetails(
preferences = preferences, chosenStreams = chosenStreams,
dto = dto,
itemPlayback = chosenStreams?.itemPlayback,
modifier = Modifier.padding(bottom = padding), modifier = Modifier.padding(bottom = padding),
) )
dto.taglines?.firstOrNull()?.let { tagline -> dto.taglines?.firstOrNull()?.let { tagline ->

View file

@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -54,11 +56,13 @@ class MovieViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService, private val trailerService: TrailerService,
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -104,7 +108,12 @@ class MovieViewModel
), ),
) { ) {
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
val result = itemPlaybackRepository.getSelectedTracks(item.id, item) val result =
itemPlaybackRepository.getSelectedTracks(
item.id,
item,
userPreferencesService.getCurrent(),
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item this@MovieViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
@ -172,10 +181,12 @@ class MovieViewModel
sourceId: UUID, sourceId: UUID,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen
@ -190,6 +201,8 @@ class MovieViewModel
type: MediaStreamType, type: MediaStreamType,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -199,7 +212,7 @@ class MovieViewModel
) )
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen

View file

@ -37,9 +37,7 @@ fun FocusedEpisodeHeader(
if (dto != null) { if (dto != null) {
VideoStreamDetails( VideoStreamDetails(
preferences = preferences, chosenStreams = chosenStreams,
dto = dto,
itemPlayback = chosenStreams?.itemPlayback,
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -21,7 +21,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.map import androidx.lifecycle.map
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
@ -53,6 +52,7 @@ import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -219,7 +219,7 @@ fun SeriesOverview(
watched = ep.data.userData?.played ?: false, watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
seriesId = series.id, seriesId = series.id,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -257,7 +257,8 @@ fun SeriesOverview(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
chooseSource( viewModel.streamChoiceService
.chooseSource(
ep.data, ep.data,
chosenStreams?.itemPlayback, chosenStreams?.itemPlayback,
)?.let { source -> )?.let { source ->

View file

@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
@ -70,6 +72,8 @@ class SeriesViewModel
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService, private val trailerService: TrailerService,
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
@ -372,7 +376,12 @@ class SeriesViewModel
chosenStreamsJob?.cancel() chosenStreamsJob?.cancel()
chosenStreamsJob = chosenStreamsJob =
viewModelScope.launchIO { viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(itemId, item) val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = result chosenStreams.value = result
} }
@ -384,10 +393,12 @@ class SeriesViewModel
sourceId: UUID, sourceId: UUID,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen
@ -402,6 +413,8 @@ class SeriesViewModel
type: MediaStreamType, type: MediaStreamType,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -411,7 +424,7 @@ class SeriesViewModel
) )
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen

View file

@ -29,8 +29,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
@ -43,6 +41,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
@ -123,6 +122,7 @@ class PlaybackViewModel
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService, private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService,
) : ViewModel(), ) : ViewModel(),
Player.Listener, Player.Listener,
AnalyticsListener { AnalyticsListener {
@ -338,7 +338,8 @@ class PlaybackViewModel
} }
} }
} }
val mediaSource = chooseSource(base, playbackConfig) val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
if (mediaSource == null) { if (mediaSource == null) {
showToast( showToast(
@ -362,12 +363,26 @@ class PlaybackViewModel
.orEmpty() .orEmpty()
val audioIndex = val audioIndex =
chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences) streamChoiceService
?.index .chooseStream(
mediaSource,
base.seriesId,
playbackConfig,
plc,
MediaStreamType.AUDIO,
preferences,
)?.index
val subtitleIndex = val subtitleIndex =
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences) streamChoiceService
?.index .chooseStream(
mediaSource,
base.seriesId,
playbackConfig,
plc,
MediaStreamType.SUBTITLE,
preferences,
)?.index
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
@ -563,7 +578,7 @@ class PlaybackViewModel
else -> throw Exception("No supported playback method") else -> throw Exception("No supported playback method")
} }
Timber.v("Playback decision: $transcodeType") Timber.i("Playback decision for $itemId: $transcodeType")
val externalSubtitleCount = source.externalSubtitlesCount val externalSubtitleCount = source.externalSubtitlesCount
@ -605,21 +620,6 @@ class PlaybackViewModel
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
mediaSourceInfo = source, mediaSourceInfo = source,
) )
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
@ -638,14 +638,13 @@ class PlaybackViewModel
api = api, api = api,
player = player, player = player,
playback = playback, playback = playback,
itemPlayback = itemPlayback, itemPlayback = currentItemPlayback,
) )
player.addListener(activityListener) player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener this@PlaybackViewModel.activityListener = activityListener
loading.value = LoadingState.Success loading.value = LoadingState.Success
this@PlaybackViewModel.currentPlayback.update { playback } this@PlaybackViewModel.currentPlayback.update { playback }
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem( player.setMediaItem(
mediaItem, mediaItem,
positionMs, positionMs,
@ -679,23 +678,41 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
viewModelScope.launchIO { viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = item,
itemPlayback = currentItemPlayback.value!!,
trackIndex = index,
type = MediaStreamType.AUDIO,
)
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
changeStreams( changeStreams(
item, item,
currentItemPlayback.value!!, itemPlayback,
index, index,
currentItemPlayback.value?.subtitleIndex, itemPlayback.subtitleIndex,
onMain { player.currentPosition }, onMain { player.currentPosition },
true, true,
) )
} }
} }
fun changeSubtitleStream(index: Int?): Job = fun changeSubtitleStream(index: Int): Job =
viewModelScope.launchIO { viewModelScope.launchIO {
Timber.d("Changing subtitle track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = item,
itemPlayback = currentItemPlayback.value!!,
trackIndex = index,
type = MediaStreamType.SUBTITLE,
)
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
changeStreams( changeStreams(
item, item,
currentItemPlayback.value!!, itemPlayback,
currentItemPlayback.value?.audioIndex, itemPlayback.audioIndex,
index, index,
onMain { player.currentPosition }, onMain { player.currentPosition },
true, true,

View file

@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -101,7 +100,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
api.userLibraryApi.getItem(itemId = it.itemId).content, api.userLibraryApi.getItem(itemId = it.itemId).content,
api, api,
) )
val mediaSource = chooseSource(item.data, it) val mediaSource = streamChoiceService.chooseSource(item.data, it)
if (mediaSource == null) { if (mediaSource == null) {
// This shouldn't happen, but just in case // This shouldn't happen, but just in case
showToast( showToast(