Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,17 +181,17 @@ class GeneratorPlayer : FullScreenPlayer() {
binding?.playerLoadingOverlay?.isVisible = true
}

private fun setSubtitles(subtitle: SubtitleData?): Boolean {
// If subtitle is changed -> Save the language
if (subtitle != currentSelectedSubtitles) {
private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean {
// If subtitle is changed and user initiated -> Save the language
if (subtitle != currentSelectedSubtitles && userInitiated) {
val subtitleLanguageTagIETF = if (subtitle == null) {
"" // -> No Subtitles
} else {
fromCodeToLangTagIETF(subtitle.languageCode)
?: fromLanguageToTagIETF(subtitle.languageCode, halfMatch = true)
subtitle.getIETF_tag()
}

if (subtitleLanguageTagIETF != null) {
Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'")
setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF)
preferredAutoSelectSubtitles = subtitleLanguageTagIETF
}
Expand Down Expand Up @@ -225,7 +225,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}

private fun noSubtitles(): Boolean {
return setSubtitles(null)
return setSubtitles(null, true)
}

private fun getPos(): Long {
Expand Down Expand Up @@ -909,7 +909,7 @@ class GeneratorPlayer : FullScreenPlayer() {
player.saveData()
player.reloadPlayer(ctx)

setSubtitles(selectedSubtitle)
setSubtitles(selectedSubtitle, false)
viewModel.addSubtitles(subtitleData.toSet())

selectSourceDialog?.dismissSafe()
Expand Down Expand Up @@ -1362,7 +1362,7 @@ class GeneratorPlayer : FullScreenPlayer() {
subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull(
subtitleOptionIndex
)?.let {
setSubtitles(it)
setSubtitles(it, true)
} ?: false
}
}
Expand Down Expand Up @@ -1659,12 +1659,6 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}

private fun SubtitleData.matchesLanguage(langCode: String): Boolean {
val langName = fromTagToEnglishLanguageName(langCode) ?: return false
val cleanedName = originalName.replace(Regex("[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ]"), "").trim()
return languageCode == langCode || cleanedName == langName || cleanedName.contains(langName) || cleanedName == langCode
}

private fun getAutoSelectSubtitle(
subtitles: Set<SubtitleData>, settings: Boolean, downloads: Boolean
): SubtitleData? {
Expand All @@ -1684,8 +1678,9 @@ class GeneratorPlayer : FullScreenPlayer() {
val current = player.getCurrentPreferredSubtitle()
Log.i(TAG, "autoSelectFromSettings = $current")
context?.let { ctx ->
if (current != null) {
if (setSubtitles(current)) {
// Only use the player preferred subtitle if it matches the available language
if (current != null && (langCode == null || current.matchesLanguage(langCode))) {
if (setSubtitles(current, false)) {
player.saveData()
player.reloadPlayer(ctx)
player.handleEvent(CSPlayerEvent.Play)
Expand All @@ -1695,7 +1690,7 @@ class GeneratorPlayer : FullScreenPlayer() {
getAutoSelectSubtitle(
currentSubs, settings = true, downloads = false
)?.let { sub ->
if (setSubtitles(sub)) {
if (setSubtitles(sub, false)) {
player.saveData()
player.reloadPlayer(ctx)
player.handleEvent(CSPlayerEvent.Play)
Expand All @@ -1711,7 +1706,7 @@ class GeneratorPlayer : FullScreenPlayer() {
if (player.getCurrentPreferredSubtitle() == null) {
getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub ->
context?.let { ctx ->
if (setSubtitles(sub)) {
if (setSubtitles(sub, false)) {
player.saveData()
player.reloadPlayer(ctx)
player.handleEvent(CSPlayerEvent.Play)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import androidx.media3.ui.SubtitleView
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle
import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import me.xdrop.fuzzywuzzy.FuzzySearch

enum class SubtitleStatus {
IS_ACTIVE,
Expand Down Expand Up @@ -47,6 +50,56 @@ data class SubtitleData(
else "$url|$name"
}

/** Returns true if langCode is the same as the IETF tag */
fun matchesLanguage(langCode: String): Boolean {
return getIETF_tag() == langCode
}

/** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */
fun getIETF_tag(): String? {
val tag = fromCodeToLangTagIETF(this.languageCode)
Copy link
Contributor

@diogob003 diogob003 Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you checked data class SubtitleFile? I feel like the same thing is done 2 times, in SubtitleFile we already try to convert lang -> ietf tag

data class SubtitleFile private constructor(
var lang: String,
var url: String,
var headers: Map<String, String>?
) {
@Deprecated("Use newSubtitleFile method", level = DeprecationLevel.WARNING)
constructor(lang: String, url: String) : this(lang = lang, url = url, headers = null)
/** Language code to properly filter auto select / download subtitles */
val langTag: String?
get() = fromCodeToLangTagIETF(lang) ?: fromLanguageToTagIETF(lang, true)
/** Backwards compatible copy */
fun copy(
lang: String = this.lang, url: String = this.url
): SubtitleFile = SubtitleFile(lang = lang, url = url, headers = this.headers)
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember correctly, SubtitleFile is always turned into SubtitleData
SubtitleFile: what we get from plugins and providers
SubtitleData: the app's internal representation (that has more properties)

if (tag != null) {
return tag
}

// Remove any numbers to make matching better
val cleanedLanguage = originalName.replace(Regex("[0-9]"), "").trim()

// First go for exact matches
SubtitleHelper.languages.forEach { language ->
if (language.languageName.equals(cleanedLanguage, ignoreCase = true) ||
language.nativeName.equals(cleanedLanguage, ignoreCase = true) ||
// Also match exact IETF tags
language.IETF_tag.equals(cleanedLanguage, ignoreCase = true)
) {
return language.IETF_tag
}
}
Comment on lines +69 to +77
Copy link
Contributor

@diogob003 diogob003 Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically what is implemented in the library , the key difference is that we turn languages in indexMapLanguageName and indexMapNativeName then get it from the map instead of .equals(cleanedLanguage). By doing it this way, the performance improved.

private fun getLanguageDataFromName(languageName: String?, halfMatch: Boolean? = false): LanguageMetadata? {
if (languageName.isNullOrBlank() || languageName.length < 2) return null
// Workaround to avoid junk like "English (original audio)" or "Spanish 123"
// or "اَلْعَرَبِيَّةُ (Original Audio) 1" or "English (hindi sub)"…
val garbage = Regex(
"\\([^)]*(?:dub|sub|original|audio|code)[^)]*\\)|" + // junk words in parenthesis
"[\\u064B-\\u065B]|" + // arabic diacritics
"\\d|" + // numbers
"[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ()]" // non-letter (from any language)
)
val lowLangName = languageName.lowercase().replace(garbage, "").trim()
val index =
indexMapLanguageName[lowLangName] ?:
indexMapNativeName[lowLangName] ?: -1
val langMetadata = languages.getOrNull(index)
if (halfMatch == true && langMetadata == null) {
for (lang in languages)
if (lang.languageName.contains(lowLangName, ignoreCase = true) ||
lang.nativeName.contains(lowLangName, ignoreCase = true))
return lang
}
return langMetadata
}


var closestMatch: Pair<String?, Int> = null to 0
// Then go for partial matches, however only use the best match
SubtitleHelper.languages.forEach { language ->
val lowerCleaned = cleanedLanguage.lowercase()
val score = maxOf(
FuzzySearch.ratio(lowerCleaned, language.languageName.lowercase()),
FuzzySearch.ratio(
lowerCleaned, language.nativeName.lowercase()
)
)

// Arbitrary cutoff at 80.
if (cleanedLanguage.contains(language.languageName, ignoreCase = true) ||
cleanedLanguage.contains(language.nativeName, ignoreCase = true) || score > 80
) {
if (score > closestMatch.second) {
closestMatch = language.IETF_tag to score
}
}
}
Comment on lines +79 to +98
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it more useful to implement the FuzzySearch within the library, so it can be used by extensions as well, and not just by the app itself.

My suggestion is to replace the halfMatch code with the FuzzySearch code. That's what I started doing but never finished or submitted a request 😬.

private fun getLanguageDataFromName(languageName: String?, halfMatch: Boolean? = false): LanguageMetadata? {
if (languageName.isNullOrBlank() || languageName.length < 2) return null
// Workaround to avoid junk like "English (original audio)" or "Spanish 123"
// or "اَلْعَرَبِيَّةُ (Original Audio) 1" or "English (hindi sub)"…
val garbage = Regex(
"\\([^)]*(?:dub|sub|original|audio|code)[^)]*\\)|" + // junk words in parenthesis
"[\\u064B-\\u065B]|" + // arabic diacritics
"\\d|" + // numbers
"[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ()]" // non-letter (from any language)
)
val lowLangName = languageName.lowercase().replace(garbage, "").trim()
val index =
indexMapLanguageName[lowLangName] ?:
indexMapNativeName[lowLangName] ?: -1
val langMetadata = languages.getOrNull(index)
if (halfMatch == true && langMetadata == null) {
for (lang in languages)
if (lang.languageName.contains(lowLangName, ignoreCase = true) ||
lang.nativeName.contains(lowLangName, ignoreCase = true))
return lang
}
return langMetadata


return closestMatch.first
}

val name = "$originalName $nameSuffix"

/**
Expand Down
120 changes: 120 additions & 0 deletions app/src/test/java/com/lagradost/cloudstream3/SubtitleSelectionTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.lagradost.cloudstream3

import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

/** Ensure partial subtitle language finding is reliable. */
class SubtitleLanguageTagTest {
fun getQuickSubtitle(originalName: String, languageCode: String?): SubtitleData {
return SubtitleData(
originalName = originalName,
nameSuffix = "1",
url = "https://example.com/test.vtt",
origin = SubtitleOrigin.URL,
mimeType = "text/vtt",
headers = emptyMap(),
languageCode = languageCode
)
}

@Test
fun `returns languageCode directly if already valid IETF tag`() {
val subtitle = getQuickSubtitle(
originalName = "Anything",
languageCode = "en"
)

assertEquals("en", subtitle.getIETF_tag())
}

@Test
fun `matches exact language name`() {
val subtitle = getQuickSubtitle(
originalName = "English",
languageCode = null
)

assertEquals("en", subtitle.getIETF_tag())
}

@Test
fun `matches native language name`() {
val subtitle = getQuickSubtitle(
originalName = "Español",
languageCode = null
)

assertEquals("es", subtitle.getIETF_tag())
}

@Test
fun `matches fuzzy partial language name`() {
val subtitle = getQuickSubtitle(
originalName = "English [SUB]",
languageCode = null
)

assertEquals("en", subtitle.getIETF_tag())
}

@Test
fun `returns null when no language matches`() {
val subtitle = getQuickSubtitle(
originalName = "Klingon",
languageCode = null
)

assertNull(subtitle.getIETF_tag())
}


@Test
fun `returns the correct language variant`() {
val subtitle1 = getQuickSubtitle(
originalName = "Chinese",
languageCode = null
)
val subtitle2 = getQuickSubtitle(
originalName = "Chinese (subtitle)",
languageCode = null
)
val subtitleSimplified1 = getQuickSubtitle(
originalName = "Chinese (simplified)",
languageCode = null
)
val subtitleSimplified2 = getQuickSubtitle(
originalName = "Chinese - simplified",
languageCode = null
)
val subtitleSimplified3 = getQuickSubtitle(
originalName = "Chinese simplified",
languageCode = "zh-"
)
val subtitleSimplified4 = getQuickSubtitle(
originalName = "Chinese (simplified)2",
languageCode = "zh-hans"
)

assertEquals("zh", subtitle1.getIETF_tag())
assertEquals("zh", subtitle2.getIETF_tag())
assertEquals("zh-hans", subtitleSimplified1.getIETF_tag())
assertEquals("zh-hans", subtitleSimplified2.getIETF_tag())
assertEquals("zh-hans", subtitleSimplified3.getIETF_tag())
assertEquals("zh-hans", subtitleSimplified4.getIETF_tag())
}


@Test
fun `returns exact language matches`() {
val subtitle = getQuickSubtitle(
originalName = "en",
languageCode = null
)

assertEquals("en", subtitle.getIETF_tag())
}
}