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(
|
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(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue