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