mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add ability to search & download subtitles (#78)
Closes #74 Adds an option to download subtitles from a remote source. This uses the Jellyfin server's API, so you need to install a plugin on your server that provides subtitle searching such as the [official OpenSubtitles plugin](https://github.com/jellyfin/jellyfin-plugin-opensubtitles). Initially it will search for subtitles in the device's current language, but you can search for a different language if needed.
This commit is contained in:
parent
4f5b0c7eee
commit
07a0db8e98
7 changed files with 454 additions and 66 deletions
|
|
@ -27,6 +27,8 @@ sealed class AppColors private constructor() {
|
||||||
val DarkRed = Color(0xFF400000)
|
val DarkRed = Color(0xFF400000)
|
||||||
val DarkCyan = Color(0xFF21556E)
|
val DarkCyan = Color(0xFF21556E)
|
||||||
val DarkPurple = Color(0xFF261370)
|
val DarkPurple = Color(0xFF261370)
|
||||||
|
|
||||||
|
val GoldenYellow = Color(0xFFDAB440)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,19 +200,15 @@ fun DialogPopup(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
properties = properties,
|
properties = properties,
|
||||||
) {
|
) {
|
||||||
val elevatedContainerColor =
|
DialogPopupContent(
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
title = title,
|
||||||
LazyColumn(
|
dialogItems = dialogItems,
|
||||||
|
waiting = waiting,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = dismissOnClick,
|
||||||
|
elevation = elevation,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier.onKeyEvent { event ->
|
||||||
// .widthIn(min = 520.dp, max = 300.dp)
|
|
||||||
// .dialogFocusable()
|
|
||||||
.graphicsLayer {
|
|
||||||
this.clip = true
|
|
||||||
this.shape = RoundedCornerShape(28.0.dp)
|
|
||||||
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
|
||||||
.padding(PaddingValues(24.dp))
|
|
||||||
.onKeyEvent { event ->
|
|
||||||
val code = event.nativeKeyEvent.keyCode
|
val code = event.nativeKeyEvent.keyCode
|
||||||
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
|
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
|
||||||
code in
|
code in
|
||||||
|
|
@ -226,6 +222,33 @@ fun DialogPopup(
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DialogPopupContent(
|
||||||
|
title: String,
|
||||||
|
dialogItems: List<DialogItemEntry>,
|
||||||
|
waiting: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dismissOnClick: Boolean = true,
|
||||||
|
elevation: Dp = 3.dp,
|
||||||
|
) {
|
||||||
|
val elevatedContainerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||||
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
// .widthIn(min = 520.dp, max = 300.dp)
|
||||||
|
// .dialogFocusable()
|
||||||
|
.graphicsLayer {
|
||||||
|
this.clip = true
|
||||||
|
this.shape = RoundedCornerShape(28.0.dp)
|
||||||
|
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||||
|
.padding(PaddingValues(24.dp)),
|
||||||
) {
|
) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -259,8 +282,6 @@ fun DialogPopup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DialogPopup(
|
fun DialogPopup(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
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.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
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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.ui.AppColors
|
||||||
|
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.tryRequestFocus
|
||||||
|
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadSubtitlesContent(
|
||||||
|
state: SubtitleSearch,
|
||||||
|
language: String,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onClickDownload: (RemoteSubtitleInfo) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
SubtitleSearch.Searching -> {
|
||||||
|
Wrapper {
|
||||||
|
Text(
|
||||||
|
text = "Searching...",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitleSearch.Downloading -> {
|
||||||
|
Wrapper {
|
||||||
|
Text(
|
||||||
|
text = "Downloading...",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SubtitleSearch.Error -> Wrapper { ErrorMessage(null, s.ex, modifier) }
|
||||||
|
|
||||||
|
is SubtitleSearch.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)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Search & download subtitles",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
var lang by remember { mutableStateOf(language) }
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Language",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = lang,
|
||||||
|
onValueChange = { lang = it },
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
onSearch(lang)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dialogItems.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No remote subtitles were found",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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>,
|
||||||
|
onClick: (RemoteSubtitleInfo) -> Unit,
|
||||||
|
) = options.map { op ->
|
||||||
|
DialogItem(
|
||||||
|
onClick = { onClick.invoke(op) },
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = op.name ?: "",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
val strings =
|
||||||
|
buildList {
|
||||||
|
op.providerName?.let(::add)
|
||||||
|
op.threeLetterIsoLanguageName?.let(::add)
|
||||||
|
if (op.forced == true) {
|
||||||
|
add("Forced")
|
||||||
|
}
|
||||||
|
add("${op.downloadCount ?: 0} downloads")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = strings.joinToString(" - "),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
op.communityRating?.let { rating ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = rating.toString(),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
tint = AppColors.GoldenYellow,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,11 @@ data class SubtitleStream(
|
||||||
val external: Boolean,
|
val external: Boolean,
|
||||||
val forced: Boolean,
|
val forced: Boolean,
|
||||||
val default: Boolean,
|
val default: Boolean,
|
||||||
|
val displayTitle: String?,
|
||||||
) {
|
) {
|
||||||
val displayName: String
|
val displayName: String
|
||||||
get() =
|
get() =
|
||||||
listOfNotNull(
|
displayTitle ?: listOfNotNull(
|
||||||
language,
|
language,
|
||||||
title,
|
title,
|
||||||
codec,
|
codec,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ sealed interface PlaybackAction {
|
||||||
|
|
||||||
data object ShowVideoFilterDialog : PlaybackAction
|
data object ShowVideoFilterDialog : PlaybackAction
|
||||||
|
|
||||||
|
data object SearchCaptions : PlaybackAction
|
||||||
|
|
||||||
data class ToggleCaptions(
|
data class ToggleCaptions(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
) : PlaybackAction
|
) : PlaybackAction
|
||||||
|
|
@ -401,9 +403,9 @@ fun RightPlaybackButtons(
|
||||||
val options = subtitleStreams.map { it.displayName }
|
val options = subtitleStreams.map { it.displayName }
|
||||||
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
|
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
|
||||||
val currentChoice =
|
val currentChoice =
|
||||||
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex }?.plus(1) ?: 0
|
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size
|
||||||
BottomDialog(
|
BottomDialog(
|
||||||
choices = listOf("None") + options,
|
choices = options + listOf("None", "Search & Download"),
|
||||||
currentChoice = currentChoice,
|
currentChoice = currentChoice,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
|
|
@ -415,13 +417,16 @@ fun RightPlaybackButtons(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectChoice = { index, _ ->
|
onSelectChoice = { index, _ ->
|
||||||
val send =
|
if (index in subtitleStreams.indices) {
|
||||||
if (index == 0) {
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index))
|
||||||
TrackIndex.DISABLED
|
|
||||||
} else {
|
} else {
|
||||||
subtitleStreams[index - 1].index
|
val idx = index - subtitleStreams.size
|
||||||
|
if (idx == 0) {
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
|
||||||
|
} else {
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(send))
|
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.END,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
@ -37,8 +39,11 @@ import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
|
@ -129,6 +134,9 @@ fun PlaybackPage(
|
||||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||||
|
|
||||||
|
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
||||||
|
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||||
|
|
||||||
// TODO move to viewmodel?
|
// TODO move to viewmodel?
|
||||||
val cueListener =
|
val cueListener =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -339,6 +347,10 @@ fun PlaybackPage(
|
||||||
viewModel.changeSubtitleStream(it.index)
|
viewModel.changeSubtitleStream(it.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackAction.SearchCaptions -> {
|
||||||
|
viewModel.searchForSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackAction.Next -> {
|
PlaybackAction.Next -> {
|
||||||
// TODO focus is lost
|
// TODO focus is lost
|
||||||
viewModel.playUpNextUp()
|
viewModel.playUpNextUp()
|
||||||
|
|
@ -468,6 +480,42 @@ fun PlaybackPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtitleSearch?.let { state ->
|
||||||
|
val wasPlaying = remember { player.isPlaying }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
val onDismissRequest = {
|
||||||
|
if (wasPlaying) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
viewModel.cancelSubtitleSearch()
|
||||||
|
}
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties =
|
||||||
|
DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DownloadSubtitlesContent(
|
||||||
|
state = state,
|
||||||
|
language = subtitleSearchLanguage,
|
||||||
|
onSearch = { lang ->
|
||||||
|
viewModel.searchForSubtitles(lang)
|
||||||
|
},
|
||||||
|
onClickDownload = {
|
||||||
|
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.heightIn(max = 400.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.data.model.chooseStream
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.seekBack
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
|
|
@ -61,6 +63,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
|
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.trickplayApi
|
import org.jellyfin.sdk.api.client.extensions.trickplayApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
|
|
@ -76,7 +79,7 @@ import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
@ -290,6 +293,7 @@ class PlaybackViewModel
|
||||||
it.isExternal,
|
it.isExternal,
|
||||||
it.isForced,
|
it.isForced,
|
||||||
it.isDefault,
|
it.isDefault,
|
||||||
|
it.displayTitle,
|
||||||
)
|
)
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
val audioStreams =
|
val audioStreams =
|
||||||
|
|
@ -556,7 +560,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeSubtitleStream(index: Int?) {
|
fun changeSubtitleStream(index: Int?): Job =
|
||||||
viewModelScope.launch(ExceptionHandler()) {
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
changeStreams(
|
changeStreams(
|
||||||
dto,
|
dto,
|
||||||
|
|
@ -567,7 +571,6 @@ class PlaybackViewModel
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun getTrickplayUrl(index: Int): String? {
|
fun getTrickplayUrl(index: Int): String? {
|
||||||
val itemId = dto.id
|
val itemId = dto.id
|
||||||
|
|
@ -616,7 +619,8 @@ class PlaybackViewModel
|
||||||
if (segments.items.isNotEmpty()) {
|
if (segments.items.isNotEmpty()) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(500L)
|
delay(500L)
|
||||||
val currentTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks }
|
val currentTicks =
|
||||||
|
withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks }
|
||||||
val currentSegment =
|
val currentSegment =
|
||||||
segments.items
|
segments.items
|
||||||
.firstOrNull {
|
.firstOrNull {
|
||||||
|
|
@ -667,7 +671,7 @@ class PlaybackViewModel
|
||||||
private var lastInteractionDate: Date = Date()
|
private var lastInteractionDate: Date = Date()
|
||||||
|
|
||||||
fun reportInteraction() {
|
fun reportInteraction() {
|
||||||
Timber.v("reportInteraction")
|
// Timber.v("reportInteraction")
|
||||||
lastInteractionDate = Date()
|
lastInteractionDate = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -781,6 +785,71 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}.launchIn(viewModelScope)
|
}.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
|
||||||
|
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
||||||
|
|
||||||
|
fun searchForSubtitles(language: String = Locale.current.language) {
|
||||||
|
subtitleSearch.value = SubtitleSearch.Searching
|
||||||
|
subtitleSearchLanguage.value = language
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
currentItemPlayback.value?.itemId?.let {
|
||||||
|
Timber.v("Searching for remote subtitles for %s", it)
|
||||||
|
val results by api.subtitleApi.searchRemoteSubtitles(
|
||||||
|
itemId = it,
|
||||||
|
language = language,
|
||||||
|
)
|
||||||
|
subtitleSearch.setValueOnMain(SubtitleSearch.Success(results))
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception while searching for subtitles")
|
||||||
|
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadAndSwitchSubtitles(
|
||||||
|
subtitleId: String?,
|
||||||
|
wasPlaying: Boolean,
|
||||||
|
) {
|
||||||
|
if (subtitleId == null) {
|
||||||
|
subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null)
|
||||||
|
} else {
|
||||||
|
subtitleSearch.value = SubtitleSearch.Downloading
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
currentItemPlayback.value?.let {
|
||||||
|
Timber.v(
|
||||||
|
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
|
||||||
|
it.itemId,
|
||||||
|
it.sourceId,
|
||||||
|
subtitleId,
|
||||||
|
)
|
||||||
|
api.subtitleApi.downloadRemoteSubtitles(
|
||||||
|
itemId = it.sourceId ?: it.itemId,
|
||||||
|
subtitleId = subtitleId,
|
||||||
|
)
|
||||||
|
|
||||||
|
subtitleSearch.setValueOnMain(null)
|
||||||
|
changeSubtitleStream(0).join()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (wasPlaying) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
||||||
|
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSubtitleSearch() {
|
||||||
|
subtitleSearch.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CurrentPlayback(
|
data class CurrentPlayback(
|
||||||
|
|
@ -790,6 +859,21 @@ data class CurrentPlayback(
|
||||||
val liveStreamId: String?,
|
val liveStreamId: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sealed interface SubtitleSearch {
|
||||||
|
data object Searching : SubtitleSearch
|
||||||
|
|
||||||
|
data object Downloading : SubtitleSearch
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
val options: List<RemoteSubtitleInfo>,
|
||||||
|
) : SubtitleSearch
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String?,
|
||||||
|
val ex: Exception?,
|
||||||
|
) : SubtitleSearch
|
||||||
|
}
|
||||||
|
|
||||||
val Format.idAsInt: Int?
|
val Format.idAsInt: Int?
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
get() =
|
get() =
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue