From 65c49248662b2381858812e553eba42f27ac9cab Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:09:59 -0400 Subject: [PATCH 1/2] Add subtitle to currently playing Kodi video (#362) Kodi's Player.AddSubtitle JSON-RPC method lets you attach a subtitle to a video that is already playing, instead of having to cast video and subtitle together as a fresh Player.Open call. This adds that command to KodiApi/KodiRepository, fetching the active player id first via Player.GetActivePlayers since AddSubtitle needs it. On the download details screen, files recognized as subtitles (srt, vtt, sub, etc, based on the existing extension icon map) now show an extra "Add subtitle to Kodi" button next to the existing cast buttons. It opens the same service picker dialog used elsewhere, filtered down to Kodi devices since this isn't something VLC casting supports here, and sends the subtitle to whatever is currently playing on the picked device. --- .../unchained/data/model/KodiModels.kt | 16 +++++ .../unchained/data/remote/KodiApi.kt | 15 +++++ .../unchained/data/remote/KodiApiHelper.kt | 8 +++ .../data/remote/KodiApiHelperImpl.kt | 11 ++++ .../data/repository/KodiRepository.kt | 62 +++++++++++++++++++ .../view/DownloadDetailsFragment.kt | 20 ++++++ .../view/ServicePickerDialog.kt | 29 +++++++-- .../viewmodel/DownloadDetailsViewModel.kt | 19 ++++++ .../res/layout/fragment_download_details.xml | 27 ++++++++ app/app/src/main/res/values/strings.xml | 1 + 10 files changed, 202 insertions(+), 6 deletions(-) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt index c3daf96da..52b8d7145 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt @@ -23,6 +23,8 @@ data class KodiRequest( data class KodiParams( @param:Json(name = "item") val item: KodiItem? = null, @param:Json(name = "properties") val properties: List? = null, + @param:Json(name = "playerid") val playerId: Int? = null, + @param:Json(name = "subtitle") val subtitle: String? = null, ) @JsonClass(generateAdapter = true) @@ -35,6 +37,20 @@ data class KodiResponse( @param:Json(name = "result") val result: String, ) +/** A single player currently active on Kodi, as returned by Player.GetActivePlayers */ +@JsonClass(generateAdapter = true) +data class KodiPlayer( + @param:Json(name = "playerid") val playerId: Int, + @param:Json(name = "type") val type: String, +) + +@JsonClass(generateAdapter = true) +data class KodiActivePlayersResponse( + @param:Json(name = "id") val id: Int, + @param:Json(name = "jsonrpc") val jsonrpc: String, + @param:Json(name = "result") val result: List, +) + @JsonClass(generateAdapter = true) data class KodiGenericResponse( @param:Json(name = "id") val id: Int, diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt index fea999d0e..11a4ba584 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt @@ -1,5 +1,6 @@ package com.github.livingwithhippos.unchained.data.remote +import com.github.livingwithhippos.unchained.data.model.KodiActivePlayersResponse import com.github.livingwithhippos.unchained.data.model.KodiGenericResponse import com.github.livingwithhippos.unchained.data.model.KodiRequest import com.github.livingwithhippos.unchained.data.model.KodiResponse @@ -23,4 +24,18 @@ interface KodiApi { @Header("Authorization") auth: String? = null, @Header("Content-Type") contentType: String = "application/json", ): Response + + @POST("jsonrpc") + suspend fun getActivePlayers( + @Body body: KodiRequest, + @Header("Authorization") auth: String? = null, + @Header("Content-Type") contentType: String = "application/json", + ): Response + + @POST("jsonrpc") + suspend fun addSubtitle( + @Body body: KodiRequest, + @Header("Authorization") auth: String? = null, + @Header("Content-Type") contentType: String = "application/json", + ): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelper.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelper.kt index 0172198cc..582f5e346 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelper.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelper.kt @@ -1,5 +1,6 @@ package com.github.livingwithhippos.unchained.data.remote +import com.github.livingwithhippos.unchained.data.model.KodiActivePlayersResponse import com.github.livingwithhippos.unchained.data.model.KodiGenericResponse import com.github.livingwithhippos.unchained.data.model.KodiRequest import com.github.livingwithhippos.unchained.data.model.KodiResponse @@ -9,4 +10,11 @@ interface KodiApiHelper { suspend fun openUrl(request: KodiRequest, auth: String?): Response suspend fun getVolume(request: KodiRequest, auth: String?): Response + + suspend fun getActivePlayers( + request: KodiRequest, + auth: String?, + ): Response + + suspend fun addSubtitle(request: KodiRequest, auth: String?): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt index f8374d1ba..2b993a681 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt @@ -1,5 +1,6 @@ package com.github.livingwithhippos.unchained.data.remote +import com.github.livingwithhippos.unchained.data.model.KodiActivePlayersResponse import com.github.livingwithhippos.unchained.data.model.KodiGenericResponse import com.github.livingwithhippos.unchained.data.model.KodiRequest import com.github.livingwithhippos.unchained.data.model.KodiResponse @@ -13,4 +14,14 @@ class KodiApiHelperImpl(private val kodiApi: KodiApi) : KodiApiHelper { request: KodiRequest, auth: String?, ): Response = kodiApi.getVolume(request, auth) + + override suspend fun getActivePlayers( + request: KodiRequest, + auth: String?, + ): Response = kodiApi.getActivePlayers(request, auth) + + override suspend fun addSubtitle( + request: KodiRequest, + auth: String?, + ): Response = kodiApi.addSubtitle(request, auth) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt index 8a8c097e7..ab4bc3e7d 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt @@ -169,6 +169,68 @@ constructor(protoStore: ProtoStore, @param:ClassicClient private val client: OkH } } + /** + * Adds a subtitle to the video currently playing on Kodi, using Player.AddSubtitle. The active + * player id is fetched first via Player.GetActivePlayers, since Kodi needs it to know which + * player to attach the subtitle to. + */ + suspend fun addSubtitle( + address: String, + subtitleUrl: String, + username: String? = null, + password: String? = null, + ): KodiResponse? { + try { + val kodiApiHelper: KodiApiHelper = + if (address.endsWith("/")) provideApiHelper(address) + else provideApiHelper("$address/") + + val auth = encodeAuthentication(username, password) + + val activePlayers = + safeApiCall( + call = { + kodiApiHelper.getActivePlayers( + request = + KodiRequest(method = "Player.GetActivePlayers", params = KodiParams()), + auth = auth, + ) + }, + errorMessage = "Error getting Kodi active players", + ) + + // prefer the video player, since that's the one a subtitle would be attached to + val playerId = + activePlayers?.result?.firstOrNull { it.type == "video" }?.playerId + ?: activePlayers?.result?.firstOrNull()?.playerId + + if (playerId == null) { + Timber.e("No active Kodi player found, can't add subtitle") + return null + } + + val kodiResponse = + safeApiCall( + call = { + kodiApiHelper.addSubtitle( + request = + KodiRequest( + method = "Player.AddSubtitle", + params = KodiParams(playerId = playerId, subtitle = subtitleUrl), + ), + auth = auth, + ) + }, + errorMessage = "Error adding subtitle on Kodi", + ) + + return kodiResponse + } catch (e: Exception) { + Timber.e(e) + return null + } + } + private fun encodeAuthentication(username: String?, password: String?): String? { return if (!username.isNullOrBlank() && !password.isNullOrBlank()) { "Basic " + diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt index 83fdedb81..936903f46 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt @@ -56,6 +56,7 @@ import com.github.livingwithhippos.unchained.utilities.extension.getAvailableSpa import com.github.livingwithhippos.unchained.utilities.extension.getFileSizeString import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.github.livingwithhippos.unchained.utilities.extensionIconMap import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber @@ -207,6 +208,16 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { binding.fabPickStreaming.setOnClickListener { popView -> manageStreamingPopup(popView) } + // a subtitle file can be attached to whatever is currently playing on Kodi, regardless of + // whether it's "streamable" on its own + val fileExtension = args.details.filename.substringAfterLast('.', "").lowercase() + if (extensionIconMap[fileExtension] == R.drawable.icon_subtitles) { + binding.llFabAddSubtitle.visibility = View.VISIBLE + binding.fabAddSubtitle.setOnClickListener { showAddSubtitleDialog() } + } else { + binding.llFabAddSubtitle.visibility = View.GONE + } + viewModel.streamLiveData.observe(viewLifecycleOwner) { if (it != null) { binding.fabLoadStreams.isEnabled = false @@ -508,6 +519,15 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } } + private fun showAddSubtitleDialog() { + val dialog = ServicePickerDialog() + val bundle = Bundle() + bundle.putString("downloadUrl", args.details.download) + bundle.putBoolean("addSubtitle", true) + dialog.arguments = bundle + dialog.show(parentFragmentManager, "ServicePickerDialog") + } + private fun playOnService( link: String, service: CompleteRemoteService, diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt index 1a63684be..a36e268f5 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.RecyclerView import com.github.livingwithhippos.unchained.R import com.github.livingwithhippos.unchained.data.local.CompleteRemoteServiceDetails +import com.github.livingwithhippos.unchained.data.local.RemoteServiceType import com.github.livingwithhippos.unchained.data.local.serviceTypeMap import com.github.livingwithhippos.unchained.downloaddetails.model.ServicePickerAdapter import com.github.livingwithhippos.unchained.downloaddetails.model.ServicePickerListener @@ -22,6 +23,11 @@ class ServicePickerDialog : DialogFragment(), ServicePickerListener { private val viewModel: DownloadDetailsViewModel by activityViewModels() + // when true, the picked service is used to add the link as a subtitle to whatever is + // currently playing on Kodi, instead of opening it as a new video + private val addSubtitleMode: Boolean + get() = arguments?.getBoolean("addSubtitle") ?: false + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return activity?.let { activity -> @@ -42,12 +48,16 @@ class ServicePickerDialog : DialogFragment(), ServicePickerListener { is DownloadEvent.AllServices -> { val devSer: List = - content.services.map { serv -> - CompleteRemoteServiceDetails( - service = serv, - type = serviceTypeMap[serv.type]!!, - ) - } + content.services + // adding a subtitle to the currently playing video is a Kodi-only + // feature, so only Kodi services make sense here + .filter { serv -> !addSubtitleMode || serv.type == RemoteServiceType.KODI.value } + .map { serv -> + CompleteRemoteServiceDetails( + service = serv, + type = serviceTypeMap[serv.type]!!, + ) + } adapter.submitList(devSer) } else -> {} @@ -71,6 +81,13 @@ class ServicePickerDialog : DialogFragment(), ServicePickerListener { if (link == null) { Timber.e("Download url is null") context?.showToast(R.string.error) + } else if (addSubtitleMode) { + if (serviceDetails.type == RemoteServiceType.KODI) { + viewModel.addSubtitleOnKodi(link, serviceDetails.service) + } else { + Timber.e("Adding a subtitle is only supported on Kodi") + context?.showToast(R.string.error) + } } else { viewModel.openOnRemoteService(serviceDetails, link) // show toast? diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt index 1f4f71cf2..5cd61e462 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt @@ -76,6 +76,25 @@ constructor( } } + fun addSubtitleOnKodi(subtitleURL: String, kodiService: CompleteRemoteService) { + viewModelScope.launch { + try { + val response = + kodiRepository.addSubtitle( + kodiService.address, + subtitleURL, + kodiService.username, + kodiService.password, + ) + if (response != null) messageLiveData.postEvent(DownloadDetailsMessage.KodiSuccess) + else messageLiveData.postEvent(DownloadDetailsMessage.KodiError) + } catch (e: Exception) { + Timber.e("Error adding subtitle on Kodi: ${e.message}") + messageLiveData.postEvent(DownloadDetailsMessage.KodiError) + } + } + } + fun openUrlOnVLC(mediaURL: String, vlcService: CompleteRemoteService) { viewModelScope.launch { diff --git a/app/app/src/main/res/layout/fragment_download_details.xml b/app/app/src/main/res/layout/fragment_download_details.xml index ae0aa0ef0..e249b9302 100644 --- a/app/app/src/main/res/layout/fragment_download_details.xml +++ b/app/app/src/main/res/layout/fragment_download_details.xml @@ -276,6 +276,33 @@ android:textAppearance="?attr/textAppearanceLabelSmall" /> + + + + + + + Save Device Update Device The default Kodi device is not set + Add subtitle to Kodi Device deleted UI Settings VLC From aadd55ba09e64cefc854e4a8af4f2b67b5bad95a Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:47:11 -0400 Subject: [PATCH 2/2] Add translations for the Kodi subtitle button --- app/app/src/main/res/values-es/strings.xml | 1 + app/app/src/main/res/values-fr/strings.xml | 1 + app/app/src/main/res/values-it/strings.xml | 1 + app/app/src/main/res/values-ko/strings.xml | 1 + app/app/src/main/res/values-tr/strings.xml | 1 + 5 files changed, 5 insertions(+) diff --git a/app/app/src/main/res/values-es/strings.xml b/app/app/src/main/res/values-es/strings.xml index d056d3300..da8297801 100644 --- a/app/app/src/main/res/values-es/strings.xml +++ b/app/app/src/main/res/values-es/strings.xml @@ -375,6 +375,7 @@ Guardar dispositivo Actualizar el dispositivo El dispositivo Kodi por defecto no está configurado + Añadir subtítulos a Kodi Dispositivo eliminado Configuración de la interfaz de usuario Mostrar el botón para enviar los medios a un reproductor instalado diff --git a/app/app/src/main/res/values-fr/strings.xml b/app/app/src/main/res/values-fr/strings.xml index 9714cfa47..bf4867fd8 100644 --- a/app/app/src/main/res/values-fr/strings.xml +++ b/app/app/src/main/res/values-fr/strings.xml @@ -375,6 +375,7 @@ Sauver le dispositif Mettre à jour le dispositif Le périphérique Kodi par défaut n\'est pas défini + Ajouter des sous-titres à Kodi Dispositif supprimé Paramètres de l\'IU Afficher le bouton pour envoyer le média vers un lecteur installé diff --git a/app/app/src/main/res/values-it/strings.xml b/app/app/src/main/res/values-it/strings.xml index d39f24dd4..d3d14d999 100644 --- a/app/app/src/main/res/values-it/strings.xml +++ b/app/app/src/main/res/values-it/strings.xml @@ -381,6 +381,7 @@ Salva dispositivo Aggiorna dispositivo Il dIspositivo Kodi di default non è impostato + Aggiungi sottotitoli a Kodi Dispositivo eliminato Impostazioni UI Mostra un bottone per inviare media ad un lettore locale diff --git a/app/app/src/main/res/values-ko/strings.xml b/app/app/src/main/res/values-ko/strings.xml index ae43582f3..e1f73f96c 100644 --- a/app/app/src/main/res/values-ko/strings.xml +++ b/app/app/src/main/res/values-ko/strings.xml @@ -378,6 +378,7 @@ 기기 저장 기기 업데이트 기본 Kodi 기기가 설정되지 않았습니다 + Kodi에 자막 추가 기기 삭제됨 UI 설정 설치된 플레이어로 미디어를 보내는 버튼 표시 diff --git a/app/app/src/main/res/values-tr/strings.xml b/app/app/src/main/res/values-tr/strings.xml index ffd6c8f75..9b0eb7f61 100644 --- a/app/app/src/main/res/values-tr/strings.xml +++ b/app/app/src/main/res/values-tr/strings.xml @@ -377,6 +377,7 @@ Cihazı Kaydet Cihazı Güncelle Varsayılan Kodi cihazı ayarlanmadı + Kodi\'ye altyazı ekle Cihaz silindi Kullanıcı Arayüzü Ayarları Medyayı yüklü bir oynatıcıya göndermek için düğmeyi göster