diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt index f7fa3da4..b00b9ee0 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt @@ -210,6 +210,9 @@ data class Subtitle( val groupIndex: Int? = null, val trackIndex: Int? = null, val isForced: Boolean = false, + // True for image-based subtitle tracks (PGS/VOBSUB/DVB). These carry no text and + // therefore cannot be used as an AI translation source. + val isBitmap: Boolean = false, ) : Serializable /** diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt index c920c558..d5b5e78d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt @@ -95,8 +95,17 @@ private class TranslatingTextOutput( return } - val text = extractText(cues) + val rawText = extractRawText(cues) + if (rawText.isBlank()) { + // Cues carry no extractable text (e.g. bitmap/PGS image subtitles). They can't be + // translated — pass the originals through so image subtitles still appear on screen + // instead of being hidden behind a blank. + delegate.onCues(cueGroup) + return + } + val text = if (manager.removeHearingImpaired) stripHearingImpaired(rawText) else rawText if (text.isBlank()) { + // Text existed but was entirely hearing-impaired markup the user chose to remove. delegate.onCues(CueGroup(emptyList(), cueGroup.presentationTimeUs)) return } @@ -128,7 +137,13 @@ private class TranslatingTextOutput( delegate.onCues(cues) return } - val text = extractText(cues) + val rawText = extractRawText(cues) + if (rawText.isBlank()) { + // No translatable text (e.g. bitmap subtitles) — show originals rather than hiding. + delegate.onCues(cues) + return + } + val text = if (manager.removeHearingImpaired) stripHearingImpaired(rawText) else rawText if (text.isBlank()) { delegate.onCues(emptyList()) return @@ -142,13 +157,12 @@ private class TranslatingTextOutput( } } - private fun extractText(cues: List): String { - val removeHI = manager.removeHearingImpaired - val raw = cues.mapNotNull { it.text?.toString()?.trim() } + // Raw joined cue text before any hearing-impaired stripping. Blank when cues carry no + // text at all (e.g. bitmap/PGS image subtitles, whose Cue.text is null). + private fun extractRawText(cues: List): String = + cues.mapNotNull { it.text?.toString()?.trim() } .filter { it.isNotBlank() } .joinToString("\n") - return if (removeHI) stripHearingImpaired(raw) else raw - } private fun stripHearingImpaired(text: String): String = text.replace(AiSubtitleRegexes.BRACKET_REGEX, "") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index d67669d9..591c0304 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -1101,6 +1101,9 @@ fun PlayerScreen( val label = format.label ?: matched?.label ?: getFullLanguageName(lang) val isExternal = matched?.url?.isNotBlank() == true val isForced = format.selectionFlags and C.SELECTION_FLAG_FORCED != 0 + // Image-based subtitle tracks (PGS/VOBSUB/DVB) carry no text — they + // can't be AI-translated, so flag them to exclude as a translation source. + val isBitmap = isBitmapSubtitleMime(format.sampleMimeType) textTracks.add(Subtitle( id = matched?.id ?: formatTrackId.ifBlank { "embedded_${groupIndex}_$i" }, url = matched?.url.orEmpty(), @@ -1111,6 +1114,7 @@ fun PlayerScreen( groupIndex = groupIndex, trackIndex = i, isForced = isForced, + isBitmap = isBitmap, )) } } @@ -3924,6 +3928,18 @@ private fun nativeAudioLanguageHints(preferredCode: String): List { } } +/** + * True if the subtitle MIME type is an image/bitmap format (PGS, VOBSUB, DVB). + * These tracks render as images and carry no text, so they can't be AI-translated. + */ +private fun isBitmapSubtitleMime(mimeType: String?): Boolean { + val mime = mimeType?.lowercase()?.trim() ?: return false + return mime == MimeTypes.APPLICATION_PGS || // application/pgs (Blu-ray) + mime == MimeTypes.APPLICATION_VOBSUB || // application/vobsub (DVD) + mime == MimeTypes.APPLICATION_DVBSUBS || // application/dvbsubs + mime.contains("pgs") || mime.contains("vobsub") || mime.contains("dvbsub") +} + /** * Language code to full name mapping */ diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 4b5d8dfc..67c4c629 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -1289,18 +1289,21 @@ class PlayerViewModel @Inject constructor( private fun findAiSourceSubtitle(subtitles: List): Subtitle? { // Some files label forced tracks "SUBFORCED" without setting SELECTION_FLAG_FORCED fun Subtitle.isEffectivelyForced() = isForced || label.contains("forced", ignoreCase = true) + // Bitmap subtitles (PGS/VOBSUB) are images with no text — they can't be translated, + // so they must never be chosen as the AI source (would render a blank screen). + fun Subtitle.isUsableSource() = !isEffectivelyForced() && !isBitmap fun List.bestEmbedded(): Subtitle? { val embedded = filter { it.isEmbedded } - // Prefer plain > SDH/CC; never use forced-only tracks as AI source - return embedded.firstOrNull { !it.isEffectivelyForced() && !it.label.equals("SDH", ignoreCase = true) && !it.label.equals("CC", ignoreCase = true) } - ?: embedded.firstOrNull { !it.isEffectivelyForced() } + // Prefer plain > SDH/CC; never use forced-only or image-based tracks as AI source + return embedded.firstOrNull { it.isUsableSource() && !it.label.equals("SDH", ignoreCase = true) && !it.label.equals("CC", ignoreCase = true) } + ?: embedded.firstOrNull { it.isUsableSource() } } // Prefer English embedded > any embedded with lang > any embedded > any non-forced subtitle return subtitles.filter { normalizeLanguage(it.lang) == "en" }.bestEmbedded() ?: subtitles.filter { it.lang.isNotBlank() }.bestEmbedded() ?: subtitles.bestEmbedded() - ?: subtitles.firstOrNull { !it.isEffectivelyForced() } + ?: subtitles.firstOrNull { it.isUsableSource() } } private fun languageCodeToName(code: String): String {