Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ class CloudSyncRepository @Inject constructor(
"dnsProvider", "subtitleAiEnabled", "subtitleAiAutoSelect", "subtitleAiFindBestMatch",
"subtitlePreloadEnabled", "dolbyVisionCompatEnabled", "subtitleAiApiKey",
"subtitleAiModel", "subtitleRemoveHearingImpaired", "telegramSearchOnClickOnly",
"subtitleHearingSyncV2"
"subtitleHearingSyncV2", "liveAiSubsEnabled", "liveAiSubsAutoStart"
)
// Per-profile fields excluded from the generic merge (handled by their own logic / not values).
private val profileMergeExclude = setOf("defaultSubtitle", "subtitleSettingsUpdatedAt")
Expand Down Expand Up @@ -793,6 +793,9 @@ class CloudSyncRepository @Inject constructor(
root.put("subtitleAiApiKey", prefs[subtitleAiApiKeyKey] ?: "")
root.put("subtitleAiModel", prefs[subtitleAiModelKey] ?: "GROQ_LLAMA_70B")
root.put("subtitleRemoveHearingImpaired", prefs[subtitleRemoveHearingImpairedKey] ?: true)
// Live TV AI subtitles (global, like the AI subtitle key they share).
root.put("liveAiSubsEnabled", prefs[com.arflix.tv.ui.screens.tv.live.aisubs.LiveAiSubsPrefs.ENABLED] ?: false)
root.put("liveAiSubsAutoStart", prefs[com.arflix.tv.ui.screens.tv.live.aisubs.LiveAiSubsPrefs.AUTO_START] ?: false)
root.put(
"telegramSearchOnClickOnly",
prefs[com.arflix.tv.data.telegram.TelegramRepository.KEY_SEARCH_ON_CLICK_ONLY] ?: true
Expand Down Expand Up @@ -1863,6 +1866,10 @@ class CloudSyncRepository @Inject constructor(
val model = root.optString("subtitleAiModel", "GROQ_LLAMA_70B")
if (model.isNotBlank()) prefs[subtitleAiModelKey] = model
prefs[subtitleRemoveHearingImpairedKey] = root.optBoolean("subtitleRemoveHearingImpaired", true)
// has() guards: backups from versions without Live TV AI subtitles must not reset them.
val live = com.arflix.tv.ui.screens.tv.live.aisubs.LiveAiSubsPrefs
if (root.has("liveAiSubsEnabled")) prefs[live.ENABLED] = root.optBoolean("liveAiSubsEnabled", false)
if (root.has("liveAiSubsAutoStart")) prefs[live.AUTO_START] = root.optBoolean("liveAiSubsAutoStart", false)
}
}
if (preservedNewerLocalSubtitle) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1052,8 +1052,8 @@ internal class AudioSubtitleSyncController(
)
recognizer?.let(engine::setRecognizer)
asr = engine
analyzer.chunkListener = SpeechSegmenter(engine::offerSegment)
liveAnalyzer.chunkListener = SpeechSegmenter(engine::offerSegment)
analyzer.chunkListener = SpeechSegmenter(onSegment = engine::offerSegment)
liveAnalyzer.chunkListener = SpeechSegmenter(onSegment = engine::offerSegment)
return AudioSyncDecoder(analyzer, liveAnalyzer) { mime ->
SyncLog.i("look-ahead capture unavailable for $mime; syncing from playback audio instead")
liveOnly = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ internal class AudioSyncDecoder(
* next to the look-ahead one, which keep to platform software decoders and FFmpeg.
*/
private val allowVendorDecoders: Boolean = true,
/**
* Priority of the decoding thread. Background by default; Live TV subtitles run a little
* higher, since a starved thread there makes every subtitle late.
*/
private val threadPriority: Int = Process.THREAD_PRIORITY_BACKGROUND,
/** Called once per audio format that has no usable decoder. */
private val onUnsupportedFormat: (mimeType: String) -> Unit = {},
) {
Expand Down Expand Up @@ -144,7 +149,7 @@ internal class AudioSyncDecoder(
}

private fun runLoop() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
Process.setThreadPriority(threadPriority)
try {
while (true) {
val item = synchronized(lock) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ private class AudioTapExtractor(
override fun getUnderlyingImplementation(): Extractor = delegate.underlyingImplementation
}

private class AudioTapExtractorOutput(
internal class AudioTapExtractorOutput(
private val delegate: ExtractorOutput,
private val sink: AudioSampleSink,
) : ExtractorOutput {
Expand All @@ -91,7 +91,7 @@ private class AudioTapExtractorOutput(
* report its metadata later (with [offset] bytes of following samples already written), so the
* pending bytes are kept until [sampleMetadata] identifies the sample.
*/
private class AudioTapTrackOutput(
internal class AudioTapTrackOutput(
private val delegate: TrackOutput,
private val sink: AudioSampleSink,
) : TrackOutput {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ import com.arflix.tv.ui.screens.player.audiosync.SpeechAnalyzer
/**
* Cuts the analysed 16 kHz audio into speech segments using the Silero probabilities that are
* already being computed, so the recogniser only ever sees dialogue: no silence, music-only
* stretches or effects. Segments are at most [MAX_SEGMENT_FRAMES] long so words come back quickly.
* stretches or effects. Segments are at most [maxSegmentFrames] long so words come back quickly.
*/
internal class SpeechSegmenter(
/**
* Longest segment in 32 ms frames. Live subtitles keep this short (about 5 s): a line can only
* be translated once its segment has ended, so it bounds how far ahead playback must be held.
*/
private val maxSegmentFrames: Int = MAX_SEGMENT_FRAMES,
/**
* Past this length, a segment also ends at the first chunk whose speech probability dips below
* [DIP_THRESHOLD], a pause between words, so long speech is not cut mid-word at the hard limit.
*/
private val softMaxSegmentFrames: Int = maxSegmentFrames,
private val onSegment: (startFrame: Int, samples: FloatArray) -> Unit,
) : SpeechAnalyzer.ChunkListener {
private val chunk = SileroVad.CHUNK_SAMPLES
Expand Down Expand Up @@ -42,7 +52,8 @@ internal class SpeechSegmenter(
}
append(chunk)
if (speech) lastSpeechFrame = frame
if (frame - lastSpeechFrame > HANGOVER_FRAMES || bufferFrames >= MAX_SEGMENT_FRAMES) finish()
val dip = bufferFrames >= softMaxSegmentFrames && probability < DIP_THRESHOLD
if (frame - lastSpeechFrame > HANGOVER_FRAMES || bufferFrames >= maxSegmentFrames || dip) finish()
}

@Synchronized
Expand Down Expand Up @@ -75,11 +86,12 @@ internal class SpeechSegmenter(

companion object {
private const val SPEECH_THRESHOLD = 0.5f
private const val DIP_THRESHOLD = 0.7f
private const val PREROLL_FRAMES = 3
private const val TAIL_FRAMES = 4
private const val HANGOVER_FRAMES = 8
private const val MIN_SPEECH_FRAMES = 6
/** 12 s. */
private const val MAX_SEGMENT_FRAMES = 375
const val MAX_SEGMENT_FRAMES = 375
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import java.util.concurrent.TimeUnit

private const val TAG = "SubtitleTranslation"

private val RTL_LANGUAGES = setOf("hebrew", "arabic", "urdu", "persian", "farsi", "yiddish")
internal val RTL_LANGUAGES = setOf("hebrew", "arabic", "urdu", "persian", "farsi", "yiddish")

data class TranslationResult(
val lines: List<String>,
Expand All @@ -28,7 +28,7 @@ data class TranslationResult(
// it's not actionable and translation keeps working for everything else.
const val TRANSLATION_ERROR_CONTENT_BLOCKED = "CONTENT_BLOCKED"

private val BLOCKED_FINISH_REASONS = setOf(
internal val BLOCKED_FINISH_REASONS = setOf(
"PROHIBITED_CONTENT", "SAFETY", "RECITATION", "BLOCKLIST", "SPII"
)

Expand All @@ -54,9 +54,62 @@ private const val GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v
// with short exponential backoff before falling back to English. Kept short so subtitles stay in
// sync with playback. NOT applied to 429 (that's our own quota — retrying fast makes it worse;
// the manager's 5s cooldown handles it) or 4xx/content blocks (retrying won't change the outcome).
private const val GEMINI_MAX_TRANSIENT_RETRIES = 2 // 3 attempts total
private const val GEMINI_RETRY_BASE_DELAY_MS = 400L // 400ms, then 800ms (×2 each retry)
private val GEMINI_RETRYABLE_HTTP = setOf(500, 502, 503)
internal const val GEMINI_MAX_TRANSIENT_RETRIES = 2 // 3 attempts total
internal const val GEMINI_RETRY_BASE_DELAY_MS = 400L // 400ms, then 800ms (×2 each retry)
internal val GEMINI_RETRYABLE_HTTP = setOf(500, 502, 503)

/**
* The JSON array in a model reply: bare, fenced, wrapped in a one-field object, or with a truncated
* tail. Shared by every Gemini/Groq caller in the app.
*/
internal fun extractJsonArray(text: String): JSONArray? {
val codeBlocks = Regex("```(?:json)?\\s*([\\s\\S]*?)```").findAll(text)
.map { it.groupValues[1].trim() }.toList().reversed()
val stripped = text.replace(Regex("```[^`]*```"), "").trim()
val candidates = codeBlocks + listOf(stripped, text)

for (candidate in candidates) {
try { return JSONArray(candidate) } catch (_: Exception) {}
// With responseMimeType=application/json the model sometimes wraps the array in an
// object (e.g. {"translations": [...]}) — unwrap a lone array-valued field.
try {
val obj = JSONObject(candidate)
val arrays = obj.keys().asSequence()
.mapNotNull { key -> obj.optJSONArray(key) }
.toList()
if (arrays.size == 1) return arrays[0]
} catch (_: Exception) {}
val start = candidate.indexOf('[')
val end = candidate.lastIndexOf(']')
if (start < 0 || end <= start) {
repairTruncatedArray(candidate)?.let { return it }
continue
}
try {
return JSONArray(candidate.substring(start, end + 1))
} catch (_: Exception) {}
}
return null
}

/**
* Gemini's JSON mode intermittently truncates output at the very tail (finishReason STOP but
* the closing bracket — sometimes a trailing element — is missing). The translations
* themselves are intact, so re-terminate the array instead of failing the whole batch.
*/
internal fun repairTruncatedArray(text: String): JSONArray? {
val t = text.trim()
if (!t.startsWith("[") || t.endsWith("]")) return null
val lastQuote = t.lastIndexOf('"')
if (lastQuote <= 0) return null
// Try closing as-is, then after dropping a trailing partial element.
for (cut in listOf(t, t.substring(0, lastQuote + 1))) {
val trimmed = cut.trimEnd().trimEnd(',')
try { return JSONArray("$trimmed]") } catch (_: Exception) {}
try { return JSONArray("$trimmed\"]") } catch (_: Exception) {}
}
return null
}

class SubtitleTranslationService(
private val apiKeyProvider: () -> String,
Expand All @@ -68,55 +121,6 @@ class SubtitleTranslationService(
.readTimeout(30, TimeUnit.SECONDS)
.build()

private fun extractJsonArray(text: String): JSONArray? {
val codeBlocks = Regex("```(?:json)?\\s*([\\s\\S]*?)```").findAll(text)
.map { it.groupValues[1].trim() }.toList().reversed()
val stripped = text.replace(Regex("```[^`]*```"), "").trim()
val candidates = codeBlocks + listOf(stripped, text)

for (candidate in candidates) {
try { return JSONArray(candidate) } catch (_: Exception) {}
// With responseMimeType=application/json the model sometimes wraps the array in an
// object (e.g. {"translations": [...]}) — unwrap a lone array-valued field.
try {
val obj = JSONObject(candidate)
val arrays = obj.keys().asSequence()
.mapNotNull { key -> obj.optJSONArray(key) }
.toList()
if (arrays.size == 1) return arrays[0]
} catch (_: Exception) {}
val start = candidate.indexOf('[')
val end = candidate.lastIndexOf(']')
if (start < 0 || end <= start) {
repairTruncatedArray(candidate)?.let { return it }
continue
}
try {
return JSONArray(candidate.substring(start, end + 1))
} catch (_: Exception) {}
}
return null
}

/**
* Gemini's JSON mode intermittently truncates output at the very tail (finishReason STOP but
* the closing bracket — sometimes a trailing element — is missing). The translations
* themselves are intact, so re-terminate the array instead of failing the whole batch.
*/
private fun repairTruncatedArray(text: String): JSONArray? {
val t = text.trim()
if (!t.startsWith("[") || t.endsWith("]")) return null
val lastQuote = t.lastIndexOf('"')
if (lastQuote <= 0) return null
// Try closing as-is, then after dropping a trailing partial element.
for (cut in listOf(t, t.substring(0, lastQuote + 1))) {
val trimmed = cut.trimEnd().trimEnd(',')
try { return JSONArray("$trimmed]") } catch (_: Exception) {}
try { return JSONArray("$trimmed\"]") } catch (_: Exception) {}
}
return null
}

private fun buildSystemPrompt(targetLanguage: String, NL: String) =
"You are a professional subtitle translator. Translate the following JSON array into natural $targetLanguage.\n" +
"Rules:\n" +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.arflix.tv.ui.screens.settings

import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.arflix.tv.R
import com.arflix.tv.ui.components.MobileSettingsRow
import com.arflix.tv.ui.components.SettingsToggleRow
import com.arflix.tv.ui.screens.tv.live.aisubs.GeminiSegmentEngine
import com.arflix.tv.ui.screens.tv.live.aisubs.LiveAiSubsPrefs
import com.arflix.tv.ui.screens.tv.live.aisubs.LiveAiSubsSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

/*
* Live TV AI subtitles settings, in the "AI subtitles" section next to the Gemini key they use:
* the feature switch and "auto-translate when a channel starts". The rows read and write their
* own preferences (LiveAiSubsPrefs); SettingsViewModel pushes changes to the cloud.
*/

/** TV settings row indices, after the existing ones (0–50). */
internal val LIVE_AI_SUBS_ROWS = listOf(51, 52)

private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

/** Handles OK/click on one of [LIVE_AI_SUBS_ROWS]. */
internal fun onLiveAiSubsRowClick(context: Context, index: Int) {
val appContext = context.applicationContext
actionScope.launch {
val settings = LiveAiSubsPrefs.flow(appContext).first()
when (index) {
51 -> if (settings.enabled) {
LiveAiSubsPrefs.setEnabled(appContext, false)
} else {
enableAfterKeyCheck(appContext, settings)
}
52 -> if (settings.enabled) LiveAiSubsPrefs.setAutoStart(appContext, !settings.autoStart)
}
}
}

/** Validates the AI subtitles Gemini key with a cheap call before turning the feature on. */
private suspend fun enableAfterKeyCheck(context: Context, settings: LiveAiSubsSettings) {
if (!settings.hasKey) {
toast(context, R.string.live_ai_subs_needs_key)
return
}
when (GeminiSegmentEngine(apiKey = { settings.apiKey }).validateKey(settings.apiKey)) {
true -> {
toast(context, R.string.live_ai_subs_key_valid)
LiveAiSubsPrefs.setEnabled(context, true)
}
false -> toast(context, R.string.live_ai_subs_key_rejected)
// Could not check (offline): turn it on; the player reports key problems itself.
null -> {
toast(context, R.string.live_ai_subs_key_unchecked)
LiveAiSubsPrefs.setEnabled(context, true)
}
}
}

private fun toast(context: Context, resId: Int) {
Toast.makeText(context, context.getString(resId), Toast.LENGTH_SHORT).show()
}

@Composable
private fun rememberLiveAiSubsSettings(): LiveAiSubsSettings {
val context = LocalContext.current
val flow = remember(context) { LiveAiSubsPrefs.flow(context.applicationContext) }
val settings by flow.collectAsState(initial = LiveAiSubsSettings())
return settings
}

/** One TV row for [index] in [LIVE_AI_SUBS_ROWS]. */
@Composable
internal fun LiveAiSubsTvRow(index: Int, isFocused: Boolean, modifier: Modifier) {
val context = LocalContext.current
val settings = rememberLiveAiSubsSettings()
when (index) {
51 -> SettingsToggleRow(
title = stringResource(R.string.live_ai_subs_title),
subtitle = stringResource(R.string.live_ai_subs_desc),
isEnabled = settings.enabled,
isFocused = isFocused,
onToggle = { onLiveAiSubsRowClick(context, 51) },
modifier = modifier,
)
52 -> SettingsToggleRow(
title = stringResource(R.string.live_ai_subs_auto_title),
subtitle = stringResource(R.string.live_ai_subs_auto_desc),
isEnabled = settings.enabled && settings.autoStart,
isFocused = isFocused,
onToggle = { onLiveAiSubsRowClick(context, 52) },
modifier = if (settings.enabled) modifier else modifier.alpha(0.4f),
)
}
}

/** The same settings as touch rows, for the mobile settings page. */
@Composable
internal fun LiveAiSubsMobileRows() {
val context = LocalContext.current
val settings = rememberLiveAiSubsSettings()
MobileSettingsRow(
title = stringResource(R.string.live_ai_subs_title),
subtitle = stringResource(R.string.live_ai_subs_desc),
value = stringResource(if (settings.enabled) R.string.on else R.string.off),
toggleChecked = settings.enabled,
onClick = { onLiveAiSubsRowClick(context, 51) },
)
MobileSettingsRow(
title = stringResource(R.string.live_ai_subs_auto_title),
subtitle = stringResource(R.string.live_ai_subs_auto_desc),
value = stringResource(if (settings.enabled && settings.autoStart) R.string.on else R.string.off),
toggleChecked = settings.enabled && settings.autoStart,
enabled = settings.enabled,
showDivider = false,
onClick = { onLiveAiSubsRowClick(context, 52) },
)
}
Loading
Loading