Skip to content
Merged
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 @@ -352,6 +352,7 @@ class CloudSyncRepository @Inject constructor(
// Global AI subtitle keys (non-profile-scoped, device-wide)
private val subtitleAiEnabledKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_ai_enabled")
private val subtitleAiAutoSelectKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_ai_auto_select")
private val subtitleAiFindBestMatchKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_ai_find_best_match")
private val subtitleAiApiKeyKey = androidx.datastore.preferences.core.stringPreferencesKey("subtitle_ai_api_key")
private val subtitleAiModelKey = androidx.datastore.preferences.core.stringPreferencesKey("subtitle_ai_model")
private val subtitleRemoveHearingImpairedKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_remove_hearing_impaired")
Expand Down Expand Up @@ -489,6 +490,7 @@ class CloudSyncRepository @Inject constructor(
// Global AI subtitle settings (non-profile-scoped)
root.put("subtitleAiEnabled", prefs[subtitleAiEnabledKey] ?: false)
root.put("subtitleAiAutoSelect", prefs[subtitleAiAutoSelectKey] ?: false)
root.put("subtitleAiFindBestMatch", prefs[subtitleAiFindBestMatchKey] ?: false)
root.put("subtitleAiApiKey", prefs[subtitleAiApiKeyKey] ?: "")
root.put("subtitleAiModel", prefs[subtitleAiModelKey] ?: "GROQ_LLAMA_70B")
root.put("subtitleRemoveHearingImpaired", prefs[subtitleRemoveHearingImpairedKey] ?: true)
Expand Down Expand Up @@ -1227,6 +1229,7 @@ class CloudSyncRepository @Inject constructor(
context.settingsDataStore.edit { prefs ->
prefs[subtitleAiEnabledKey] = root.optBoolean("subtitleAiEnabled", false)
prefs[subtitleAiAutoSelectKey] = root.optBoolean("subtitleAiAutoSelect", false)
prefs[subtitleAiFindBestMatchKey] = root.optBoolean("subtitleAiFindBestMatch", false)
val apiKey = root.optString("subtitleAiApiKey", "")
if (apiKey.isNotBlank()) prefs[subtitleAiApiKeyKey] = apiKey
val model = root.optString("subtitleAiModel", "GROQ_LLAMA_70B")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -1410,7 +1411,10 @@ class StreamRepository @Inject constructor(
private val ADDON_EXTENDED_EPISODE_TIMEOUT_MS = 45_000L
private val ADDON_EXTENDED_SINGLE_STREAM_REQUEST_TIMEOUT_MS = 35_000L
// Subtitles should not block playback but need enough time on slow connections.
private val SUBTITLE_TIMEOUT_MS = 6_000L
// Generous because aggregator addons (AIOStreams) fan out to upstreams server-side and can
// take 10s+ cold (or 502 outright, then succeed warm — hence the retry). Fetches run in
// parallel and in the background, so a slow addon delays nothing.
private val SUBTITLE_TIMEOUT_MS = 20_000L
// If addons return nothing, allow Xtream VOD lookup to recover playback.
private val VOD_LOOKUP_TIMEOUT_MS = 6_000L
// If addons already returned streams, keep VOD lookup shorter to avoid UI delay.
Expand Down Expand Up @@ -2754,21 +2758,24 @@ class StreamRepository @Inject constructor(
else -> ""
}

subtitleAddons.flatMap { addon ->
runCatching {
withTimeout(SUBTITLE_TIMEOUT_MS) {
val addonUrl = addon.url ?: return@withTimeout emptyList<Subtitle>()
val (baseUrl, queryParams) = getAddonBaseUrl(addonUrl)
val url = buildSubtitlesUrl(
baseUrl = baseUrl,
type = type,
id = contentId,
addonQueryParams = queryParams,
videoHash = videoHash,
videoSize = videoSize
)
val response = streamApi.getSubtitles(url)
response.subtitles?.mapIndexed { index, sub ->
// Query addons in parallel — with a generous timeout, sequential fetches would stack
// each slow addon's latency onto the total.
subtitleAddons.map { addon ->
async {
suspend fun attempt(): List<Subtitle> = runCatching {
withTimeout(SUBTITLE_TIMEOUT_MS) {
val addonUrl = addon.url ?: return@withTimeout emptyList<Subtitle>()
val (baseUrl, queryParams) = getAddonBaseUrl(addonUrl)
val url = buildSubtitlesUrl(
baseUrl = baseUrl,
type = type,
id = contentId,
addonQueryParams = queryParams,
videoHash = videoHash,
videoSize = videoSize
)
val response = streamApi.getSubtitles(url)
response.subtitles?.mapIndexed { index, sub ->
val normalizedLang = normalizeLanguageCode(sub.lang)
val langFallback = sub.lang?.trim()?.lowercase()?.take(2) ?: "und"
Subtitle(
Expand All @@ -2779,9 +2786,21 @@ class StreamRepository @Inject constructor(
provider = addon.name
)
} ?: emptyList()
}
}.onFailure {
Log.w("StreamRepository", "subtitles fetch failed addon=${addon.name} err=${it.message}")
}.getOrDefault(emptyList())

// Aggregator endpoints (AIOStreams) often hang or 502 on the first (cold) hit and
// succeed once their upstream fan-out is warm — one retry recovers those.
var subs = attempt()
if (subs.isEmpty()) {
delay(1_500)
subs = attempt()
}
}.getOrDefault(emptyList())
}
subs
}
}.awaitAll().flatten()
}

private fun buildSubtitlesUrl(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.text.TextOutput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
Expand All @@ -26,6 +28,52 @@ class AiSubtitleRenderersFactory(

val syncOffsetUs = java.util.concurrent.atomic.AtomicLong(0L)

var audioCaptureProcessor: AudioCaptureProcessor? = null
private set

private val offsetRenderers = mutableListOf<SubtitleOffsetRenderer>()

/**
* Cue-visible intervals (startMs to endMs) of the currently selected text track, read from
* ExoPlayer's buffered cue list via reflection — i.e. upcoming cues that haven't rendered yet.
* Lets "Find best match" build its built-in reference from prefetched cues instead of waiting
* for playback to reach them. Returns empty if nothing is buffered yet.
*/
fun extractBufferedReferenceIntervals(maxCount: Int): List<Pair<Long, Long>> {
for (renderer in offsetRenderers) {
val intervals = renderer.extractBufferedIntervals(maxCount)
if (intervals.isNotEmpty()) return intervals
}
return emptyList()
}

/**
* Text of the currently-buffered cues of the selected text track. Lets "Find best match"
* verify WHICH track the buffer belongs to (by comparing against the displayed candidate's
* own lines) — timing-based self-detection false-positived on subs cut from the same master.
*/
fun extractBufferedCueTexts(maxCount: Int): List<String> {
for (renderer in offsetRenderers) {
val texts = renderer.extractAllCueTexts()
if (texts.isNotEmpty()) return texts.take(maxCount)
}
return emptyList()
}

override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean
): AudioSink {
val capture = AudioCaptureProcessor()
audioCaptureProcessor = capture
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setAudioProcessors(arrayOf(capture))
.build()
}

override fun buildTextRenderers(
context: Context,
output: TextOutput,
Expand All @@ -41,7 +89,7 @@ class AiSubtitleRenderersFactory(
)
val startIndex = out.size
super.buildTextRenderers(context, translatingOutput, outputLooper, extensionRendererMode, out)
val offsetRenderers = mutableListOf<SubtitleOffsetRenderer>()
offsetRenderers.clear()
for (index in startIndex until out.size) {
val offsetRenderer = SubtitleOffsetRenderer(
baseRenderer = out[index],
Expand Down Expand Up @@ -287,6 +335,10 @@ private class SubtitleOffsetRenderer(
* while TextRenderer.render() has the subtitle buffer populated.
*/
fun triggerPreTranslation() {
// No AI translation active → no lookahead. tryPeriodicLookahead already checks this;
// this first-cue trigger fires for any newly selected track (e.g. the reference track
// "find best match" selects with AI off) and must not spend API requests either.
if (!translationManager.isEnabled) return
if (preTranslatedUpToUs != Long.MIN_VALUE &&
preTranslatedUpToUs > currentPositionUs + PREFETCH_TRIGGER_US) {
return
Expand All @@ -311,7 +363,7 @@ private class SubtitleOffsetRenderer(

// ── Reflection-based cue extraction ──────────────────────────────────────

private fun extractAllCueTexts(): List<String> {
fun extractAllCueTexts(): List<String> {
val removeHI = translationManager.removeHearingImpaired
val texts = mutableSetOf<String>()

Expand Down Expand Up @@ -374,6 +426,104 @@ private class SubtitleOffsetRenderer(
return texts.toList()
}

/** Buffered cue intervals (startMs, endMs) for text-carrying cues, from the modern resolver. */
fun extractBufferedIntervals(maxCount: Int): List<Pair<Long, Long>> {
val intervals = ArrayList<Pair<Long, Long>>()
// Cue buffer timestamps are on ExoPlayer's internal stream timeline, which adds a large
// base offset; subtract the renderer's stream offset to get 0-based media time.
val offsetMs = readStreamOffsetUs() / 1000L
try {
val resolverField = findField(baseRenderer.javaClass, "cuesResolver")
val resolver = resolverField?.get(baseRenderer)
if (resolver != null) {
// Media3 has multiple resolver impls chosen per track cue-replacement behavior
// (MergingCuesResolver, ReplacingCuesResolver) — probe the known field names.
for (candidate in listOf(
"cuesWithTimingList", "cuesWithTimings",
"cueGroupsByStartTime", "cueGroups", "cueGroupList", "groups"
)) {
val f = findField(resolver.javaClass, candidate) ?: continue
val v = f.get(resolver) ?: continue
val items: Collection<*> = when (v) {
is Map<*, *> -> v.values
is Collection<*> -> v
else -> continue
}
for (item in items) {
val (s, e) = item?.let(::intervalFromCueWrapper) ?: continue
intervals.add((s - offsetMs) to (e - offsetMs))
}
if (intervals.isNotEmpty()) break
}
if (intervals.isEmpty()) {
// Unknown impl/field name (new media3 version?) — scan every field for a
// cue-carrying collection, like the text extractor already does.
var cls: Class<*>? = resolver.javaClass
outer@ while (cls != null && cls != Any::class.java) {
for (f in cls.declaredFields) {
try {
f.isAccessible = true
val v = f.get(resolver) ?: continue
val items: Collection<*> = when (v) {
is Map<*, *> -> v.values
is Collection<*> -> v
else -> continue
}
for (item in items) {
val (s, e) = item?.let(::intervalFromCueWrapper) ?: continue
intervals.add((s - offsetMs) to (e - offsetMs))
}
if (intervals.isNotEmpty()) break@outer
} catch (_: Exception) {
}
}
cls = cls.superclass
}
}
}
} catch (_: Exception) {
}
return intervals.filter { it.first >= 0 }.distinct().sortedBy { it.first }.take(maxCount)
}

/** The renderer's stream offset (µs) — the base added to buffer sample timestamps. */
private fun readStreamOffsetUs(): Long {
for (name in listOf("streamOffsetUs", "outputStreamOffsetUs")) {
val v = runCatching { findField(baseRenderer.javaClass, name)?.getLong(baseRenderer) }.getOrNull()
if (v != null && v > 0L) return v
}
return 0L
}

private fun intervalFromCueWrapper(obj: Any): Pair<Long, Long>? {
val cues: List<Cue>? = when (obj) {
is CueGroup -> obj.cues
is List<*> -> obj.filterIsInstance<Cue>()
else -> (findField(obj.javaClass, "cues")?.get(obj) as? List<*>)?.filterIsInstance<Cue>()
}
if (cues.isNullOrEmpty() || cues.none { !it.text?.toString()?.trim().isNullOrBlank() }) return null

val startUs: Long
val endUs: Long
if (obj is CueGroup) {
startUs = obj.presentationTimeUs
endUs = startUs + 2_000_000L // CueGroup carries no duration — assume 2s on screen
} else {
val start = runCatching { findField(obj.javaClass, "startTimeUs")?.getLong(obj) }.getOrNull() ?: return null
// REPLACE-behavior tracks (ReplacingCuesResolver, e.g. MKV SubRip) carry
// durationUs = C.TIME_UNSET (large negative) — each cue lasts until replaced. The
// negative value made endUs < startUs and every item got rejected, silently killing
// the buffered fast path for that whole content category. Fall back to a nominal
// duration: the start timestamp carries the alignment signal.
val dur = runCatching { findField(obj.javaClass, "durationUs")?.getLong(obj) }.getOrNull()
?.takeIf { it > 0L } ?: 2_000_000L
startUs = start
endUs = start + dur
}
if (endUs <= startUs) return null
return (startUs / 1000L) to (endUs / 1000L)
}

private fun extractFromCollectionOrMap(v: Any, texts: MutableSet<String>, removeHI: Boolean): Int {
var count = 0
when (v) {
Expand Down
Loading
Loading