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