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 1b1b867a..0ede4f2f 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 @@ -1269,33 +1269,42 @@ fun PlayerScreen( for (i in 0 until group.length) { val format = group.getTrackFormat(i) val formatTrackId = format.id?.trim().orEmpty() - // ExoPlayer prefixes external subtitle IDs with "{periodIndex}:" - // (e.g. "1:189618" for an external sub whose id we set to "189618"). - // Fall back to matching by the bare id after the last colon. - val matched = if (formatTrackId.isNotBlank()) { - subtitleByTrackId[formatTrackId] - ?: subtitleByTrackId[formatTrackId.substringAfterLast(':')] + // A track we side-loaded carries the addon prefix (ExoPlayer may + // prepend "{periodIndex}:" → e.g. "0:arvio-addon-sub:prov|id"). + // Anything WITHOUT the prefix is a genuine muxed/built-in track — + // deterministic, no fuzzy label/lang guessing. + val isAddon = formatTrackId.contains(ADDON_SUB_ID_PREFIX) + val matched = if (isAddon) { + // Colon-safe: key off our prefix, never the last ':' — addon + // ids can themselves contain colons (e.g. GTSubs "9892399:he"). + // substringAfter also drops ExoPlayer's leading "periodIndex:". + subtitleByTrackId[formatTrackId.substringAfter(ADDON_SUB_ID_PREFIX)] } else { - latestUiState.subtitles.firstOrNull { candidate -> - !candidate.isEmbedded && - candidate.label.equals(format.label, ignoreCase = true) && - candidate.lang.equals(format.language ?: candidate.lang, ignoreCase = true) - } + null } val lang = format.language ?: matched?.lang ?: "und" val label = format.label ?: matched?.label ?: getFullLanguageName(lang) - val isExternal = matched?.url?.isNotBlank() == true - val isForced = format.selectionFlags and C.SELECTION_FLAG_FORCED != 0 + // Forced/signage detection: the container flag OR a name hint — + // release groups often ship promo/"songs & signs" tracks without + // setting the flag, so the name is a second signal. + val trackTexts = listOfNotNull(format.label, format.language, format.id) + val isForced = (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0) || + trackTexts.any { it.contains("forced", ignoreCase = true) } || + trackTexts.any { it.contains("songs", ignoreCase = true) && it.contains("sign", ignoreCase = true) } // 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(), + id = if (isAddon) { + matched?.id ?: formatTrackId.substringAfter(ADDON_SUB_ID_PREFIX) + } else { + formatTrackId.ifBlank { "embedded_${groupIndex}_$i" } + }, + url = if (isAddon) matched?.url.orEmpty() else "", lang = lang, label = label, - provider = matched?.provider.orEmpty(), - isEmbedded = !isExternal, + provider = if (isAddon) matched?.provider.orEmpty() else "", + isEmbedded = !isAddon, groupIndex = groupIndex, trackIndex = i, isForced = isForced, @@ -1826,6 +1835,9 @@ fun PlayerScreen( // track id as the attached remote entry (subtitleTrackId never hashes an id-carrying // sub's URL). if (latestUiState.subtitlePreloadEnabled) { + // Match on the base id AFTER our prefix — colon-safe (addon ids may contain colons) and + // it strips ExoPlayer's leading "periodIndex:". Exact (not contains) so a base id can't + // mis-match its own "…#ofs" offset variant. val targetTrackId = subtitleTrackId(subtitle) repeat(2) { attempt -> val groups = exoPlayer.currentTracks.groups @@ -1835,7 +1847,7 @@ fun PlayerScreen( for (ti in 0 until group.length) { val fid = group.getTrackFormat(ti).id?.trim().orEmpty() val matches = fid.isNotBlank() && - (fid == targetTrackId || fid.substringAfterLast(':') == targetTrackId) + fid.substringAfter(ADDON_SUB_ID_PREFIX) == targetTrackId if (matches) { exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters .buildUpon() @@ -1908,7 +1920,7 @@ fun PlayerScreen( for (ti in 0 until group.length) { val fid = group.getTrackFormat(ti).id?.trim().orEmpty() val matches = fid.isNotBlank() && - (fid == targetTrackId || fid.substringAfterLast(':') == targetTrackId) + fid.substringAfter(ADDON_SUB_ID_PREFIX) == targetTrackId if (matches) { exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters .buildUpon() @@ -3443,7 +3455,7 @@ fun PlayerScreen( val idx = subtitleGroups.indexOfFirst { (name, _) -> name.equals(langName, ignoreCase = true) } subtitleLangIndex = if (idx >= 0) idx + 1 else 0 subtitleTrackIndex = subtitleGroups.getOrNull(subtitleLangIndex - 1)?.second - ?.indexOfFirst { (_, sub) -> sub.id == selected.id }?.coerceAtLeast(0) ?: 0 + ?.indexOfFirst { (_, sub) -> isSameSubtitleTrack(selected, sub.id) }?.coerceAtLeast(0) ?: 0 } showSubtitleMenu = true // Move focus to container so all D-pad keys go to the menu handler @@ -4693,7 +4705,7 @@ private fun SubtitleMenu( (isAiTranslating || isFindingBestMatch) && aiTargetLanguageName.isNotBlank() && langName.equals(aiTargetLanguageName, ignoreCase = true) -> true else -> selectedSubtitle != null && - items.any { (_, sub) -> sub.id == selectedSubtitle.id } + items.any { (_, sub) -> isSameSubtitleTrack(selectedSubtitle, sub.id) } } ) } @@ -4768,17 +4780,25 @@ private fun SubtitleMenu( itemsIndexed(selectedGroup.second) { idx, (_, subtitle) -> val score = subtitleMatchScore(streamSource, subtitle) val langName = getFullLanguageName(subtitle.lang) - val mainLabel = if (score > 0) "$langName ($score%)" else langName + val offsetNote = matchedOffsetMsFor(selectedSubtitle, subtitle.id) + ?.let { " · ${formatMatchOffset(it)}" } ?: "" + // Built-in tracks show no match % — muxed is assumed synced, + // so a fake 100% is misleading; only addon subs carry a real score. + val mainLabel = (if (!subtitle.isEmbedded && score > 0) "$langName ($score%)" else langName) + offsetNote val badge: String? val detail: String? if (subtitle.isEmbedded && subtitle.url.isBlank()) { val langFullName = getFullLanguageName(subtitle.lang) val trackLabel = subtitle.label.takeIf { it.isNotBlank() && !it.equals(langFullName, ignoreCase = true) } - badge = if (trackLabel != null) "Built-in · $trackLabel" else "Built-in" + badge = listOfNotNull( + "Built-in", trackLabel, if (subtitle.isForced) "Forced" else null + ).joinToString(" · ") detail = null } else { - badge = subtitle.provider.ifBlank { null } + badge = listOfNotNull( + subtitle.provider.ifBlank { null }, if (subtitle.isForced) "Forced" else null + ).joinToString(" · ").ifBlank { null } detail = subtitle.id .replace(PlayerScreenRegexes.BRACKET_REGEX, "").trim() .ifBlank { subtitle.id } @@ -4789,7 +4809,7 @@ private fun SubtitleMenu( label = mainLabel, subtitle = badge, subtitleDetail = detail, - isSelected = !isAiTranslating && !isLiveAudioTranslating && selectedSubtitle?.id == subtitle.id, + isSelected = !isAiTranslating && !isLiveAudioTranslating && isSameSubtitleTrack(selectedSubtitle, subtitle.id), isFocused = subtitlePanelFocus == 1 && subtitleTrackIndex == itemIdx, onClick = { /* D-pad only */ } ) @@ -5067,20 +5087,26 @@ private fun SubtitleMenu( item(key = "mobile_${sub.id}") { val score = subtitleMatchScore(streamSource, sub) val langFullName = getFullLanguageName(sub.lang) - val displayName = if (score > 0) "$langFullName ($score%)" else langFullName + val offsetNote = matchedOffsetMsFor(selectedSubtitle, sub.id) + ?.let { " · ${formatMatchOffset(it)}" } ?: "" + // No fake % on built-in tracks; only addon subs carry a real score. + val displayName = (if (!sub.isEmbedded && score > 0) "$langFullName ($score%)" else langFullName) + offsetNote val description = when { sub.isEmbedded && sub.url.isBlank() -> { val trackLabel = sub.label.takeIf { it.isNotBlank() && !it.equals(langFullName, ignoreCase = true) } - if (trackLabel != null) "Built-in · $trackLabel" else "Built-in" + listOfNotNull( + "Built-in", trackLabel, if (sub.isForced) "Forced" else null + ).joinToString(" · ") } - sub.provider.isNotBlank() -> sub.provider - else -> null + else -> listOfNotNull( + sub.provider.ifBlank { null }, if (sub.isForced) "Forced" else null + ).joinToString(" · ").ifBlank { null } } MobileTrackItem( name = displayName, description = description, - isSelected = !isAiTranslating && !isLiveAudioTranslating && selectedSubtitle?.id == sub.id, + isSelected = !isAiTranslating && !isLiveAudioTranslating && isSameSubtitleTrack(selectedSubtitle, sub.id), onClick = { onSelectSubtitle(originalIndex + 1) } ) Box( @@ -5418,6 +5444,34 @@ private fun detectAudioCodecLabel(codec: String?, trackLabel: String?): String? // subs at once and bare numeric ids can collide across providers. '|' separator, never ':' — // ExoPlayer prefixes side-loaded format ids with "periodIndex:" and matching strips at the // last ':'. +// Stamped onto the track id of every EXTERNAL (addon / side-loaded) subtitle we attach to the +// player. Detection is then deterministic: a text track whose format id carries this prefix is one +// we injected (external); anything without it is a genuine muxed (in-container / built-in) track — +// no fuzzy label/lang matching that could misclassify. Ends with ':' so the existing selection +// match (which strips at the last ':', past ExoPlayer's "periodIndex:") still resolves the base id. +private const val ADDON_SUB_ID_PREFIX = "arvio-addon-sub:" + +// Marker appended to a served subtitle's id when a constant-offset rescue was baked in +// ("…#ofs2000"). Kept in sync with PlayerViewModel.MATCH_OFFSET_ID_MARKER. +private const val MATCH_OFFSET_ID_MARKER = "#ofs" + +/** A subtitle id with any offset-rescue marker stripped. */ +private fun subtitleBaseId(id: String): String = id.substringBefore(MATCH_OFFSET_ID_MARKER) + +/** True when [selected] is [rowId]'s track, ignoring an offset marker on either side. */ +private fun isSameSubtitleTrack(selected: Subtitle?, rowId: String): Boolean = + selected != null && subtitleBaseId(selected.id) == subtitleBaseId(rowId) + +/** The baked-in rescue offset (ms) when [selected] is [rowId]'s shifted copy, else null. */ +private fun matchedOffsetMsFor(selected: Subtitle?, rowId: String): Long? { + if (!isSameSubtitleTrack(selected, rowId)) return null + return selected!!.id.substringAfter(MATCH_OFFSET_ID_MARKER, "").toLongOrNull()?.takeIf { it != 0L } +} + +/** "+2.0s" / "-1.5s". */ +private fun formatMatchOffset(ms: Long): String = + (if (ms >= 0) "+" else "-") + String.format(java.util.Locale.US, "%.1f", kotlin.math.abs(ms) / 1000.0) + "s" + private fun subtitleTrackId(subtitle: Subtitle): String { val explicit = subtitle.id.trim() if (explicit.isNotBlank()) { @@ -5462,7 +5516,7 @@ private fun buildExternalSubtitleConfigurations(subtitles: List): List val normalizedUrl = if (rawUrl.startsWith("//")) "https:$rawUrl" else rawUrl runCatching { MediaItem.SubtitleConfiguration.Builder(Uri.parse(normalizedUrl)) - .setId(subtitleTrackId(subtitle)) + .setId(ADDON_SUB_ID_PREFIX + subtitleTrackId(subtitle)) .setMimeType(subtitleMimeTypeFromUrl(normalizedUrl)) .setLanguage(subtitle.lang) .setLabel(subtitle.label) 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 9d51572b..5d5e9816 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 @@ -43,6 +43,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -1588,13 +1589,15 @@ class PlayerViewModel @Inject constructor( ?: embedded.firstOrNull { it.isUsableSource() } } - // Prefer English embedded > any embedded with lang > any embedded > external English. - // External subs in other languages are never a sane source: their timing is unverified - // and a target-language addon sub as "source" means translating a language to itself. + // AI translation only ever translates a BUILT-IN (embedded) track — English first, else any + // other embedded foreign-language track. It must NEVER use an EXTERNAL addon sub as source + // (unverified timing, and when the user has target-language addon subs the app should + // pick/verify one of those or use the hearing scan — not AI-translate an English addon). + // So "no embedded source ⇒ no AI translation": the scan then falls to hearing (if a Gemini + // key + AI feature are on), then to the top release-name-scored addon sub. return subtitles.filter { normalizeLanguage(it.lang) == "en" }.bestEmbedded() ?: subtitles.filter { it.lang.isNotBlank() }.bestEmbedded() ?: subtitles.bestEmbedded() - ?: subtitles.firstOrNull { it.isUsableSource() && normalizeLanguage(it.lang) == "en" } } private fun languageCodeToName(code: String): String { @@ -2890,18 +2893,30 @@ class PlayerViewModel @Inject constructor( // still offered by the addons wins immediately — no need to rescan. Skipped for // user-triggered rescans, which mean the remembered pick is unwanted. if (useCache) { - val remembered = readCachedMatch()?.let { cached -> - candidates.firstOrNull { it.id == cached.id && it.provider == cached.provider } + val cached = readCachedMatch() + val remembered = cached?.let { c -> + candidates.firstOrNull { it.id == c.id && it.provider == c.provider } } if (remembered != null) { // Serve from a local cache file so the MediaItem rebuild doesn't stall on a // slow addon server (download bounded by the matcher's client timeouts). - val local = SubtitleSyncMatcher.loadRaw(remembered.url) - ?.let { localizeSubtitle(remembered, it) } ?: remembered - endMatch() - selectSubtitle(local, isUserAction = false) - showMatchToast("Matched: ${remembered.label} (remembered)") - return@launch + // Re-bake the remembered rescue offset, if any. + val offsetMs = cached.offsetMs + val raw = SubtitleSyncMatcher.loadRaw(remembered.url) + // A remembered OFFSET can only be honoured if we can download the text to bake + // the shift in. If that download fails, DON'T serve the un-shifted remote copy + // under an "auto-offset" label (the sub would be mistimed while the UI claims it + // was corrected) — fall through to a fresh scan instead. + if (raw == null && offsetMs != 0L) { + Log.w("SubMatch", "remembered offset download failed — rescanning instead of serving unshifted") + } else { + val local = raw?.let { localizeSubtitle(remembered, it, offsetMs) } ?: remembered + endMatch() + selectSubtitle(local, isUserAction = false) + val offsetNote = if (offsetMs != 0L) " (auto-offset ${formatMatchOffset(offsetMs)})" else "" + showMatchToast("Matched: ${remembered.label} (remembered)$offsetNote") + return@launch + } } } @@ -2936,10 +2951,13 @@ class PlayerViewModel @Inject constructor( if (cues.isEmpty()) null else sub to cues } - /** Select [sub], serving it from a local file when its text was downloaded. */ - fun selectServedLocally(sub: Subtitle) { + /** + * Select [sub], serving it from a local file when its text was downloaded. A non-zero + * [offsetMs] is baked into that local copy (constant-offset rescue). + */ + fun selectServedLocally(sub: Subtitle, offsetMs: Long = 0L) { val localized = rawBySubKey["${sub.provider}|${sub.id}"] - ?.let { localizeSubtitle(sub, it) } ?: sub + ?.let { localizeSubtitle(sub, it, offsetMs) } ?: sub selectSubtitle(localized, isUserAction = false) } @@ -2989,16 +3007,27 @@ class PlayerViewModel @Inject constructor( } else { MATCH_SUCCESS_THRESHOLD_HEARING } - val best = scored?.maxByOrNull { it.second } - if (best != null && best.second >= successThreshold) { - selectServedLocally(best.first) - // Cache the original (addon) identity — the local file is per-session transient. - writeCachedMatch(best.first) - showMatchToast("Matched: ${best.first.label} · ${(best.second * 100).toInt()}% ($sourceLabel)") + val best = scored?.maxByOrNull { it.score } + // A corroborated constant-offset rescue (offsetMs != 0, set only when ≥2 candidates + // agreed) accepts at its own lower bar — segmentation caps its timing score below the + // normal 0.70. The lone-strong offset path already required ≥0.80, so it clears this too. + if (best != null && ( + best.score >= successThreshold || + (best.offsetMs != 0L && best.score >= MATCH_OFFSET_CORROBORATED_ACCEPT) + ) + ) { + selectServedLocally(best.sub, best.offsetMs) + // Cache the original (addon) identity + any rescue offset — the local file is + // per-session transient, but the offset must be re-applied on the next playback. + writeCachedMatch(best.sub, best.offsetMs) + val offsetNote = if (best.offsetMs != 0L) " (auto-offset ${formatMatchOffset(best.offsetMs)})" else "" + showMatchToast( + "Matched: ${best.sub.label} · ${(best.score * 100).toInt()}% ($sourceLabel)$offsetNote" + ) } else { // Nothing synced found (or too little dialogue) → let the caller fall back (AI translate). restoreSubtitle(previousSubtitle) - noMatch(best?.second) + noMatch(best?.score) selectLastResort() } } @@ -3008,13 +3037,116 @@ class PlayerViewModel @Inject constructor( private fun normalizeCueTextForCompare(text: String): String = text.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim() + /** + * A scored candidate. [offsetMs] is 0 for a normal (as-authored) match, or the uniform delay + * that had to be baked in for a constant-offset rescue — in which case [score] is the + * *corrected* (post-shift) timing score. + */ + private data class ScoredCandidate(val sub: Subtitle, val score: Double, val offsetMs: Long) + + /** "+2.0s" / "-1.5s" for toasts and menu rows. */ + private fun formatMatchOffset(ms: Long): String = + (if (ms >= 0) "+" else "-") + String.format(java.util.Locale.US, "%.1f", kotlin.math.abs(ms) / 1000.0) + "s" + + /** + * Score every candidate against the reference [refs], with constant-offset rescue. Each + * candidate gets: (a) its as-authored score; (b) a lone strong offset (search corrected ≥ + * [MATCH_OFFSET_ACCEPT]); and (c) a *corroborated* offset — when ≥[MATCH_OFFSET_CORROBORATE_MIN] + * candidates independently agree on an offset (within [MATCH_OFFSET_CORROBORATE_MS]), that + * shared shift is trusted and applied to every candidate at the lower [MATCH_OFFSET_CORROBORATED_ACCEPT] + * bar. The candidates are the same episode from different addons, so agreement is strong evidence + * the offset is real (and it discards search-noise outliers that no one else voted for). + */ + private fun scoreCandidatesWithOffsets( + loaded: List>>, + rawRefs: List>, + debug: Boolean + ): List { + val tol = MATCH_OVERLAP_TOLERANCE_MS + // Orphan-drop: discard reference windows that NO candidate covers at all — almost always + // SDH/non-dialogue reference cues (sound effects, on-screen text) the dialogue subs lack. + // Left in, each scores a hard 0 for everyone and craters an otherwise-perfect sub. But only + // drop when the uncovered windows are a small MINORITY: if a large fraction is uncovered + // that's bad sync across the board (not SDH), and dropping it would shrink the denominator + // enough to inflate a sub that matches only a couple of windows into a false ~100%. + val covered = rawRefs.filter { (rs, re) -> + loaded.any { (_, cues) -> cues.any { it.startMs < re && rs < it.endMs } } + } + val keepFloor = Math.ceil(rawRefs.size * MATCH_ORPHAN_KEEP_MIN_FRACTION).toInt() + val refs = if (covered.size >= MATCH_MIN_REF_INTERVALS && covered.size >= keepFloor) { + covered + } else { + rawRefs + } + data class Est( + val sub: Subtitle, + val cues: List, + val normal: Double, + val offset: SubtitleSyncMatcher.OffsetMatch? + ) + val ests = loaded.map { (sub, cues) -> + Est( + sub, cues, + SubtitleSyncMatcher.scoreByTiming(cues, refs, tol), + // Offset search stays strict (no tolerance) so its peaks stay sharp for corroboration. + SubtitleSyncMatcher.estimateOffsetMatch(cues, refs, MATCH_OFFSET_MIN_MS, MATCH_OFFSET_MAX_MS) + ) + } + // Each candidate votes its best offset if it meaningfully improves that candidate. Compare + // against the strict base (both from the offset search) — not the tolerant accept score. + val votes = ests.mapNotNull { e -> + e.offset?.takeIf { it.correctedScore >= MATCH_OFFSET_CORROBORATE_FLOOR && it.correctedScore > it.baseScore } + ?.offsetMs + }.sorted() + // Largest cluster of votes within the agreement window; median of it is the trusted offset. + var corroboratedOffset: Long? = null + var corroboratedSupport = 0 + for (anchor in votes) { + val members = votes.filter { kotlin.math.abs(it - anchor) <= MATCH_OFFSET_CORROBORATE_MS } + if (members.size > corroboratedSupport) { + corroboratedSupport = members.size + corroboratedOffset = members[members.size / 2] + } + } + val corrob = corroboratedOffset?.takeIf { corroboratedSupport >= MATCH_OFFSET_CORROBORATE_MIN } + return ests.map { e -> + var score = e.normal // tolerant, offset 0 + var off = 0L + // (b) lone strong offset — qualify via the strict search (≥ 0.80), then take the + // tolerant score at that shift so the accept bar is measured consistently. + e.offset?.let { + if (it.correctedScore >= MATCH_OFFSET_ACCEPT) { + val sc = SubtitleSyncMatcher.scoreByTiming(SubtitleSyncMatcher.shiftCues(e.cues, it.offsetMs), refs, tol) + if (sc > score) { score = sc; off = it.offsetMs } + } + } + // (c) corroborated offset — tolerant re-score at the shared shift (a candidate's own + // argmax may have landed elsewhere on search noise; the corroborated value is trusted). + if (corrob != null) { + val sc = SubtitleSyncMatcher.scoreByTiming(SubtitleSyncMatcher.shiftCues(e.cues, corrob), refs, tol) + if (sc >= MATCH_OFFSET_CORROBORATED_ACCEPT && sc > score) { + score = sc; off = corrob + } + } + if (debug) { + android.util.Log.i( + "SubMatch", + "[builtin] candidate label=\"${e.sub.label}\" provider=${e.sub.provider} cues=${e.cues.size} " + + "refIntervals=${refs.size} score=${"%.2f".format(score)}" + + (if (off != 0L) " offset=${off}ms" else "") + ) + } + ScoredCandidate(e.sub, score, off) + } + } + /** Reference = a built-in track's cue timing (any language). Returns null if too little dialogue. */ private suspend fun scoreAgainstBuiltIn( loaded: List>>, referenceSub: Subtitle, sourceLabel: String, previousSubtitle: Subtitle? - ): List>? { + ): List? { synchronized(referenceIntervals) { referenceIntervals.clear() } refIntervalStart = -1L // If AI translation is already showing (it has the source/English track selected under the @@ -3138,8 +3270,13 @@ class PlayerViewModel @Inject constructor( ) break // Early accept: scoring is free, so score incrementally — once some candidate is // already clearly in sync there's no need to keep collecting up to the target. + // Scoring is CPU-heavy (esp. the offset sweep below, ×N candidates) — run it off the + // Main dispatcher so it can't stutter playback/UI on slower TV boxes. The buffered-cue + // reflection reads at the top of the loop stay on Main (withContext returns here after). val bestNow = if (refs.size >= MATCH_EARLY_ACCEPT_REFS) { - loaded.maxOf { (_, cues) -> SubtitleSyncMatcher.scoreByTiming(cues, refs) } + withContext(Dispatchers.Default) { + loaded.maxOf { (_, cues) -> SubtitleSyncMatcher.scoreByTiming(cues, refs) } + } } else { 0.0 } @@ -3150,6 +3287,20 @@ class PlayerViewModel @Inject constructor( if (span >= MATCH_FAST_ACCEPT_SPAN_MS && refs.size >= MATCH_FAST_ACCEPT_REFS && bestNow >= MATCH_FAST_ACCEPT_SCORE ) break + // Constant-offset early accept: none of the candidates may be synced as-authored, yet a + // single uniform delay can make one work. Once there's solid buffered evidence (the + // offset search needs trustworthy authored timing), if a corroborated offset already + // produces an acceptable winner, end collection now instead of waiting out the scan. + if (spanOk && refs.size >= MATCH_OFFSET_EARLY_REFS && + bufferedCount >= MATCH_MIN_BUFFERED_FOR_REJECT + ) { + val early = withContext(Dispatchers.Default) { + scoreCandidatesWithOffsets(loaded, refs, debug = false) + } + .filter { it.offsetMs != 0L } + .maxByOrNull { it.score } + if (early != null && early.score >= MATCH_OFFSET_CORROBORATED_ACCEPT) break + } // Give-up timers: a long one while waiting for speech to exist at all (openings can // run minutes without dialogue), and a progress-anchored one afterwards — a silent // scene mid-scan pauses the clock instead of draining it. Absolute ceiling on top. @@ -3193,19 +3344,11 @@ class PlayerViewModel @Inject constructor( ) } - val results = loaded.map { (sub, cues) -> - val s = SubtitleSyncMatcher.scoreByTiming(cues, refs) - android.util.Log.i( - "SubMatch", - "[builtin] candidate label=\"${sub.label}\" provider=${sub.provider} cues=${cues.size} " + - "refIntervals=${refs.size} src=$srcLabel score=${"%.2f".format(s)}" - ) - sub to s - } + val results = withContext(Dispatchers.Default) { scoreCandidatesWithOffsets(loaded, refs, debug = true) } // A LOW score from a realtime-dominated reference (give-up exit without buffered quorum) // is not evidence of a bad sub — report inconclusive instead of a confident // "no well-synced subtitle (best N%)" reject. Accepts pass through unchanged. - val best = results.maxOfOrNull { it.second } ?: 0.0 + val best = results.maxOfOrNull { it.score } ?: 0.0 if (best < MATCH_SUCCESS_THRESHOLD_TIMING && bufferedCount < MATCH_MIN_BUFFERED_FOR_REJECT) { android.util.Log.i( "SubMatch", @@ -3220,7 +3363,7 @@ class PlayerViewModel @Inject constructor( private suspend fun scoreAgainstHearing( loaded: List>>, sourceLabel: String - ): List>? { + ): List? { startMatchListening() val samples = mutableListOf() val collector = viewModelScope.launch { @@ -3275,7 +3418,7 @@ class PlayerViewModel @Inject constructor( "[hearing] candidate label=\"${sub.label}\" provider=${sub.provider} cues=${cues.size} " + "samples=${samples.size} score=${"%.2f".format(s)}" ) - sub to s + ScoredCandidate(sub, s, 0L) } } @@ -3321,7 +3464,12 @@ class PlayerViewModel @Inject constructor( // ── Per-stream match cache ────────────────────────────────────────────────── - private data class CachedSubMatch(val key: String, val provider: String, val id: String) + private data class CachedSubMatch( + val key: String, + val provider: String, + val id: String, + val offsetMs: Long = 0L // constant-offset rescue delay to re-apply (0 = as authored) + ) /** * Identity of the currently playing file, stable across sessions. Torrent infoHash+fileIdx is @@ -3359,14 +3507,14 @@ class PlayerViewModel @Inject constructor( } /** Remember [subtitle] as the match for the current stream (null = forget the entry). */ - private fun writeCachedMatch(subtitle: Subtitle?) { + private fun writeCachedMatch(subtitle: Subtitle?, offsetMs: Long = 0L) { val key = currentStreamCacheKey() ?: return viewModelScope.launch { runCatching { val cache = loadMatchCache() val removed = cache.removeAll { it.key == key } if (subtitle == null && !removed) return@runCatching - if (subtitle != null) cache.add(CachedSubMatch(key, subtitle.provider, subtitle.id)) + if (subtitle != null) cache.add(CachedSubMatch(key, subtitle.provider, subtitle.id, offsetMs)) while (cache.size > MATCH_CACHE_MAX_ENTRIES) cache.removeAt(0) context.settingsDataStore.edit { it[subtitleMatchCacheKey] = gson.toJson(cache) } }.onFailure { Log.w("SubMatch", "match cache write failed: ${it.message}") } @@ -3392,15 +3540,24 @@ class PlayerViewModel @Inject constructor( * server — which is what used to leave the player stuck buffering after a match. Also * normalizes the content (UTF-8, gunzipped) as a side effect. */ - private fun localizeSubtitle(sub: Subtitle, raw: String): Subtitle { + private fun localizeSubtitle(sub: Subtitle, raw: String, offsetMs: Long = 0L): Subtitle { return runCatching { + // Constant-offset rescue: bake the delay into the served text so the correction lives + // in the file, independent of the player's delay knob (no state leaks across track/ + // source switches). The id gets an "…#ofs" marker so this shifted copy is a + // DISTINCT track — otherwise preload mode would override to the un-shifted preloaded + // copy that shares the base id. + val text = if (offsetMs != 0L) SubtitleSyncMatcher.shiftTimestamps(raw, offsetMs) else raw val dir = java.io.File(context.cacheDir, "matched_subs").apply { mkdirs() } // Keep the directory bounded; files are ~100KB and keyed stably per subtitle. dir.listFiles()?.sortedBy { it.lastModified() }?.dropLast(39)?.forEach { it.delete() } - val ext = if (raw.trimStart().startsWith("WEBVTT")) "vtt" else "srt" - val file = java.io.File(dir, "${("${sub.provider}|${sub.id}").hashCode().toUInt()}.$ext") - file.writeText(raw) - sub.copy(url = file.toURI().toString()) + val ext = if (text.trimStart().startsWith("WEBVTT")) "vtt" else "srt" + val idBase = "${sub.provider}|${sub.id}" + val fileKey = if (offsetMs != 0L) "$idBase$MATCH_OFFSET_ID_MARKER$offsetMs" else idBase + val file = java.io.File(dir, "${fileKey.hashCode().toUInt()}.$ext") + file.writeText(text) + val newId = if (offsetMs != 0L) "${sub.id}$MATCH_OFFSET_ID_MARKER$offsetMs" else sub.id + sub.copy(id = newId, url = file.toURI().toString()) }.getOrDefault(sub) } @@ -4291,6 +4448,40 @@ class PlayerViewModel @Inject constructor( private const val MATCH_MAX_CANDIDATES = 10 // cap downloaded candidates (best release-name matches first) private const val MATCH_CACHE_MAX_ENTRIES = 50 // per-stream remembered matches (oldest evicted) + // Constant-offset rescue: a candidate that fails at offset 0 but is a perfectly-cut sub + // with a UNIFORM delay gets shifted into sync instead of rejected (common addon defect). + // Accept/reject scoring widens each candidate cue by this before measuring overlap, so a + // genuinely-synced sub isn't docked for sub-second caption pre-roll or different cue + // boundaries (SDH reference vs merged dialogue sub). The offset SEARCH stays strict (0) to + // keep its peaks sharp. Kept modest so a truly mis-synced sub (≥~1s off) still fails here + // and is instead caught by the offset search. + private const val MATCH_OVERLAP_TOLERANCE_MS = 400L + // Orphan-drop only fires when at least this fraction of reference windows stay covered by + // some candidate; below it, keep all windows (widespread non-coverage = bad sync, not SDH). + private const val MATCH_ORPHAN_KEEP_MIN_FRACTION = 0.7 + private const val MATCH_OFFSET_MIN_MS = 300L // below this the sub is "already aligned" — normal path owns it + private const val MATCH_OFFSET_MAX_MS = 10_000L // above this a "constant offset" is implausible + private const val MATCH_OFFSET_ACCEPT = 0.80 // single candidate: strict bar (no corroboration available) + private const val MATCH_OFFSET_EARLY_REFS = 6 // min refs before the mid-scan offset check runs + // Cross-candidate corroboration: the candidates are the same episode from different addons, so + // a REAL global offset shows up in several of them; a spurious one (search noise) does not. + // When ≥N agree on an offset within a tolerance, we trust it and accept at a lower bar than a + // lone candidate — interval-overlap scoring caps well below 1.0 when the reference (finely + // split CC) and candidate (merged lines) are segmented differently, even at the true offset. + private const val MATCH_OFFSET_CORROBORATE_MS = 400L // agreement window between candidate offsets + private const val MATCH_OFFSET_CORROBORATE_MIN = 2 // min candidates that must agree + private const val MATCH_OFFSET_CORROBORATE_FLOOR = 0.62 // min corrected score to be an eligible voter + // Accept bar once an offset is corroborated. Lower than the normal 0.70 timing bar on purpose: + // the agreement of ≥2 independent addon files IS the evidence, and interval-overlap scoring is + // structurally capped (~0.71 here) when the reference (finely-split CC) and candidate (merged + // lines) are segmented differently even at the perfect offset. A corroborated match may accept + // below 0.70 — see the offset clause in findBestSubtitleMatch's accept test. + private const val MATCH_OFFSET_CORROBORATED_ACCEPT = 0.68 + // Marker appended to a served copy's id when an offset was baked in (e.g. "…#ofs2000"). + // Forces a distinct track id (so preload mode rebuilds onto the shifted file rather than + // overriding to the un-shifted preloaded copy); the menu strips it to match rows by base id. + const val MATCH_OFFSET_ID_MARKER = "#ofs" + /** Known debrid service CDN domains. Reachability checks are skipped for these. */ private val DEBRID_CDN_DOMAINS = setOf( // Real-Debrid diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt index ef210f3f..c6492cfa 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt @@ -92,24 +92,56 @@ object SubtitleSyncMatcher { * * Language-agnostic (no translation needed). */ - fun scoreByTiming(cues: List, referenceIntervals: List>): Double { + /** + * @param toleranceMs each candidate cue is widened by this on both edges before measuring + * overlap — absorbs sub-second caption pre-roll / display lag and cue-boundary differences so a + * genuinely-synced but differently-segmented sub isn't docked. **0 (strict) for the offset + * search** (keeps its peaks sharp); a small positive value for accept/reject scoring. + */ + fun scoreByTiming( + cues: List, + referenceIntervals: List>, + toleranceMs: Long = 0L + ): Double { if (cues.isEmpty() || referenceIntervals.isEmpty()) return 0.0 - val sorted = cues.sortedBy { it.startMs } + return scoreSortedShifted(cues.sortedBy { it.startMs }, referenceIntervals, 0L, toleranceMs) + } + + /** + * [scoreByTiming]'s core, over cues **pre-sorted by start** and with a uniform [offsetMs] added + * to every cue inline (no list copy). Adding a constant offset preserves start order, so the + * sort stays valid — this lets the offset search sweep many offsets cheaply. + */ + private fun scoreSortedShifted( + sorted: List, + referenceIntervals: List>, + offsetMs: Long, + toleranceMs: Long = 0L + ): Double { var sum = 0.0 for ((rs, re) in referenceIntervals) { val refDur = (re - rs).coerceAtLeast(1L) val fraction = if (refDur <= SINGLE_CUE_MAX_MS) { - sorted.maxOfOrNull { c -> - (minOf(re, c.endMs) - maxOf(rs, c.startMs)).toDouble() / refDur - } ?: 0.0 + var best = 0.0 + for (c in sorted) { + val cs = c.startMs + offsetMs - toleranceMs + if (cs >= re) break // sorted by start → nothing later can overlap + val ce = c.endMs + offsetMs + toleranceMs + if (ce <= rs) continue + val ov = (minOf(re, ce) - maxOf(rs, cs)).toDouble() / refDur + if (ov > best) best = ov + } + best } else { var covered = 0L var cursor = rs for (c in sorted) { - if (c.endMs <= cursor) continue - if (c.startMs >= re) break - val s = maxOf(c.startMs, cursor) - val e = minOf(c.endMs, re) + val cs = c.startMs + offsetMs - toleranceMs + if (cs >= re) break + val ce = c.endMs + offsetMs + toleranceMs + if (ce <= cursor) continue + val s = maxOf(cs, cursor) + val e = minOf(ce, re) if (e > s) { covered += e - s cursor = e @@ -122,6 +154,98 @@ object SubtitleSyncMatcher { return sum / referenceIntervals.size } + // ── Constant-offset rescue ────────────────────────────────────────────────── + + /** + * A candidate that fails at offset 0 but becomes well-synced once shifted by a single uniform + * delay. [offsetMs] is added to every cue (positive = the sub must be pushed *later*); + * [correctedScore] is [scoreByTiming] of the shifted cues against the reference; [baseScore] is + * the offset-0 score (so the caller can require a real improvement). + */ + data class OffsetMatch(val offsetMs: Long, val correctedScore: Double, val baseScore: Double) + + fun shiftCues(cues: List, offsetMs: Long): List = + if (offsetMs == 0L) cues + else cues.map { TimedCue(it.startMs + offsetMs, it.endMs + offsetMs, it.text) } + + /** + * Detect whether [cues] are a perfectly-cut subtitle carrying a UNIFORM delay against the + * reference (a common addon defect: right words, globally-shifted timing — the base metric + * punishes it). Returns the delay that maximizes the timing score + that corrected score, or + * null when the best offset is negligible (< [minOffsetMs]). + * + * We **search the offset directly**, maximizing [scoreByTiming] (the same overlap metric that + * decides acceptance), NOT cue-start clustering. Start-clustering was tried first and FAILED on + * real data: the reference is usually a finely-split CC track while the candidate merges + * dialogue into fewer/longer cues, so cue *starts* never line up 1:1 even at the correct global + * offset (Dutton Ranch S01E01: a flat +1000ms is visually perfect, yet start-deltas scattered + * +583/−617/−2208 and the cluster never formed). Overlap is segmentation-agnostic. A coarse + * sweep over ±[maxOffsetMs] locates the peak; a fine sweep around it pins the exact value. + * Wrong-show/drift subs never reach a high corrected score at any offset, so the caller's score + * bar is what rejects them — no separate spread test needed. + */ + fun estimateOffsetMatch( + cues: List, + referenceIntervals: List>, + minOffsetMs: Long, + maxOffsetMs: Long + ): OffsetMatch? { + if (cues.size < 3 || referenceIntervals.size < 4) return null + val sorted = cues.sortedBy { it.startMs } + val baseScore = scoreSortedShifted(sorted, referenceIntervals, 0L) + + var bestOff = 0L + var bestScore = baseScore + fun consider(off: Long) { + val s = scoreSortedShifted(sorted, referenceIntervals, off) + // Strictly better wins; on a tie prefer the offset CLOSEST to zero — real sync errors + // are small, and a far offset that merely ties a near one is spurious (the sweep starts + // at −max, so without this the far offset would win every tie). + if (s > bestScore + 1e-9) { + bestScore = s + bestOff = off + } else if (s > bestScore - 1e-9 && Math.abs(off) < Math.abs(bestOff)) { + bestOff = off + } + } + // Coarse sweep (250ms): the score-vs-offset curve is smooth with a peak ~cue-duration wide, + // so this can't step over it. Then refine ±250ms at 25ms for exact placement. + var off = -maxOffsetMs + while (off <= maxOffsetMs) { consider(off); off += 250L } + val lo = bestOff - 250L + val hi = bestOff + 250L + off = lo + while (off <= hi) { consider(off); off += 25L } + + if (Math.abs(bestOff) < minOffsetMs) return null + return OffsetMatch(bestOff, bestScore, baseScore) + } + + /** + * Add [offsetMs] to every timestamp in raw SRT/WEBVTT text, preserving format (comma vs dot) + * and any trailing cue settings. Used to bake a detected offset into the served local file so + * the correction survives independently of the player's delay knob. + */ + fun shiftTimestamps(raw: String, offsetMs: Long): String { + if (offsetMs == 0L) return raw + return TIME_LINE.replace(raw) { m -> + val start = parseTimestamp(m.groupValues[1]) ?: return@replace m.value + val end = parseTimestamp(m.groupValues[2]) ?: return@replace m.value + val useComma = m.groupValues[1].contains(',') + "${formatTimestamp(start + offsetMs, useComma)} --> ${formatTimestamp(end + offsetMs, useComma)}" + } + } + + private fun formatTimestamp(ms: Long, useComma: Boolean): String { + val v = ms.coerceAtLeast(0L) + val h = v / 3_600_000 + val m = (v % 3_600_000) / 60_000 + val s = (v % 60_000) / 1_000 + val millis = v % 1_000 + val sep = if (useComma) ',' else '.' + return String.format(java.util.Locale.US, "%02d:%02d:%02d%c%03d", h, m, s, sep, millis) + } + // ── Similarity ──────────────────────────────────────────────────────────── /** Overlap coefficient of normalized word sets — tolerant of different wording/length. */ diff --git a/docs/subtitle-auto-match.md b/docs/subtitle-auto-match.md index 59ffdf7f..048a9043 100644 --- a/docs/subtitle-auto-match.md +++ b/docs/subtitle-auto-match.md @@ -148,6 +148,52 @@ Calibration (user-verified, July 2026): synced subs score 0.71–0.98; a confirm scored 0.65. Accept threshold **0.70** (timing) / **0.30** (hearing). The good/bad boundary is narrow — do not nudge thresholds or the metric without fresh A/B evidence. +**Two robustness adjustments to the accept score (July 15 2026, branch `find_best_match_offset`)** — +both apply to accept/reject scoring ONLY, never to the offset search (§ below), which stays strict: +- **Overlap tolerance** (`scoreByTiming(cues, refs, toleranceMs)`, `MATCH_OVERLAP_TOLERANCE_MS` = + 400): each candidate cue is widened ±400ms before measuring overlap, absorbing sub-second caption + pre-roll and cue-boundary differences (a finely-split SDH reference vs a sub that merges lines). + Without it a genuinely-perfect sub scored 0.61 on an SDH reference. Kept modest so a sub ≥~1s off + still fails here and is caught by the offset search instead. +- **Orphan-drop** (`scoreCandidatesWithOffsets`): reference windows covered by NO candidate are + dropped before scoring — almost always SDH/non-dialogue reference cues the dialogue subs lack. + Each would score a hard 0 for everyone and crater a perfect sub, worst on sparse refs. + +### Constant-offset rescue (`estimateOffsetMatch` + `scoreCandidatesWithOffsets`) + +A candidate that scores badly at offset 0 but is a **perfectly-cut sub with a UNIFORM delay** (a +common addon defect — right words, globally-shifted timing) is rescued instead of rejected. During +the *same* scan every candidate is tested for a single constant delay that lines it up. + +- **Detection is an offset SEARCH, not cue-start clustering.** `estimateOffsetMatch` sweeps the + offset directly, maximizing the strict `scoreByTiming`: coarse ±10s @250ms, then fine ±250ms + @25ms. A cluster-of-`refStart−cueStart`-deltas approach was tried FIRST and failed on real data — + the reference is a finely-split English CC track while addon subs merge dialogue into fewer/longer + cues, so cue *starts* never line up 1:1 even at the correct offset (Dutton Ranch S01E01: a flat + +1000ms was visually perfect yet start-deltas scattered +583/−617/−2208). Overlap-max is + segmentation-agnostic. Offsets `< MATCH_OFFSET_MIN_MS` (300) are "already aligned" — ignored. +- **Cross-candidate corroboration is the acceptance signal.** The candidates are the same episode + from different addons, so a real global offset appears in ≥2 of them; a search-noise outlier does + not. Each candidate votes its best offset (if it beats its strict base and clears `FLOOR` 0.62); + votes within `CORROBORATE_MS` (400) are clustered; if ≥`CORROBORATE_MIN` (2) agree, the median is + the trusted offset. Every candidate is then re-scored (tolerant) at that shared shift and accepts + at `CORROBORATED_ACCEPT` (0.68) — **decoupled from the 0.70 timing bar** because segmentation caps + a perfect-but-differently-cut sub around 0.71. `findBestSubtitleMatch`'s accept test is therefore + `score ≥ successThreshold OR (offsetMs ≠ 0 && score ≥ 0.68)`. Real example that both rescued the + true offset and rejected the outlier: `votes=[−4400, 1000, 1000, 1000] → 1000ms (support=3)`. +- A **lone** candidate (no corroboration possible) still rescues at the stricter `MATCH_OFFSET_ACCEPT` + (0.80). Mid-scan, once a corroborated offset already yields an acceptable winner, collection ends + early (`refs ≥ MATCH_OFFSET_EARLY_REFS`, buffered quorum). +- The offset is **baked into the served local file** (`shiftTimestamps` in `localizeSubtitle`), + never the player's delay knob — so it can't leak across track/source switches. The served copy's + id gets a `#ofs` marker (`MATCH_OFFSET_ID_MARKER`): a distinct track id that forces the + preload path to rebuild onto the shifted file rather than override to the un-shifted preloaded + copy sharing the base id. The menu matches rows by **base id** (`subtitleBaseId` / + `isSameSubtitleTrack` in PlayerScreen) and shows `· +1.0s` on the selected row. The per-stream + cache stores `offsetMs` and re-bakes it on remembered hits. Toast: `… (auto-offset +1.0s)`. +- Log (tag `SubMatch`): the per-candidate verdict line carries the outcome — + `[builtin] candidate … score= offset=` (offset shown only when one was applied). + --- ## 3. Selection & the local-file trick @@ -262,9 +308,19 @@ default is SRT for extensionless URLs. - Batch translation pre-fetch (`triggerPreTranslation`/`preTranslateWindow`) is gated on `translationManager.isEnabled` — without this it spends API requests whenever any track renders (the 401-toast-with-AI-off bug). +- **AI translation source is a built-in (embedded) ENGLISH track ONLY** (`findAiSourceSubtitle`, + July 15 2026). Never an external addon sub, never a non-English embedded track. So "no embedded + English ⇒ no AI translation": on such a source the AI interim doesn't activate, which (a) matches + the intended feature scope and (b) frees the single Gemini pipeline so the **hearing** scan can + run — previously the AI interim activated off an *external* English sub, grabbed Gemini, and the + `isAiTranslating -> null` dispatch branch silently skipped hearing so "Find Best Match" never ran. + Ladder on a no-built-in source: hearing (needs AI on + Gemini key + model) → else the top + release-name-scored addon sub (`selectLastResort`). The timing-scan reference (`builtInReference`) + is separate and may still use a non-English embedded track. Tradeoff: a source with only an + external English sub (no embedded English, no target-language addon) no longer gets AI Hebrew. - AI interim during scans: only when `aiSubtitleEnabled && aiSubtitleAutoSelect`. Source is - re-resolved on every activation (embedded preferred; external only if English) — a stale - external source caused mistimed translations. + re-resolved on every activation (embedded English only, per above) — a stale source caused + mistimed translations. - Hearing fallback: Gemini model + key + AI on; aborts on WS ERROR; same progress-anchored timers as the timing scan. - Gemini Live target language comes from the preference (`targetLanguageCode`), default `he`.