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

View file

@ -4,7 +4,10 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter 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.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.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.ticks 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 = fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata =
MediaMetadata MediaMetadata
.Builder() .Builder()

View file

@ -1,11 +1,14 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupport
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodingInfo import org.jellyfin.sdk.model.api.TranscodingInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
/** /**
@ -25,4 +28,12 @@ data class CurrentPlayback(
val audioDecoder: String? = null, val audioDecoder: String? = null,
val transcodeInfo: TranscodingInfo? = null, val transcodeInfo: TranscodingInfo? = null,
val subtitleDelay: Duration = Duration.ZERO, 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 package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.layout.Arrangement 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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -25,11 +19,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -37,15 +29,13 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors 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.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.EditTextBox
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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 com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
@ -55,53 +45,41 @@ fun DownloadSubtitlesContent(
language: String, language: String,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onClickDownload: (RemoteSubtitleInfo) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (val s = state) { when (val s = state) {
SubtitleSearchStatus.Inactive -> {} SubtitleSearchStatus.Inactive -> {}
SubtitleSearchStatus.Searching -> { SubtitleSearchStatus.Searching -> {
Wrapper { Text(
Text( text = stringResource(R.string.searching),
text = stringResource(R.string.searching), style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface, modifier = modifier,
) )
}
} }
SubtitleSearchStatus.Downloading -> { SubtitleSearchStatus.Downloading -> {
Wrapper { Text(
Text( text = stringResource(R.string.downloading),
text = stringResource(R.string.downloading), style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface, modifier = modifier,
) )
}
} }
is SubtitleSearchStatus.Error -> { is SubtitleSearchStatus.Error -> {
Wrapper { ErrorMessage(null, s.ex, modifier) } ErrorMessage(null, s.ex, modifier)
} }
is SubtitleSearchStatus.Success -> { is SubtitleSearchStatus.Success -> {
val dialogItems = convertRemoteSubtitles(s.options, onClickDownload)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier = modifier,
modifier
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
) { ) {
Text( Text(
text = stringResource(R.string.search_and_download_subtitles), text = stringResource(R.string.search_and_download_subtitles),
@ -128,51 +106,35 @@ fun DownloadSubtitlesContent(
onSearch(lang) onSearch(lang)
}, },
), ),
// onKeyboardAction = {
// onSearch(lang.text.toString())
// },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
imeAction = ImeAction.Search, imeAction = ImeAction.Search,
), ),
) )
} }
if (dialogItems.isEmpty()) { if (s.options.isEmpty()) {
Text( Text(
text = stringResource(R.string.no_subtitles_found), text = stringResource(R.string.no_subtitles_found),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
} else { } else {
val focusRequesters =
remember(s.options.size) { List(s.options.size) { FocusRequester() } }
LaunchedEffect(Unit) {
focusRequesters.firstOrNull()?.tryRequestFocus()
}
LazyColumn( LazyColumn(
modifier = Modifier, modifier = Modifier,
) { ) {
itemsIndexed(dialogItems) { index, item -> itemsIndexed(s.options) { index, item ->
when (item) { SubtitleInfo(
is DialogItemDivider -> { subtitle = item,
HorizontalDivider(Modifier.height(16.dp)) onClick = onClickDownload,
} modifier =
Modifier
is DialogItem -> { .focusRequester(focusRequesters[index]),
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),
),
)
}
}
} }
} }
} }
@ -182,54 +144,56 @@ fun DownloadSubtitlesContent(
} }
@Composable @Composable
private fun Wrapper(content: @Composable BoxScope.() -> Unit) { fun SubtitleInfo(
val elevatedContainerColor = subtitle: RemoteSubtitleInfo,
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>,
onClick: (RemoteSubtitleInfo) -> Unit, onClick: (RemoteSubtitleInfo) -> Unit,
) = options.map { op -> modifier: Modifier = Modifier,
DialogItem( ) {
onClick = { onClick.invoke(op) }, ListItem(
selected = false,
onClick = { onClick.invoke(subtitle) },
headlineContent = { headlineContent = {
Text( Text(
text = op.name ?: "", text = subtitle.name ?: "",
) )
}, },
overlineContent =
if (subtitle.isHashMatch == true) {
{
Text(stringResource(R.string.hash_matches))
}
} else {
null
},
supportingContent = { supportingContent = {
val strings = val resources = LocalResources.current
buildList { val details =
op.providerName?.let(::add) remember(resources, subtitle) {
op.threeLetterIsoLanguageName?.let(::add) val strings =
if (op.forced == true) { buildList {
add(stringResource(R.string.forced_track)) subtitle.providerName?.let(::add)
} if (subtitle.forced == true) {
add( add(resources.getString(R.string.forced_track))
pluralStringResource( }
R.plurals.downloads, if (subtitle.hearingImpaired == true) {
op.downloadCount ?: 0, add(resources.getString(R.string.subtitles_hearing_impaired))
abbreviateNumber(op.downloadCount ?: 0), }
), add(
) resources.getQuantityString(
R.plurals.downloads,
subtitle.downloadCount ?: 0,
abbreviateNumber(subtitle.downloadCount ?: 0),
),
)
}
strings.joinToString(" - ")
} }
Text( Text(
text = strings.joinToString(" - "), text = details,
) )
}, },
trailingContent = { trailingContent = {
op.communityRating?.let { rating -> subtitle.communityRating?.let { rating ->
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, 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 android.view.Gravity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView 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.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
import kotlin.time.Duration import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
@ -276,6 +284,24 @@ fun SubtitleChoiceBottomDialog(
hasDownloadPermission: Boolean, hasDownloadPermission: Boolean,
currentChoice: Int? = null, 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 // TODO enforcing a width ends up ignore the gravity
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -298,6 +324,7 @@ fun SubtitleChoiceBottomDialog(
), ),
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -307,8 +334,11 @@ fun SubtitleChoiceBottomDialog(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
item { item {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem( ListItem(
selected = currentChoice == TrackIndex.DISABLED, selected = currentChoice == TrackIndex.DISABLED,
interactionSource = interactionSource,
onClick = { onClick = {
onSelectChoice(TrackIndex.DISABLED) onSelectChoice(TrackIndex.DISABLED)
}, },
@ -321,6 +351,19 @@ fun SubtitleChoiceBottomDialog(
) )
}, },
supportingContent = {}, 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 { item {
@ -339,6 +382,9 @@ fun SubtitleChoiceBottomDialog(
}, },
supportingContent = {}, supportingContent = {},
) )
if (choices.isNotEmpty()) {
HorizontalDivider()
}
} }
itemsIndexed(choices) { index, choice -> itemsIndexed(choices) { index, choice ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -359,10 +405,13 @@ fun SubtitleChoiceBottomDialog(
if (choice.streamTitle != null) Text(choice.displayTitle) if (choice.streamTitle != null) Text(choice.displayTitle)
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(choiceFocusRequesters[index]),
) )
} }
item { item {
HorizontalDivider() HorizontalDivider()
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem( ListItem(
selected = false, selected = false,
enabled = hasDownloadPermission, enabled = hasDownloadPermission,
@ -374,6 +423,20 @@ fun SubtitleChoiceBottomDialog(
) )
}, },
supportingContent = {}, 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.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination 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( PauseIndicator(
player = player, player = player,
modifier = modifier =
@ -444,7 +450,7 @@ fun PlaybackPageContent(
val subtitleVisible = val subtitleVisible =
skipIndicatorDuration == 0L && skipIndicatorDuration == 0L &&
state.currentItemPlayback?.subtitleIndexEnabled == true && state.currentPlayback?.subtitleIndexEnabled == true &&
!presentationState.coverSurface !presentationState.coverSurface
AndroidView( AndroidView(
@ -629,28 +635,29 @@ fun PlaybackPageContent(
} }
viewModel.cancelSubtitleSearch() viewModel.cancelSubtitleSearch()
} }
Dialog( BasicDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
properties = properties =
DialogProperties( DialogProperties(
usePlatformDefaultWidth = false, usePlatformDefaultWidth = false,
), ),
) { ) {
DownloadSubtitlesContent( Box(modifier = Modifier.padding(24.dp)) {
state = subtitleSearchState.status, DownloadSubtitlesContent(
language = subtitleSearchState.language, state = subtitleSearchState.status,
onSearch = { lang -> language = subtitleSearchState.language,
viewModel.searchForSubtitles(lang) onSearch = { lang ->
}, viewModel.searchForSubtitles(lang)
onClickDownload = { },
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) onClickDownload = {
}, viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
onDismissRequest = onDismissRequest, },
modifier = modifier =
Modifier Modifier
.widthIn(max = 640.dp) .widthIn(max = 640.dp)
.heightIn(max = 400.dp), .heightIn(max = 400.dp),
) )
}
} }
} }
@ -660,9 +667,9 @@ fun PlaybackPageContent(
settings = settings =
PlaybackSettings( PlaybackSettings(
showDebugInfo = showDebugInfo, showDebugInfo = showDebugInfo,
audioIndex = state.currentItemPlayback?.audioIndex, audioIndex = state.currentPlayback?.audioIndex,
audioStreams = state.currentMediaInfo.audioStreams, audioStreams = state.currentMediaInfo.audioStreams,
subtitleIndex = state.currentItemPlayback?.subtitleIndex, subtitleIndex = state.currentPlayback?.subtitleIndex,
subtitleStreams = state.currentMediaInfo.subtitleStreams, subtitleStreams = state.currentMediaInfo.subtitleStreams,
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
contentScale = contentScale, 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.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter 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.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex 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.seekBack
import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.showToast 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.PlaybackItemState
@ -142,7 +140,7 @@ class PlaybackViewModel
private val playlistCreator: PlaylistCreator, private val playlistCreator: PlaylistCreator,
private val itemPlaybackDao: ItemPlaybackDao, private val itemPlaybackDao: ItemPlaybackDao,
internal val serverRepository: ServerRepository, internal val serverRepository: ServerRepository,
private val itemPlaybackRepository: ItemPlaybackRepository, internal val itemPlaybackRepository: ItemPlaybackRepository,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
@ -405,7 +403,7 @@ class PlaybackViewModel
} }
} }
private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { internal fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) {
_state.update { _state.update {
it.copy(currentPlayback = block.invoke(it.currentPlayback)) it.copy(currentPlayback = block.invoke(it.currentPlayback))
} }
@ -494,32 +492,9 @@ class PlaybackViewModel
// Create the correct player for the media // Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) 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 = val subtitleStreams = getSubtitleStreams(mediaSource)
mediaSource.mediaStreams val audioStreams = getAudioStreams(mediaSource)
?.filter { it.type == MediaStreamType.AUDIO }
?.map {
SimpleMediaStream.from(context.resources, it, true)
}
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()
val audioStream = val audioStream =
streamChoiceService streamChoiceService
.chooseAudioStream( .chooseAudioStream(
@ -544,15 +519,6 @@ class PlaybackViewModel
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") 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 = val trickPlayInfo =
item.data.trickplay item.data.trickplay
?.get(mediaSource.id) ?.get(mediaSource.id)
@ -572,7 +538,10 @@ class PlaybackViewModel
val chapters = Chapter.fromDto(base) val chapters = Chapter.fromDto(base)
_state.update { _state.update {
it.copy(currentItemPlayback = itemPlaybackToUse) it.copy(
currentItemPlayback = itemPlayback,
currentPlayback = null,
)
} }
updateCurrentMedia { updateCurrentMedia {
CurrentMediaInfo( CurrentMediaInfo(
@ -586,12 +555,11 @@ class PlaybackViewModel
} }
withContext(WholphinDispatchers.Main) { withContext(WholphinDispatchers.Main) {
changeStreams( changeStreams(
item, item = item,
itemPlaybackToUse, audioIndex = audioIndex,
audioIndex, subtitleIndex = subtitleIndex,
subtitleIndex, positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET,
if (positionMs > 0) positionMs else C.TIME_UNSET, sourceId = mediaSource.id,
itemPlayback != null, // If it was passed in, then it was not queried from the database
enableDirectPlay = !forceTranscoding, enableDirectPlay = !forceTranscoding,
enableDirectStream = !forceTranscoding, enableDirectStream = !forceTranscoding,
) )
@ -608,38 +576,34 @@ class PlaybackViewModel
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
internal suspend fun changeStreams( internal suspend fun changeStreams(
item: BaseItem, item: BaseItem,
currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!, sourceId: String?,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
positionMs: Long = 0, positionMs: Long = 0,
userInitiated: Boolean,
enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding,
): Unit = ): Unit =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
val itemId = item.id val itemId = item.id
val currentPlayback = state.value.currentPlayback state.value.currentPlayback?.let { currentPlayback ->
if (currentPlayback != null && if (currentPlayback.item.id == item.id &&
currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && enableDirectPlay
enableDirectPlay ) {
) { val wasSuccessful =
val wasSuccessful = changeStreamsDirectPlay(
changeStreamsDirectPlay( currentPlayback = currentPlayback,
currentPlayback = currentPlayback, audioIndex = audioIndex,
currentItemPlayback = currentItemPlayback, subtitleIndex = subtitleIndex,
audioIndex = audioIndex, )
subtitleIndex = subtitleIndex, if (wasSuccessful) return@withContext
userInitiated = userInitiated, }
)
if (wasSuccessful) return@withContext
} }
Timber.i( 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, itemId,
userInitiated,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
enableDirectPlay, enableDirectPlay,
@ -668,7 +632,7 @@ class PlaybackViewModel
maxAudioChannels = null, maxAudioChannels = null,
audioStreamIndex = audioIndex, audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex, subtitleStreamIndex = subtitleIndex,
mediaSourceId = currentItemPlayback.sourceId?.toServerString(), mediaSourceId = sourceId,
alwaysBurnInSubtitleWhenTranscoding = false, alwaysBurnInSubtitleWhenTranscoding = false,
maxStreamingBitrate = maxBitrate.toInt(), maxStreamingBitrate = maxBitrate.toInt(),
enableDirectPlay = enableDirectPlay, enableDirectPlay = enableDirectPlay,
@ -680,6 +644,7 @@ class PlaybackViewModel
), ),
) )
if (response.errorCode != null) { if (response.errorCode != null) {
Timber.e("Error in PostedPlaybackInfo: %s", response.errorCode)
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
return@withContext return@withContext
} }
@ -782,6 +747,8 @@ class PlaybackViewModel
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
mediaSourceInfo = source, mediaSourceInfo = source,
audioIndex = audioIndex ?: TrackIndex.DISABLED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
) )
preferences.appPreferences.playbackPreferences.let { prefs -> preferences.appPreferences.playbackPreferences.let { prefs ->
@ -802,7 +769,7 @@ class PlaybackViewModel
player.removeListener(it) player.removeListener(it)
} }
val playbackItemState = PlaybackItemState(playback, currentItemPlayback) val playbackItemState = PlaybackItemState(playback)
val activityListener = val activityListener =
TrackActivityPlaybackListener( TrackActivityPlaybackListener(
api = api, api = api,
@ -843,15 +810,17 @@ class PlaybackViewModel
result.trackSelectionParameters result.trackSelectionParameters
} else { } else {
// Fall back to transcoding // 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 { viewModelScope.launchIO {
changeStreams( changeStreams(
item = item, item = item,
currentItemPlayback = currentItemPlayback, sourceId = sourceId,
audioIndex = audioIndex, audioIndex = audioIndex,
subtitleIndex = subtitleIndex, subtitleIndex = subtitleIndex,
positionMs = positionMs, positionMs = positionMs,
userInitiated = userInitiated,
enableDirectPlay = false, enableDirectPlay = false,
enableDirectStream = true, enableDirectStream = true,
) )
@ -874,10 +843,8 @@ class PlaybackViewModel
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun changeStreamsDirectPlay( private suspend fun changeStreamsDirectPlay(
currentPlayback: CurrentPlayback, currentPlayback: CurrentPlayback,
currentItemPlayback: ItemPlayback,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
userInitiated: Boolean,
): Boolean = ): Boolean =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
Timber.v("changeStreams direct play") Timber.v("changeStreams direct play")
@ -912,29 +879,7 @@ class PlaybackViewModel
} }
if (result.bothSelected) { if (result.bothSelected) {
onMain { player.trackSelectionParameters = result.trackSelectionParameters } onMain { player.trackSelectionParameters = result.trackSelectionParameters }
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") 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 { _state.update {
it.copy( it.copy(
currentPlayback = currentPlayback =
@ -954,78 +899,92 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
viewModelScope.launchIO { viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index) val currentPlayback = state.value.currentPlayback
val itemPlayback = if (currentPlayback != null) {
itemPlaybackRepository.saveTrackSelection( Timber.d("Changing audio track to %s", index)
item = currentItem.item, saveTrackSelection(index, MediaStreamType.AUDIO)
itemPlayback = state.value.currentItemPlayback!!, _state.update {
trackIndex = index, it.copy(
type = MediaStreamType.AUDIO, currentPlayback = it.currentPlayback?.copy(audioIndex = index),
)
_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,
) )
} else {
itemPlayback.subtitleIndex.takeIf { it >= 0 }
} }
changeStreams( // Resolve ONLY_FORCED to actual track based on new audio language
currentItem.item, val resolvedSubtitleIndex =
itemPlayback, streamChoiceService.resolveSubtitleIndex(
index, source = currentPlayback.mediaSourceInfo,
resolvedSubtitleIndex, audioStreamIndex = index,
onMain { player.currentPosition }, seriesId = currentItem.item.data.seriesId,
true, 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 = fun changeSubtitleStream(index: Int): Job =
viewModelScope.launchIO { viewModelScope.launchIO {
Timber.d("Changing subtitle track to %s", index) val currentPlayback = state.value.currentPlayback
val itemPlayback = if (currentPlayback != null) {
itemPlaybackRepository.saveTrackSelection( Timber.d("Changing subtitle track to %s", index)
item = currentItem.item, saveTrackSelection(index, MediaStreamType.SUBTITLE)
itemPlayback = state.value.currentItemPlayback!!, _state.update {
trackIndex = index, it.copy(
type = MediaStreamType.SUBTITLE, currentPlayback = it.currentPlayback?.copy(subtitleIndex = index),
) )
_state.update { it.copy(currentItemPlayback = itemPlayback) } }
// Resolve ONLY_FORCED to actual track index for playback // Resolve ONLY_FORCED to actual track index for playback
val source = state.value.currentPlayback?.mediaSourceInfo val resolvedIndex =
val resolvedIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex( streamChoiceService.resolveSubtitleIndex(
source = source, source = currentPlayback.mediaSourceInfo,
audioStreamIndex = itemPlayback.audioIndex, audioStreamIndex = currentPlayback.audioIndex,
seriesId = currentItem.item.data.seriesId, seriesId = currentItem.item.data.seriesId,
subtitleIndex = index, subtitleIndex = index,
prefs = preferences, prefs = preferences,
) )
} else {
index.takeIf { it >= 0 }
}
changeStreams( changeStreams(
currentItem.item, item = currentItem.item,
itemPlayback, sourceId = currentPlayback.mediaSourceInfo.id,
itemPlayback.audioIndex, audioIndex = currentPlayback.audioIndex,
resolvedIndex, subtitleIndex = resolvedIndex,
onMain { player.currentPosition }, positionMs = onMain { player.currentPosition },
true, 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( private suspend fun prefetchTrickplay(
duration: Duration, duration: Duration,
@ -1050,7 +1009,11 @@ class PlaybackViewModel
fun getTrickplayUrl( fun getTrickplayUrl(
index: Int, index: Int,
trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo, trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo,
mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId, mediaSourceId: UUID? =
state.value.currentPlayback
?.mediaSourceInfo
?.id
?.toUUIDOrNull(),
): String? = ): String? =
trickPlayInfo?.let { trickPlayInfo?.let {
val itemId = currentItem.id val itemId = currentItem.id
@ -1388,14 +1351,16 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") 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( changeStreams(
currentItem.item, item = currentItem.item,
currentItemPlayback, sourceId = currentPlayback?.mediaSourceInfo?.id,
currentItemPlayback.audioIndex, audioIndex = currentPlayback?.audioIndex,
currentItemPlayback.subtitleIndex, subtitleIndex = currentPlayback?.subtitleIndex,
player.currentPosition, positionMs = player.currentPosition,
false,
enableDirectPlay = false, enableDirectPlay = false,
enableDirectStream = false, enableDirectStream = false,
) )
@ -1475,7 +1440,7 @@ class PlaybackViewModel
* Atomically update [currentMediaInfo] * Atomically update [currentMediaInfo]
*/ */
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.Default) {
_state.update { _state.update {
it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo))
} }
@ -1598,16 +1563,16 @@ class PlaybackViewModel
} }
suspend fun loadSubtitleDelay() { suspend fun loadSubtitleDelay() {
state.value.currentItemPlayback?.let { state.value.currentPlayback?.let {
if (it.subtitleIndexEnabled) { if (it.subtitleIndexEnabled) {
val result = val result =
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex) itemPlaybackRepository.getTrackModifications(it.item.id, it.subtitleIndex)
if (result != null) { if (result != null) {
Timber.v( Timber.v(
"Loading subtitle delay %s for track=%s, itemId=%s", "Loading subtitle delay %s for track=%s, itemId=%s",
result.delayMs, result.delayMs,
it.subtitleIndex, it.subtitleIndex,
it.itemId, it.item.id,
) )
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
sealed interface SubtitleSearchStatus { sealed interface SubtitleSearchStatus {
data object Inactive : SubtitleSearchStatus data object Inactive : SubtitleSearchStatus
@ -62,16 +64,17 @@ fun PlaybackViewModel.searchForSubtitles(language: String? = null) {
} }
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
state.value.currentItemPlayback?.itemId?.let { state.value.currentPlayback?.itemId?.let { itemId ->
Timber.v("Searching for remote subtitles for %s", it) Timber.v("Searching for remote subtitles for %s", itemId)
val results = val results =
api.subtitleApi api.subtitleApi
.searchRemoteSubtitles( .searchRemoteSubtitles(
itemId = it, itemId = itemId,
language = language, language = language,
).content ).content
.sortedWith( .sortedWith(
compareByDescending<RemoteSubtitleInfo> { it.communityRating } compareByDescending<RemoteSubtitleInfo> { it.isHashMatch }
.thenByDescending { it.communityRating }
.thenByDescending { it.downloadCount }, .thenByDescending { it.downloadCount },
) )
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) }
@ -104,21 +107,19 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) } subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) }
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
state.value.currentItemPlayback?.let { state.value.currentPlayback?.let { currentPlayback ->
Timber.v( Timber.v(
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s", "Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
it.itemId, currentPlayback.itemId,
it.sourceId, currentPlayback.sourceId,
subtitleId, subtitleId,
) )
api.subtitleApi.downloadRemoteSubtitles( api.subtitleApi.downloadRemoteSubtitles(
itemId = it.sourceId ?: it.itemId, itemId = currentPlayback.sourceId ?: currentPlayback.itemId,
subtitleId = subtitleId, subtitleId = subtitleId,
) )
val currentSource = state.value.currentPlayback?.mediaSourceInfo
val currentSubtitleStreams = val currentSubtitleStreams =
currentSource currentPlayback.mediaSourceInfo.mediaStreams
?.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty() .orEmpty()
val externalPaths = currentSubtitleStreams.map { it.path } 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 // The server triggers a refresh in the background, so query periodically for the item until its updated
while (maxAttempts > 0 && subtitleCount == newCount) { while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts-- maxAttempts--
delay(1500) delay(1500.milliseconds)
val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content) val base =
BaseItem(api.userLibraryApi.getItem(itemId = currentPlayback.itemId).content)
currentItem = currentItem =
when (currentItem) { when (currentItem) {
is PlaylistItem.Intro -> PlaylistItem.Intro(base) is PlaylistItem.Intro -> PlaylistItem.Intro(base)
is PlaylistItem.Media -> PlaylistItem.Media(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) { if (mediaSource == null) {
// This shouldn't happen, but just in case // This shouldn't happen, but just in case
showToast( showToast(
@ -169,28 +172,43 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
stream.isExternal && stream.path !in externalPaths stream.isExternal && stream.path !in externalPaths
} }
if (newStream != null) { if (newStream != null) {
var audioIndex = it?.audioIndex var audioIndex = currentPlayback.audioIndex
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { if (audioIndex != TrackIndex.UNSPECIFIED && audioIndex >= newStream.index) {
// User has picked a specific audio track // User has previously picked a specific audio track
// Since, now adding a new external subtitle track, need to adjust the audio index as well // 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") Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1")
audioIndex += 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 { updateCurrentMedia {
it.copy( it.copy(
subtitleStreams = sourceId = mediaSource.id,
subtitlesStreams.map { audioStreams = getAudioStreams(mediaSource),
SimpleMediaStream.from(context.resources, it, true) subtitleStreams = getSubtitleStreams(mediaSource),
},
) )
} }
updateCurrentPlayback {
it?.copy(
item = currentItem.item,
mediaSourceInfo = mediaSource,
audioIndex = audioIndex,
subtitleIndex = newStream.index,
)
}
this@downloadAndSwitchSubtitles.changeStreams( this@downloadAndSwitchSubtitles.changeStreams(
currentItem.item, item = currentItem.item,
it, sourceId = currentPlayback.mediaSourceInfo.id,
audioIndex, audioIndex = audioIndex,
newStream.index, subtitleIndex = newStream.index,
onMain { player.currentPosition }, positionMs = onMain { player.currentPosition },
true, enableDirectPlay = true,
) )
} }
} }

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi 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.launchIO
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -177,12 +176,11 @@ data class PlaybackItemState(
) { ) {
constructor( constructor(
playback: CurrentPlayback, playback: CurrentPlayback,
itemPlayback: ItemPlayback,
) : this( ) : this(
itemId = itemPlayback.itemId, itemId = playback.item.id,
playMethod = playback.playMethod, playMethod = playback.playMethod,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, audioStreamIndex = playback.audioIndex.takeIf { it >= 0 },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, subtitleStreamIndex = playback.subtitleIndex.takeIf { it >= 0 },
playSessionId = playback.playSessionId, playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId, 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">More user profile settings</string>
<string name="override_user_profile_settings_summary">Override your user profile settings from the server</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="any_language">Any language</string>
<string name="hash_matches">Hash matches</string>
<string name="subtitles_hearing_impaired">SDH</string>
</resources> </resources>