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:
damontecres 2025-10-27 15:53:58 -04:00 committed by GitHub
parent 4f5b0c7eee
commit 07a0db8e98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 454 additions and 66 deletions

View file

@ -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)
} }
} }

View file

@ -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(
@ -258,8 +281,6 @@ fun DialogPopup(
} }
} }
} }
}
}
} }
@Composable @Composable

View file

@ -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,
)
}
}
},
)
}

View file

@ -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,

View file

@ -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,
) )

View file

@ -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),
)
}
}
} }
} }
} }

View file

@ -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() =