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
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@ -11,22 +12,25 @@ import java.util.UUID
@Dao
interface ItemPlaybackDao {
fun getItem(
suspend fun getItem(
user: JellyfinUser,
itemId: UUID,
): ItemPlayback? = getItem(user.rowId, itemId)
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
fun getItem(
suspend fun getItem(
userId: Int,
itemId: UUID,
): ItemPlayback?
@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")
fun getItems(userId: Int): List<ItemPlayback>
suspend fun getItems(userId: Int): List<ItemPlayback>
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
suspend fun getTrackModifications(

View file

@ -25,6 +25,7 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService,
) {
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(

View file

@ -1,6 +1,7 @@
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
@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
@Delete
fun delete(plc: PlaybackLanguageChoice)
}

View file

@ -199,14 +199,16 @@ class StreamChoiceService
}
}
val candidates =
candidates.sortedWith(
compareBy<MediaStream>(
{ it.isDefault },
{ !it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.isUnknown },
{ it.isForced },
),
candidates
.sortedWith(
compareByDescending<MediaStream> { it.isExternal }
.thenByDescending { it.isDefault }
.thenByDescending {
!it.isForced && it.language.equals(subtitleLanguage, true)
}.thenByDescending {
it.isForced && it.language.equals(subtitleLanguage, true)
}.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
@ -232,7 +234,7 @@ class StreamChoiceService
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
@ -240,7 +242,7 @@ class StreamChoiceService
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} 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.automirrored.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.PlayArrow
import androidx.compose.material.icons.filled.Refresh
@ -29,6 +30,12 @@ data class MoreDialogActions(
var onClickAddPlaylist: (UUID) -> Unit,
)
enum class ClearChosenStreams {
NONE,
ITEM_AND_SERIES,
SERIES,
}
/**
* Build the [DialogItem]s when clicking "More"
*
@ -53,10 +60,12 @@ fun buildMoreDialogItems(
sourceId: UUID?,
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
onClearChosenStreams: () -> Unit,
): List<DialogItem> =
buildList {
add(
@ -172,6 +181,16 @@ fun buildMoreDialogItems(
},
)
}
if (canClearChosenStreams) {
add(
DialogItem(
context.getString(R.string.clear_track_choices),
Icons.Default.Delete,
) {
onClearChosenStreams()
},
)
}
add(
DialogItem(
context.getString(R.string.play_with_transcoding),

View file

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

View file

@ -182,4 +182,19 @@ class EpisodeViewModel
release()
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,
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -241,6 +242,9 @@ fun MovieDetails(
files = movie.data.mediaSources.orEmpty(),
)
},
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
),
)
},

View file

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

View file

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

View file

@ -104,16 +104,6 @@ class TestStreamChoiceServiceBasic(
),
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",
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(
val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?,