Skip to content
Closed
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 @@ -3902,6 +3902,11 @@ fun PlayerScreen(
}
}

// AutoSync Glass Bubble Notifications
com.arflix.tv.ui.screens.player.audiosync.bubble.AutoSyncBubbleToastHost(
controlsVisible = isOverlayVisible
)

// ARVIO Mobile Player Overlay (Phone & Tablet Touch Devices)
if (isTouchDevice && !isInPipMode) {
ArvioMobilePlayer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3991,12 +3991,16 @@ class PlayerViewModel @Inject constructor(
* listens to the AI hearing for a short window, then scores each candidate by how often its
* on-screen cue matches what was spoken at that moment (see [SubtitleSyncMatcher]).
*/
fun findBestSubtitleMatch(onNoMatch: ((Double?) -> Unit)? = null, useCache: Boolean = true) {
fun findBestSubtitleMatch(
onNoMatch: ((Double?) -> Unit)? = null,
useCache: Boolean = true,
scanLang: String? = null,
) {
if (_uiState.value.isFindingBestMatch) return
val previousSubtitle = _uiState.value.selectedSubtitle
findMatchJob?.cancel()
findMatchJob = viewModelScope.launch {
val targetLang = targetSubtitleLangCode.ifBlank { normalizeLanguage(getDefaultSubtitle()) }
val targetLang = scanLang ?: targetSubtitleLangCode.ifBlank { normalizeLanguage(getDefaultSubtitle()) }
val targetLangName = languageCodeToName(targetLang)
// Include the best (rejected) score so a fast verdict is visibly a real scan result.
val noMatch = onNoMatch ?: { score ->
Expand Down Expand Up @@ -5731,11 +5735,12 @@ class PlayerViewModel @Inject constructor(
// Verdicts are recorded against the family representative that was checked, which
// is `decided` — `winner` may be a family member served in its place.
val winnerVerdict = aiVerdicts["${decided.sub.provider}|${decided.sub.id}"]
val isSecondaryRun = scanLang != null
if (retimedCopy != null) {
// The cache stores a subtitle and one offset; a cue-by-cue retime is neither,
// and replaying it would need the whole scan anyway. Scan again next time.
Log.i("SubMatch", "not remembering \"${winner.sub.label}\": re-timed cue by cue")
} else if (mayRememberMatch(aiIsArbiter, winnerVerdict)) {
} else if (!isSecondaryRun && mayRememberMatch(aiIsArbiter, winnerVerdict)) {
rememberMatchAfterDwell(winner.sub, winner.offsetMs)
} else {
Log.i(
Expand Down Expand Up @@ -5836,6 +5841,26 @@ class PlayerViewModel @Inject constructor(
"audio sync: skipped — the model found none of the ${candidates.size} subtitles to be this episode's dialogue"
)
}
// Secondary preferred language fallback: before falling back to audio sync,
// try scanning candidate subtitles in the secondary preferred language if defined.
val secondaryLang = runCatching {
val prefs = context.settingsDataStore.data.first()
prefs[secondarySubtitleKey()]?.trim().orEmpty()
.let { if (isSubtitleDisabledPreference(it)) null else normalizeLanguage(it) }
}.getOrNull()
val canTrySecondary = scanLang == null && !secondaryLang.isNullOrBlank() && secondaryLang != targetLang
if (canTrySecondary) {
val hasSecondaryCandidates = _uiState.value.subtitles.any {
!it.isEmbedded && !it.isBitmap && it.url.isNotBlank() && normalizeLanguage(it.lang) == secondaryLang
}
if (hasSecondaryCandidates) {
Log.i("SubMatch", "primary language ($targetLang) unverified; attempting secondary language ($secondaryLang)")
matchStep("4· primary language unverified — trying secondary ($secondaryLang)…")
findBestSubtitleMatch(onNoMatch = onNoMatch, useCache = false, scanLang = secondaryLang)
return@launch
}
}

val hearing = !wrongEpisode && hearingPick != null && startHearingFallback(hearingPick, anchor) {
noMatch(best?.score)
selectLastResort()
Expand Down Expand Up @@ -6268,7 +6293,12 @@ class PlayerViewModel @Inject constructor(
currentMediaType == MediaType.MOVIE -> "movie" to imdb
else -> "series" to "$imdb:${currentSeason ?: 1}:${currentEpisode ?: 1}"
}
sync.arm(references, type, videoId)
val secondaryLang = runCatching {
val prefs = context.settingsDataStore.data.first()
prefs[secondarySubtitleKey()]?.trim().orEmpty()
.let { if (isSubtitleDisabledPreference(it)) null else normalizeLanguage(it) }
}.getOrNull()
sync.arm(references, type, videoId, secondaryLang)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,20 @@ internal class ArvioAudioSync(
references: List<AudioSubtitleSyncController.ReferenceCandidate>,
contentType: String?,
videoId: String?,
secondaryLanguage: String? = null,
) {
stop()
if (!AudioSyncSettings.fallbackEnabled.value) return
controller.enabled = true
controller.listensBeforeSession = true
controller.secondaryPreferredLanguage = secondaryLanguage
val key = "$contentType|$videoId"
if (key != contentKey && !contentType.isNullOrBlank() && !videoId.isNullOrBlank()) {
contentKey = key
controller.setContent(contentType, videoId)
}
controller.setReferenceSubtitles(references)
SyncLog.i("armed: ${references.size} addon subtitles, content=$contentType/$videoId")
SyncLog.i("armed: ${references.size} addon subtitles, secondary=$secondaryLanguage, content=$contentType/$videoId")
}

/** Whether a takeover can happen at all. */
Expand Down Expand Up @@ -391,7 +393,15 @@ internal class ArvioAudioSync(
is AudioSyncStatus.Adjusted ->
appContext.getString(R.string.player_audio_sync_adjusted, formatOffset(status.offsetMs))
}
mainHandler.post { Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show() }
val kind = when (status) {
AudioSyncStatus.Listening, is AudioSyncStatus.ModelDownloading ->
com.arflix.tv.ui.screens.player.audiosync.bubble.AutoSyncBubbleKind.Working
AudioSyncStatus.Withdrawn ->
com.arflix.tv.ui.screens.player.audiosync.bubble.AutoSyncBubbleKind.Failure
else ->
com.arflix.tv.ui.screens.player.audiosync.bubble.AutoSyncBubbleKind.Success
}
com.arflix.tv.ui.screens.player.audiosync.bubble.showAutoSyncMessage(appContext, kind, message)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -750,10 +750,18 @@ internal class AudioSubtitleSyncController(
)
}

@Volatile
var secondaryPreferredLanguage: String? = null

private fun englishCandidates(): List<ReferenceCandidate> =
(candidates.filter { isEnglish(it.language) } + fallbackCandidates).distinctBy { it.url }
(candidates.filter { isEnglish(it.language) || isSecondary(it.language) } + fallbackCandidates).distinctBy { it.url }

@Volatile
private fun isSecondary(language: String?): Boolean {
val target = secondaryPreferredLanguage?.trim()?.lowercase() ?: return false
if (target.isBlank() || target == "off" || target == "none") return false
val value = language?.trim()?.lowercase() ?: return false
return value == target || value.startsWith("$target-") || value.startsWith("${target}_")
}
private var contentKey: String? = null

/** English subtitles from the public OpenSubtitles service, independent of addon language settings. */
Expand Down
Loading
Loading