From 4a71c171e914e1991fe663c12a42dfa38ca1b33b Mon Sep 17 00:00:00 2001 From: ReichiMD Date: Fri, 4 Sep 2026 13:17:59 +0000 Subject: [PATCH] i18n(player): resolve PlayerViewModel messages in the composable The player view model built its user-facing text itself: loading phases, playback errors, the AI translation toast and every "Find best match" status and result. A view model only holds the application context, whose resources follow the system language rather than the language selected in the app, so those strings ignored the in-app language even once they lived in strings.xml. The messages now travel through the UI state as a PlayerMessage - a string resource id plus its format arguments - and PlayerScreen resolves them. Nested messages are supported so the reference-source label ("Built-in" / "Hearing") inside a match status is localized too. Same shape as PluginMessage, with an extra Raw case for a platform exception message that has no resource. The playback error telemetry now records the stable resource entry name instead of the displayed sentence, so error reports stay comparable across languages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018j1WJm8A8ZwaiUp6WmQ5qB --- .../tv/ui/screens/player/PlayerScreen.kt | 65 +++-- .../tv/ui/screens/player/PlayerViewModel.kt | 255 +++++++++++++----- app/src/main/res/values-de/strings.xml | 31 +++ app/src/main/res/values/strings.xml | 32 +++ 4 files changed, 298 insertions(+), 85 deletions(-) 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 56a1aa417..215369912 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 @@ -259,6 +259,22 @@ private fun Map.safePlaybackHeaders(): Map { .mapValues { (_, value) -> value.trim() } } +/** + * Resolves a [PlayerMessage] against the app language. Nested messages (e.g. the reference-source + * label inside a match status) are resolved first so they are localized too. + */ +@Composable +private fun PlayerMessage.localizedText(): String = when (this) { + is PlayerMessage.Raw -> text + is PlayerMessage.Res -> { + val args = mutableListOf() + formatArgs.forEach { arg -> + args += if (arg is PlayerMessage) arg.localizedText() else arg + } + stringResource(resourceId, *args.toTypedArray()) + } +} + /** * Netflix-style Player UI for Android TV */ @@ -661,7 +677,7 @@ fun PlayerScreen( var startupSameSourceRefreshAttempted by remember { mutableStateOf(false) } var startupUrlLock by remember { mutableStateOf(null) } var pendingStartupFailover by remember { mutableStateOf(false) } - var pendingStartupFailoverMessage by remember { mutableStateOf(null) } + var pendingStartupFailoverMessage by remember { mutableStateOf(null) } var pendingStartupFailureRecorded by remember { mutableStateOf(false) } var dvStartupFallbackStage by remember { mutableIntStateOf(0) } // 0=none, 1=HEVC forced, 2=AVC forced var midPlaybackRecoveryAttempts by remember { mutableIntStateOf(0) } @@ -900,13 +916,13 @@ fun PlayerScreen( val sourceSearchStillActive = uiState.sourceSearchActive || uiState.streamProgress != null || - !uiState.streamLoadPhase.isNullOrBlank() + uiState.streamLoadPhase != null if (!sourceSearchStillActive && !playbackIssueReported) { playbackIssueReported = true pendingStartupFailover = false viewModel.reportPlaybackError( pendingStartupFailoverMessage - ?: context.getString(R.string.player_fail_startup_generic) + ?: PlayerMessage.Res(R.string.player_fail_startup_generic) ) } } @@ -1368,14 +1384,14 @@ fun PlayerScreen( } val sourceSearchStillActive = latestUiState.sourceSearchActive || latestUiState.streamProgress != null || - !latestUiState.streamLoadPhase.isNullOrBlank() + latestUiState.streamLoadPhase != null if (!hasPlaybackStarted && allowStartupSourceFallback && !userSelectedSourceManually && sourceSearchStillActive ) { pendingStartupFailover = true - pendingStartupFailoverMessage = playbackErrorMessageFor(context, error, hasPlaybackStarted) + pendingStartupFailoverMessage = playbackErrorMessageFor(error, hasPlaybackStarted) if (!pendingStartupFailureRecorded) { pendingStartupFailureRecorded = true viewModel.onSelectedStreamPlaybackFailure() @@ -1389,7 +1405,7 @@ fun PlayerScreen( if (!playbackIssueReported) { playbackIssueReported = true viewModel.onSelectedStreamPlaybackFailure() - viewModel.reportPlaybackError(playbackErrorMessageFor(context, error, hasPlaybackStarted)) + viewModel.reportPlaybackError(playbackErrorMessageFor(error, hasPlaybackStarted)) } } } @@ -2619,9 +2635,9 @@ fun PlayerScreen( viewModel.onSelectedStreamPlaybackFailure() viewModel.reportPlaybackError( if (autoAdvanceAttempts > 0 || startupSameSourceRetryCount > 0) { - context.getString(R.string.player_fail_no_start_after_retries) + PlayerMessage.Res(R.string.player_fail_no_start_after_retries) } else { - context.getString(R.string.player_fail_no_start_in_time) + PlayerMessage.Res(R.string.player_fail_no_start_in_time) } ) } @@ -2713,7 +2729,7 @@ fun PlayerScreen( playbackIssueReported = true viewModel.onSelectedStreamPlaybackFailure() viewModel.reportPlaybackError( - context.getString(R.string.player_fail_render_failed) + PlayerMessage.Res(R.string.player_fail_render_failed) ) } } @@ -3523,7 +3539,7 @@ fun PlayerScreen( ) } else stringResource(phaseRes) } - ?: uiState.streamLoadPhase + ?: uiState.streamLoadPhase?.localizedText() ) } } @@ -3624,9 +3640,8 @@ fun PlayerScreen( color = androidx.compose.ui.graphics.Color(0xFF7EC8F0) ) Text( - text = uiState.matchStatusText.ifBlank { - stringResource(R.string.player_subtitle_searching_match) - }, + text = uiState.matchStatus?.localizedText() + ?: stringResource(R.string.player_subtitle_searching_match), style = androidx.compose.material3.MaterialTheme.typography.labelLarge, color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f) ) @@ -3636,7 +3651,7 @@ fun PlayerScreen( // AI translation API error toast uiState.aiErrorToast?.let { msg -> Toast( - message = msg, + message = msg.localizedText(), type = ToastType.ERROR, isVisible = true, durationMs = 5000, @@ -3666,7 +3681,7 @@ fun PlayerScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = msg, + text = msg.localizedText(), style = androidx.compose.material3.MaterialTheme.typography.labelLarge, color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f) ) @@ -4584,7 +4599,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.height(12.dp)) Text( - text = uiState.error ?: stringResource(R.string.player_error_generic), + text = uiState.error?.localizedText() ?: stringResource(R.string.player_error_generic), style = ArflixTypography.body, color = TextSecondary, textAlign = androidx.compose.ui.text.style.TextAlign.Center, @@ -6213,11 +6228,10 @@ private fun estimateInitialStartupTimeoutMs( } private fun playbackErrorMessageFor( - context: android.content.Context, error: androidx.media3.common.PlaybackException, hasPlaybackStarted: Boolean -): String { - val reason = context.getString( +): PlayerMessage { + val reason = PlayerMessage.Res( when (error.errorCode) { androidx.media3.common.PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, androidx.media3.common.PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, @@ -6241,11 +6255,14 @@ private fun playbackErrorMessageFor( } ) - return if (hasPlaybackStarted) { - context.getString(R.string.player_err_try_another, reason) - } else { - context.getString(R.string.player_err_startup_try_another, reason) - } + return PlayerMessage.Res( + if (hasPlaybackStarted) { + R.string.player_err_try_another + } else { + R.string.player_err_startup_try_another + }, + listOf(reason) + ) } /** 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 dbe90cf99..342859a76 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 @@ -2,6 +2,7 @@ package com.arflix.tv.ui.screens.player import android.content.Context import android.util.Log +import androidx.annotation.StringRes import com.arflix.tv.R import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -93,6 +94,23 @@ private fun playbackDiag(message: String) { } } +/** + * A user-facing player message, kept as a resource reference until a composable renders it. + * A ViewModel only has the application context, whose resources follow the SYSTEM language + * instead of the language selected in the app, so resolving here would show the wrong + * language whenever the two differ. Mirrors [com.arflix.tv.ui.screens.plugin.PluginMessage]. + */ +sealed interface PlayerMessage { + /** A localizable message. [formatArgs] may itself contain [PlayerMessage] entries. */ + data class Res( + @param:StringRes val resourceId: Int, + val formatArgs: List = emptyList() + ) : PlayerMessage + + /** Text without a resource (e.g. a platform exception message); shown as it is. */ + data class Raw(val text: String) : PlayerMessage +} + data class PlayerUiState( val isLoading: Boolean = true, val isLoadingStreams: Boolean = false, @@ -137,7 +155,7 @@ data class PlayerUiState( val subtitleFont: String = SubtitleFontOption.DefaultPreference, val subtitleStylized: Boolean = true, val subtitleOffset: String = "Bottom", - val error: String? = null, + val error: PlayerMessage? = null, val isSetupError: Boolean = false, // true when error is due to missing addons (shows friendly guide instead of red error) // Auto-play next episode at end of current one. Mirrors the profile-scoped // "auto_play_next" DataStore setting so the player can respect the toggle @@ -157,7 +175,7 @@ data class PlayerUiState( val streamProgress: Float? = null, // Human-readable phase label for the loading UI (e.g. "Searching 3/8 // sources"). Null when progress isn't meaningful. - val streamLoadPhase: String? = null, + val streamLoadPhase: PlayerMessage? = null, // True while source discovery can still add playback alternatives. This // remains true after autoplay selects the first stream. val sourceSearchActive: Boolean = false, @@ -168,14 +186,15 @@ data class PlayerUiState( // Language name being translated into (e.g. "Hebrew") when AI is available val aiTargetLanguageName: String = "", // Non-null while an AI translation API error toast should be visible - val aiErrorToast: String? = null, + val aiErrorToast: PlayerMessage? = null, // True while Gemini Live audio translation is active for this session val isLiveAudioTranslating: Boolean = false, // True while "Find best match" is scanning subtitles; message shown as a transient toast. val isFindingBestMatch: Boolean = false, - val matchToast: String? = null, + val matchToast: PlayerMessage? = null, // Persistent status shown on screen for the whole duration of a "Find best match" scan. - val matchStatusText: String = "", + // Null while no scan is running. + val matchStatus: PlayerMessage? = null, // Full name of the preferred subtitle language (e.g. "Hebrew") — drives the "Find Best Match" // menu entry. Independent of AI availability: the timing scan needs no AI/API key. val matchLanguageName: String = "", @@ -350,10 +369,13 @@ class PlayerViewModel @Inject constructor( } else if (!success && !aiErrorToastShown) { aiErrorToastShown = true val msg = when { - errorMessage == "API key missing" -> context.getString(R.string.player_ai_no_key) - errorMessage == "RATE_LIMITED" -> context.getString(R.string.player_ai_rate_limited) - errorMessage?.startsWith("HTTP 401") == true -> context.getString(R.string.player_ai_invalid_key) - else -> context.getString(R.string.player_ai_translation_error, errorMessage.orEmpty()) + errorMessage == "API key missing" -> PlayerMessage.Res(R.string.player_ai_no_key) + errorMessage == "RATE_LIMITED" -> PlayerMessage.Res(R.string.player_ai_rate_limited) + errorMessage?.startsWith("HTTP 401") == true -> PlayerMessage.Res(R.string.player_ai_invalid_key) + else -> PlayerMessage.Res( + R.string.player_ai_translation_error, + listOf(errorMessage.orEmpty()) + ) } _uiState.value = _uiState.value.copy(aiErrorToast = msg) } @@ -676,7 +698,7 @@ class PlayerViewModel @Inject constructor( val providedIsHubPage = providedStreamCandidate?.url ?.let(::isHubCloudPageUrl) == true val preResolvedHubStream = if (providedIsHubPage) { - _uiState.value = _uiState.value.copy(streamLoadPhase = "Preparing stream") + _uiState.value = _uiState.value.copy(streamLoadPhase = PlayerMessage.Res(R.string.player_phase_preparing_stream)) providedStreamCandidate?.let { stream -> runCatching { streamRepository.resolveStreamForPlayback(stream) }.getOrNull() } @@ -712,7 +734,7 @@ class PlayerViewModel @Inject constructor( // path sat 5-10s with no overlay text (selectedStreamUrl not set yet, so startupPhase // is gated off), unlike the manual selectStream() path which already labels this step. if (providedStream != null) { - _uiState.value = _uiState.value.copy(streamLoadPhase = "Preparing stream") + _uiState.value = _uiState.value.copy(streamLoadPhase = PlayerMessage.Res(R.string.player_phase_preparing_stream)) } val resolvedProvidedStream = preResolvedHubStream ?: providedStream?.let { stream -> runCatching { streamRepository.resolveStreamForPlayback(stream) }.getOrNull() ?: stream @@ -738,9 +760,9 @@ class PlayerViewModel @Inject constructor( isLoadingStreams = false, sourceSearchActive = false, error = if (isMagnet) { - "Selected source is P2P (magnet) and not supported. Choose an HTTP/debrid source." + PlayerMessage.Res(R.string.player_error_magnet_unsupported) } else { - "Failed to open selected source. Try another one." + PlayerMessage.Res(R.string.player_error_open_source_failed) } ) return@launch @@ -931,7 +953,7 @@ class PlayerViewModel @Inject constructor( isLoading = false, isLoadingStreams = false, sourceSearchActive = false, - error = context.getString(R.string.player_error_imdb_resolve) + error = PlayerMessage.Res(R.string.player_error_imdb_resolve) ) return@launch } @@ -983,7 +1005,14 @@ class PlayerViewModel @Inject constructor( error = null, isSetupError = false, streamProgress = 0f, - streamLoadPhase = if (streamingAddonCount > 0) "Searching 0/$streamingAddonCount sources" else "Preparing sources" + streamLoadPhase = if (streamingAddonCount > 0) { + PlayerMessage.Res( + R.string.player_phase_searching_sources, + listOf(0, streamingAddonCount) + ) + } else { + PlayerMessage.Res(R.string.player_phase_preparing_sources) + } ) val preferredLanguage = _uiState.value.preferredAudioLanguage.ifBlank { resolvePreferredAudioLanguage() } @@ -1048,9 +1077,9 @@ class PlayerViewModel @Inject constructor( !supplementalSourcesStillLoading ) { if (streamingAddonCount == 0) { - "No streaming addons configured.\n\nGo to Settings \u2192 Addons to add a streaming addon, then come back and try again." + PlayerMessage.Res(R.string.player_error_no_streaming_addons) } else { - "No streams found for this content. The addons may not have sources for this title." + PlayerMessage.Res(R.string.player_error_no_streams_from_addons) } } else null if (errorMessage != null && !sourceEmptyReported) { @@ -1080,8 +1109,14 @@ class PlayerViewModel @Inject constructor( } val phaseLabel = when { progressive.isFinal -> null - mergedStreams.isNotEmpty() -> "Found ${mergedStreams.size} sources ($completed/$total)" - else -> "Searching $completed/$total sources" + mergedStreams.isNotEmpty() -> PlayerMessage.Res( + R.string.player_phase_found_sources, + listOf(mergedStreams.size, completed, total) + ) + else -> PlayerMessage.Res( + R.string.player_phase_searching_sources, + listOf(completed, total) + ) } val filteredSubtitles = filterSubsByPreferredLanguage(progressive.subtitles) @@ -1234,7 +1269,7 @@ class PlayerViewModel @Inject constructor( sourceSearchActive = false, streamProgress = null, streamLoadPhase = null, - error = e.message + error = e.message?.let { PlayerMessage.Raw(it) } ) } } @@ -2375,7 +2410,7 @@ class PlayerViewModel @Inject constructor( isLoading = true, isLoadingStreams = false, streamProgress = null, - streamLoadPhase = "Preparing stream", + streamLoadPhase = PlayerMessage.Res(R.string.player_phase_preparing_stream), error = null, isSetupError = false ) @@ -2405,7 +2440,7 @@ class PlayerViewModel @Inject constructor( sourceSearchActive = false, streamProgress = null, streamLoadPhase = null, - error = "Failed to resolve stream. Try another source." + error = PlayerMessage.Res(R.string.player_error_resolve_stream_failed) ) return@launch } @@ -2431,9 +2466,9 @@ class PlayerViewModel @Inject constructor( streamProgress = null, streamLoadPhase = null, error = if (isP2p) { - "P2P stream requires TorrServer. Install TorrServer and set its URL in Settings > Addons." + PlayerMessage.Res(R.string.player_error_p2p_needs_torrserver) } else { - "Failed to resolve stream. Try another source." + PlayerMessage.Res(R.string.player_error_resolve_stream_failed) } ) return@launch @@ -2604,7 +2639,19 @@ class PlayerViewModel @Inject constructor( return DEBRID_CDN_DOMAINS.any { domain -> host == domain || host.endsWith(".$domain") } } - fun reportPlaybackError(message: String) { + /** + * Stable English identifier for telemetry. The displayed text is localized per user, so the + * message itself would make error reports unsortable across languages; the resource entry + * name ("player_fail_render_failed") never changes with the locale. + */ + private fun telemetryKeyOf(message: PlayerMessage): String = when (message) { + is PlayerMessage.Raw -> message.text + is PlayerMessage.Res -> runCatching { + context.resources.getResourceEntryName(message.resourceId) + }.getOrDefault(message.resourceId.toString()) + } + + fun reportPlaybackError(message: PlayerMessage) { playbackErrorReportJob?.cancel() val errorSelectionNonce = _uiState.value.streamSelectionNonce val errorSelectedUrl = _uiState.value.selectedStreamUrl @@ -2629,7 +2676,7 @@ class PlayerViewModel @Inject constructor( throwable = IllegalStateException("Playback error displayed"), context = playbackDiagnosticContext( phase = "playback_error_displayed", - extra = mapOf("playback_error_message" to message) + extra = mapOf("playback_error_message" to telemetryKeyOf(message)) ) ) } @@ -2844,7 +2891,7 @@ class PlayerViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isFindingBestMatch = false, isLiveAudioTranslating = false, - matchStatusText = "" + matchStatus = null ) } } @@ -2896,9 +2943,14 @@ class PlayerViewModel @Inject constructor( if (_uiState.value.isAiTranslating) { activateAiTranslation() showMatchToast( - "No well-synced subtitle found" + - (score?.let { " (best ${(it * 100).toInt()}%)" } ?: "") + - " — keeping AI translation" + if (score == null) { + PlayerMessage.Res(R.string.player_match_none_keeping_ai) + } else { + PlayerMessage.Res( + R.string.player_match_none_score_keeping_ai, + listOf((score * 100).toInt()) + ) + } ) } }, @@ -2921,7 +2973,7 @@ class PlayerViewModel @Inject constructor( if (!aiSubtitleEnabled || aiApiKey.isBlank()) return false if (findAiSourceSubtitle(_uiState.value.subtitles) == null) return false activateAiTranslation() - showMatchToast("No well-synced subtitle found — using AI translation") + showMatchToast(PlayerMessage.Res(R.string.player_match_none_using_ai)) return true } @@ -2946,7 +2998,7 @@ class PlayerViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isAiTranslating = false, isAiAvailable = false, - aiErrorToast = context.getString(R.string.player_ai_no_text_source) + aiErrorToast = PlayerMessage.Res(R.string.player_ai_no_text_source) ) } } @@ -3037,15 +3089,24 @@ class PlayerViewModel @Inject constructor( // Include the best (rejected) score so a fast verdict is visibly a real scan result. val noMatch = onNoMatch ?: { score -> showMatchToast( - "No well-synced $targetLangName subtitle found" + - (score?.let { " (best ${(it * 100).toInt()}%)" } ?: "") + if (score == null) { + PlayerMessage.Res( + R.string.player_match_none_language, + listOf(targetLangName) + ) + } else { + PlayerMessage.Res( + R.string.player_match_none_language_score, + listOf(targetLangName, (score * 100).toInt()) + ) + } ) } if (targetLang.isBlank() || isSubtitleDisabledPreference(targetLang)) { noMatch(null) return@launch } - beginMatch("Finding best subtitle…") + beginMatch(PlayerMessage.Res(R.string.player_match_finding)) // Subtitle sources resolve asynchronously after playback starts: embedded tracks a beat // later, addon subtitles when their fetch completes. Wait for both a usable muxed @@ -3106,7 +3167,12 @@ class PlayerViewModel @Inject constructor( if (embedded != null) { endMatch() selectSubtitle(embedded, isUserAction = false) - showMatchToast("Matched: embedded $targetLangName subtitle (in sync)") + showMatchToast( + PlayerMessage.Res( + R.string.player_match_embedded, + listOf(targetLangName) + ) + ) return@launch } @@ -3151,8 +3217,19 @@ class PlayerViewModel @Inject constructor( 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") + showMatchToast( + if (offsetMs != 0L) { + PlayerMessage.Res( + R.string.player_match_remembered_offset, + listOf(remembered.label, formatMatchOffset(offsetMs)) + ) + } else { + PlayerMessage.Res( + R.string.player_match_remembered, + listOf(remembered.label) + ) + } + ) return@launch } } @@ -3175,7 +3252,12 @@ class PlayerViewModel @Inject constructor( selectSubtitle(exactLocal, isUserAction = false) writeCachedMatch(exactNameMatch) Log.i("SubMatch", "exact release-name match — scan skipped: ${exactNameMatch.label}") - showMatchToast("Matched: ${exactNameMatch.label} (exact release name)") + showMatchToast( + PlayerMessage.Res( + R.string.player_match_exact_release_name, + listOf(exactNameMatch.label) + ) + ) return@launch } @@ -3188,13 +3270,23 @@ class PlayerViewModel @Inject constructor( val builtInReference = embeddedRefs.firstOrNull { normalizeLanguage(it.lang) == "en" } ?: embeddedRefs.firstOrNull() val sourceLabel = if (builtInReference != null) "Built-in" else "Hearing" + val sourceLabelRes = if (builtInReference != null) { + R.string.player_match_source_builtin + } else { + R.string.player_match_source_hearing + } android.util.Log.i( "SubMatch", "reference source=$sourceLabel embeddedRefs=${embeddedRefs.size} " + "ref=\"${builtInReference?.label ?: "-"}\" (lang=${builtInReference?.lang}) " + "allEmbedded=${subs.count { it.isEmbedded }}" ) - updateMatchStatus("Finding best subtitle ($sourceLabel)…") + updateMatchStatus( + PlayerMessage.Res( + R.string.player_match_finding_source, + listOf(PlayerMessage.Res(sourceLabelRes)) + ) + ) // Keep the raw text alongside the parsed cues: the winning subtitle is later served to // ExoPlayer from a local cache file (already downloaded here) instead of re-fetching @@ -3235,7 +3327,12 @@ class PlayerViewModel @Inject constructor( if (current != null && normalizeLanguage(current.lang) == targetLang) return candidates.firstOrNull()?.let { selectServedLocally(it) - showMatchToast("Selected ${it.label} (sync unverified)") + showMatchToast( + PlayerMessage.Res( + R.string.player_match_sync_unverified, + listOf(it.label) + ) + ) } } @@ -3249,7 +3346,7 @@ class PlayerViewModel @Inject constructor( val scored = when { builtInReference != null -> - scoreAgainstBuiltIn(loaded, builtInReference, sourceLabel, previousSubtitle) + scoreAgainstBuiltIn(loaded, builtInReference, sourceLabelRes, previousSubtitle) // AI translation is already on screen as the fallback — the hearing path is too // unreliable to risk replacing it, so just stay on AI. _uiState.value.isAiTranslating -> null @@ -3257,7 +3354,7 @@ class PlayerViewModel @Inject constructor( // feature on, a key, and the Gemini model (a Groq key can't open that connection). !aiSubtitleEnabled || aiApiKey.isBlank() || aiModel != SubtitleAiModel.GEMINI_FLASH_25 -> null - else -> scoreAgainstHearing(loaded, sourceLabel) + else -> scoreAgainstHearing(loaded, sourceLabelRes) } endMatch() @@ -3279,9 +3376,27 @@ class PlayerViewModel @Inject constructor( // 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" + if (best.offsetMs != 0L) { + PlayerMessage.Res( + R.string.player_match_scored_offset, + listOf( + best.sub.label, + (best.score * 100).toInt(), + PlayerMessage.Res(sourceLabelRes), + formatMatchOffset(best.offsetMs) + ) + ) + } else { + PlayerMessage.Res( + R.string.player_match_scored, + listOf( + best.sub.label, + (best.score * 100).toInt(), + PlayerMessage.Res(sourceLabelRes) + ) + ) + } ) } else { // Nothing synced found (or too little dialogue) → let the caller fall back (AI translate). @@ -3403,7 +3518,7 @@ class PlayerViewModel @Inject constructor( private suspend fun scoreAgainstBuiltIn( loaded: List>>, referenceSub: Subtitle, - sourceLabel: String, + @StringRes sourceLabelRes: Int, previousSubtitle: Subtitle? ): List? { synchronized(referenceIntervals) { referenceIntervals.clear() } @@ -3570,8 +3685,17 @@ class PlayerViewModel @Inject constructor( } if (elapsed >= deadline) break updateMatchStatus( - if (refs.isEmpty()) "Searching for a match ($sourceLabel) — waiting for speech…" - else "Searching for a match ($sourceLabel) — (${refs.size})" + if (refs.isEmpty()) { + PlayerMessage.Res( + R.string.player_match_waiting_for_speech, + listOf(PlayerMessage.Res(sourceLabelRes)) + ) + } else { + PlayerMessage.Res( + R.string.player_match_collecting_reference, + listOf(PlayerMessage.Res(sourceLabelRes), refs.size) + ) + } ) delay(300) } @@ -3621,7 +3745,7 @@ class PlayerViewModel @Inject constructor( /** Reference = AI hearing transcription (fallback when there's no built-in English track). */ private suspend fun scoreAgainstHearing( loaded: List>>, - sourceLabel: String + @StringRes sourceLabelRes: Int ): List? { startMatchListening() val samples = mutableListOf() @@ -3660,8 +3784,17 @@ class PlayerViewModel @Inject constructor( break } updateMatchStatus( - if (samples.isEmpty()) "Searching for a match ($sourceLabel) — waiting for speech…" - else "Searching for a match ($sourceLabel) — scanning subtitles… (${samples.size})" + if (samples.isEmpty()) { + PlayerMessage.Res( + R.string.player_match_waiting_for_speech, + listOf(PlayerMessage.Res(sourceLabelRes)) + ) + } else { + PlayerMessage.Res( + R.string.player_match_scanning_subtitles, + listOf(PlayerMessage.Res(sourceLabelRes), samples.size) + ) + } ) delay(300) } @@ -3681,13 +3814,13 @@ class PlayerViewModel @Inject constructor( } } - private fun beginMatch(status: String) { - _uiState.value = _uiState.value.copy(isFindingBestMatch = true, matchStatusText = status) + private fun beginMatch(status: PlayerMessage) { + _uiState.value = _uiState.value.copy(isFindingBestMatch = true, matchStatus = status) } - private fun updateMatchStatus(status: String) { - if (_uiState.value.matchStatusText != status) { - _uiState.value = _uiState.value.copy(matchStatusText = status) + private fun updateMatchStatus(status: PlayerMessage) { + if (_uiState.value.matchStatus != status) { + _uiState.value = _uiState.value.copy(matchStatus = status) } } @@ -3711,7 +3844,7 @@ class PlayerViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isFindingBestMatch = false, isLiveAudioTranslating = false, - matchStatusText = "" + matchStatus = null ) } @@ -3888,7 +4021,7 @@ class PlayerViewModel @Inject constructor( } } - private fun showMatchToast(message: String) { + private fun showMatchToast(message: PlayerMessage) { _uiState.value = _uiState.value.copy(matchToast = message) } @@ -4436,7 +4569,7 @@ class PlayerViewModel @Inject constructor( return !primaryStreamResolutionFinal || supplementalStillLoading } - private fun finishSupplementalSourceLookupIfReady(currentJob: Job?, errorMessage: String) { + private fun finishSupplementalSourceLookupIfReady(currentJob: Job?, errorMessage: PlayerMessage) { val state = _uiState.value val stillActive = sourceLookupStillActive(currentJob) if (state.streams.isNotEmpty() || !state.selectedStreamUrl.isNullOrBlank()) { @@ -4498,7 +4631,7 @@ class PlayerViewModel @Inject constructor( if (validSources.isEmpty()) { finishSupplementalSourceLookupIfReady( currentJob = currentCoroutineContext()[Job], - errorMessage = "No streams found for this content. The configured media servers may not have this title." + errorMessage = PlayerMessage.Res(R.string.player_error_no_streams_media_servers) ) return } @@ -4560,7 +4693,7 @@ class PlayerViewModel @Inject constructor( if (validVodSources.isEmpty()) { finishSupplementalSourceLookupIfReady( currentJob = currentCoroutineContext()[Job], - errorMessage = "No streams found for this content. Try another source or check your configured sources." + errorMessage = PlayerMessage.Res(R.string.player_error_no_streams_other_source) ) return } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e61d2bb02..bb4e1c77d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -166,6 +166,37 @@ Rückblick überspringen Abspann überspringen Überspringen + Stream wird vorbereitet + Quellen werden vorbereitet + %1$d/%2$d Quellen werden durchsucht + %1$d Quellen gefunden (%2$d/%3$d) + Die gewählte Quelle ist P2P (Magnet) und wird nicht unterstützt. Wähle eine HTTP-/Debrid-Quelle. + Die gewählte Quelle konnte nicht geöffnet werden. Probiere eine andere. + Keine Streaming-Addons eingerichtet.\n\nGehe zu Einstellungen → Addons, füge ein Streaming-Addon hinzu und versuche es dann erneut. + Keine Streams für diesen Inhalt gefunden. Die Addons haben für diesen Titel möglicherweise keine Quellen. + Keine Streams für diesen Inhalt gefunden. Die eingerichteten Medienserver haben diesen Titel möglicherweise nicht. + Keine Streams für diesen Inhalt gefunden. Probiere eine andere Quelle oder prüfe deine eingerichteten Quellen. + Stream konnte nicht aufgelöst werden. Probiere eine andere Quelle. + P2P-Streams benötigen TorrServer. Installiere TorrServer und trage seine URL unter Einstellungen > Addons ein. + Eingebaut + Mithören + Bester Untertitel wird gesucht … + Bester Untertitel wird gesucht (%1$s) … + Übereinstimmung wird gesucht (%1$s) — warten auf Sprache … + Übereinstimmung wird gesucht (%1$s) — (%2$d) + Übereinstimmung wird gesucht (%1$s) — Untertitel werden durchsucht … (%2$d) + Treffer: eingebetteter %1$s-Untertitel (synchron) + Treffer: %1$s (gemerkt) + Treffer: %1$s (gemerkt) (Auto-Versatz %2$s) + Treffer: %1$s (exakter Release-Name) + %1$s ausgewählt (Synchronität ungeprüft) + Treffer: %1$s · %2$d %% (%3$s) + Treffer: %1$s · %2$d %% (%3$s) (Auto-Versatz %4$s) + Kein gut synchronisierter Untertitel gefunden — KI-Übersetzung wird beibehalten + Kein gut synchronisierter Untertitel gefunden (bester Wert %1$d %%) — KI-Übersetzung wird beibehalten + Kein gut synchronisierter Untertitel gefunden — KI-Übersetzung wird verwendet + Kein gut synchronisierter %1$s-Untertitel gefunden + Kein gut synchronisierter %1$s-Untertitel gefunden (bester Wert %2$d %%) QR-Code Avatar Beliebige Taste drücken zum Fortfahren diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5e3d2d86..42677c358 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -949,6 +949,38 @@ Skip Recap Skip Ending Skip + + Preparing stream + Preparing sources + Searching %1$d/%2$d sources + Found %1$d sources (%2$d/%3$d) + Selected source is P2P (magnet) and not supported. Choose an HTTP/debrid source. + Failed to open selected source. Try another one. + No streaming addons configured.\n\nGo to Settings → Addons to add a streaming addon, then come back and try again. + No streams found for this content. The addons may not have sources for this title. + No streams found for this content. The configured media servers may not have this title. + No streams found for this content. Try another source or check your configured sources. + Failed to resolve stream. Try another source. + P2P stream requires TorrServer. Install TorrServer and set its URL in Settings > Addons. + Built-in + Hearing + Finding best subtitle… + Finding best subtitle (%1$s)… + Searching for a match (%1$s) — waiting for speech… + Searching for a match (%1$s) — (%2$d) + Searching for a match (%1$s) — scanning subtitles… (%2$d) + Matched: embedded %1$s subtitle (in sync) + Matched: %1$s (remembered) + Matched: %1$s (remembered) (auto-offset %2$s) + Matched: %1$s (exact release name) + Selected %1$s (sync unverified) + Matched: %1$s · %2$d%% (%3$s) + Matched: %1$s · %2$d%% (%3$s) (auto-offset %4$s) + No well-synced subtitle found — keeping AI translation + No well-synced subtitle found (best %1$d%%) — keeping AI translation + No well-synced subtitle found — using AI translation + No well-synced %1$s subtitle found + No well-synced %1$s subtitle found (best %2$d%%) LIVE