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:
parent
48005ce67a
commit
00490b14ec
10 changed files with 437 additions and 328 deletions
|
|
@ -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(
|
|||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue