Improvements to subtitle search & download (#1728)

## Description
Changes to searching & downloading subtitles:
- Activating newly downloaded subtitles more reliably
- Better audio index adjustment checks after downloading subtitles
- Sort subtitle downloadable options by whether the file hash matches
first
- Update the UI for subtitle downloadable options

Also, some improvements to the subtitle choice dialog:
- It now loops from top to bottom (and vice versa)
- It now focuses initially on current choice

### Dev notes
This PR now the selected tracks from `state.currentItemPlayback` to
`state.currentPlayback`. `currentItemPlayback` now only reflects the
user's choices saved in the database instead of a combination of DB and
"actual" state. `currentPlayback` stores the actual, resolved track
indexes regardless of how they were derived.

This means the indexes values in `state.currentItemPlayback` and
`state.currentPlayback` may be different. For example, if the user has
chosen "Only forced", `state.currentItemPlayback==-3` but
`state.currentPlayback` might be an actual forced track index.

### Related issues
Fixes #1549
Fixes #1718
Closes #1723
Fixes bug reported in
https://github.com/damontecres/Wholphin/issues/1498#issuecomment-5019965271

### Testing
Emulator

## Screenshots

Now shows SDH & hash matching

<img width="779" height="280" alt="image"
src="https://github.com/user-attachments/assets/4c33e5af-c9a1-4c9c-8217-6bc2ab3fcd27"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-23 13:30:43 -04:00 • committed by GitHub
parent 48005ce67a
commit 00490b14ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 437 additions and 328 deletions

View file

@ -88,7 +88,7 @@ import java.util.UUID
data class DialogParams(
val fromLongClick: Boolean,
val title: String,
val items: List<DialogItem>,
val items: List<DialogItemEntry>,
)
sealed interface DialogItemEntry
@ -675,8 +675,9 @@ fun chooseStream(
type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit,
): DialogParams =
DialogParams(
): DialogParams {
val filteredStreams = streams.filter { it.type == type }
return DialogParams(
fromLongClick = false,
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
items =
@ -709,10 +710,12 @@ fun chooseStream(
onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) },
),
)
if (filteredStreams.isNotEmpty()) {
add(DialogItemDivider)
}
}
addAll(
streams
.filter { it.type == type }
filteredStreams
.let {
if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) {
it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage }
@ -728,7 +731,9 @@ fun chooseStream(
},
headlineContent = {
Text(
text = simpleStream.streamTitle ?: simpleStream.displayTitle,
text =
simpleStream.streamTitle
?: simpleStream.displayTitle,
)
},
supportingContent = {
@ -740,3 +745,4 @@ fun chooseStream(
)
},
)
}

View file

@ -4,7 +4,10 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaMetadata
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.ticks
@ -26,6 +29,40 @@ data class CurrentMediaInfo(
}
}
/**
* Gets a sorted list of subtitle streams in a [MediaSourceInfo]
*/
internal fun PlaybackViewModel.getSubtitleStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> {
val subtitleLanguagePreference =
serverRepository.currentUserDto
?.configuration
?.subtitleLanguagePreference
return mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.let {
if (subtitleLanguagePreference.isNotNullOrBlank()) {
it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language }
} else {
it
}
}?.map {
// TODO should use a string provider instead
SimpleMediaStream.from(context.resources, it, true)
}.orEmpty()
}
/**
* Gets a list of audio streams in a [MediaSourceInfo]
*/
internal fun PlaybackViewModel.getAudioStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
?.map {
SimpleMediaStream.from(context.resources, it, true)
}
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()
fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata =
MediaMetadata
.Builder()

View file

@ -1,11 +1,14 @@
package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.TrackSupport
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodingInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
/**
@ -25,4 +28,12 @@ data class CurrentPlayback(
val audioDecoder: String? = null,
val transcodeInfo: TranscodingInfo? = null,
val subtitleDelay: Duration = Duration.ZERO,
)
val audioIndex: Int = TrackIndex.UNSPECIFIED,
val subtitleIndex: Int = TrackIndex.UNSPECIFIED,
) {
val audioIndexEnabled = audioIndex >= 0
val subtitleIndexEnabled = subtitleIndex >= 0
val itemId: UUID get() = item.id
val sourceId: UUID? = mediaSourceInfo.id?.toUUIDOrNull()
}

View file

@ -1,21 +1,15 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -25,11 +19,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@ -37,15 +29,13 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogItemDivider
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
@ -55,53 +45,41 @@ fun DownloadSubtitlesContent(
language: String,
onSearch: (String) -> Unit,
onClickDownload: (RemoteSubtitleInfo) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
when (val s = state) {
SubtitleSearchStatus.Inactive -> {}
SubtitleSearchStatus.Searching -> {
Wrapper {
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier,
)
}
SubtitleSearchStatus.Downloading -> {
Wrapper {
Text(
text = stringResource(R.string.downloading),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.downloading),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier,
)
}
is SubtitleSearchStatus.Error -> {
Wrapper { ErrorMessage(null, s.ex, modifier) }
ErrorMessage(null, s.ex, modifier)
}
is SubtitleSearchStatus.Success -> {
val dialogItems = convertRemoteSubtitles(s.options, onClickDownload)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
modifier = modifier,
) {
Text(
text = stringResource(R.string.search_and_download_subtitles),
@ -128,51 +106,35 @@ fun DownloadSubtitlesContent(
onSearch(lang)
},
),
// onKeyboardAction = {
// onSearch(lang.text.toString())
// },
keyboardOptions =
KeyboardOptions(
imeAction = ImeAction.Search,
),
)
}
if (dialogItems.isEmpty()) {
if (s.options.isEmpty()) {
Text(
text = stringResource(R.string.no_subtitles_found),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
val focusRequesters =
remember(s.options.size) { List(s.options.size) { FocusRequester() } }
LaunchedEffect(Unit) {
focusRequesters.firstOrNull()?.tryRequestFocus()
}
LazyColumn(
modifier = Modifier,
) {
itemsIndexed(dialogItems) { index, item ->
when (item) {
is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp))
}
is DialogItem -> {
ListItem(
selected = false,
enabled = item.enabled,
onClick = {
item.onClick.invoke()
},
headlineContent = item.headlineContent,
overlineContent = item.overlineContent,
supportingContent = item.supportingContent,
leadingContent = item.leadingContent,
trailingContent = item.trailingContent,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
itemsIndexed(s.options) { index, item ->
SubtitleInfo(
subtitle = item,
onClick = onClickDownload,
modifier =
Modifier
.focusRequester(focusRequesters[index]),
)
}
}
}
@ -182,54 +144,56 @@ fun DownloadSubtitlesContent(
}
@Composable
private fun Wrapper(content: @Composable BoxScope.() -> Unit) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
Box(
modifier =
Modifier
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
content = content,
)
}
fun convertRemoteSubtitles(
options: List<RemoteSubtitleInfo>,
fun SubtitleInfo(
subtitle: RemoteSubtitleInfo,
onClick: (RemoteSubtitleInfo) -> Unit,
) = options.map { op ->
DialogItem(
onClick = { onClick.invoke(op) },
modifier: Modifier = Modifier,
) {
ListItem(
selected = false,
onClick = { onClick.invoke(subtitle) },
headlineContent = {
Text(
text = op.name ?: "",
text = subtitle.name ?: "",
)
},
overlineContent =
if (subtitle.isHashMatch == true) {
{
Text(stringResource(R.string.hash_matches))
}
} else {
null
},
supportingContent = {
val strings =
buildList {
op.providerName?.let(::add)
op.threeLetterIsoLanguageName?.let(::add)
if (op.forced == true) {
add(stringResource(R.string.forced_track))
}
add(
pluralStringResource(
R.plurals.downloads,
op.downloadCount ?: 0,
abbreviateNumber(op.downloadCount ?: 0),
),
)
val resources = LocalResources.current
val details =
remember(resources, subtitle) {
val strings =
buildList {
subtitle.providerName?.let(::add)
if (subtitle.forced == true) {
add(resources.getString(R.string.forced_track))
}
if (subtitle.hearingImpaired == true) {
add(resources.getString(R.string.subtitles_hearing_impaired))
}
add(
resources.getQuantityString(
R.plurals.downloads,
subtitle.downloadCount ?: 0,
abbreviateNumber(subtitle.downloadCount ?: 0),
),
)
}
strings.joinToString(" - ")
}
Text(
text = strings.joinToString(" - "),
text = details,
)
},
trailingContent = {
op.communityRating?.let { rating ->
subtitle.communityRating?.let { rating ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
@ -245,5 +209,43 @@ fun convertRemoteSubtitles(
}
}
},
modifier = modifier,
)
}
@PreviewTvSpec
@Composable
fun SubtitleInfoPreview() {
WholphinTheme {
Column(Modifier.width(400.dp)) {
SubtitleInfo(
onClick = {},
subtitle =
RemoteSubtitleInfo(
name = "Filename.mkv",
providerName = "OpenSubs",
threeLetterIsoLanguageName = "eng",
forced = true,
hearingImpaired = false,
isHashMatch = true,
communityRating = 7.5f,
downloadCount = 10_500,
),
)
SubtitleInfo(
onClick = {},
subtitle =
RemoteSubtitleInfo(
name = "Filename.mkv",
providerName = "OpenSubs",
threeLetterIsoLanguageName = "eng",
forced = false,
hearingImpaired = true,
isHashMatch = false,
communityRating = 7.5f,
downloadCount = 10_500,
),
)
}
}
}

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
@ -11,15 +12,21 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
@ -42,6 +49,7 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
import kotlin.time.Duration
enum class PlaybackDialogType {
@ -276,6 +284,24 @@ fun SubtitleChoiceBottomDialog(
hasDownloadPermission: Boolean,
currentChoice: Int? = null,
) {
val numberBefore = 2
val initialChoice =
remember(choices) {
choices.indexOfFirstOrNull { it.index == currentChoice }
}
val scope = rememberCoroutineScope()
val listState =
rememberLazyListState(
initialFirstVisibleItemIndex = initialChoice?.plus(numberBefore) ?: 0,
)
val firstFocusRequester = remember { FocusRequester() }
val lastFocusRequester = remember { FocusRequester() }
val choiceFocusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
LaunchedEffect(Unit) {
val focusRequester =
initialChoice?.let { choiceFocusRequesters.getOrNull(it) } ?: firstFocusRequester
focusRequester.tryRequestFocus()
}
// TODO enforcing a width ends up ignore the gravity
Dialog(
onDismissRequest = onDismissRequest,
@ -298,6 +324,7 @@ fun SubtitleChoiceBottomDialog(
),
) {
LazyColumn(
state = listState,
modifier =
Modifier
.fillMaxWidth()
@ -307,8 +334,11 @@ fun SubtitleChoiceBottomDialog(
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = currentChoice == TrackIndex.DISABLED,
interactionSource = interactionSource,
onClick = {
onSelectChoice(TrackIndex.DISABLED)
},
@ -321,6 +351,19 @@ fun SubtitleChoiceBottomDialog(
)
},
supportingContent = {},
modifier =
Modifier
.focusRequester(firstFocusRequester)
.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(Int.MAX_VALUE)
lastFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
)
}
item {
@ -339,6 +382,9 @@ fun SubtitleChoiceBottomDialog(
},
supportingContent = {},
)
if (choices.isNotEmpty()) {
HorizontalDivider()
}
}
itemsIndexed(choices) { index, choice ->
val interactionSource = remember { MutableInteractionSource() }
@ -359,10 +405,13 @@ fun SubtitleChoiceBottomDialog(
if (choice.streamTitle != null) Text(choice.displayTitle)
},
interactionSource = interactionSource,
modifier = Modifier.focusRequester(choiceFocusRequesters[index]),
)
}
item {
HorizontalDivider()
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = false,
enabled = hasDownloadPermission,
@ -374,6 +423,20 @@ fun SubtitleChoiceBottomDialog(
)
},
supportingContent = {},
interactionSource = interactionSource,
modifier =
Modifier
.focusRequester(lastFocusRequester)
.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(0)
firstFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
)
}
}

View file

@ -53,7 +53,6 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -75,6 +74,7 @@ import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
@ -379,7 +379,13 @@ fun PlaybackPageContent(
}
}
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) {
val controlsVisible =
remember(controllerViewState.controlsVisible, playbackDialog, subtitleSearchState) {
controllerViewState.controlsVisible ||
playbackDialog != null ||
subtitleSearchState.status != SubtitleSearchStatus.Inactive
}
if (!controlsVisible && skipIndicatorDuration == 0L) {
PauseIndicator(
player = player,
modifier =
@ -444,7 +450,7 @@ fun PlaybackPageContent(
val subtitleVisible =
skipIndicatorDuration == 0L &&
state.currentItemPlayback?.subtitleIndexEnabled == true &&
state.currentPlayback?.subtitleIndexEnabled == true &&
!presentationState.coverSurface
AndroidView(
@ -629,28 +635,29 @@ fun PlaybackPageContent(
}
viewModel.cancelSubtitleSearch()
}
Dialog(
BasicDialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
DownloadSubtitlesContent(
state = subtitleSearchState.status,
language = subtitleSearchState.language,
onSearch = { lang ->
viewModel.searchForSubtitles(lang)
},
onClickDownload = {
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
},
onDismissRequest = onDismissRequest,
modifier =
Modifier
.widthIn(max = 640.dp)
.heightIn(max = 400.dp),
)
Box(modifier = Modifier.padding(24.dp)) {
DownloadSubtitlesContent(
state = subtitleSearchState.status,
language = subtitleSearchState.language,
onSearch = { lang ->
viewModel.searchForSubtitles(lang)
},
onClickDownload = {
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
},
modifier =
Modifier
.widthIn(max = 640.dp)
.heightIn(max = 400.dp),
)
}
}
}
@ -660,9 +667,9 @@ fun PlaybackPageContent(
settings =
PlaybackSettings(
showDebugInfo = showDebugInfo,
audioIndex = state.currentItemPlayback?.audioIndex,
audioIndex = state.currentPlayback?.audioIndex,
audioStreams = state.currentMediaInfo.audioStreams,
subtitleIndex = state.currentItemPlayback?.subtitleIndex,
subtitleIndex = state.currentPlayback?.subtitleIndex,
subtitleStreams = state.currentMediaInfo.subtitleStreams,
playbackSpeed = playbackSpeed,
contentScale = contentScale,

View file

@ -31,7 +31,6 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex
@ -65,7 +64,6 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState
@ -142,7 +140,7 @@ class PlaybackViewModel
private val playlistCreator: PlaylistCreator,
private val itemPlaybackDao: ItemPlaybackDao,
internal val serverRepository: ServerRepository,
private val itemPlaybackRepository: ItemPlaybackRepository,
internal val itemPlaybackRepository: ItemPlaybackRepository,
private val playerFactory: PlayerFactory,
private val datePlayedService: DatePlayedService,
private val deviceInfo: DeviceInfo,
@ -405,7 +403,7 @@ class PlaybackViewModel
}
}
private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) {
internal fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) {
_state.update {
it.copy(currentPlayback = block.invoke(it.currentPlayback))
}
@ -494,32 +492,9 @@ class PlaybackViewModel
// Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
val subtitleLanguagePreference =
serverRepository.currentUserDto
?.configuration
?.subtitleLanguagePreference
val subtitleStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.let {
if (subtitleLanguagePreference.isNotNullOrBlank()) {
it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language }
} else {
it
}
}?.map {
// TODO should use a string provider instead
SimpleMediaStream.from(context.resources, it, true)
}.orEmpty()
val audioStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
?.map {
SimpleMediaStream.from(context.resources, it, true)
}
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()
val subtitleStreams = getSubtitleStreams(mediaSource)
val audioStreams = getAudioStreams(mediaSource)
val audioStream =
streamChoiceService
.chooseAudioStream(
@ -544,15 +519,6 @@ class PlaybackViewModel
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
val itemPlaybackToUse =
itemPlayback ?: ItemPlayback(
rowId = -1,
userId = -1,
itemId = base.id,
sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null,
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
)
val trickPlayInfo =
item.data.trickplay
?.get(mediaSource.id)
@ -572,7 +538,10 @@ class PlaybackViewModel
val chapters = Chapter.fromDto(base)
_state.update {
it.copy(currentItemPlayback = itemPlaybackToUse)
it.copy(
currentItemPlayback = itemPlayback,
currentPlayback = null,
)
}
updateCurrentMedia {
CurrentMediaInfo(
@ -586,12 +555,11 @@ class PlaybackViewModel
}
withContext(WholphinDispatchers.Main) {
changeStreams(
item,
itemPlaybackToUse,
audioIndex,
subtitleIndex,
if (positionMs > 0) positionMs else C.TIME_UNSET,
itemPlayback != null, // If it was passed in, then it was not queried from the database
item = item,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET,
sourceId = mediaSource.id,
enableDirectPlay = !forceTranscoding,
enableDirectStream = !forceTranscoding,
)
@ -608,38 +576,34 @@ class PlaybackViewModel
@OptIn(UnstableApi::class)
internal suspend fun changeStreams(
item: BaseItem,
currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!,
sourceId: String?,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = 0,
userInitiated: Boolean,
enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding,
): Unit =
withContext(WholphinDispatchers.IO) {
val itemId = item.id
val currentPlayback = state.value.currentPlayback
if (currentPlayback != null &&
currentPlayback.item.id == item.id &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
enableDirectPlay
) {
val wasSuccessful =
changeStreamsDirectPlay(
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
userInitiated = userInitiated,
)
if (wasSuccessful) return@withContext
state.value.currentPlayback?.let { currentPlayback ->
if (currentPlayback.item.id == item.id &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
enableDirectPlay
) {
val wasSuccessful =
changeStreamsDirectPlay(
currentPlayback = currentPlayback,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
)
if (wasSuccessful) return@withContext
}
}
Timber.i(
"changeStreams (%s): userInitiated=%s, audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
"changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
itemId,
userInitiated,
audioIndex,
subtitleIndex,
enableDirectPlay,
@ -668,7 +632,7 @@ class PlaybackViewModel
maxAudioChannels = null,
audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex,
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
mediaSourceId = sourceId,
alwaysBurnInSubtitleWhenTranscoding = false,
maxStreamingBitrate = maxBitrate.toInt(),
enableDirectPlay = enableDirectPlay,
@ -680,6 +644,7 @@ class PlaybackViewModel
),
)
if (response.errorCode != null) {
Timber.e("Error in PostedPlaybackInfo: %s", response.errorCode)
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
return@withContext
}
@ -782,6 +747,8 @@ class PlaybackViewModel
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
mediaSourceInfo = source,
audioIndex = audioIndex ?: TrackIndex.DISABLED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
)
preferences.appPreferences.playbackPreferences.let { prefs ->
@ -802,7 +769,7 @@ class PlaybackViewModel
player.removeListener(it)
}
val playbackItemState = PlaybackItemState(playback, currentItemPlayback)
val playbackItemState = PlaybackItemState(playback)
val activityListener =
TrackActivityPlaybackListener(
api = api,
@ -843,15 +810,17 @@ class PlaybackViewModel
result.trackSelectionParameters
} else {
// Fall back to transcoding
Timber.w("Failed to select tracks, falling back to transcoding")
Timber.w(
"Failed to select tracks, falling back to transcoding: %s",
result,
)
viewModelScope.launchIO {
changeStreams(
item = item,
currentItemPlayback = currentItemPlayback,
sourceId = sourceId,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
positionMs = positionMs,
userInitiated = userInitiated,
enableDirectPlay = false,
enableDirectStream = true,
)
@ -874,10 +843,8 @@ class PlaybackViewModel
@OptIn(UnstableApi::class)
private suspend fun changeStreamsDirectPlay(
currentPlayback: CurrentPlayback,
currentItemPlayback: ItemPlayback,
audioIndex: Int?,
subtitleIndex: Int?,
userInitiated: Boolean,
): Boolean =
withContext(WholphinDispatchers.IO) {
Timber.v("changeStreams direct play")
@ -912,29 +879,7 @@ class PlaybackViewModel
}
if (result.bothSelected) {
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
subtitleIndex =
if (currentItemPlayback.subtitleIndex < 0) {
currentItemPlayback.subtitleIndex
} else {
subtitleIndex ?: TrackIndex.DISABLED
},
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
_state.update { it.copy(currentItemPlayback = updated) }
}
} else {
_state.update { it.copy(currentItemPlayback = itemPlayback) }
}
_state.update {
it.copy(
currentPlayback =
@ -954,78 +899,92 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) {
viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = currentItem.item,
itemPlayback = state.value.currentItemPlayback!!,
trackIndex = index,
type = MediaStreamType.AUDIO,
)
_state.update { it.copy(currentItemPlayback = itemPlayback) }
// Resolve ONLY_FORCED to actual track based on new audio language
val source = state.value.currentPlayback?.mediaSourceInfo
val resolvedSubtitleIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = index,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = itemPlayback.subtitleIndex,
prefs = preferences,
val currentPlayback = state.value.currentPlayback
if (currentPlayback != null) {
Timber.d("Changing audio track to %s", index)
saveTrackSelection(index, MediaStreamType.AUDIO)
_state.update {
it.copy(
currentPlayback = it.currentPlayback?.copy(audioIndex = index),
)
} else {
itemPlayback.subtitleIndex.takeIf { it >= 0 }
}
changeStreams(
currentItem.item,
itemPlayback,
index,
resolvedSubtitleIndex,
onMain { player.currentPosition },
true,
)
// Resolve ONLY_FORCED to actual track based on new audio language
val resolvedSubtitleIndex =
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = index,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = currentPlayback.subtitleIndex,
prefs = preferences,
)
changeStreams(
item = currentItem.item,
sourceId = currentPlayback.mediaSourceInfo.id,
audioIndex = index,
subtitleIndex = resolvedSubtitleIndex,
positionMs = onMain { player.currentPosition },
enableDirectPlay = true,
)
} else {
Timber.w("Trying to change audio, but currentPlayback is null")
}
}
}
fun changeSubtitleStream(index: Int): Job =
viewModelScope.launchIO {
Timber.d("Changing subtitle track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = currentItem.item,
itemPlayback = state.value.currentItemPlayback!!,
trackIndex = index,
type = MediaStreamType.SUBTITLE,
)
_state.update { it.copy(currentItemPlayback = itemPlayback) }
val currentPlayback = state.value.currentPlayback
if (currentPlayback != null) {
Timber.d("Changing subtitle track to %s", index)
saveTrackSelection(index, MediaStreamType.SUBTITLE)
_state.update {
it.copy(
currentPlayback = it.currentPlayback?.copy(subtitleIndex = index),
)
}
// Resolve ONLY_FORCED to actual track index for playback
val source = state.value.currentPlayback?.mediaSourceInfo
val resolvedIndex =
if (source != null) {
// Resolve ONLY_FORCED to actual track index for playback
val resolvedIndex =
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = itemPlayback.audioIndex,
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = currentPlayback.audioIndex,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = index,
prefs = preferences,
)
} else {
index.takeIf { it >= 0 }
}
changeStreams(
currentItem.item,
itemPlayback,
itemPlayback.audioIndex,
resolvedIndex,
onMain { player.currentPosition },
true,
changeStreams(
item = currentItem.item,
sourceId = currentPlayback.mediaSourceInfo.id,
audioIndex = currentPlayback.audioIndex,
subtitleIndex = resolvedIndex,
positionMs = onMain { player.currentPosition },
enableDirectPlay = true,
)
} else {
Timber.w("Trying to change subtitle, but currentPlayback is null")
}
}
internal suspend fun saveTrackSelection(
trackIndex: Int,
type: MediaStreamType,
) {
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = currentItem.item,
itemPlayback = state.value.currentItemPlayback,
trackIndex = trackIndex,
type = type,
)
_state.update {
it.copy(
currentItemPlayback = itemPlayback,
)
}
}
private suspend fun prefetchTrickplay(
duration: Duration,
@ -1050,7 +1009,11 @@ class PlaybackViewModel
fun getTrickplayUrl(
index: Int,
trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo,
mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId,
mediaSourceId: UUID? =
state.value.currentPlayback
?.mediaSourceInfo
?.id
?.toUUIDOrNull(),
): String? =
trickPlayInfo?.let {
val itemId = currentItem.id
@ -1388,14 +1351,16 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
val currentItemPlayback = state.value.currentItemPlayback!!
val currentPlayback = state.value.currentPlayback
if (currentPlayback == null) {
Timber.w("Playback error, currentPlayback is null")
}
changeStreams(
currentItem.item,
currentItemPlayback,
currentItemPlayback.audioIndex,
currentItemPlayback.subtitleIndex,
player.currentPosition,
false,
item = currentItem.item,
sourceId = currentPlayback?.mediaSourceInfo?.id,
audioIndex = currentPlayback?.audioIndex,
subtitleIndex = currentPlayback?.subtitleIndex,
positionMs = player.currentPosition,
enableDirectPlay = false,
enableDirectStream = false,
)
@ -1475,7 +1440,7 @@ class PlaybackViewModel
* Atomically update [currentMediaInfo]
*/
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
withContext(WholphinDispatchers.IO) {
withContext(WholphinDispatchers.Default) {
_state.update {
it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo))
}
@ -1598,16 +1563,16 @@ class PlaybackViewModel
}
suspend fun loadSubtitleDelay() {
state.value.currentItemPlayback?.let {
state.value.currentPlayback?.let {
if (it.subtitleIndexEnabled) {
val result =
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
itemPlaybackRepository.getTrackModifications(it.item.id, it.subtitleIndex)
if (result != null) {
Timber.v(
"Loading subtitle delay %s for track=%s, itemId=%s",
result.delayMs,
it.subtitleIndex,
it.itemId,
it.item.id,
)
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
}

View file

@ -21,7 +21,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
sealed interface SubtitleSearchStatus {
data object Inactive : SubtitleSearchStatus
@ -62,16 +64,17 @@ fun PlaybackViewModel.searchForSubtitles(language: String? = null) {
}
viewModelScope.launchIO {
try {
state.value.currentItemPlayback?.itemId?.let {
Timber.v("Searching for remote subtitles for %s", it)
state.value.currentPlayback?.itemId?.let { itemId ->
Timber.v("Searching for remote subtitles for %s", itemId)
val results =
api.subtitleApi
.searchRemoteSubtitles(
itemId = it,
itemId = itemId,
language = language,
).content
.sortedWith(
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
compareByDescending<RemoteSubtitleInfo> { it.isHashMatch }
.thenByDescending { it.communityRating }
.thenByDescending { it.downloadCount },
)
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) }
@ -104,21 +107,19 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) }
viewModelScope.launchIO {
try {
state.value.currentItemPlayback?.let {
state.value.currentPlayback?.let { currentPlayback ->
Timber.v(
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
it.itemId,
it.sourceId,
currentPlayback.itemId,
currentPlayback.sourceId,
subtitleId,
)
api.subtitleApi.downloadRemoteSubtitles(
itemId = it.sourceId ?: it.itemId,
itemId = currentPlayback.sourceId ?: currentPlayback.itemId,
subtitleId = subtitleId,
)
val currentSource = state.value.currentPlayback?.mediaSourceInfo
val currentSubtitleStreams =
currentSource
?.mediaStreams
currentPlayback.mediaSourceInfo.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty()
val externalPaths = currentSubtitleStreams.map { it.path }
@ -131,14 +132,16 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
// The server triggers a refresh in the background, so query periodically for the item until its updated
while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts--
delay(1500)
val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content)
delay(1500.milliseconds)
val base =
BaseItem(api.userLibraryApi.getItem(itemId = currentPlayback.itemId).content)
currentItem =
when (currentItem) {
is PlaylistItem.Intro -> PlaylistItem.Intro(base)
is PlaylistItem.Media -> PlaylistItem.Media(base)
}
mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it)
mediaSource =
base.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == currentPlayback.sourceId }
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
@ -169,28 +172,43 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
stream.isExternal && stream.path !in externalPaths
}
if (newStream != null) {
var audioIndex = it?.audioIndex
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
// User has picked a specific audio track
// Since, now adding a new external subtitle track, need to adjust the audio index as well
var audioIndex = currentPlayback.audioIndex
if (audioIndex != TrackIndex.UNSPECIFIED && audioIndex >= newStream.index) {
// User has previously picked a specific audio track
// If the new external subtitle track was added before the audio track, need to adjust the audio index as well
Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1")
audioIndex += 1
state.value.currentItemPlayback?.let { currentItemPlayback ->
if (currentItemPlayback.audioIndex != TrackIndex.UNSPECIFIED) {
Timber.d("User has a previously saved audio index that needs to be updated")
saveTrackSelection(audioIndex, MediaStreamType.AUDIO)
}
}
}
saveTrackSelection(newStream.index, MediaStreamType.SUBTITLE)
updateCurrentMedia {
it.copy(
subtitleStreams =
subtitlesStreams.map {
SimpleMediaStream.from(context.resources, it, true)
},
sourceId = mediaSource.id,
audioStreams = getAudioStreams(mediaSource),
subtitleStreams = getSubtitleStreams(mediaSource),
)
}
updateCurrentPlayback {
it?.copy(
item = currentItem.item,
mediaSourceInfo = mediaSource,
audioIndex = audioIndex,
subtitleIndex = newStream.index,
)
}
this@downloadAndSwitchSubtitles.changeStreams(
currentItem.item,
it,
audioIndex,
newStream.index,
onMain { player.currentPosition },
true,
item = currentItem.item,
sourceId = currentPlayback.mediaSourceInfo.id,
audioIndex = audioIndex,
subtitleIndex = newStream.index,
positionMs = onMain { player.currentPosition },
enableDirectPlay = true,
)
}
}

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope
@ -177,12 +176,11 @@ data class PlaybackItemState(
) {
constructor(
playback: CurrentPlayback,
itemPlayback: ItemPlayback,
) : this(
itemId = itemPlayback.itemId,
itemId = playback.item.id,
playMethod = playback.playMethod,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
audioStreamIndex = playback.audioIndex.takeIf { it >= 0 },
subtitleStreamIndex = playback.subtitleIndex.takeIf { it >= 0 },
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
)

View file

@ -872,4 +872,6 @@
<string name="override_user_profile_settings">More user profile settings</string>
<string name="override_user_profile_settings_summary">Override your user profile settings from the server</string>
<string name="any_language">Any language</string>
<string name="hash_matches">Hash matches</string>
<string name="subtitles_hearing_impaired">SDH</string>
</resources>