Improve track persistence for series & episodes (#1802)

## Description
This PR overhauls how audio and subtitle track persistence works for
series! Previously, when you chose an audio or subtitle track for an
episode, only the language was saved and used for subsequent episodes.
This meant aspects about why that choice were lost such as a forced or
hearing impaired track.

This PR changes the persistence to include more details about the chosen
track. Additionally, the persistence is saved at both the season and
series levels.

## How it works

Selecting the track uses the first success option:
1. Episode specific - User has explicitly chosen an audio/subtitle track
for this episode
2. Season specific - User has chosen the track for another episode in
this season
3. Series specific - User has chosen the track for another episode in
this series
4. User preferences - User has _not_ chosen a track for the episode,
season, nor series, so determine track based on the preferred audio
language, preferred subtitle language, and subtitle mode

Additionally, the season or series specific choices use a scoring
algorithm to find the best suited track. For example, if the chosen
tracks for an episode in the season had the forced flag, this will be
preferred for subsequent episodes. Same for SDH/hearing impaired or
title, etc. This means that the common case, where a season has
substantially identical tracks, will use the same ones across all
episodes (after choosing for one, otherwise falling back to the series
or user preferences).

Finally, this also means that users that prefer SDH/hearing impaired
subtitles can be prioritized now, though there is no global preference
for this yet.

The previous track language choices are migrated to the new algorithm.
They lack the additional context, but this makes for a seamless
transition.

## UI changes

The context menu for choosing audio or subtitle tracks now displays why
that track was chosen.

### Related issues
Fixes #1795
Maybe fixes #1783
Related to #1136 & #1798
Closes #1470

### Testing
Previous unit tests for track selection all pass, plus there are a few
additional unit tests. I've done some testing on the emulator and it
seems to work well. But I think real world testing will be needed.

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-10-16 10:53:44 -04:00 • committed by GitHub
parent 55960c2462
commit 3ef221bf91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2780 additions and 286 deletions

View file

@ -0,0 +1,834 @@
{
"formatVersion": 1,
"database": {
"version": 40,
"identityHash": "b297c087485321c3ee8282ad1ff675a0",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', 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"
},
{
"fieldPath": "requireLogin",
"columnName": "requireLogin",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
},
{
"fieldPath": "uiLanguage",
"columnName": "uiLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "appPreferences.preferredAudioLanguage",
"columnName": "preferredAudioLanguage",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appPreferences.preferredSubtitleLanguage",
"columnName": "preferredSubtitleLanguage",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appPreferences.subtitleMode",
"columnName": "subtitleMode",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'USE_USER_PROFILE'"
}
],
"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, `order` INTEGER NOT NULL DEFAULT -1, 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
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
}
],
"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": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"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"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), 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": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "RememberedTab",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER 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": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_RememberedTab_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "SeriesTrackChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentType",
"columnName": "parentType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT"
},
{
"fieldPath": "activation",
"columnName": "activation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackFlags",
"columnName": "trackFlags",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "codec",
"columnName": "codec",
"affinity": "TEXT"
},
{
"fieldPath": "relativeTrackIndex",
"columnName": "relativeTrackIndex",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "audioChannels",
"columnName": "audioChannels",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"parentId",
"type"
]
},
"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, 'b297c087485321c3ee8282ad1ff675a0')"
]
}
}

View file

@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
@ -48,8 +49,9 @@ import java.util.UUID
SeerrServer::class,
SeerrUser::class,
RememberedTab::class,
SeriesTrackChoice::class,
],
version = 35,
version = 40,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -68,6 +70,7 @@ import java.util.UUID
AutoMigration(32, 33),
AutoMigration(33, 34),
AutoMigration(34, 35),
AutoMigration(35, 40),
],
)
@TypeConverters(Converters::class)
@ -87,6 +90,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao
abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
}
class Converters {

View file

@ -3,9 +3,12 @@ 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.ItemTrackModification
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RelativeMediaStream
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
@ -28,7 +31,7 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
private val streamChoiceService: StreamChoiceService,
) {
/**
@ -45,54 +48,79 @@ class ItemPlaybackRepository
): ChosenStreams? =
serverRepository.currentUser?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
return getChosenItemFromPlayback(item, itemPlayback, prefs)
}
suspend fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): ChosenStreams? {
val audioStc =
streamChoiceService.getSeriesTrackChoices(
item.data,
SeriesTrackChoiceType.AUDIO,
)
val subtitleStc =
streamChoiceService.getSeriesTrackChoices(
item.data,
SeriesTrackChoiceType.SUBTITLE,
)
return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs)
}
/**
* Get the chosen source & stream for the given item
*
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
* This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null
*/
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
audioStc: List<SeriesTrackChoice>,
subtitleStc: List<SeriesTrackChoice>,
prefs: UserPreferences,
): ChosenStreams? {
Timber.v(
"For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
item.id,
itemPlayback != null,
audioStc.isNotEmpty(),
subtitleStc.isNotEmpty(),
)
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) {
val audioStream =
val audioResult =
streamChoiceService.chooseAudioStream(
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
stc = audioStc,
prefs = prefs,
)
val subtitleStream =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = audioStream?.language,
audioStreamLang = audioResult.stream?.language,
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
stc = subtitleStc,
prefs = prefs,
)
return ChosenStreams(
itemPlayback = itemPlayback,
plc = plc,
audioStc = audioStc,
subtitleStc = subtitleStc,
itemId = item.id,
source = source,
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
audioStream = audioStream,
audioStream = audioResult,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
)
@ -156,26 +184,36 @@ class ItemPlaybackRepository
Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
if (seriesId != null || item.data.parentId != null) {
if (type == MediaStreamType.AUDIO) {
val stream = source.mediaStreams?.first { it.index == trackIndex }
val stream =
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
if (stream?.language != null) {
streamChoiceService.updateAudio(item.data, stream.language!!)
streamChoiceService.saveSeriesTrackChoice(
item.data,
stream.type,
stream,
)
}
} else if (type == MediaStreamType.SUBTITLE) {
if (trackIndex == TrackIndex.DISABLED) {
streamChoiceService.updateSubtitles(
streamChoiceService.saveDisabledSeriesTrackChoice(
item.data,
subtitleLang = null,
subtitlesDisabled = true,
MediaStreamType.SUBTITLE,
)
} else if (trackIndex == TrackIndex.ONLY_FORCED) {
streamChoiceService.saveOnlyForcedSeriesTrackChoice(
item.data,
MediaStreamType.SUBTITLE,
)
} else {
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
val stream =
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
if (stream?.language != null) {
streamChoiceService.updateSubtitles(
streamChoiceService.saveSeriesTrackChoice(
item.data,
stream.language!!,
subtitlesDisabled = false,
stream.type,
stream,
)
}
}
@ -240,9 +278,10 @@ class ItemPlaybackRepository
*/
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let {
Timber.d("Deleting %s", it)
playbackLanguageChoiceDao.delete(it)
val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
if (stc.isNotEmpty()) {
Timber.d("Deleting %s", stc)
seriesTrackChoiceDao.delete(stc)
}
chosenStreams?.itemPlayback?.let {
Timber.d("Deleting %s", it)
@ -256,11 +295,12 @@ class ItemPlaybackRepository
*/
data class ChosenStreams(
val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,
val audioStc: List<SeriesTrackChoice>,
val subtitleStc: List<SeriesTrackChoice>,
val itemId: UUID,
val source: MediaSourceInfo,
val videoStream: MediaStream?,
val audioStream: MediaStream?,
val subtitleStream: MediaStream?,
val audioStream: StreamChoiceResult,
val subtitleStream: StreamChoiceResult,
val subtitlesDisabled: Boolean,
)

View file

@ -8,17 +8,27 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import java.util.UUID
@Deprecated("Use SeriesTrackChoiceDao")
@Dao
interface PlaybackLanguageChoiceDao {
@Deprecated("Use SeriesTrackChoiceDao")
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
suspend fun get(
userId: Int,
seriesId: UUID,
): PlaybackLanguageChoice?
@Deprecated("Use SeriesTrackChoiceDao")
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
@Deprecated("Use SeriesTrackChoiceDao")
@Delete
fun delete(plc: PlaybackLanguageChoice)
@Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset")
suspend fun getAll(
limit: Int,
offset: Int,
): List<PlaybackLanguageChoice>
}

View file

@ -0,0 +1,52 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import java.util.UUID
@Dao
interface SeriesTrackChoiceDao {
@Query(
"SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC",
)
suspend fun get(
userId: Int,
seasonId: UUID,
seriesId: UUID,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice>
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId")
suspend fun getBySeasonId(
userId: Int,
seasonId: UUID,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice>
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId")
suspend fun getBySeriesId(
userId: Int,
seriesId: UUID,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(stc: SeriesTrackChoice): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(stc: List<SeriesTrackChoice>): List<Long>
@Delete
suspend fun delete(stc: List<SeriesTrackChoice>)
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId")
suspend fun getAll(userId: Int): List<SeriesTrackChoice>
@Query("SELECT count(*) from SeriesTrackChoice")
suspend fun count(): Int
}

View file

@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
/**
* DEPRECATED
*
* Stores the language choices for a series so they can be applied automatically to other episodes
* without the user needing to explicitly choose the tracks
*
* @see SeriesTrackChoice
*/
@Deprecated("Use SeriesTrackChoice")
@Entity(
foreignKeys = [
ForeignKey(

View file

@ -0,0 +1,61 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
/**
* A [MediaStream] with its relative track index, its position within a specific stream type
*/
data class RelativeMediaStream(
val relativeIndex: Int,
val stream: MediaStream,
) {
val type: MediaStreamType get() = stream.type
val language: String? get() = stream.language
val isForced: Boolean get() = stream.isForced
val isExternal: Boolean get() = stream.isExternal
val isDefault: Boolean get() = stream.isDefault
val isHearingImpaired: Boolean get() = stream.isHearingImpaired
companion object {
fun getRelativeIndex(
index: Int,
streams: List<MediaStream>,
): Int? {
val stream = streams.firstOrNull { it.index == index } ?: return null
return getRelativeIndex(stream, streams)
}
fun getRelativeIndex(
stream: MediaStream,
streams: List<MediaStream>,
): Int? =
streams
.filter { it.type == stream.type && it.isExternal == stream.isExternal }
.sortedBy { it.index }
.indexOfFirstOrNull { it.index == stream.index }
fun from(
index: Int,
streams: List<MediaStream>,
): RelativeMediaStream? {
val stream = streams.firstOrNull { it.index == index } ?: return null
val relativeIndex = getRelativeIndex(stream, streams) ?: return null
return RelativeMediaStream(relativeIndex, stream)
}
}
}
/**
* Maps a list of [MediaStream] into [RelativeMediaStream]
*/
fun List<MediaStream>.asRelative(): List<RelativeMediaStream> {
val map = this.groupBy { Pair(it.type, it.isExternal) }
return map.values
.flatMap { typedStreams ->
typedStreams.sortedBy { it.index }.mapIndexed { index, stream ->
RelativeMediaStream(index, stream)
}
}.sortedBy { it.stream.index }
}

View file

@ -0,0 +1,199 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import com.github.damontecres.wholphin.services.isSigns
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
/**
* Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks
*
* Additional information about the chosen track is stored to compare against tracks in other media
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
primaryKeys = ["userId", "parentId", "type"],
)
@Serializable
data class SeriesTrackChoice(
val userId: Int,
/**
* Season or Series
*/
val parentId: UUID,
/**
* Type of track, ie audio or subtitle
*/
val type: SeriesTrackChoiceType,
/**
* Origin of the [parentId]
*/
val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN,
val itemId: UUID? = null,
val language: String? = null,
val activation: ActivationFlag = ActivationFlag.ACTIVATED,
/**
* Flags for the track such as external or forced
*
* @see [TrackFlag]
*/
val trackFlags: Int = 0,
val codec: String? = null,
/**
* The track index based on its position within all streams of the same type
*
* @see [RelativeMediaStream]
*/
val relativeTrackIndex: Int? = null,
val title: String? = null,
val audioChannels: Int? = null,
) {
constructor(
userId: Int,
itemId: UUID,
parentId: UUID,
type: SeriesTrackChoiceType,
parentType: TrackChoiceParentType,
stream: RelativeMediaStream?,
activation: ActivationFlag,
) : this(
userId = userId,
parentId = parentId,
parentType = parentType,
type = type,
itemId = itemId,
language = stream?.language,
activation = activation,
trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0,
codec = stream?.stream?.codec,
relativeTrackIndex = stream?.relativeIndex,
title = stream?.stream?.title,
audioChannels = stream?.stream?.channels,
)
companion object {
/**
* Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s
*/
fun from(plc: PlaybackLanguageChoice): List<SeriesTrackChoice> =
buildList {
if (plc.subtitleLanguage != null) {
add(
SeriesTrackChoice(
userId = plc.userId,
parentId = plc.seriesId,
parentType = TrackChoiceParentType.DEPRECATED,
type = SeriesTrackChoiceType.SUBTITLE,
itemId = plc.itemId,
language = plc.subtitleLanguage,
activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
),
)
}
if (plc.audioLanguage != null) {
add(
SeriesTrackChoice(
userId = plc.userId,
parentId = plc.seriesId,
parentType = TrackChoiceParentType.DEPRECATED,
type = SeriesTrackChoiceType.AUDIO,
itemId = plc.itemId,
language = plc.audioLanguage,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
),
)
}
}
}
}
enum class SeriesTrackChoiceType(
val mediaStreamType: MediaStreamType,
) {
AUDIO(MediaStreamType.AUDIO),
SUBTITLE(MediaStreamType.SUBTITLE),
}
/**
* Flags for a track such as forced or defect
*/
enum class TrackFlag(
val flag: Int,
val hasFlag: (MediaStream) -> Boolean,
) {
DEFAULT(flag = 1, hasFlag = { it.isDefault }),
FORCED(flag = 2, hasFlag = { it.isForced }),
SDH(flag = 4, hasFlag = { it.isHearingImpaired }),
EXTERNAL(flag = 8, hasFlag = { it.isExternal }),
SIGNS(flag = 16, hasFlag = { isSigns(it) }),
;
fun within(flag: Int) = flag and this.flag == this.flag
companion object {
fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags)
fun Iterable<TrackFlag>.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag }
fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag()
}
}
/**
* How was the track activated
*/
enum class ActivationFlag {
ACTIVATED,
DISABLED,
ONLY_FORCED,
}
/**
* Where the [SeriesTrackChoice.parentId] comes from
*/
enum class TrackChoiceParentType {
/**
* Parent ID is from the series
*/
SERIES,
/**
* Parent ID is from the season
*/
SEASON,
/**
* Parent ID is from the deprecated [PlaybackLanguageChoice]
*/
DEPRECATED,
/**
* Parent ID origin is unknown
*/
UNKNOWN,
}

View file

@ -9,10 +9,13 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
@ -61,6 +64,8 @@ class AppUpgradeHandler
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
private val mostRecentServerProvider: MostRecentServerProvider,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@ -485,5 +490,21 @@ class AppUpgradeHandler
}
}
}
if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) {
val count = seriesTrackChoiceDao.count()
if (count == 0) {
var offset = 0
while (true) {
val plcs = playbackLanguageChoiceDao.getAll(100, offset)
if (plcs.isEmpty()) break
offset += plcs.size
val stcs = plcs.flatMap(SeriesTrackChoice::from)
Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size)
seriesTrackChoiceDao.save(stcs)
}
}
}
}
}

View file

@ -1,15 +1,27 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RelativeMediaStream
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
import com.github.damontecres.wholphin.data.model.TrackFlag
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.asRelative
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
@ -30,50 +42,69 @@ class StreamChoiceService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) {
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
suspend fun updateAudio(
suspend fun saveSeriesTrackChoice(
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,
type: MediaStreamType,
stream: RelativeMediaStream?,
activation: ActivationFlag = ActivationFlag.ACTIVATED,
) {
val seriesId = dto.seriesId
if (seriesId != null) {
val userId = serverRepository.currentUser!!.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)
}
val type =
when (stream?.type ?: type) {
MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
else -> return
}
val userId = serverRepository.currentUser!!.rowId
val newStc =
buildList {
dto.parentId
?.let { seasonId ->
SeriesTrackChoice(
userId = userId,
itemId = dto.id,
parentId = seasonId,
type = type,
parentType = TrackChoiceParentType.SEASON,
stream = stream,
activation = activation,
)
}?.let(::add)
dto.seriesId
?.let { seriesId ->
SeriesTrackChoice(
userId = userId,
itemId = dto.id,
parentId = seriesId,
type = type,
parentType = TrackChoiceParentType.SERIES,
stream = stream,
activation = activation,
)
}?.let(::add)
}
Timber.d(
"Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s",
dto.id,
dto.seriesId,
dto.parentId,
newStc,
)
seriesTrackChoiceDao.save(newStc)
}
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
dto.seriesId?.let {
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
}
suspend fun saveDisabledSeriesTrackChoice(
dto: BaseItemDto,
type: MediaStreamType,
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED)
suspend fun saveOnlyForcedSeriesTrackChoice(
dto: BaseItemDto,
type: MediaStreamType,
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED)
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
@ -104,21 +135,15 @@ class StreamChoiceService
*/
suspend fun chooseAudioStream(
source: MediaSourceInfo,
seriesId: UUID?,
item: BaseItemDto,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
stc: List<SeriesTrackChoice>?,
prefs: UserPreferences,
): MediaStream? {
val plc =
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser!!.rowId,
it,
)
}
): StreamChoiceResult? {
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
chooseAudioStream(candidates, itemPlayback, plc, prefs)
chooseAudioStream(candidates, itemPlayback, stc, prefs)
}
}
@ -128,59 +153,70 @@ class StreamChoiceService
fun chooseAudioStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?,
stc: List<SeriesTrackChoice>,
prefs: UserPreferences,
): MediaStream? =
): StreamChoiceResult =
if (itemPlayback?.audioIndexEnabled == true) {
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
val seriesLang =
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
// If the user has chosen a different language for the series, prefer that
val audioLanguage =
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
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()
candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
StreamChoiceReason.Item(
itemPlayback,
)
} else if (stc.isNotEmpty()) {
val first = stc.first()
val result = scoreStreams(candidates, first)
if (result == null) {
null with StreamChoiceReason.from(first)
} else if (result.isEmpty() && stc.size > 1) {
// SeriesTrackChoice did not apply to any streams, but there are more options
chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
} else if (result.isEmpty()) {
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
Timber.v("No audio STC applied")
chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
} else {
candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull()
// Otherwise, use the best scored stream
Timber.v("Using audio STC from %s", first.parentId)
result.first().stream with StreamChoiceReason.from(first, result)
}
} else {
val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
val stream =
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()
}
stream with StreamChoiceReason.UserPreferences
}
/**
* Returns the subtitle stream that should play
*/
suspend fun chooseSubtitleStream(
item: BaseItemDto,
source: MediaSourceInfo,
audioStream: MediaStream?,
seriesId: UUID?,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
stc: List<SeriesTrackChoice>?,
prefs: UserPreferences,
): MediaStream? {
val plc =
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser!!.rowId,
it,
)
}
return source.mediaStreams?.letNotEmpty { streams ->
): StreamChoiceResult? =
source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
chooseSubtitleStream(
audioStream?.language,
candidates,
itemPlayback,
plc,
stc,
prefs,
)
}
}
/**
* Resolves ONLY_FORCED to an actual subtitle track index.
@ -189,7 +225,7 @@ class StreamChoiceService
suspend fun resolveSubtitleIndex(
source: MediaSourceInfo,
audioStreamIndex: Int?,
seriesId: UUID?,
item: BaseItemDto,
subtitleIndex: Int,
prefs: UserPreferences,
): Int? =
@ -203,17 +239,17 @@ class StreamChoiceService
val itemPlayback =
ItemPlayback(
userId = serverRepository.currentUser!!.rowId,
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
itemId = item.id,
subtitleIndex = TrackIndex.ONLY_FORCED,
)
chooseSubtitleStream(
item = item,
source = source,
audioStream = audioStream,
seriesId = seriesId,
itemPlayback = itemPlayback,
plc = null,
stc = null,
prefs = prefs,
)?.index
)?.stream?.index
}
/**
@ -223,47 +259,81 @@ class StreamChoiceService
audioStreamLang: String?,
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?,
stc: List<SeriesTrackChoice>,
prefs: UserPreferences,
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
}
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
): StreamChoiceResult {
if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
return null with StreamChoiceReason.Item(itemPlayback)
} else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
val subtitleLanguage =
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
// Client-side manual override: User selected "Only Forced" in player menu
return findForcedTrack(
candidates,
subtitleLanguage,
audioStreamLang,
) with StreamChoiceReason.Item(itemPlayback)
} else {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with
StreamChoiceReason.Item(itemPlayback)
}
} else if (stc.isNotEmpty()) {
val first = stc.first()
return when (first.activation) {
ActivationFlag.DISABLED -> {
null with StreamChoiceReason.from(first)
}
ActivationFlag.ONLY_FORCED -> {
val subtitleLanguage =
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
val stream =
findForcedTrack(
candidates,
subtitleLanguage,
audioStreamLang,
)
if (stream != null) {
stream with StreamChoiceReason.from(first)
} else {
handleSubtitleStreamChoiceResult(
result = emptyList(),
audioStreamLang = audioStreamLang,
candidates = candidates,
itemPlayback = itemPlayback,
stc = stc,
prefs = prefs,
)
}
}
ActivationFlag.ACTIVATED -> {
val result =
scoreStreams(candidates, first)
?: throw IllegalStateException("scoreStreams should not return null for activated")
handleSubtitleStreamChoiceResult(
result = result,
audioStreamLang = audioStreamLang,
candidates = candidates,
itemPlayback = itemPlayback,
stc = stc,
prefs = prefs,
)
}
}
} else {
val subtitleLanguage =
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
val subtitleMode =
when {
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
// User has chosen a series level subtitle language, so override their normal
// subtitle mode to display that language
SubtitlePlaybackMode.ALWAYS
}
playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
// Series level settings disables subtitles
SubtitlePlaybackMode.NONE
}
else -> {
// Fallback to the user's preference
when (prefs.userPreferences?.subtitleMode) {
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
null -> SubtitlePlaybackMode.DEFAULT
}
}
when (prefs.userPreferences?.subtitleMode) {
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
null -> SubtitlePlaybackMode.DEFAULT
} ?: SubtitlePlaybackMode.DEFAULT
val candidates =
candidates
@ -277,73 +347,104 @@ class StreamChoiceService
}.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull {
// Prefer non-forced first
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
} ?: candidates.firstOrNull {
it.language.equalsLangOrUnknown(subtitleLanguage)
}
} else {
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
val stream =
when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull {
// Prefer non-forced first
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
} ?: candidates.firstOrNull {
it.language.equalsLangOrUnknown(subtitleLanguage)
}
} else {
// Otherwise, show forced subtitles in preferred lang
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
}
} else {
candidates.firstOrNull { it.isDefault }
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
// Otherwise, show forced subtitles in preferred lang
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} else {
candidates.firstOrNull { it.isDefault }
}
}
SubtitlePlaybackMode.DEFAULT -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
?: candidates.firstOrNull { it.isDefault || it.isForced }
} else {
candidates.firstOrNull { it.isDefault || it.isForced }
}
}
SubtitlePlaybackMode.NONE -> {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
?: candidates.firstOrNull { it.isDefault || it.isForced }
} else {
candidates.firstOrNull { it.isDefault || it.isForced }
}
}
SubtitlePlaybackMode.NONE -> {
null
}
}
return stream with StreamChoiceReason.UserPreferences
}
}
/** Returns true if the track is forced (via metadata flag or title patterns). */
private fun isForcedOrSigns(track: MediaStream): Boolean {
if (track.isForced) return true
val title = track.title ?: track.displayTitle ?: return false
return title.contains("forced", ignoreCase = true) ||
title.contains("signs", ignoreCase = true) ||
title.contains("songs", ignoreCase = true)
/**
* Recursively calls [chooseSubtitleStream] based on [result] and [stc]
*/
private fun handleSubtitleStreamChoiceResult(
result: List<ScoredMediaStream>,
audioStreamLang: String?,
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
stc: List<SeriesTrackChoice>,
prefs: UserPreferences,
): StreamChoiceResult {
val first = stc.first()
return if (result.isEmpty() && stc.size > 1) {
// SeriesTrackChoice did not apply to any streams, but there are more options
chooseSubtitleStream(
audioStreamLang,
candidates,
itemPlayback,
stc.subList(1, stc.size),
prefs,
)
} else if (result.isEmpty()) {
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
Timber.v("No subtitle STC applied")
chooseSubtitleStream(
audioStreamLang,
candidates,
itemPlayback,
emptyList(),
prefs,
)
} else {
// Otherwise, use the best scored stream
Timber.v("Using subtitle STC from %s", first.parentId)
result.first().stream with StreamChoiceReason.from(first, result)
}
}
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
@ -367,19 +468,92 @@ class StreamChoiceService
// 3. Unknown language forced track
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
}
suspend fun getSeriesTrackChoices(
seriesId: UUID?,
seasonId: UUID?,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice> {
val userId = serverRepository.currentUser?.rowId ?: return emptyList()
return when {
seriesId != null && seasonId != null -> {
seriesTrackChoiceDao.get(
userId = userId,
seasonId = seasonId,
seriesId = seriesId,
type = type,
)
}
seriesId != null -> {
seriesTrackChoiceDao.getBySeriesId(
userId = userId,
seriesId = seriesId,
type = type,
)
}
seasonId != null -> {
seriesTrackChoiceDao.getBySeasonId(
userId = userId,
seasonId = seasonId,
type = type,
)
}
else -> {
emptyList()
}
}
}
suspend fun getSeriesTrackChoices(
item: BaseItemDto,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice> = getSeriesTrackChoices(item.seriesId, item.parentId, type)
suspend fun getSeriesTrackChoices(
item: BaseItemDto,
type: MediaStreamType,
): List<SeriesTrackChoice> =
getSeriesTrackChoices(
seriesId = item.seriesId,
seasonId = item.parentId,
type =
when (type) {
MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
else -> return emptyList()
},
)
}
private val String?.isUnknown: Boolean
get() =
this == null ||
this.isNullOrBlank() ||
this.equals("unknown", true) ||
this.equals("und", true) ||
this.equals("undetermined", true) ||
this.equals("mul", true) ||
this.equals("zxx", true)
private val String?.isNotUnknown: Boolean
get() = !this.isUnknown
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true)
/** Returns true if the track is forced (via metadata flag or title patterns). */
fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track)
fun isSigns(track: MediaStream): Boolean {
val title = track.title ?: track.displayTitle ?: return false
return title.contains("forced", ignoreCase = true) ||
title.contains("signs", ignoreCase = true) ||
title.contains("songs", ignoreCase = true)
}
/**
* Based on the user's preferences, get their preferred language for audio or subtitles
*
@ -412,3 +586,153 @@ fun getPreferredLanguage(
else -> pref
}?.takeIf { it.isNotNullOrBlank() }
}
/**
* Scores streams based the user's explicit [SeriesTrackChoice]
*
* @return sorted list of preferred streams or null if stream should be disabled
*/
fun scoreStreams(
streams: List<MediaStream>,
choice: SeriesTrackChoice,
): List<ScoredMediaStream>? {
if (choice.activation == ActivationFlag.DISABLED) {
return null
}
val streams =
streams.filter {
if (
(choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) &&
!isForcedOrSigns(it)
) {
// Filter out non-forced tracks if the user only wants forced
return@filter false
}
if (choice.language.isNotUnknown &&
!choice.language.equalsLangExact(it.language)
) {
// Filter out languages that do not match
return@filter false
}
// Filter by type
choice.type.mediaStreamType == it.type
}
val scored =
streams
.asRelative()
.map { s ->
var score = 0
// Prefer exact language match over unknown
if (choice.language.equalsLangExact(s.language)) score += 10_000
if (s.isForced && choice.has(TrackFlag.FORCED)) {
score += 1_000
}
if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) {
score += 500
}
if (s.isDefault && choice.has(TrackFlag.DEFAULT)) {
score += 500
}
if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) {
score += 500
} else if (choice.has(TrackFlag.SDH) && s.isForced) {
// If user wants SDH, prefer non-forced "regular" over forced
score -= 10
}
if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) {
score += 100
}
if (s.relativeIndex == choice.relativeTrackIndex) {
score += 100
}
if (s.stream.title == choice.title) {
score += 10
}
if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) {
score += 100
} else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) {
score += 50
}
if (s.isDefault) {
// All else being equal, prefer the default track
score += 1
}
ScoredMediaStream(score, s.stream)
}.sortedByDescending { it.score }
return scored
}
data class StreamChoiceResult(
val stream: MediaStream?,
val reason: StreamChoiceReason,
)
data class ScoredMediaStream(
val score: Int,
val stream: MediaStream,
)
private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason)
/**
* Why a particular [MediaStream] was chosen
*/
sealed interface StreamChoiceReason {
val description: StringProvider
val scores: List<ScoredMediaStream> get() = emptyList()
data object Unknown : StreamChoiceReason {
override val description: StringProvider = ResStringProvider(R.string.unknown)
}
data object UserPreferences : StreamChoiceReason {
override val description: StringProvider =
ResStringProvider(R.string.profile_specific_settings)
}
data class Series(
val stc: SeriesTrackChoice,
override val scores: List<ScoredMediaStream>,
) : StreamChoiceReason {
override val description: StringProvider =
ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER)
}
data class Season(
val stc: SeriesTrackChoice,
override val scores: List<ScoredMediaStream>,
) : StreamChoiceReason {
override val description: StringProvider =
ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER)
}
data class Item(
val itemPlayback: ItemPlayback,
) : StreamChoiceReason {
override val description: StringProvider =
ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER)
}
companion object {
fun from(
stc: SeriesTrackChoice,
scores: List<ScoredMediaStream> = emptyList(),
) = when (stc.parentType) {
TrackChoiceParentType.SERIES -> Series(stc, scores)
TrackChoiceParentType.SEASON -> Season(stc, scores)
TrackChoiceParentType.DEPRECATED -> Unknown
TrackChoiceParentType.UNKNOWN -> Unknown
}
private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) }
private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) }
private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) }
}
}

View file

@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -79,6 +80,10 @@ object DatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides
@Singleton
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
@Provides
@Singleton
fun userPreferencesDataStore(

View file

@ -209,7 +209,12 @@ fun ContextMenu(
favorite = item.favorite,
seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
canClearChosenStreams =
chosenStreams.let {
it?.itemPlayback != null ||
it?.audioStc?.isNotEmpty() == true ||
it?.subtitleStc?.isNotEmpty() == true
},
contextMenu = contextMenu,
actions = actions,
onChooseVersion = {
@ -235,11 +240,11 @@ fun ContextMenu(
resources = resources,
streams = source.mediaStreams.orEmpty(),
type = type,
currentIndex =
streamChoice =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
chosenStreams?.audioStream
} else {
chosenStreams?.subtitleStream?.index
chosenStreams?.subtitleStream
},
onClick = { trackIndex ->
actions.onChooseTracks.invoke(

View file

@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -651,12 +652,13 @@ fun resourceFor(type: MediaStreamType): Int =
fun chooseStream(
resources: Resources,
streams: List<MediaStream>,
currentIndex: Int?,
streamChoice: StreamChoiceResult?,
type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit,
): DialogParams {
val filteredStreams = streams.filter { it.type == type }
val currentIndex = streamChoice?.stream?.index
return DialogParams(
fromLongClick = false,
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
@ -674,6 +676,16 @@ fun chooseStream(
},
supportingContent = {
},
trailingContent =
if (currentIndex == null && streamChoice != null) {
{
Text(
text = streamChoice.reason.description.getString(),
)
}
} else {
null
},
onClick = { onClick.invoke(TrackIndex.DISABLED) },
),
)
@ -719,6 +731,16 @@ fun chooseStream(
supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
},
trailingContent =
if (currentIndex == stream.index) {
{
Text(
text = streamChoice.reason.description.getString(),
)
}
} else {
null
},
onClick = { onClick.invoke(stream.index) },
)
},

View file

@ -54,8 +54,8 @@ fun VideoStreamDetails(
) = VideoStreamDetails(
chosenStreams?.source,
chosenStreams?.videoStream,
chosenStreams?.audioStream,
chosenStreams?.subtitleStream,
chosenStreams?.audioStream?.stream,
chosenStreams?.subtitleStream?.stream,
numberOfVersions,
modifier,
)

View file

@ -40,6 +40,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -56,6 +57,7 @@ import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.Date
@ -68,6 +70,7 @@ class DebugViewModel
@param:ApplicationContext private val context: Context,
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
val seriesTrackChoiceDao: SeriesTrackChoiceDao,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
@ -169,6 +172,13 @@ class DebugViewModel
itemPlaybacks.value = results
}
}
viewModelScope.launchIO {
serverRepository.currentUser?.rowId?.let {
val results = seriesTrackChoiceDao.getAll(it)
Timber.v("Got %s SeriesTrackChoice", results.size)
results.forEach { Timber.v("%s", it) }
}
}
viewModelScope.launchIO {
val logcat = getLogCatLines()
this@DebugViewModel.logcat.value = logcat

View file

@ -145,11 +145,10 @@ class EpisodeViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@ -163,7 +162,6 @@ class EpisodeViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@ -171,10 +169,7 @@ class EpisodeViewModel
trackIndex = trackIndex,
type = type,
)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
_state.update { it.copy(chosenStreams = chosen) }
}
}

View file

@ -223,11 +223,10 @@ class MovieViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@ -241,7 +240,6 @@ class MovieViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@ -249,10 +247,7 @@ class MovieViewModel
trackIndex = trackIndex,
type = type,
)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
_state.update { it.copy(chosenStreams = chosen) }
}
}

View file

@ -561,11 +561,10 @@ class SeriesViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@ -579,7 +578,6 @@ class SeriesViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@ -587,10 +585,7 @@ class SeriesViewModel
trackIndex = trackIndex,
type = type,
)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
_state.update { it.copy(chosenStreams = chosen) }
}
}

View file

@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult
@ -158,7 +159,11 @@ class PlayExternalViewModel
is PlaylistItem.Media -> playlistItem.item
}
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val subtitleStc =
streamChoiceService.getSeriesTrackChoices(
item.data,
SeriesTrackChoiceType.SUBTITLE,
)
if (mediaSource == null) {
Timber.w("Media source is null")
return@launchDefault
@ -168,13 +173,14 @@ class PlayExternalViewModel
val subtitleIndex =
streamChoiceService
.chooseSubtitleStream(
item = item.data,
source = mediaSource,
audioStream = null,
seriesId = item.data.seriesId,
itemPlayback = playbackConfig,
plc = plc,
stc = subtitleStc,
prefs = prefs,
)?.index
)?.stream
?.index
val externalSubtitles =
mediaSource.mediaStreams
?.filter { it.isExternal }

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreference
@ -490,7 +491,16 @@ class PlaybackViewModel
null
}
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
val audioStc =
streamChoiceService.getSeriesTrackChoices(
base,
SeriesTrackChoiceType.AUDIO,
)
val subtitleStc =
streamChoiceService.getSeriesTrackChoices(
base,
SeriesTrackChoiceType.AUDIO,
)
if (mediaSource == null && !isLiveTv) {
showToast(
@ -519,30 +529,31 @@ class PlaybackViewModel
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
val audioStream =
val audioResult =
mediaSource?.let {
streamChoiceService
.chooseAudioStream(
source = mediaSource,
seriesId = base.seriesId,
item = base,
itemPlayback = itemPlayback,
plc = plc,
stc = audioStc,
prefs = preferences,
)
}
val audioIndex = audioStream?.index
val audioIndex = audioResult?.stream?.index
val subtitleIndex =
mediaSource?.let {
streamChoiceService
.chooseSubtitleStream(
item = base,
source = mediaSource,
audioStream = audioStream,
seriesId = base.seriesId,
audioStream = audioResult?.stream,
itemPlayback = itemPlayback,
plc = plc,
stc = subtitleStc,
prefs = preferences,
)?.index
)?.stream
?.index
}
Timber.d(
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
@ -956,7 +967,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = index,
seriesId = currentItem.item.data.seriesId,
item = currentItem.item.data,
subtitleIndex = currentPlayback.subtitleIndex,
prefs = preferences,
)
@ -992,7 +1003,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = currentPlayback.audioIndex,
seriesId = currentItem.item.data.seriesId,
item = currentItem.item.data,
subtitleIndex = index,
prefs = preferences,
)

View file

@ -20,6 +20,8 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceReason
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO
@ -229,12 +231,21 @@ class SlideshowViewModel
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
ChosenStreams(
itemPlayback = null,
plc = null,
audioStc = emptyList(),
subtitleStc = emptyList(),
itemId = image.id,
source = source,
videoStream = video,
audioStream = audio,
subtitleStream = null,
audioStream =
StreamChoiceResult(
audio,
StreamChoiceReason.Unknown,
),
subtitleStream =
StreamChoiceResult(
null,
StreamChoiceReason.Unknown,
),
subtitlesDisabled = false,
)
}

View file

@ -964,4 +964,7 @@
<item quantity="one">%1$d Episode</item>
<item quantity="other">%1$d Episodes</item>
</plurals>
<string name="chosen_by">Chosen by %1$s</string>
<string name="user">User</string>
<string name="series">Series</string>
</resources>

View file

@ -0,0 +1,149 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.test.TestTracks
import com.github.damontecres.wholphin.test.assertNotNull
import org.junit.Assert.assertEquals
import org.junit.Test
class RelativeMediaStreamTest {
@Test
fun `Test getRelativeIndex`() {
val source =
TestTracks
.Builder()
.addSubtitle(2)
.addAudio(2)
.addVideo()
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(0, relativeIndex)
}
RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(1, relativeIndex)
}
RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(0, relativeIndex)
}
RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(1, relativeIndex)
}
}
@Test
fun `Test getRelativeIndex for stream`() {
val source =
TestTracks
.Builder()
.addSubtitle(2)
.addAudio(2)
.addVideo()
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(1, relativeIndex)
}
}
@Test
fun `Test asRelative basic`() {
val source =
TestTracks
.Builder()
.addSubtitle(2)
.addAudio(2)
.addVideo()
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
assertEquals(5, streams.size)
val relative = streams.asRelative()
// Subtitle
assertEquals(0, relative[0].relativeIndex)
assertEquals(0, relative[0].stream.index)
assertEquals(1, relative[1].relativeIndex)
assertEquals(1, relative[1].stream.index)
// Audio
assertEquals(0, relative[2].relativeIndex)
assertEquals(2, relative[2].stream.index)
assertEquals(1, relative[3].relativeIndex)
assertEquals(3, relative[3].stream.index)
// Video
assertEquals(0, relative[4].relativeIndex)
assertEquals(4, relative[4].stream.index)
}
@Test
fun `Test asRelative with external`() {
val source =
TestTracks
.Builder()
.addExternalSubtitle(2)
.addSubtitle(2)
.addAudio(2)
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
assertEquals(6, streams.size)
val relative = streams.asRelative()
// External Subtitle
assertEquals(0, relative[0].relativeIndex)
assertEquals(0, relative[0].stream.index)
assertEquals(1, relative[1].relativeIndex)
assertEquals(1, relative[1].stream.index)
// Subtitle
assertEquals(0, relative[2].relativeIndex)
assertEquals(2, relative[2].stream.index)
assertEquals(1, relative[3].relativeIndex)
assertEquals(3, relative[3].stream.index)
// Audio
assertEquals(0, relative[4].relativeIndex)
assertEquals(4, relative[4].stream.index)
assertEquals(1, relative[5].relativeIndex)
assertEquals(5, relative[5].stream.index)
}
@Test
fun `Test asRelative with external mixed`() {
val source =
TestTracks
.Builder()
.addExternalSubtitle(1)
.addSubtitle(1)
.addAudio(1)
.addExternalSubtitle(1)
.addAudio(1)
.addSubtitle(1)
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
assertEquals(6, streams.size)
val relative = streams.asRelative()
// External Subtitle
assertEquals(0, relative[0].relativeIndex)
assertEquals(0, relative[0].stream.index)
assertEquals(1, relative[3].relativeIndex)
assertEquals(3, relative[3].stream.index)
// Subtitle
assertEquals(0, relative[1].relativeIndex)
assertEquals(1, relative[1].stream.index)
assertEquals(1, relative[5].relativeIndex)
assertEquals(5, relative[5].stream.index)
// Audio
assertEquals(0, relative[2].relativeIndex)
assertEquals(2, relative[2].stream.index)
assertEquals(1, relative[4].relativeIndex)
assertEquals(4, relative[4].stream.index)
}
}

View file

@ -1,10 +1,12 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced(
),
streamAudioLang = "eng",
),
TestInput(
0,
SubtitlePlaybackMode.NONE,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
userSubtitleLang = "eng",
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
TestInput(
null,
SubtitlePlaybackMode.NONE,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
)
}
}
@ -885,7 +935,7 @@ data class TestInput(
val streamAudioLang: String? = "eng",
val subtitles: List<MediaStream>,
val itemPlayback: ItemPlayback? = null,
val plc: PlaybackLanguageChoice? = null,
val plc: List<SeriesTrackChoice> = emptyList(),
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
@ -895,7 +945,7 @@ data class TestInput(
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
private fun serverRepo(
fun mockServerRepo(
audioLang: String?,
subtitleMode: SubtitlePlaybackMode?,
subtitleLang: String?,
@ -918,15 +968,15 @@ private fun serverRepo(
private fun runTest(input: TestInput) {
val service =
StreamChoiceService(
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
mockk<PlaybackLanguageChoiceDao>(),
mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
mockk<SeriesTrackChoiceDao>(),
)
val result =
service.chooseSubtitleStream(
audioStreamLang = input.streamAudioLang,
candidates = input.subtitles,
itemPlayback = input.itemPlayback,
playbackLanguageChoice = input.plc,
stc = input.plc,
prefs =
UserPreferences(
AppPreferences.getDefaultInstance(),
@ -937,7 +987,7 @@ private fun runTest(input: TestInput) {
),
),
)
Assert.assertEquals(input.expectedIndex, result?.index)
Assert.assertEquals(input.expectedIndex, result.stream?.index)
}
fun subtitle(
@ -945,6 +995,8 @@ fun subtitle(
lang: String?,
default: Boolean = false,
forced: Boolean = false,
sdh: Boolean = false,
external: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
@ -952,6 +1004,26 @@ fun subtitle(
language = lang,
isDefault = default,
isForced = forced,
isHearingImpaired = sdh,
isInterlaced = false,
index = index,
isExternal = external,
isTextSubtitleStream = true,
supportsExternalStream = true,
title = title,
)
fun audio(
index: Int,
lang: String?,
default: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
type = MediaStreamType.AUDIO,
language = lang,
isDefault = default,
isForced = false,
isHearingImpaired = false,
isInterlaced = false,
index = index,
@ -978,11 +1050,63 @@ private fun plc(
audioLang: String? = null,
subtitleLang: String? = null,
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
): PlaybackLanguageChoice =
PlaybackLanguageChoice(
userId = 1,
seriesId = UUID.randomUUID(),
audioLanguage = audioLang,
subtitleLanguage = subtitleLang,
subtitlesDisabled = subtitlesDisabled,
)
subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null,
): List<SeriesTrackChoice> =
buildList {
audioLang
?.let {
SeriesTrackChoice(
userId = 1,
language = audioLang,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.AUDIO,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
)
}?.let(::add)
if (subtitleLang != null) {
SeriesTrackChoice(
userId = 1,
language = subtitleLang,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
).let(::add)
} else if (subtitlesDisabled == true) {
SeriesTrackChoice(
userId = 1,
language = null,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
).let(::add)
} else if (subtitlesOnlyForced == true) {
SeriesTrackChoice(
userId = 1,
language = null,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ONLY_FORCED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
).let(::add)
}
}

View file

@ -0,0 +1,534 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
import com.github.damontecres.wholphin.data.model.TrackFlag
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceReason
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.audio
import com.github.damontecres.wholphin.services.mockServerRepo
import com.github.damontecres.wholphin.services.subtitle
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class TestSeriesTrackChoice {
private val mockServerRepository =
mockServerRepo(
audioLang = "eng",
subtitleLang = "eng",
subtitleMode = SubtitlePlaybackMode.DEFAULT,
)
private val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = false)
private val streamChoiceService =
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
private val seriesId = UUID.randomUUID()
private val season1Id = UUID.randomUUID()
private val season2Id = UUID.randomUUID()
private val season3Id = UUID.randomUUID()
private val user =
JellyfinUser(
1,
UUID.randomUUID(),
null,
UUID.randomUUID(),
null,
)
@Before
fun before() {
every { mockServerRepository.currentUser } returns user
}
@Test
fun `Test StreamChoiceService call correct dao function`() =
runTest {
val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = true)
val streamChoiceService =
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
val seriesId = UUID.randomUUID()
val seasonId = UUID.randomUUID()
// Has both series & season
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
seriesId = seriesId,
parentId = seasonId,
).let { item ->
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
coVerify(exactly = 1) {
mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
}
}
// Has only series
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
seriesId = seriesId,
parentId = null,
).let { item ->
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
coVerify(exactly = 1) {
mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE)
}
}
// Has only season
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
seriesId = null,
parentId = seasonId,
).let { item ->
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
coVerify(exactly = 1) {
mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
}
}
}
@Test
fun `Test series disabled`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
),
)
assertNull(result.stream)
}
@Test
fun `Test series only forced`() =
runTest {
val mockServerRepository =
mockServerRepo(
audioLang = "eng",
subtitleLang = "eng",
subtitleMode = SubtitlePlaybackMode.NONE,
)
val streamChoiceService =
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs =
UserPreferences(
AppPreferences.getDefaultInstance(),
JellyfinUserPreferences(),
),
candidates =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "eng", forced = false),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ONLY_FORCED,
),
),
)
assertNotNull(result.stream)
assertEquals(0, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test series disabled with season`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
),
)
assertNull(result.stream)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test item playback overrides STC for subtitles`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = UUID.randomUUID(),
subtitleIndex = 1,
),
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Item)
}
@Test
fun `Test item playback overrides STC for audio`() =
runTest {
val result =
streamChoiceService.chooseAudioStream(
itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = UUID.randomUUID(),
audioIndex = 1,
),
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
audio(0, "eng", default = true),
audio(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.AUDIO,
language = "eng",
),
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.AUDIO,
language = "eng",
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Item)
}
@Test
fun `Test season STC doesn't apply but series STC does - subtitle`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = UUID.randomUUID(),
audioIndex = 1,
),
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ONLY_FORCED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
language = "spa",
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test season STC doesn't apply but series STC does - audio`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
audio(0, "eng", default = true),
audio(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.AUDIO,
activation = ActivationFlag.ONLY_FORCED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.AUDIO,
activation = ActivationFlag.ACTIVATED,
language = "spa",
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test STC has forced flag`() =
runTest {
val stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = listOf(TrackFlag.FORCED).calculateFlag(),
),
)
// Choose forced track
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = true),
subtitle(1, "spa"),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(0, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
// No forced track to choose from, so nothing should be chosen
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = false, forced = false),
subtitle(1, "spa"),
),
stc = stc,
).let { result ->
assertNull(result.stream)
}
}
@Test
fun `Test prefer SDH`() =
runTest {
val stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = listOf(TrackFlag.SDH).calculateFlag(),
),
)
// Prefer SDH over default and non-SDH
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = true),
subtitle(1, "eng", default = false, forced = false, sdh = false),
subtitle(2, "eng", default = false, forced = false, sdh = true),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(2, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
// No SDH, so prefer regular over forced
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = true),
subtitle(1, "eng", default = false, forced = false, sdh = false),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
}
@Test
fun `Test prefer default track with no other flags`() =
runTest {
val stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
),
)
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = false),
subtitle(1, "eng", default = false, forced = false),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(0, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
// Test out-of-order tracks
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = false, forced = false),
subtitle(1, "eng", default = true, forced = false),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
}
}

View file

@ -0,0 +1,44 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.data.model.TrackFlag
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.EnumSet
class TestTrackFlag {
@Test
fun basicTest() {
val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag()
assertTrue(TrackFlag.DEFAULT.within(flag))
assertTrue(TrackFlag.EXTERNAL.within(flag))
assertFalse(TrackFlag.FORCED.within(flag))
assertFalse(TrackFlag.SDH.within(flag))
}
@Test
fun none() {
val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag()
assertFalse(TrackFlag.DEFAULT.within(flag))
assertFalse(TrackFlag.EXTERNAL.within(flag))
assertFalse(TrackFlag.FORCED.within(flag))
assertFalse(TrackFlag.SDH.within(flag))
}
@Test
fun all() {
val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag()
TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) }
}
@Test
fun checkFlags() {
TrackFlag.entries.forEach {
assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0)
}
val distinctFlags = TrackFlag.entries.map { it.flag }.distinct()
assertEquals(TrackFlag.entries.size, distinctFlags.size)
}
}

View file

@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.NameGuidPair
import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert.assertNotNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@ -149,3 +148,16 @@ inline fun <reified T : Any> assertIs(obj: Any?) {
Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ")
}
}
/**
* Asserts that the object is not null
*
* Includes a [contract] to smart-cast the object to T
*/
@OptIn(ExperimentalContracts::class)
fun assertNotNull(obj: Any?) {
contract {
returns() implies (obj != null)
}
org.junit.Assert.assertNotNull(obj)
}

View file

@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -266,6 +267,10 @@ object TestDatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides
@Singleton
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
@Provides
@Singleton
fun userPreferencesDataStore(

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceReason
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.test.TestTracks
@ -64,6 +67,7 @@ import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult
import org.jellyfin.sdk.model.api.MediaSegmentType
@ -201,7 +205,12 @@ class PlaybackViewModelTests {
coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null
coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource
coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null
coEvery {
mockStreamChoiceService.getSeriesTrackChoices(
any<BaseItemDto>(),
any<SeriesTrackChoiceType>(),
)
} returns emptyList()
coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns
PlayerCreation(mockPlayer)
every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true)
@ -214,18 +223,26 @@ class PlaybackViewModelTests {
any(),
any(),
)
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }
} returns
StreamChoiceResult(
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO },
StreamChoiceReason.Unknown,
)
coEvery {
mockStreamChoiceService.chooseSubtitleStream(
item = any(),
source = mediaSource,
any(),
any(),
any(),
any(),
any(),
audioStream = any(),
itemPlayback = any(),
stc = any(),
prefs = any(),
)
} returns
StreamChoiceResult(
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE },
StreamChoiceReason.Unknown,
)
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }
}
@OptIn(ExperimentalCoroutinesApi::class)