From 07a0db8e980e337ecb7907bc7f624053e76cb905 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:53:58 -0400 Subject: [PATCH] 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. --- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../wholphin/ui/components/Dialogs.kt | 127 ++++++---- .../ui/playback/DownloadSubtitlesDialog.kt | 227 ++++++++++++++++++ .../wholphin/ui/playback/Models.kt | 3 +- .../wholphin/ui/playback/PlaybackControls.kt | 19 +- .../wholphin/ui/playback/PlaybackPage.kt | 48 ++++ .../wholphin/ui/playback/PlaybackViewModel.kt | 94 +++++++- 7 files changed, 454 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index fe0cfe27..c0678736 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -27,6 +27,8 @@ sealed class AppColors private constructor() { val DarkRed = Color(0xFF400000) val DarkCyan = Color(0xFF21556E) val DarkPurple = Color(0xFF261370) + + val GoldenYellow = Color(0xFFDAB440) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 74a921bb..2c639bff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -200,63 +200,84 @@ fun DialogPopup( onDismissRequest = onDismissRequest, properties = properties, ) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) - LazyColumn( + DialogPopupContent( + title = title, + dialogItems = dialogItems, + waiting = waiting, + onDismissRequest = onDismissRequest, + dismissOnClick = dismissOnClick, + elevation = elevation, modifier = - Modifier + Modifier.onKeyEvent { event -> + val code = event.nativeKeyEvent.keyCode + if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && + code in + setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + ) + ) { + waiting = false + } + false + }, + ) + } + } +} + +@Composable +fun DialogPopupContent( + title: String, + dialogItems: List, + 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)) - .onKeyEvent { event -> - val code = event.nativeKeyEvent.keyCode - if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && - code in - setOf( - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER, - ) - ) { - waiting = false - } - false - }, - ) { - stickyHeader { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - items(dialogItems) { - when (it) { - is DialogItemDivider -> HorizontalDivider(Modifier.height(16.dp)) + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), + ) { + stickyHeader { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + items(dialogItems) { + when (it) { + is DialogItemDivider -> HorizontalDivider(Modifier.height(16.dp)) - is DialogItem -> - ListItem( - selected = false, - enabled = !waiting && it.enabled, - onClick = { - if (dismissOnClick) { - onDismissRequest.invoke() - } - it.onClick.invoke() - }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, - ) - } - } + is DialogItem -> + ListItem( + selected = false, + enabled = !waiting && it.enabled, + onClick = { + if (dismissOnClick) { + onDismissRequest.invoke() + } + it.onClick.invoke() + }, + headlineContent = it.headlineContent, + overlineContent = it.overlineContent, + supportingContent = it.supportingContent, + leadingContent = it.leadingContent, + trailingContent = it.trailingContent, + modifier = Modifier, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt new file mode 100644 index 00000000..d6bef681 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -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, + 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, + ) + } + } + }, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt index ff871544..71374ffc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt @@ -9,10 +9,11 @@ data class SubtitleStream( val external: Boolean, val forced: Boolean, val default: Boolean, + val displayTitle: String?, ) { val displayName: String get() = - listOfNotNull( + displayTitle ?: listOfNotNull( language, title, codec, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index d628d30e..1e46cef6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -80,6 +80,8 @@ sealed interface PlaybackAction { data object ShowVideoFilterDialog : PlaybackAction + data object SearchCaptions : PlaybackAction + data class ToggleCaptions( val index: Int, ) : PlaybackAction @@ -401,9 +403,9 @@ fun RightPlaybackButtons( val options = subtitleStreams.map { it.displayName } Timber.v("subtitleIndex=$subtitleIndex, options=$options") val currentChoice = - subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex }?.plus(1) ?: 0 + subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size BottomDialog( - choices = listOf("None") + options, + choices = options + listOf("None", "Search & Download"), currentChoice = currentChoice, onDismissRequest = { onControllerInteraction.invoke() @@ -415,13 +417,16 @@ fun RightPlaybackButtons( } }, onSelectChoice = { index, _ -> - val send = - if (index == 0) { - TrackIndex.DISABLED + if (index in subtitleStreams.indices) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) + } else { + val idx = index - subtitleStreams.size + if (idx == 0) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) } else { - subtitleStreams[index - 1].index + onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) } - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(send)) + } }, gravity = Gravity.END, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 001adcd3..45fd4cc4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable 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.RectangleShape import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect import androidx.media3.common.Player @@ -129,6 +134,9 @@ fun PlaybackPage( val nextUp by viewModel.nextUp.observeAsState(null) 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? val cueListener = remember { @@ -339,6 +347,10 @@ fun PlaybackPage( viewModel.changeSubtitleStream(it.index) } + PlaybackAction.SearchCaptions -> { + viewModel.searchForSubtitles() + } + PlaybackAction.Next -> { // TODO focus is lost 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), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index a4ff435c..4b03acfa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import android.content.Context import android.widget.Toast import androidx.annotation.OptIn +import androidx.compose.ui.text.intl.Locale import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData 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.SkipSegmentBehavior 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.NavigationManager 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.extensions.mediaInfoApi 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.userLibraryApi 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.PlaystateCommand 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.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks @@ -290,6 +293,7 @@ class PlaybackViewModel it.isExternal, it.isForced, it.isDefault, + it.displayTitle, ) }.orEmpty() val audioStreams = @@ -556,7 +560,7 @@ class PlaybackViewModel } } - fun changeSubtitleStream(index: Int?) { + fun changeSubtitleStream(index: Int?): Job = viewModelScope.launch(ExceptionHandler()) { changeStreams( dto, @@ -567,7 +571,6 @@ class PlaybackViewModel true, ) } - } fun getTrickplayUrl(index: Int): String? { val itemId = dto.id @@ -616,7 +619,8 @@ class PlaybackViewModel if (segments.items.isNotEmpty()) { while (isActive) { delay(500L) - val currentTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks } + val currentTicks = + withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks } val currentSegment = segments.items .firstOrNull { @@ -667,7 +671,7 @@ class PlaybackViewModel private var lastInteractionDate: Date = Date() fun reportInteraction() { - Timber.v("reportInteraction") +// Timber.v("reportInteraction") lastInteractionDate = Date() } @@ -781,6 +785,71 @@ class PlaybackViewModel } }.launchIn(viewModelScope) } + + val subtitleSearch = MutableLiveData(null) + val subtitleSearchLanguage = MutableLiveData(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( @@ -790,6 +859,21 @@ data class CurrentPlayback( val liveStreamId: String?, ) +sealed interface SubtitleSearch { + data object Searching : SubtitleSearch + + data object Downloading : SubtitleSearch + + data class Success( + val options: List, + ) : SubtitleSearch + + data class Error( + val message: String?, + val ex: Exception?, + ) : SubtitleSearch +} + val Format.idAsInt: Int? @OptIn(UnstableApi::class) get() =