More subtitle selection fixes (#630)

## Description
Fixes more subtitle selection issues, primarily fixes the sort order of
subtitle streams. But also prefer default subtitle in some cases

Adds test cases for the described situations below

Also adds an option in the More dialog to clear track selections made on
an item/series.

### Related issues
Addresses
https://github.com/damontecres/Wholphin/issues/570#issuecomment-3707255647
&
https://github.com/damontecres/Wholphin/issues/570#issuecomment-3707292928
This commit is contained in:
Ray 2026-01-04 17:06:54 -05:00 committed by GitHub
parent 3d4b06db44
commit 02d6a98ba8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 289 additions and 27 deletions

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.data package com.github.damontecres.wholphin.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -11,22 +12,25 @@ import java.util.UUID
@Dao @Dao
interface ItemPlaybackDao { interface ItemPlaybackDao {
fun getItem( suspend fun getItem(
user: JellyfinUser, user: JellyfinUser,
itemId: UUID, itemId: UUID,
): ItemPlayback? = getItem(user.rowId, itemId) ): ItemPlayback? = getItem(user.rowId, itemId)
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId") @Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
fun getItem( suspend fun getItem(
userId: Int, userId: Int,
itemId: UUID, itemId: UUID,
): ItemPlayback? ): ItemPlayback?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: ItemPlayback): Long suspend fun saveItem(item: ItemPlayback): Long
@Delete
suspend fun deleteItem(item: ItemPlayback)
@Query("SELECT * from ItemPlayback WHERE userId=:userId") @Query("SELECT * from ItemPlayback WHERE userId=:userId")
fun getItems(userId: Int): List<ItemPlayback> suspend fun getItems(userId: Int): List<ItemPlayback>
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId") @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
suspend fun getTrackModifications( suspend fun getTrackModifications(

View file

@ -25,6 +25,7 @@ class ItemPlaybackRepository
constructor( constructor(
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService, private val streamChoiceService: StreamChoiceService,
) { ) {
suspend fun getSelectedTracks( suspend fun getSelectedTracks(
@ -204,6 +205,18 @@ class ItemPlaybackRepository
) )
} }
} }
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let {
Timber.d("Deleting %s", it)
playbackLanguageChoiceDao.delete(it)
}
chosenStreams?.itemPlayback?.let {
Timber.d("Deleting %s", it)
itemPlaybackDao.deleteItem(it)
}
}
} }
data class ChosenStreams( data class ChosenStreams(

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.data package com.github.damontecres.wholphin.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long suspend fun save(plc: PlaybackLanguageChoice): Long
@Delete
fun delete(plc: PlaybackLanguageChoice)
} }

View file

@ -199,15 +199,17 @@ class StreamChoiceService
} }
} }
val candidates = val candidates =
candidates.sortedWith( candidates
compareBy<MediaStream>( .sortedWith(
{ it.isDefault }, compareByDescending<MediaStream> { it.isExternal }
{ !it.isForced && it.language.equals(subtitleLanguage, true) }, .thenByDescending { it.isDefault }
{ it.isForced && it.language.equals(subtitleLanguage, true) }, .thenByDescending {
{ it.isForced && it.language.isUnknown }, !it.isForced && it.language.equals(subtitleLanguage, true)
{ it.isForced }, }.thenByDescending {
), it.isForced && it.language.equals(subtitleLanguage, true)
) }.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
return when (subtitleMode) { return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> { SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) { if (subtitleLanguage.isNotNullOrBlank()) {
@ -232,7 +234,7 @@ class StreamChoiceService
SubtitlePlaybackMode.SMART -> { SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) { if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) { if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage } candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown } ?: candidates.firstOrNull { it.language.isUnknown }
} else { } else {
@ -240,7 +242,7 @@ class StreamChoiceService
?: candidates.firstOrNull { it.isForced && it.language.isUnknown } ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
} }
} else { } else {
candidates.firstOrNull { it.isForced } candidates.firstOrNull { it.isDefault }
} }
} }

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@ -29,6 +30,12 @@ data class MoreDialogActions(
var onClickAddPlaylist: (UUID) -> Unit, var onClickAddPlaylist: (UUID) -> Unit,
) )
enum class ClearChosenStreams {
NONE,
ITEM_AND_SERIES,
SERIES,
}
/** /**
* Build the [DialogItem]s when clicking "More" * Build the [DialogItem]s when clicking "More"
* *
@ -53,10 +60,12 @@ fun buildMoreDialogItems(
sourceId: UUID?, sourceId: UUID?,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canClearChosenStreams: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit, onShowOverview: () -> Unit,
onClearChosenStreams: () -> Unit,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
add( add(
@ -172,6 +181,16 @@ fun buildMoreDialogItems(
}, },
) )
} }
if (canClearChosenStreams) {
add(
DialogItem(
context.getString(R.string.clear_track_choices),
Icons.Default.Delete,
) {
onClearChosenStreams()
},
)
}
add( add(
DialogItem( DialogItem(
context.getString(R.string.play_with_transcoding), context.getString(R.string.play_with_transcoding),

View file

@ -155,6 +155,7 @@ fun EpisodeDetails(
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId, seriesId = ep.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -204,6 +205,9 @@ fun EpisodeDetails(
) )
} }
}, },
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
), ),
) )
}, },

View file

@ -182,4 +182,19 @@ class EpisodeViewModel
release() release()
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
this@EpisodeViewModel.chosenStreams.setValueOnMain(result)
}
}
}
} }

View file

@ -193,6 +193,7 @@ fun MovieDetails(
favorite = movie.data.userData?.isFavorite ?: false, favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null, seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -241,6 +242,9 @@ fun MovieDetails(
files = movie.data.mediaSources.orEmpty(), files = movie.data.mediaSources.orEmpty(),
) )
}, },
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
), ),
) )
}, },

View file

@ -247,4 +247,19 @@ class MovieViewModel
release() release()
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
this@MovieViewModel.chosenStreams.setValueOnMain(result)
}
}
}
} }

View file

@ -19,6 +19,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.map import androidx.lifecycle.map
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
@ -179,6 +180,7 @@ fun SeriesOverview(
fun buildMoreForEpisode( fun buildMoreForEpisode(
ep: BaseItem, ep: BaseItem,
chosenStreams: ChosenStreams?,
fromLongClick: Boolean, fromLongClick: Boolean,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
@ -192,6 +194,7 @@ fun SeriesOverview(
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
seriesId = series.id, seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -259,6 +262,9 @@ fun SeriesOverview(
files = ep.data.mediaSources.orEmpty(), files = ep.data.mediaSources.orEmpty(),
) )
}, },
onClearChosenStreams = {
viewModel.clearChosenStreams(ep, chosenStreams)
},
), ),
) )
@ -304,7 +310,7 @@ fun SeriesOverview(
) )
}, },
onLongClick = { ep -> onLongClick = { ep ->
moreDialog = buildMoreForEpisode(ep, true) moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
}, },
playOnClick = { resume -> playOnClick = { resume ->
rowFocused = EPISODE_ROW rowFocused = EPISODE_ROW
@ -333,7 +339,7 @@ fun SeriesOverview(
}, },
moreOnClick = { moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog = buildMoreForEpisode(ep, false) moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
} }
}, },
overviewOnClick = { overviewOnClick = {

View file

@ -510,6 +510,16 @@ class SeriesViewModel
} }
} }
} }
fun clearChosenStreams(
item: BaseItem,
chosenStreams: ChosenStreams?,
) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
lookUpChosenTracks(item.id, item)
}
}
} }
sealed interface EpisodeList { sealed interface EpisodeList {

View file

@ -402,6 +402,7 @@
<string name="local">Local</string> <string name="local">Local</string>
<string name="play_trailer">Play trailer</string> <string name="play_trailer">Play trailer</string>
<string name="no_trailers">No trailers</string> <string name="no_trailers">No trailers</string>
<string name="clear_track_choices">Clear track choices</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>

View file

@ -104,16 +104,6 @@ class TestStreamChoiceServiceBasic(
), ),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
), ),
TestInput(
1,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", false),
),
),
) )
} }
} }
@ -352,6 +342,31 @@ class TestStreamChoiceServiceSmart(
userSubtitleLang = "spa", userSubtitleLang = "spa",
userAudioLang = "eng", userAudioLang = "eng",
), ),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "spa",
userAudioLang = null,
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "eng", true),
subtitle(2, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "eng",
userAudioLang = null,
),
) )
} }
} }
@ -448,6 +463,156 @@ class TestStreamChoiceServiceOnlyForced(
} }
} }
@RunWith(Parameterized::class)
class TestStreamChoiceServiceMultipleChoices(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
0,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", false),
),
),
TestInput(
2,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userAudioLang = null,
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = null,
userAudioLang = null,
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "spa",
userAudioLang = null,
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "eng",
userAudioLang = null,
streamAudioLang = "spa",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "eng",
userAudioLang = "eng",
streamAudioLang = "eng",
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "spa",
userAudioLang = "eng",
streamAudioLang = "spa",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "spa", forced = true, default = false),
subtitle(1, "spa", false),
subtitle(2, "spa", default = true),
),
userSubtitleLang = "spa",
userAudioLang = "eng",
streamAudioLang = "eng",
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "spa", forced = true, default = false),
subtitle(1, "spa", false),
subtitle(2, "spa", default = true),
),
userSubtitleLang = "spa",
userAudioLang = "",
streamAudioLang = "eng",
),
TestInput(
2,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = null,
userAudioLang = null,
),
)
}
}
data class TestInput( data class TestInput(
val expectedIndex: Int?, val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?, val userSubtitleMode: SubtitlePlaybackMode?,