diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt index 11c48c60a..0d883d42e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt @@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import com.arflix.tv.R import com.arflix.tv.util.AppLogger import com.arflix.tv.util.Constants import com.arflix.tv.util.AuthEmailValidator @@ -536,7 +537,8 @@ class AuthRepository @Inject constructor( */ suspend fun signIn(email: String, password: String): Result { val normalizedEmail = AuthEmailValidator.normalize(email) - AuthEmailValidator.validate(normalizedEmail, rejectDisposable = false)?.let { message -> + AuthEmailValidator.validate(normalizedEmail, rejectDisposable = false)?.let { messageRes -> + val message = context.getString(messageRes) _authState.value = AuthState.Error(message) return Result.failure(Exception(message)) } @@ -575,13 +577,13 @@ class AuthRepository @Inject constructor( AppLogger.breadcrumb("Auth", "email_sign_in_success") Result.success(Unit) } else { - val message = safeErrorMessage(null, "Sign in failed") + val message = safeErrorMessage(null, context.getString(R.string.auth_signin_failed)) _authState.value = AuthState.Error(message) AppLogger.breadcrumb("Auth", "email_sign_in_no_session", severity = "warning") Result.failure(Exception(message)) } } catch (e: Exception) { - val message = safeErrorMessage(e, "Sign in failed") + val message = safeErrorMessage(e, context.getString(R.string.auth_signin_failed)) _authState.value = AuthState.Error(message) AppLogger.breadcrumb("Auth", "email_sign_in_failed ${e::class.java.simpleName}", severity = "warning") Result.failure(Exception(message)) @@ -593,7 +595,8 @@ class AuthRepository @Inject constructor( */ suspend fun signUp(email: String, password: String): Result { val normalizedEmail = AuthEmailValidator.normalize(email) - AuthEmailValidator.validate(normalizedEmail)?.let { message -> + AuthEmailValidator.validate(normalizedEmail)?.let { messageRes -> + val message = context.getString(messageRes) _authState.value = AuthState.Error(message) return Result.failure(Exception(message)) } @@ -608,7 +611,7 @@ class AuthRepository @Inject constructor( } } } catch (e: Exception) { - val message = safeErrorMessage(e, "Sign up failed") + val message = safeErrorMessage(e, context.getString(R.string.auth_signup_failed)) _authState.value = AuthState.Error(message) AppLogger.recordException( throwable = e, @@ -632,7 +635,7 @@ class AuthRepository @Inject constructor( url = Constants.AUTH_LOGIN_URL, email = email, password = password, - defaultError = "Sign in failed" + defaultError = context.getString(R.string.auth_signin_failed) ) } @@ -641,7 +644,7 @@ class AuthRepository @Inject constructor( url = Constants.CLOUD_AUTH_EMAIL_URL, email = email, password = password, - defaultError = "Unable to create account" + defaultError = context.getString(R.string.auth_unable_create_account) ) } @@ -675,7 +678,7 @@ class AuthRepository @Inject constructor( val accessToken = json?.optString("access_token").orEmpty() val refreshToken = json?.optString("refresh_token").orEmpty() if (accessToken.isBlank() || refreshToken.isBlank()) { - throw IllegalStateException("Auth response incomplete") + throw IllegalStateException(context.getString(R.string.auth_response_incomplete)) } CloudAccountSession(accessToken, refreshToken) } @@ -739,12 +742,13 @@ class AuthRepository @Inject constructor( AppLogger.breadcrumb("Auth", "session_import_success") Result.success(Unit) } else { - _authState.value = AuthState.Error("Failed to import auth session") + val message = context.getString(R.string.auth_failed_import_session) + _authState.value = AuthState.Error(message) AppLogger.breadcrumb("Auth", "session_import_missing_user", severity = "warning") - Result.failure(Exception("Failed to import auth session")) + Result.failure(Exception(message)) } } catch (e: Exception) { - val message = safeErrorMessage(e, "Sign in failed") + val message = safeErrorMessage(e, context.getString(R.string.auth_signin_failed)) _authState.value = AuthState.Error(message) AppLogger.recordException( throwable = e, @@ -820,24 +824,27 @@ class AuthRepository @Inject constructor( _authState.value = AuthState.Authenticated(user.id, user.email ?: "", profile) Result.success(Unit) } else { - _authState.value = AuthState.Error("Google Sign-In failed") - Result.failure(Exception("Google Sign-In failed")) + val message = context.getString(R.string.auth_google_failed) + _authState.value = AuthState.Error(message) + Result.failure(Exception(message)) } } else { - _authState.value = AuthState.Error("Unexpected credential type") - Result.failure(Exception("Unexpected credential type")) + val message = context.getString(R.string.auth_unexpected_credential) + _authState.value = AuthState.Error(message) + Result.failure(Exception(message)) } } else -> { - _authState.value = AuthState.Error("Unexpected credential type") - Result.failure(Exception("Unexpected credential type")) + val message = context.getString(R.string.auth_unexpected_credential) + _authState.value = AuthState.Error(message) + Result.failure(Exception(message)) } } } catch (e: GoogleIdTokenParsingException) { - _authState.value = AuthState.Error("Failed to parse Google credentials") + _authState.value = AuthState.Error(context.getString(R.string.auth_failed_parse_google)) Result.failure(e) } catch (e: Exception) { - _authState.value = AuthState.Error(e.message ?: "Google Sign-In failed") + _authState.value = AuthState.Error(e.message ?: context.getString(R.string.auth_google_failed)) Result.failure(e) } } @@ -943,11 +950,11 @@ class AuthRepository @Inject constructor( return when { "arvio cloud moved" in message || "password setup" in message -> rawMessage Constants.USE_NETLIFY_CLOUD_SYNC && "invalid email or password" in message -> netlifyPasswordHelp - "database error saving new user" in message -> "Account already exists. Sign in instead." - "settingssessionmanager" in message -> "Sign in failed. Please try again." - "invalid login credentials" in message -> "Invalid email or password." - "email not confirmed" in message || "confirm" in message -> "Please verify your email to continue." - "user already" in message || "already registered" in message -> "Account already exists. Sign in instead." + "database error saving new user" in message -> context.getString(R.string.auth_account_exists) + "settingssessionmanager" in message -> context.getString(R.string.auth_signin_retry) + "invalid login credentials" in message -> context.getString(R.string.auth_invalid_credentials) + "email not confirmed" in message || "confirm" in message -> context.getString(R.string.auth_verify_email) + "user already" in message || "already registered" in message -> context.getString(R.string.auth_account_exists) else -> fallback } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt index 8d05d24ac..ba84b9219 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt @@ -1,9 +1,12 @@ package com.arflix.tv.data.repository +import android.content.Context import com.arflix.tv.data.api.TraktApi import com.arflix.tv.data.model.CatalogDiscoveryResult import com.arflix.tv.data.model.CatalogSourceType +import com.arflix.tv.R import com.arflix.tv.util.Constants +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -15,6 +18,7 @@ import javax.inject.Singleton @Singleton class CatalogDiscoveryRepository @Inject constructor( + @ApplicationContext private val context: Context, private val traktApi: TraktApi, private val okHttpClient: OkHttpClient ) { @@ -43,7 +47,7 @@ class CatalogDiscoveryRepository @Inject constructor( Result.failure( trakt.exceptionOrNull() ?: mdblist.exceptionOrNull() - ?: IllegalStateException("Failed to search catalogs") + ?: IllegalStateException(context.getString(R.string.catalog_failed_search)) ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index 77d7cbe0e..21668f2ae 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -17,6 +17,7 @@ import com.arflix.tv.data.model.CatalogSourceType import com.arflix.tv.data.model.CatalogValidationResult import com.arflix.tv.data.model.Category import com.arflix.tv.data.repository.HomeServerCatalogCandidate +import com.arflix.tv.R import com.arflix.tv.util.CatalogUrlParser import com.arflix.tv.util.Constants import com.arflix.tv.util.ParsedCatalogUrl @@ -709,11 +710,11 @@ class CatalogRepository @Inject constructor( val sourceType = validation.sourceType val resolved = resolveMetadata(normalizedUrl, sourceType) ?: fallbackMetadata(normalizedUrl, sourceType) - ?: return Result.failure(IllegalArgumentException("Failed to read catalog metadata")) + ?: return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_failed_read_metadata))) val current = getCatalogs().toMutableList() if (current.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) { - return Result.failure(IllegalArgumentException("Catalog already added")) + return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_already_added))) } val newCatalog = CatalogConfig( @@ -732,10 +733,10 @@ class CatalogRepository @Inject constructor( suspend fun updateCustomCatalog(catalogId: String, rawUrl: String): Result { val current = getCatalogs().toMutableList() val index = current.indexOfFirst { it.id == catalogId } - if (index < 0) return Result.failure(IllegalArgumentException("Catalog not found")) + if (index < 0) return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_not_found))) val existing = current[index] if (existing.isPreinstalled) { - return Result.failure(IllegalArgumentException("Preinstalled catalogs cannot be edited")) + return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_preinstalled_no_edit))) } val validation = validateCatalogUrl(rawUrl) @@ -745,12 +746,12 @@ class CatalogRepository @Inject constructor( val normalizedUrl = validation.normalizedUrl if (current.any { it.id != catalogId && it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) { - return Result.failure(IllegalArgumentException("Catalog already added")) + return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_already_added))) } val resolved = resolveMetadata(normalizedUrl, validation.sourceType) ?: fallbackMetadata(normalizedUrl, validation.sourceType) - ?: return Result.failure(IllegalArgumentException("Failed to read catalog metadata")) + ?: return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_failed_read_metadata))) val updated = existing.copy( title = resolved.title, sourceType = validation.sourceType, @@ -765,7 +766,7 @@ class CatalogRepository @Inject constructor( suspend fun removeCustomCatalog(catalogId: String): Result { val current = getCatalogs().toMutableList() val target = current.firstOrNull { it.id == catalogId } - ?: return Result.failure(IllegalArgumentException("Catalog not found")) + ?: return Result.failure(IllegalArgumentException(context.getString(R.string.catalog_not_found))) val profileId = activeProfileId() if (isPreinstalledCatalog(target)) { hidePreinstalledCatalog(profileId, catalogId) @@ -910,7 +911,7 @@ class CatalogRepository @Inject constructor( } } CatalogSourceType.MDBLIST -> ResolvedCatalog( - title = "MDBList Catalog", + title = context.getString(R.string.catalog_mdblist_title), sourceRef = "mdblist:$url" ) CatalogSourceType.PREINSTALLED -> null @@ -979,9 +980,10 @@ class CatalogRepository @Inject constructor( ?.replace(" - MDBList", "", ignoreCase = true) val titleFromSlug = extractMdblistSlugTitle(url) - val finalTitle = (titleFromMeta ?: titleFromTag ?: titleFromSlug ?: "MDBList Catalog").trim() + val mdblistFallbackTitle = context.getString(R.string.catalog_mdblist_title) + val finalTitle = (titleFromMeta ?: titleFromTag ?: titleFromSlug ?: mdblistFallbackTitle).trim() return ResolvedCatalog( - title = finalTitle.ifBlank { "MDBList Catalog" }, + title = finalTitle.ifBlank { mdblistFallbackTitle }, sourceRef = "mdblist:$url" ) } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt index 8927f4fd5..bef198b39 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import android.provider.Settings import androidx.datastore.preferences.core.edit import com.arflix.tv.BuildConfig +import com.arflix.tv.R import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.ProxyHeaders import com.arflix.tv.data.model.StreamBehaviorHints @@ -270,8 +271,8 @@ class HomeServerRepository @Inject constructor( val serverUrl = normalizeServerUrl(rawUrl) val trimmedUsername = username.trim() val trimmedDisplayName = displayName.trim() - require(serverUrl.isNotBlank()) { "Enter a valid server URL" } - require(password.isNotBlank()) { "Enter a password or token" } + require(serverUrl.isNotBlank()) { context.getString(R.string.homeserver_enter_url) } + require(password.isNotBlank()) { context.getString(R.string.homeserver_enter_password) } val publicInfo = fetchPublicInfo(serverUrl) val detectedKind = publicInfo.serverKind @@ -289,7 +290,7 @@ class HomeServerRepository @Inject constructor( return@runCatching connection } - require(trimmedUsername.isNotBlank()) { "Enter a username" } + require(trimmedUsername.isNotBlank()) { context.getString(R.string.homeserver_enter_username) } val auth = authenticate(serverUrl, trimmedUsername, password) val connectionShell = HomeServerConnection( enabled = true, @@ -337,7 +338,7 @@ class HomeServerRepository @Inject constructor( suspend fun testConnections(): Result> = withContext(Dispatchers.IO) { runCatching { val current = currentConnections() - require(current.isNotEmpty()) { "No Home Server connected" } + require(current.isNotEmpty()) { context.getString(R.string.homeserver_none_connected) } val refreshed = current.map { refreshConnection(it) } saveConnections(refreshed) refreshed @@ -361,7 +362,7 @@ class HomeServerRepository @Inject constructor( ?.addQueryParameter("X-Plex-Product", "ARVIO") ?.build() ?.toString() - ?: error("Invalid code sign-in URL") + ?: error(context.getString(R.string.homeserver_invalid_code_url)) val request = Request.Builder() .url(url) .post(ByteArray(0).toRequestBody(null)) @@ -370,12 +371,12 @@ class HomeServerRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - error("Code sign in failed (${response.code})") + error(context.getString(R.string.homeserver_code_signin_failed_code, response.code)) } val json = JsonParser().parse(body).asJsonObjectOrNull() ?: JsonObject() val id = json.string("id").ifBlank { json.string("pinId") } val code = json.string("code") - require(id.isNotBlank() && code.isNotBlank()) { "Server did not return an activation code" } + require(id.isNotBlank() && code.isNotBlank()) { context.getString(R.string.homeserver_no_activation_code) } PlexPinAuthSession( id = id, code = code, @@ -394,7 +395,7 @@ class HomeServerRepository @Inject constructor( ?.addQueryParameter("X-Plex-Client-Identifier", deviceId()) ?.build() ?.toString() - ?: error("Invalid code sign-in URL") + ?: error(context.getString(R.string.homeserver_invalid_code_url)) val request = Request.Builder() .url(url) .get() @@ -403,7 +404,7 @@ class HomeServerRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - error("Code sign in polling failed (${response.code})") + error(context.getString(R.string.homeserver_code_poll_failed, response.code)) } val json = JsonParser().parse(body).asJsonObjectOrNull() ?: JsonObject() json.string("authToken") @@ -829,7 +830,7 @@ class HomeServerRepository @Inject constructor( path: String, query: Map = emptyMap() ): String { - val base = baseUrl.toHttpUrlOrNull() ?: error("Invalid server URL") + val base = baseUrl.toHttpUrlOrNull() ?: error(context.getString(R.string.homeserver_invalid_url)) val builder = base.newBuilder() path.trim('/').split('/').filter { it.isNotBlank() }.forEach { builder.addPathSegment(it) } query.forEach { (key, value) -> @@ -867,7 +868,7 @@ class HomeServerRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - error("Server request failed (${response.code})") + error(context.getString(R.string.homeserver_request_failed, response.code)) } return JsonParser().parse(body).asJsonObjectOrNull() ?: JsonObject() } @@ -878,7 +879,7 @@ class HomeServerRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - error("Server request failed (${response.code})") + error(context.getString(R.string.homeserver_request_failed, response.code)) } return body } @@ -892,7 +893,7 @@ class HomeServerRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - error("Server sign in failed (${response.code})") + error(context.getString(R.string.homeserver_signin_failed_code, response.code)) } return JsonParser().parse(body).asJsonObjectOrNull() ?: JsonObject() } @@ -955,7 +956,7 @@ class HomeServerRepository @Inject constructor( userName = user?.string("Name").orEmpty() ).also { require(it.accessToken.isNotBlank() && it.userId.isNotBlank()) { - "Server sign in did not return a playable account" + context.getString(R.string.homeserver_no_playable_account) } } } @@ -985,7 +986,7 @@ class HomeServerRepository @Inject constructor( ): HomeServerConnection { val trimmedAccountToken = accountToken.trim() val trimmedDisplayName = displayName.trim() - require(trimmedAccountToken.isNotBlank()) { "Missing account token" } + require(trimmedAccountToken.isNotBlank()) { context.getString(R.string.homeserver_missing_token) } val normalizedPreferredUrl = normalizeServerUrl(preferredServerUrl) val preferredIdentity = preferredInfo?.takeIf { it.serverId.isNotBlank() } @@ -1020,7 +1021,7 @@ class HomeServerRepository @Inject constructor( .takeIf { it.isNotBlank() } ?: trimmedAccountToken val candidateUrls = plexCandidateServerUrls(normalizedPreferredUrl, targetDevice) - require(candidateUrls.isNotEmpty()) { "No reachable server URL found for this account" } + require(candidateUrls.isNotEmpty()) { context.getString(R.string.homeserver_no_reachable_url) } var lastError: Throwable? = null candidateUrls.forEach { candidateUrl -> @@ -1046,7 +1047,7 @@ class HomeServerRepository @Inject constructor( ?: return@forEach val expectedServerId = targetServerId.ifBlank { preferredIdentity?.serverId.orEmpty() } if (expectedServerId.isNotBlank() && info.serverId.isNotBlank() && expectedServerId != info.serverId) { - lastError = IllegalStateException("Server identity did not match the selected account server") + lastError = IllegalStateException(context.getString(R.string.homeserver_identity_mismatch)) return@forEach } val shell = candidate.copy( @@ -1071,7 +1072,7 @@ class HomeServerRepository @Inject constructor( } val message = lastError?.message?.takeIf { it.isNotBlank() } - error(message ?: "No accessible libraries found for this account") + error(message ?: context.getString(R.string.homeserver_no_libraries)) } private fun fetchPlexResources(accountToken: String): List { @@ -1186,7 +1187,7 @@ class HomeServerRepository @Inject constructor( } private fun refreshConnection(connection: HomeServerConnection): HomeServerConnection { - require(connection.isUsable) { "${connection.serverName.ifBlank { "Home Server" }} is disabled or incomplete" } + require(connection.isUsable) { context.getString(R.string.homeserver_disabled_incomplete) } if (connection.serverKind == HomeServerKind.PLEX) { val accountToken = connection.accountToken.ifBlank { connection.accessToken } val refreshed = buildPlexConnection( @@ -1241,7 +1242,7 @@ class HomeServerRepository @Inject constructor( if (id.isBlank()) return@mapNotNull null HomeServerCollection( id = id, - name = directory.string("title").ifBlank { directory.string("name") }.ifBlank { "Library $id" }, + name = directory.string("title").ifBlank { directory.string("name") }.ifBlank { context.getString(R.string.library_named, id) }, type = directory.string("type"), enabled = true ) @@ -1256,7 +1257,7 @@ class HomeServerRepository @Inject constructor( if (id.isBlank()) return@mapNotNull null HomeServerCollection( id = id, - name = item.string("Name").ifBlank { "Library" }, + name = item.string("Name").ifBlank { context.getString(R.string.library_default) }, type = item.string("CollectionType"), enabled = true ) @@ -1265,7 +1266,7 @@ class HomeServerRepository @Inject constructor( private fun HomeServerConnection.toCatalogCandidate(collection: HomeServerCollection): HomeServerCatalogCandidate { val connectionLabel = displayLabel() - val collectionLabel = collection.name.ifBlank { "Library" } + val collectionLabel = collection.name.ifBlank { context.getString(R.string.library_default) } val title = if (collectionLabel.contains(connectionLabel, ignoreCase = true)) { collectionLabel } else { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt index 25889b797..3a9f40d84 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt @@ -1,5 +1,7 @@ package com.arflix.tv.data.repository +import android.content.Context +import com.arflix.tv.R import com.arflix.tv.data.api.TmdbApi import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.AddonBehaviorHints @@ -13,6 +15,7 @@ import com.google.gson.Gson import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject +import dagger.hilt.android.qualifiers.ApplicationContext import java.net.URI import java.net.URL import java.security.MessageDigest @@ -40,6 +43,7 @@ data class HttpLocalScraperInstallCandidate( @Singleton class HttpLocalScraperRuntime @Inject constructor( + @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient, private val tmdbApi: TmdbApi ) { @@ -67,7 +71,7 @@ class HttpLocalScraperRuntime @Inject constructor( id = stableId, name = sanitizeProviderLabel(customName?.trim()?.takeIf { it.isNotBlank() } ?: manifest.name), version = manifest.version, - description = "HTTP local scraper bundle (${httpScrapers.size} HTTP providers)", + description = context.getString(R.string.addon_http_local_scraper_description, httpScrapers.size), types = listOf("movie", "series"), resources = listOf( AddonResource( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 38c600b9b..6ea05f83c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -12,6 +12,7 @@ import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvSnapshot import com.arflix.tv.data.model.StreamSource +import com.arflix.tv.R import com.arflix.tv.util.settingsDataStore import com.google.gson.Gson import com.google.gson.JsonArray @@ -925,7 +926,7 @@ class IptvRepository @Inject constructor( ) } } - throw IOException("No playable catchup stream returned by provider") + throw IOException(context.getString(R.string.iptv_no_catchup)) } } @@ -1467,7 +1468,7 @@ class IptvRepository @Inject constructor( return withContext(Dispatchers.IO) { loadMutex.withLock { cleanupStaleEpgTempFiles() - onProgress(IptvLoadProgress("Starting IPTV load...", 2)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_starting_load), 2)) val now = System.currentTimeMillis() val config = observeConfig().first() val profileId = profileManager.getProfileIdSync() @@ -1487,15 +1488,15 @@ class IptvRepository @Inject constructor( // ── Stalker Portal path ── if (config.m3uUrl.isBlank() && config.stalkerPortalUrl.isNotBlank()) { - onProgress(IptvLoadProgress("Connecting to Stalker portal...", 10)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_connecting_stalker), 10)) val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) if (!stalker.handshake()) { return@withContext IptvSnapshot(epgWarning = "Stalker handshake failed. Check Portal URL and MAC.", loadedAt = Instant.now()) } - onProgress(IptvLoadProgress("Loading channels from portal...", 30)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_channels), 30)) stalker.getProfile() val channels = stalker.getChannels() - onProgress(IptvLoadProgress("Loaded ${channels.size} channels", 80)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_loaded_channels, channels.size), 80)) val grouped = buildGroupedChannels(channels) val favGroups = observeFavoriteGroups().first() val favChannels = observeFavoriteChannels().first() @@ -1514,7 +1515,7 @@ class IptvRepository @Inject constructor( groupOrder = groupOrder, loadedAt = Instant.now() ) - onProgress(IptvLoadProgress("Done", 100)) + onProgress(IptvLoadProgress(context.getString(R.string.done), 100)) return@withContext snapshot } @@ -1619,12 +1620,12 @@ class IptvRepository @Inject constructor( reDeriveCachedNowNext(channels.asSequence().map { it.id }.toSet()) ?: cachedNowNext } val nowNext = if (shouldUseCachedEpg) { - onProgress(IptvLoadProgress("Using cached EPG", 92)) + onProgress(IptvLoadProgress(context.getString(R.string.epg_using_cached), 92)) System.err.println("[EPG] Using cached EPG (${cachedNowNext.size} channels, age=${(now - cachedEpgAt)/1000}s)") cachedFallbackNowNext } else if (!allowNetworkEpgFetch) { if (cachedFallbackNowNext.isNotEmpty()) { - onProgress(IptvLoadProgress("Using cached EPG", 92)) + onProgress(IptvLoadProgress(context.getString(R.string.epg_using_cached), 92)) System.err.println("[EPG] Skipping broad network EPG fetch and using cached fallback (${cachedFallbackNowNext.size} channels)") cachedFallbackNowNext } else { @@ -1633,11 +1634,11 @@ class IptvRepository @Inject constructor( } } else if (epgCandidates.isEmpty() && !hasXtreamChannels) { if (cachedFallbackNowNext.isNotEmpty()) { - onProgress(IptvLoadProgress("Using cached EPG", 92)) + onProgress(IptvLoadProgress(context.getString(R.string.epg_using_cached), 92)) System.err.println("[EPG] No active EPG source, keeping cached EPG fallback (${cachedFallbackNowNext.size} channels)") cachedFallbackNowNext } else { - onProgress(IptvLoadProgress("No EPG URL configured", 90)) + onProgress(IptvLoadProgress(context.getString(R.string.epg_no_url), 90)) System.err.println("[EPG] No EPG URL and no Xtream creds - skipping EPG") emptyMap() } @@ -1700,13 +1701,13 @@ class IptvRepository @Inject constructor( val candidateChannels = channelsForScopedEpgCandidate(candidate, channels) if (candidateChannels.isEmpty()) continue val pct = (90 + ((index * 8) / epgCandidatesToTry.size.coerceAtLeast(1))).coerceIn(90, 98) - onProgress(IptvLoadProgress("Loading full EPG (${index + 1}/${epgCandidatesToTry.size})...", pct)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_full_epg, index + 1, epgCandidatesToTry.size), pct)) val attempt = runCatching { // 300 s: some providers (like TX-4K) serve a 100 MB // XMLTV dump that needs 2-3 min on a TV's WiFi. // 90 s was aborting before the file finished. withTimeoutOrNull(300_000L) { fetchAndParseEpg(epgUrl, candidateChannels) } - ?: throw java.util.concurrent.TimeoutException("EPG download timed out for ${epgUrl.take(80)}") + ?: throw java.util.concurrent.TimeoutException(context.getString(R.string.epg_timeout, epgUrl.take(80))) } if (attempt.isSuccess) { val parsed = attempt.getOrDefault(emptyMap()) @@ -1911,7 +1912,7 @@ class IptvRepository @Inject constructor( loadedAtMs = System.currentTimeMillis() ) } - onProgress(IptvLoadProgress("Loaded ${channels.size} channels", 100)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_loaded_channels, channels.size), 100)) } } } @@ -2966,15 +2967,15 @@ class IptvRepository @Inject constructor( onProgress: (IptvLoadProgress) -> Unit ): List { resolveXtreamCredentials(playlist)?.let { creds -> - onProgress(IptvLoadProgress("Detected Xtream provider. Loading live channels...", 6)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_xtream_detected), 6)) runCatching { withTimeoutOrNull(120_000L) { fetchXtreamLiveChannels(creds, onProgress) - } ?: throw IllegalStateException("Xtream provider timed out while loading live channels.") + } ?: throw IllegalStateException(context.getString(R.string.iptv_xtream_timeout)) } .onSuccess { channels -> if (channels.isNotEmpty()) { - onProgress(IptvLoadProgress("Loaded ${channels.size} live channels from provider API", 95)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_loaded_api, channels.size), 95)) return channels } } @@ -2988,15 +2989,15 @@ class IptvRepository @Inject constructor( ): List { val normalizedUrl = normalizeStoredIptvUrl(url) resolveXtreamCredentials(normalizedUrl)?.let { creds -> - onProgress(IptvLoadProgress("Detected Xtream provider. Loading live channels...", 6)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_xtream_detected), 6)) runCatching { withTimeoutOrNull(120_000L) { fetchXtreamLiveChannels(creds, onProgress) - } ?: throw IllegalStateException("Xtream provider timed out while loading live channels.") + } ?: throw IllegalStateException(context.getString(R.string.iptv_xtream_timeout)) } .onSuccess { channels -> if (channels.isNotEmpty()) { - onProgress(IptvLoadProgress("Loaded ${channels.size} live channels from provider API", 95)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_loaded_api, channels.size), 95)) return channels } } @@ -3009,10 +3010,10 @@ class IptvRepository @Inject constructor( runCatching { withTimeoutOrNull(90_000L) { fetchAndParseM3uOnce(normalizedUrl, onProgress) - } ?: throw IllegalStateException("Playlist loading timed out. Try refreshing or using the provider's Xtream credentials.") + } ?: throw IllegalStateException(context.getString(R.string.iptv_playlist_timeout)) }.onSuccess { channels -> if (channels.isNotEmpty()) return channels - lastError = IllegalStateException("Playlist loaded but contains no channels.") + lastError = IllegalStateException(context.getString(R.string.iptv_no_channels)) }.onFailure { error -> lastError = error } @@ -3023,7 +3024,7 @@ class IptvRepository @Inject constructor( delay(backoffMs) } } - throw (lastError ?: IllegalStateException("Failed to load M3U playlist.")) + throw (lastError ?: IllegalStateException(context.getString(R.string.iptv_failed_load_m3u))) } private data class XtreamCredentials( @@ -5233,7 +5234,7 @@ class IptvRepository @Inject constructor( val categoryMap = categories .associate { it.categoryId.orEmpty() to (it.categoryName?.trim().orEmpty().ifBlank { "Uncategorized" }) } - onProgress(IptvLoadProgress("Loading live streams...", 35)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_live), 35)) val streams: List = requestJson( streamsUrl, @@ -5332,7 +5333,7 @@ class IptvRepository @Inject constructor( .get() .build() iptvHttpClient.newCall(request).execute().use { response -> - val raw = response.body?.byteStream() ?: throw IllegalStateException("M3U response was empty.") + val raw = response.body?.byteStream() ?: throw IllegalStateException(context.getString(R.string.iptv_m3u_empty)) val contentLength = response.body?.contentLength()?.takeIf { it > 0L } val progressStream = ProgressInputStream(raw) { bytesRead -> if (contentLength != null) { @@ -5361,7 +5362,7 @@ class IptvRepository @Inject constructor( cleanPreview.isBlank() -> "HTTP ${response.code}" else -> cleanPreview } - throw IllegalStateException("M3U request failed (HTTP ${response.code}). $detail") + throw IllegalStateException(context.getString(R.string.iptv_m3u_failed, response.code) + " " + detail) } onProgress(IptvLoadProgress("Parsing channels...", 78)) return parseM3u(stream, onProgress) @@ -5418,12 +5419,12 @@ class IptvRepository @Inject constructor( saveEpgHttpCacheHeaders(url, etag, lastModified) } } - val stream = safeResponse.body?.byteStream() ?: throw IllegalStateException("Empty EPG response") + val stream = safeResponse.body?.byteStream() ?: throw IllegalStateException(context.getString(R.string.epg_empty)) val prepared = BufferedInputStream(prepareInputStream(stream, url)) if (!safeResponse.isSuccessful && !looksLikeXmlTv(prepared)) { val preview = safeResponse.peekBody(220).string().replace('\n', ' ').trim() val detail = if (preview.isBlank()) "No response body." else preview - throw IllegalStateException("EPG request failed (HTTP ${safeResponse.code}). $detail") + throw IllegalStateException(context.getString(R.string.epg_failed, safeResponse.code) + " " + detail) } // Try streaming parse first (avoids disk I/O for the common case). @@ -5443,7 +5444,7 @@ class IptvRepository @Inject constructor( ).execute() retryResponse.use { rr -> val retryStream = rr.body?.byteStream() - ?: throw IllegalStateException("Empty EPG retry response") + ?: throw IllegalStateException(context.getString(R.string.epg_retry_empty)) BufferedInputStream(prepareInputStream(retryStream, url)).use { input -> BufferedOutputStream(tmpFile.outputStream()).use { output -> input.copyTo(output, DEFAULT_BUFFER_SIZE) @@ -5605,7 +5606,7 @@ class IptvRepository @Inject constructor( if (providerChannels.isEmpty()) return@forEachIndexed onProgress( IptvLoadProgress( - "Loading EPG provider ${index + 1}/${groups.size}...", + context.getString(R.string.iptv_progress_loading_epg_provider, index + 1, groups.size), 90 + ((index * 6) / groups.size.coerceAtLeast(1)) ) ) @@ -5630,7 +5631,7 @@ class IptvRepository @Inject constructor( if (providerChannels.isEmpty()) return@forEachIndexed onProgress( IptvLoadProgress( - "Loading full Xtream guide ${index + 1}/${groups.size}...", + context.getString(R.string.iptv_progress_loading_xtream_guide, index + 1, groups.size), 90 + ((index * 6) / groups.size.coerceAtLeast(1)) ) ) @@ -5808,7 +5809,7 @@ class IptvRepository @Inject constructor( val streamIds = representatives.streamIds if (streamIds.isEmpty()) return null - onProgress(IptvLoadProgress("Loading full Xtream EPG...", 90)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_xtream_epg), 90)) System.err.println( "[EPG] Xtream full EPG: fetching ${streamIds.size} representative streams " + "for ${xtreamChannels.size}/${channels.size} channels skippedNoGuide=${representatives.skippedWithoutGuideKey}" @@ -5840,7 +5841,7 @@ class IptvRepository @Inject constructor( if (hadError) errors++ if (fetched % 50 == 0) { val pct = (90 + ((fetched.toLong() * 8L) / total.toLong())).toInt().coerceIn(90, 98) - onProgress(IptvLoadProgress("Loading full EPG... $fetched/$total streams", pct)) + onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_full_epg_streams, fetched, total), pct)) } } if (batchListings.isEmpty()) continue @@ -6157,7 +6158,7 @@ class IptvRepository @Inject constructor( // Skip programs that ended before the oldest possible catchup window. if (stopMs < oldestRecentCutoff) continue - val title = decodeBase64Field(listing.title).ifBlank { "No Title" } + val title = decodeBase64Field(listing.title).ifBlank { context.getString(R.string.program_no_title) } val description = decodeBase64Field(listing.description).takeIf { it.isNotBlank() } val program = IptvProgram( @@ -6602,7 +6603,7 @@ class IptvRepository @Inject constructor( }.orEmpty() if (resolvedChannels.isNotEmpty() && currentStop > currentStart) { val program = IptvProgram( - title = currentTitle ?: "Unknown program", + title = currentTitle ?: context.getString(R.string.program_unknown), description = currentDesc, startUtcMillis = currentStart, endUtcMillis = currentStop @@ -6763,7 +6764,7 @@ class IptvRepository @Inject constructor( }.orEmpty() if (resolvedChannels.isNotEmpty() && currentStop > currentStart) { val program = IptvProgram( - title = currentTitle ?: "Unknown program", + title = currentTitle ?: context.getString(R.string.program_unknown), description = currentDesc, startUtcMillis = currentStart, endUtcMillis = currentStop diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/LauncherContinueWatchingRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/LauncherContinueWatchingRepository.kt index 7d4142834..ee1720613 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/LauncherContinueWatchingRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/LauncherContinueWatchingRepository.kt @@ -143,7 +143,7 @@ class LauncherContinueWatchingRepository @Inject constructor( val channel = Channel.Builder() .setType(TvContractCompat.Channels.TYPE_PREVIEW) - .setDisplayName("Continue Watching") + .setDisplayName(context.getString(R.string.continue_watching)) .setDescription("Resume watching in Arvio") .setInternalProviderId(CHANNEL_INTERNAL_ID) .build() @@ -166,7 +166,7 @@ class LauncherContinueWatchingRepository @Inject constructor( val program = PreviewProgram.Builder() .setChannelId(channelId) .setType(item.toPreviewType()) - .setTitle(item.title.ifBlank { "Continue Watching" }) + .setTitle(item.title.ifBlank { context.getString(R.string.continue_watching) }) .setDescription(item.buildSubtitle()) .setInternalProviderId(item.previewProgramId()) .setPosterArtUri(item.posterPath?.takeIf { it.isNotBlank() }?.let(Uri::parse)) @@ -182,7 +182,7 @@ class LauncherContinueWatchingRepository @Inject constructor( private fun insertWatchNextProgram(item: ContinueWatchingItem, index: Int) { val builder = WatchNextProgram.Builder() .setType(item.toPreviewType()) - .setTitle(item.title.ifBlank { "Continue Watching" }) + .setTitle(item.title.ifBlank { context.getString(R.string.continue_watching) }) .setDescription(item.buildSubtitle()) .setInternalProviderId(item.watchNextProgramId()) .setIntentUri(buildLaunchIntent(item).toUri(Intent.URI_INTENT_SCHEME).let(Uri::parse)) @@ -324,14 +324,16 @@ class LauncherContinueWatchingRepository @Inject constructor( private fun ContinueWatchingItem.buildSubtitle(): String { val episodeLabel = if (mediaType == MediaType.TV && season != null && episode != null) { - "Continue S${season}E${episode}" + context.getString(R.string.continue_season_episode, season, episode) } else { - "Continue" + context.getString(R.string.continue_label) } val resumeClock = resumePositionSeconds.takeIf { it > 0L }?.let(::formatResumeClock) return when { - !resumeClock.isNullOrBlank() -> "$episodeLabel from $resumeClock" - !episodeTitle.isNullOrBlank() -> "$episodeLabel - $episodeTitle" + !resumeClock.isNullOrBlank() -> + context.getString(R.string.launcher_continue_from, episodeLabel, resumeClock) + !episodeTitle.isNullOrBlank() -> + context.getString(R.string.launcher_continue_dash, episodeLabel, episodeTitle) else -> episodeLabel } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index e4cef95d1..36996bd45 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -1,5 +1,6 @@ package com.arflix.tv.data.repository +import android.content.Context import com.arflix.tv.R import com.arflix.tv.data.api.TmdbApi import com.arflix.tv.data.api.TmdbCastMember @@ -33,6 +34,7 @@ import com.arflix.tv.data.model.PersonDetails import com.arflix.tv.data.model.Review import com.arflix.tv.util.CatalogUrlParser import com.arflix.tv.util.Constants +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -81,6 +83,7 @@ data class PersonMediaSearchResult( */ @Singleton class MediaRepository @Inject constructor( + @ApplicationContext private val context: Context, private val tmdbApi: TmdbApi, private val traktRepository: TraktRepository, private val traktApi: TraktApi, @@ -1652,17 +1655,17 @@ class MediaRepository @Inject constructor( val categories = listOf( Category( id = "trending_movies", - title = "Trending Movies", + title = context.getString(R.string.trending_movies), items = safeItems({ trendingMovies.await() }, MediaType.MOVIE) ), Category( id = "trending_tv", - title = "Trending Series", + title = context.getString(R.string.trending_series), items = safeItems({ trendingTv.await() }, MediaType.TV) ), Category( id = "trending_anime", - title = "Trending Anime", + title = context.getString(R.string.trending_anime), items = safeItems({ trendingAnime.await() }, MediaType.TV) ) ) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt index 9ab585e88..914365c7f 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt @@ -5,6 +5,7 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.util.Base64 +import com.arflix.tv.R import com.arflix.tv.data.model.Profile import com.arflix.tv.util.Constants import com.arflix.tv.util.ProfileAvatarFiles @@ -43,7 +44,7 @@ class ProfileAvatarImageManager @Inject constructor( bitmap.compress(Bitmap.CompressFormat.JPEG, 86, out) } bitmap.recycle() - } ?: throw IllegalArgumentException("Could not decode selected avatar image") + } ?: throw IllegalArgumentException(context.getString(R.string.avatar_decode_failed)) ProfileAvatarFiles.cleanupProfile(context, profileId, keepVersion = version) ImportedProfileAvatar( @@ -157,7 +158,7 @@ class ProfileAvatarImageManager @Inject constructor( } val userId = authRepository.getCurrentUserId().orEmpty() val token = authRepository.getAccessToken().orEmpty() - if (userId.isBlank() || token.isBlank()) error("Not logged in") + if (userId.isBlank() || token.isBlank()) error(context.getString(R.string.error_not_logged_in)) val path = "$userId/$profileId/$version.jpg" val request = Request.Builder() @@ -170,7 +171,7 @@ class ProfileAvatarImageManager @Inject constructor( .post(file.asRequestBody("image/jpeg".toMediaType())) .build() httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("Avatar upload failed: HTTP ${response.code}") + if (!response.isSuccessful) error(context.getString(R.string.avatar_upload_failed, response.code)) } path } @@ -183,7 +184,7 @@ class ProfileAvatarImageManager @Inject constructor( error("Remote avatar storage is handled by account sync") } val token = authRepository.getAccessToken().orEmpty() - if (token.isBlank()) error("Not logged in") + if (token.isBlank()) error(context.getString(R.string.error_not_logged_in)) val request = Request.Builder() .url("${Constants.SUPABASE_URL.trimEnd('/')}/storage/v1/object/$BUCKET/$storagePath") .header("apikey", Constants.SUPABASE_ANON_KEY) @@ -191,8 +192,8 @@ class ProfileAvatarImageManager @Inject constructor( .get() .build() httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("Avatar download failed: HTTP ${response.code}") - val bytes = response.body?.bytes() ?: error("Empty avatar response") + if (!response.isSuccessful) error(context.getString(R.string.avatar_download_failed, response.code)) + val bytes = response.body?.bytes() ?: error(context.getString(R.string.avatar_response_empty)) destination.writeBytes(bytes) } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 0be266271..862837a02 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.arflix.tv.R import com.arflix.tv.data.api.* import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.AddonInstallSource @@ -462,7 +463,7 @@ class StreamRepository @Inject constructor( name = config.name, version = "1.0.0", description = when (config.id) { - "opensubtitles" -> "Subtitles from OpenSubtitles" + "opensubtitles" -> context.getString(R.string.addon_opensubtitles_description) else -> "" }, isInstalled = true, @@ -482,7 +483,7 @@ class StreamRepository @Inject constructor( id = "opensubtitles", name = "OpenSubtitles v3", version = "1.0.0", - description = "Subtitles from OpenSubtitles", + description = context.getString(R.string.addon_opensubtitles_description), isInstalled = true, isEnabled = preservedEnabled, type = AddonType.SUBTITLE, @@ -492,7 +493,7 @@ class StreamRepository @Inject constructor( id = "opensubtitles", name = "OpenSubtitles v3", version = addon?.version ?: "1.0.0", - description = "Subtitles from OpenSubtitles", + description = context.getString(R.string.addon_opensubtitles_description), isInstalled = true, isEnabled = preservedEnabled, type = AddonType.SUBTITLE, @@ -554,7 +555,7 @@ class StreamRepository @Inject constructor( try { val normalizedUrl = resolveAddonInstallUrl(url) if (normalizedUrl.isBlank()) { - return@withContext Result.failure(IllegalArgumentException("Addon URL is empty")) + return@withContext Result.failure(IllegalArgumentException(context.getString(R.string.addon_error_url_empty))) } httpLocalScraperRuntime.fetchInstallCandidate( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 746ee77be..545be0b7d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -6,6 +6,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import com.arflix.tv.R import com.arflix.tv.data.api.* import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType @@ -2580,7 +2581,7 @@ class TraktRepository @Inject constructor( MediaItem( id = tmdbId, title = movie.title, - subtitle = "Movie", + subtitle = context.getString(R.string.movie), overview = "", year = movie.year?.toString().orEmpty(), mediaType = MediaType.MOVIE, @@ -2595,7 +2596,7 @@ class TraktRepository @Inject constructor( MediaItem( id = tmdbId, title = show.title, - subtitle = "TV Series", + subtitle = context.getString(R.string.component_label_tv_series), overview = "", year = show.year?.toString().orEmpty(), mediaType = MediaType.TV, @@ -2648,7 +2649,7 @@ class TraktRepository @Inject constructor( MediaItem( id = details.id, title = details.title, - subtitle = "Movie", + subtitle = context.getString(R.string.movie), overview = details.overview ?: "", year = details.releaseDate?.take(4) ?: "", tmdbRating = String.format(Locale.US, "%.1f", details.voteAverage), @@ -2669,7 +2670,7 @@ class TraktRepository @Inject constructor( MediaItem( id = details.id, title = details.name, - subtitle = "TV Series", + subtitle = context.getString(R.string.component_label_tv_series), overview = details.overview ?: "", year = details.firstAirDate?.take(4) ?: "", tmdbRating = String.format(Locale.US, "%.1f", details.voteAverage), @@ -2697,7 +2698,7 @@ class TraktRepository @Inject constructor( return MediaItem( id = tmdbId, title = movie.title, - subtitle = "Movie", + subtitle = context.getString(R.string.movie), overview = "", year = movie.year?.toString().orEmpty(), mediaType = MediaType.MOVIE, @@ -2717,7 +2718,7 @@ class TraktRepository @Inject constructor( return MediaItem( id = tmdbId, title = show.title, - subtitle = "TV Series", + subtitle = context.getString(R.string.component_label_tv_series), overview = "", year = show.year?.toString().orEmpty(), mediaType = MediaType.TV, @@ -3783,7 +3784,7 @@ data class ContinueWatchingItem( val totalEpisodes: Int = 0, val watchedEpisodes: Int = 0 ) { - fun toMediaItem(): MediaItem { + fun toMediaItem(context: Context? = null): MediaItem { val effectiveDurationSeconds = durationSeconds.takeIf { it > 0L } ?: parseRuntimeLabelSeconds(duration) val showPlaybackProgress = !isUpNext && progress in 1..94 val resumeSeconds = when { @@ -3798,13 +3799,23 @@ data class ContinueWatchingItem( val resumeLabel = resumeSeconds.takeIf { it > 0L }?.let { formatResumeClock(it) } val subtitle = if (mediaType == MediaType.TV && season != null && episode != null) { - val base = "Continue S${season}.E${episode}" - if (!resumeLabel.isNullOrBlank()) "$base from $resumeLabel" else base + val base = context?.getString(R.string.continue_season_episode, season, episode) + ?: "Continue S${season}E${episode}" + if (!resumeLabel.isNullOrBlank()) { + context?.getString(R.string.continue_from, resumeLabel) ?: "$base from $resumeLabel" + } else { + base + } } else { if (mediaType == MediaType.MOVIE) { - if (!resumeLabel.isNullOrBlank()) "Continue from $resumeLabel" else "Continue" + if (!resumeLabel.isNullOrBlank()) { + context?.getString(R.string.continue_from, resumeLabel) + ?: "Continue from $resumeLabel" + } else { + context?.getString(R.string.continue_label) ?: "Continue" + } } else { - "TV Series" + context?.getString(R.string.component_label_tv_series) ?: "TV Series" } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt index 3106f578f..b017d1e20 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.arflix.tv.R import com.arflix.tv.data.api.* import com.arflix.tv.data.model.MediaType import com.arflix.tv.util.Constants @@ -104,7 +105,7 @@ class TraktSyncService @Inject constructor( suspend fun performFullSync(): SyncResult = withContext(Dispatchers.IO) { if (_isSyncing.value) { - return@withContext SyncResult.Error("Sync already in progress") + return@withContext SyncResult.Error(context.getString(R.string.sync_in_progress)) } _isSyncing.value = true @@ -120,7 +121,7 @@ class TraktSyncService @Inject constructor( if (hasSupabase) { try { - val supabaseUserId = userId ?: return@withContext SyncResult.Error("Not logged in") + val supabaseUserId = userId ?: return@withContext SyncResult.Error(context.getString(R.string.error_not_logged_in)) updateSyncState(supabaseUserId, syncInProgress = true, lastError = null) } catch (e: Exception) { } @@ -297,7 +298,7 @@ class TraktSyncService @Inject constructor( } catch (_: Exception) { } - SyncResult.Error(e.message ?: "Unknown error") + SyncResult.Error(e.message ?: context.getString(R.string.error_unknown)) } finally { _isSyncing.value = false } @@ -310,7 +311,7 @@ class TraktSyncService @Inject constructor( suspend fun performIncrementalSync(): SyncResult = withContext(Dispatchers.IO) { if (_isSyncing.value) { - return@withContext SyncResult.Error("Sync already in progress") + return@withContext SyncResult.Error(context.getString(R.string.sync_in_progress)) } _isSyncing.value = true @@ -325,7 +326,7 @@ class TraktSyncService @Inject constructor( _isSyncing.value = false return@withContext performFullSync() } - val safeUserId = userId ?: return@withContext SyncResult.Error("Not logged in") + val safeUserId = userId ?: return@withContext SyncResult.Error(context.getString(R.string.error_not_logged_in)) var syncState: SyncStateRecord? = null try { @@ -471,7 +472,7 @@ class TraktSyncService @Inject constructor( status = SyncStatus.ERROR, message = "Sync failed: ${e.message}" ) - SyncResult.Error(e.message ?: "Unknown error") + SyncResult.Error(e.message ?: context.getString(R.string.error_unknown)) } finally { _isSyncing.value = false } @@ -1755,7 +1756,7 @@ class TraktSyncService @Inject constructor( operation: String, block: suspend (String) -> T ): T { - val auth = getAuthHeader() ?: throw IllegalStateException("Not authenticated with Trakt") + val auth = getAuthHeader() ?: throw IllegalStateException(context.getString(R.string.trakt_not_authenticated)) return try { block(auth) } catch (e: HttpException) { @@ -1783,7 +1784,7 @@ class TraktSyncService @Inject constructor( } auth = if (!refreshed.isNullOrBlank()) "Bearer $refreshed" else null } - if (auth == null) throw IllegalStateException("Supabase auth failed") + if (auth == null) throw IllegalStateException(context.getString(R.string.sync_supabase_auth_failed)) return try { block(auth) } catch (e: HttpException) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt index 1d8e7c943..d529768b1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt @@ -1,7 +1,10 @@ package com.arflix.tv.data.repository +import android.content.Context +import com.arflix.tv.R import com.arflix.tv.util.Constants import com.arflix.tv.util.AuthEmailValidator +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -42,6 +45,7 @@ data class TvDeviceAuthCompleteResult( @Singleton class TvDeviceAuthRepository @Inject constructor( + @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient ) { private val jsonMediaType = "application/json; charset=utf-8".toMediaType() @@ -59,7 +63,7 @@ class TvDeviceAuthRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - throw IllegalStateException(parseError(body, "Failed to start TV auth")) + throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed_start))) } val json = JSONObject(body) val userCode = json.getString("user_code") @@ -102,13 +106,13 @@ class TvDeviceAuthRepository @Inject constructor( okHttpClient.newCall(pollRequest).execute().use { fallback -> val fallbackBody = fallback.body?.string().orEmpty() if (!fallback.isSuccessful) { - throw IllegalStateException(parseError(fallbackBody, "Failed to poll TV auth status")) + throw IllegalStateException(parseError(fallbackBody, context.getString(R.string.tv_link_failed_poll))) } return@use parseStatus(fallbackBody) } } if (!response.isSuccessful) { - throw IllegalStateException(parseError(body, "Failed to poll TV auth status")) + throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed_poll))) } parseStatus(body) } @@ -124,7 +128,8 @@ class TvDeviceAuthRepository @Inject constructor( ): Result { val normalizedEmail = AuthEmailValidator.normalize(email) val isSignup = intent.equals("signup", ignoreCase = true) - AuthEmailValidator.validate(normalizedEmail, rejectDisposable = isSignup)?.let { message -> + AuthEmailValidator.validate(normalizedEmail, rejectDisposable = isSignup)?.let { messageRes -> + val message = context.getString(messageRes) return Result.failure(IllegalArgumentException(message)) } return withContext(Dispatchers.IO) { @@ -146,7 +151,7 @@ class TvDeviceAuthRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { - throw IllegalStateException(parseError(body, "Failed to link TV")) + throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed))) } TvDeviceAuthCompleteResult(ok = true) } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt index f4362484f..3dea46381 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt @@ -2,6 +2,7 @@ package com.arflix.tv.data.repository import android.content.Context import androidx.datastore.preferences.core.edit +import com.arflix.tv.R import com.arflix.tv.data.api.TmdbApi import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType @@ -499,7 +500,7 @@ class WatchlistRepository @Inject constructor( MediaItem( id = item.tmdbId, title = item.title, - subtitle = if (item.mediaType == "tv") "TV Series" else "Movie", + subtitle = if (item.mediaType == "tv") context.getString(R.string.component_label_tv_series) else context.getString(R.string.movie), overview = "", year = "", mediaType = if (item.mediaType == "tv") MediaType.TV else MediaType.MOVIE, @@ -516,7 +517,7 @@ class WatchlistRepository @Inject constructor( return MediaItem( id = tmdbId, title = details.name, - subtitle = "TV Series", + subtitle = context.getString(R.string.component_label_tv_series), overview = details.overview ?: "", year = details.firstAirDate?.take(4) ?: "", releaseDate = details.firstAirDate ?: "", @@ -535,7 +536,7 @@ class WatchlistRepository @Inject constructor( return MediaItem( id = tmdbId, title = details.title, - subtitle = "Movie", + subtitle = context.getString(R.string.movie), overview = details.overview ?: "", year = details.releaseDate?.take(4) ?: "", releaseDate = details.releaseDate ?: "", @@ -560,7 +561,7 @@ class WatchlistRepository @Inject constructor( return MediaItem( id = tmdbId, title = title, - subtitle = if (type == MediaType.TV) "TV Series" else "Movie", + subtitle = if (type == MediaType.TV) context.getString(R.string.component_label_tv_series) else context.getString(R.string.movie), overview = "", year = "", mediaType = type, diff --git a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramClient.kt b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramClient.kt index 5ab5decb2..1cc492184 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramClient.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramClient.kt @@ -2,6 +2,7 @@ package com.arflix.tv.data.telegram import android.content.Context import android.util.Log +import com.arflix.tv.R import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -53,7 +54,7 @@ class TelegramClient @Inject constructor( if (client != null) return@launch stepLog("checking library availability") if (!isAvailable) { - _authState.value = TelegramAuthState.Error("TDLib not available on this device") + _authState.value = TelegramAuthState.Error(context.getString(R.string.telegram_tdlib_unavailable)) return@launch } stepLog("library loaded OK") @@ -71,7 +72,7 @@ class TelegramClient @Inject constructor( } catch (e: Throwable) { Log.e(TAG, "TDLib Client.create failed", e) stepLog("EXCEPTION: ${e.message}") - _authState.value = TelegramAuthState.Error("TDLib failed to start: ${e.message}") + _authState.value = TelegramAuthState.Error(context.getString(R.string.telegram_tdlib_failed, e.message ?: "")) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSourceResolver.kt b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSourceResolver.kt index c05e1902e..d5568e3c4 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSourceResolver.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSourceResolver.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.widget.Toast +import com.arflix.tv.R import com.arflix.tv.data.api.TmdbApi import com.arflix.tv.data.model.StreamSource import com.arflix.tv.util.Constants @@ -71,7 +72,7 @@ class TelegramSourceResolver @Inject constructor( resolveInternal(title, year, season, episode, imdbId, isMovie) } ?: emptyList().also { Log.w(TAG, "Telegram search timed out for '$title'") - showToast("Telegram search timed out") + showToast(context.getString(R.string.telegram_search_timed_out)) } cache[key] = CacheEntry(results, System.currentTimeMillis() + cacheTtl(year, isMovie)) @@ -175,12 +176,12 @@ class TelegramSourceResolver @Inject constructor( } private fun friendlyError(raw: String?): String { - if (raw == null) return "Telegram search failed" + if (raw == null) return context.getString(R.string.telegram_search_failed) val waitSeconds = raw.removePrefix("FLOOD_WAIT_").toIntOrNull() return if (waitSeconds != null) - "Too many searches — please wait ${waitSeconds}s before retrying" + context.getString(R.string.telegram_too_many_searches, waitSeconds) else - "Telegram: $raw" + context.getString(R.string.telegram_error_raw, raw) } private fun showToast(message: String) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt index 6f9dbef3a..7e96f5223 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -70,31 +70,44 @@ fun AppUpdateModal( onDismiss: () -> Unit, onIgnore: () -> Unit ) { - val buttons = remember(status) { + val labelClose = stringResource(R.string.close) + val labelIgnore = stringResource(R.string.update_btn_ignore) + val labelDownload = stringResource(R.string.update_btn_download) + val labelInstall = stringResource(R.string.update_btn_install) + val labelHide = stringResource(R.string.update_btn_hide) + val labelRetryInstall = stringResource(R.string.update_btn_retry_install) + val labelCancel = stringResource(R.string.cancel) + val labelRetry = stringResource(R.string.retry) + + val buttons = remember( + status, + labelClose, labelIgnore, labelDownload, labelInstall, + labelHide, labelRetryInstall, labelCancel, labelRetry + ) { when (status) { is UpdateStatus.UpdateAvailable -> listOf( - ActionButtonConfig("Close", onDismiss), - ActionButtonConfig("Ignore", onIgnore), - ActionButtonConfig("Download", onDownload, highlighted = true) + ActionButtonConfig(labelClose, onDismiss), + ActionButtonConfig(labelIgnore, onIgnore), + ActionButtonConfig(labelDownload, onDownload, highlighted = true) ) is UpdateStatus.ReadyToInstall -> listOf( - ActionButtonConfig("Close", onDismiss), - ActionButtonConfig("Install", onInstall, highlighted = true) + ActionButtonConfig(labelClose, onDismiss), + ActionButtonConfig(labelInstall, onInstall, highlighted = true) ) is UpdateStatus.Installing -> listOf( - ActionButtonConfig("Hide", onDismiss), - ActionButtonConfig("Retry Install", onInstall, highlighted = true) + ActionButtonConfig(labelHide, onDismiss), + ActionButtonConfig(labelRetryInstall, onInstall, highlighted = true) ) is UpdateStatus.Downloading -> listOf( - ActionButtonConfig("Hide", onDismiss), - ActionButtonConfig("Cancel", onCancelDownload) + ActionButtonConfig(labelHide, onDismiss), + ActionButtonConfig(labelCancel, onCancelDownload) ) is UpdateStatus.Failure -> listOf( - ActionButtonConfig("Close", onDismiss), - ActionButtonConfig("Retry", onDownload, highlighted = true) + ActionButtonConfig(labelClose, onDismiss), + ActionButtonConfig(labelRetry, onDownload, highlighted = true) ) else -> listOf( - ActionButtonConfig("Close", onDismiss) + ActionButtonConfig(labelClose, onDismiss) ) } } @@ -153,28 +166,28 @@ fun AppUpdateModal( Spacer(modifier = Modifier.height(10.dp)) val subtitle = when (status) { - is UpdateStatus.Checking -> "Checking GitHub Releases..." - is UpdateStatus.UpdateAvailable -> "Update available: ${status.update.title} (${status.update.tag})" - is UpdateStatus.Downloading -> "Downloading update..." - is UpdateStatus.ReadyToInstall -> "${status.update.title} is ready to install." - is UpdateStatus.Installing -> "Installing update... Please follow the system prompt." - is UpdateStatus.Failure -> "Update failed." - is UpdateStatus.Success -> "You already have the latest version installed." - is UpdateStatus.Idle -> "No release information available." + is UpdateStatus.Checking -> stringResource(R.string.update_msg_checking) + is UpdateStatus.UpdateAvailable -> stringResource(R.string.update_msg_available, status.update.title, status.update.tag) + is UpdateStatus.Downloading -> stringResource(R.string.update_msg_downloading) + is UpdateStatus.ReadyToInstall -> stringResource(R.string.update_msg_ready_subtitle, status.update.title) + is UpdateStatus.Installing -> stringResource(R.string.update_msg_installing) + is UpdateStatus.Failure -> stringResource(R.string.update_msg_failed) + is UpdateStatus.Success -> stringResource(R.string.update_msg_uptodate) + is UpdateStatus.Idle -> stringResource(R.string.update_msg_no_info) } androidx.compose.material3.Text(subtitle, style = ArflixTypography.body, color = TextSecondary) if (status is UpdateStatus.UpdateAvailable) { Spacer(modifier = Modifier.height(8.dp)) androidx.compose.material3.Text( - text = "Current version ${BuildConfig.VERSION_NAME} -> latest ${status.update.tag}", + text = stringResource(R.string.update_msg_current_to_latest, BuildConfig.VERSION_NAME, status.update.tag), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.78f) ) } else if (status is UpdateStatus.Success) { Spacer(modifier = Modifier.height(8.dp)) androidx.compose.material3.Text( - text = "Current version ${BuildConfig.VERSION_NAME} is up to date", + text = stringResource(R.string.update_msg_current_uptodate, BuildConfig.VERSION_NAME), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.78f) ) @@ -197,16 +210,16 @@ fun AppUpdateModal( ) Spacer(modifier = Modifier.height(8.dp)) androidx.compose.material3.Text( - text = status.progress?.let { "${(it * 100).toInt()}%" } ?: "Preparing...", + text = status.progress?.let { "${(it * 100).toInt()}%" } ?: stringResource(R.string.update_msg_preparing), style = ArflixTypography.caption, color = TextSecondary ) } is UpdateStatus.ReadyToInstall -> { - androidx.compose.material3.Text("The latest ARVIO update has been downloaded and is ready to install.", style = ArflixTypography.body, color = TextPrimary) + androidx.compose.material3.Text(stringResource(R.string.update_msg_ready), style = ArflixTypography.body, color = TextPrimary) } is UpdateStatus.Installing -> { - androidx.compose.material3.Text("The Android package installer should appear. If it does not, you can try pressing Install again.", style = ArflixTypography.body, color = TextPrimary) + androidx.compose.material3.Text(stringResource(R.string.update_msg_installer_hint), style = ArflixTypography.body, color = TextPrimary) } is UpdateStatus.UpdateAvailable -> { if (status.update.notes.isNotBlank()) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ArvioLoadingScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ArvioLoadingScreen.kt index 303ed2f0a..b22f10e30 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ArvioLoadingScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ArvioLoadingScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -109,7 +110,7 @@ fun ArvioLoadingScreen( Spacer(modifier = Modifier.height(30.dp)) Text( - text = "Loading...", + text = stringResource(R.string.loading), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = TextSecondary, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AvatarRegistry.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AvatarRegistry.kt index c24054e3b..3ead5a592 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AvatarRegistry.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AvatarRegistry.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import com.arflix.tv.R /** @@ -230,6 +231,20 @@ object AvatarRegistry { } } +/** + * Display-only localization of avatar category headers. + * The Map keys in [AvatarRegistry.categories] stay English (used as logic keys); + * only the shown header label is translated. Unknown categories pass through. + */ +@Composable +fun avatarCategoryLabel(raw: String): String = when (raw) { + "Animals" -> stringResource(R.string.avatar_cat_animals) + "Characters" -> stringResource(R.string.avatar_cat_characters) + "Media" -> stringResource(R.string.avatar_cat_media) + "Nature" -> stringResource(R.string.avatar_cat_nature) + else -> raw +} + /** * Renders an avatar image from drawable resources. */ @@ -237,7 +252,7 @@ object AvatarRegistry { fun AvatarIcon(avatarId: Int, modifier: Modifier = Modifier) { Image( painter = painterResource(id = AvatarRegistry.getDrawableRes(avatarId)), - contentDescription = "Avatar", + contentDescription = stringResource(R.string.component_avatar), modifier = modifier.fillMaxSize(), contentScale = ContentScale.Fit ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt index 79affff99..a245fa5c9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.focusable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.focus.FocusRequester @@ -56,6 +57,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.BackgroundElevated import com.arflix.tv.ui.theme.Pink @@ -221,7 +223,7 @@ fun ContextMenu( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Press Back to cancel", + text = stringResource(R.string.context_press_back_cancel), style = ArflixTypography.caption, color = TextSecondary ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt index 4c45fb6d2..fa5c47146 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import coil.size.Precision import androidx.compose.ui.platform.LocalContext +import com.arflix.tv.R import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.arflix.tv.ui.skin.ArvioFocusableSurface @@ -115,7 +117,7 @@ fun ContinueWatchingCard( ) { Icon( imageVector = Icons.Default.PlayArrow, - contentDescription = "Play", + contentDescription = stringResource(R.string.play), tint = ArvioSkin.colors.textPrimary, modifier = Modifier.size(32.dp), ) @@ -162,7 +164,7 @@ fun ContinueWatchingCard( } } - val typeLabel = if (item.mediaType == MediaType.TV) "TV" else "MOVIE" + val typeLabel = if (item.mediaType == MediaType.TV) stringResource(R.string.component_badge_tv) else stringResource(R.string.component_badge_movie) Box( modifier = Modifier .align(Alignment.TopStart) @@ -323,7 +325,7 @@ fun ContinueWatchingCardCompact( ) { Icon( imageVector = Icons.Default.PlayArrow, - contentDescription = "Play", + contentDescription = stringResource(R.string.play), tint = ArvioSkin.colors.textPrimary, modifier = Modifier.size(24.dp), ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt index 7b7be2218..fce4c0b89 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -37,6 +38,7 @@ import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.request.ImageRequest import coil.size.Precision +import com.arflix.tv.R import com.arflix.tv.data.model.CollectionGroupKind import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType @@ -301,7 +303,7 @@ fun MediaCard( if (logoRequest != null && isLandscape && !isCollectionTile) { AsyncImage( model = logoRequest, - contentDescription = "${item.title} logo", + contentDescription = stringResource(R.string.component_media_logo, item.title), contentScale = ContentScale.Fit, alignment = if (isLandscape) Alignment.BottomStart else Alignment.BottomCenter, modifier = Modifier @@ -367,7 +369,7 @@ fun MediaCard( if (showProgress) { // Top-right: time remaining or "New Episode" badge val topRightLabel = item.timeRemainingLabel - ?: if (item.mediaType == MediaType.TV && item.progress == 0 && !item.isWatched) "New Episode" else null + ?: if (item.mediaType == MediaType.TV && item.progress == 0 && !item.isWatched) stringResource(R.string.component_badge_new_episode) else null if (topRightLabel != null) { Box( modifier = Modifier @@ -392,7 +394,8 @@ fun MediaCard( val epsRemaining = item.totalEpisodes - (item.watchedEpisodes ?: 0) if (epsRemaining > 0) { val epsLabel = if (isLandscape) { - if (epsRemaining == 1) "1 ep left" else "$epsRemaining eps left" + if (epsRemaining == 1) stringResource(R.string.component_ep_left_one) + else stringResource(R.string.component_eps_left, epsRemaining) } else { epsRemaining.toString() } @@ -433,7 +436,11 @@ fun MediaCard( .padding(horizontal = ArvioSkin.spacing.x2, vertical = ArvioSkin.spacing.x1), ) { Text( - text = "S${nextEpisode.seasonNumber} • E${nextEpisode.episodeNumber}", + text = stringResource( + R.string.component_season_episode_marker, + nextEpisode.seasonNumber, + nextEpisode.episodeNumber + ), style = ArvioSkin.typography.badge, color = ArvioSkin.colors.textPrimary, ) @@ -473,15 +480,18 @@ fun MediaCard( // Prefer release date (or year) under the title. Fall back to the // explicit subtitle or media-type label only when neither is set. - val subtitle = remember(item.subtitle, item.releaseDate, item.year, item.mediaType) { + val tvSeriesLabel = stringResource(R.string.component_label_tv_series) + val movieLabel = stringResource(R.string.movie) + val mediaLabel = stringResource(R.string.component_label_media) + val subtitle = remember(item.subtitle, item.releaseDate, item.year, item.mediaType, tvSeriesLabel, movieLabel, mediaLabel) { val release = item.releaseDate?.takeIf { it.isNotBlank() } ?: item.year.takeIf { it.isNotBlank() } release ?: item.subtitle.ifBlank { when (item.mediaType) { - MediaType.TV -> "TV Series" - MediaType.MOVIE -> "Movie" - else -> "Media" + MediaType.TV -> tvSeriesLabel + MediaType.MOVIE -> movieLabel + else -> mediaLabel } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MobileBackButton.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MobileBackButton.kt index 514503ca7..a7c8143a4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MobileBackButton.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MobileBackButton.kt @@ -15,7 +15,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.arflix.tv.R import com.arflix.tv.util.DeviceType import com.arflix.tv.util.LocalDeviceType @@ -64,7 +66,7 @@ fun MobileBackButton( ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), tint = Color.White.copy(alpha = 0.9f), modifier = Modifier.size(28.dp) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt index 185df2db4..7a7468645 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -65,6 +66,7 @@ import androidx.tv.foundation.lazy.list.itemsIndexed import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import coil.compose.AsyncImage +import com.arflix.tv.R import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.PersonDetails @@ -309,7 +311,7 @@ fun PersonModal( // Biography section if (person.biography.isNotEmpty()) { Text( - text = "Biography", + text = stringResource(R.string.person_section_biography), style = ArflixTypography.sectionTitle.copy( fontSize = 18.sp, fontWeight = FontWeight.Medium, @@ -344,7 +346,7 @@ fun PersonModal( } Text( - text = "Known For", + text = stringResource(R.string.person_section_known_for), style = ArflixTypography.sectionTitle.copy( fontSize = 18.sp, fontWeight = FontWeight.Medium, @@ -406,7 +408,7 @@ private fun MobilePersonContent( ) { Icon( imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), tint = Color.White, modifier = Modifier.size(20.dp) ) @@ -483,7 +485,7 @@ private fun MobilePersonContent( if (person.biography.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Biography", + text = stringResource(R.string.person_section_biography), style = ArflixTypography.sectionTitle.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = TextPrimary.copy(alpha = 0.9f) ) @@ -499,7 +501,7 @@ private fun MobilePersonContent( if (person.knownFor.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Known For", + text = stringResource(R.string.person_section_known_for), style = ArflixTypography.sectionTitle.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = TextPrimary.copy(alpha = 0.9f) ) @@ -636,7 +638,7 @@ private fun HorizontalKnownForCard( .background(Color.White.copy(alpha = 0.5f), CircleShape) ) Text( - text = if (item.mediaType == MediaType.TV) "TV Series" else "Movie", + text = if (item.mediaType == MediaType.TV) stringResource(R.string.component_label_tv_series) else stringResource(R.string.movie), style = ArflixTypography.caption.copy(fontSize = 11.sp), color = Color.White.copy(alpha = 0.7f) ) @@ -665,7 +667,7 @@ private fun HorizontalKnownForCard( if (item.character.isNotEmpty()) { Spacer(modifier = Modifier.height(6.dp)) Text( - text = "as ${item.character}", + text = stringResource(R.string.person_as_character, item.character), style = ArflixTypography.caption.copy( fontSize = 11.sp, fontStyle = androidx.compose.ui.text.font.FontStyle.Italic diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PlayerLoadingScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PlayerLoadingScreen.kt index e53485450..9478d8ebd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PlayerLoadingScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PlayerLoadingScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -25,6 +26,7 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import coil.compose.AsyncImage +import com.arflix.tv.R import com.arflix.tv.ui.theme.appBackgroundDark import com.arflix.tv.ui.theme.BackgroundElevated import com.arflix.tv.ui.theme.BackgroundGlass @@ -54,8 +56,9 @@ fun PlayerLoadingScreen( backdropUrl: String? = null, title: String = "", subtitle: String? = null, - loadingMessage: String = "Loading sources..." + loadingMessage: String = "" ) { + val resolvedLoadingMessage = loadingMessage.ifEmpty { stringResource(R.string.loading_sources) } val infiniteTransition = rememberInfiniteTransition(label = "playerLoading") // Pulsing play icon @@ -215,7 +218,7 @@ fun PlayerLoadingScreen( ) { Icon( imageVector = Icons.Default.PlayArrow, - contentDescription = "Loading", + contentDescription = stringResource(R.string.loading_label), tint = Color.White, modifier = Modifier.size(40.dp) ) @@ -261,7 +264,7 @@ fun PlayerLoadingScreen( // Loading message Text( - text = loadingMessage, + text = resolvedLoadingMessage, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = TextSecondary, @@ -423,7 +426,7 @@ fun SourceLoadingScreen( Spacer(modifier = Modifier.height(20.dp)) Text( - text = "Resolving from $sourceName", + text = stringResource(R.string.loading_resolving_from, sourceName), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = TextPrimary @@ -432,7 +435,7 @@ fun SourceLoadingScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Finding best quality stream...", + text = stringResource(R.string.loading_finding_best_quality), fontSize = 13.sp, color = TextSecondary ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/QrCodeImage.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/QrCodeImage.kt index 92829f532..1d0f581c4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/QrCodeImage.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/QrCodeImage.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import com.arflix.tv.R import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter @@ -32,7 +34,7 @@ fun QrCodeImage( Image( bitmap = bitmap.asImageBitmap(), - contentDescription = "QR code", + contentDescription = stringResource(R.string.component_qr_code), modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt index 2f83caf50..b978e8661 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt @@ -39,11 +39,13 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.ui.skin.ArvioSkin @OptIn(ExperimentalTvMaterial3Api::class) @@ -129,13 +131,13 @@ fun QuickActionMenu( ) { QuickActionTile( icon = if (isWatched) Icons.Default.Check else Icons.Default.Visibility, - label = if (isWatched) "Watched" else "Mark Watched", + label = if (isWatched) stringResource(R.string.watched) else stringResource(R.string.component_mark_watched), isFocused = focusedIndex == 0, isEnabled = true ) QuickActionTile( icon = Icons.Default.Close, - label = "Remove Continue Watching", + label = stringResource(R.string.component_remove_continue_watching), isFocused = focusedIndex == 1, isEnabled = canRemoveContinueWatching ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Screensaver.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Screensaver.kt index c8e42c6f5..661542dd0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Screensaver.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Screensaver.kt @@ -26,9 +26,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.Pink import kotlinx.coroutines.delay @@ -133,7 +135,7 @@ fun Screensaver( .offset(y = (-20).dp) ) { Text( - text = "Press any key to continue", + text = stringResource(R.string.component_screensaver_hint), style = ArflixTypography.caption, color = Color.White.copy(alpha = 0.3f) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt index dd958b69c..da4c93193 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt @@ -25,8 +25,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api +import com.arflix.tv.R import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.Pink import com.arflix.tv.ui.theme.SuccessGreen @@ -34,6 +36,47 @@ import com.arflix.tv.ui.theme.TextPrimary import com.arflix.tv.ui.theme.TextSecondary import com.arflix.tv.ui.skin.resolveAccentColor +/** Display-only localization of stored setting values (Off/Any/Medium/White...). + * The stored/compared value stays English; only the shown label is translated. + * Unknown values (720p, 4K, language/DNS names) pass through unchanged. */ +@Composable +internal fun localizeSettingValue(value: String): String = when (value) { + "Off" -> stringResource(R.string.off) + "On" -> stringResource(R.string.on) + "Auto" -> stringResource(R.string.auto) + "Any" -> stringResource(R.string.settings_value_any) + "Always" -> stringResource(R.string.settings_value_always) + "Seamless only" -> stringResource(R.string.settings_value_seamless) + "Small" -> stringResource(R.string.settings_value_small) + "Medium" -> stringResource(R.string.settings_value_medium) + "Large" -> stringResource(R.string.settings_value_large) + "Extra Large" -> stringResource(R.string.settings_value_extra_large) + "White" -> stringResource(R.string.settings_value_white) + "Red" -> stringResource(R.string.settings_value_red) + "Orange" -> stringResource(R.string.settings_value_orange) + "Yellow" -> stringResource(R.string.settings_value_yellow) + "Green" -> stringResource(R.string.settings_value_green) + "Blue" -> stringResource(R.string.settings_value_blue) + "Indigo" -> stringResource(R.string.settings_value_indigo) + "Violet" -> stringResource(R.string.settings_value_violet) + "Cyan" -> stringResource(R.string.settings_value_cyan) + "Normal" -> stringResource(R.string.settings_value_normal) + "Bold" -> stringResource(R.string.settings_value_bold) + "Background" -> stringResource(R.string.settings_value_background) + "Low" -> stringResource(R.string.settings_value_low) + "Bottom" -> stringResource(R.string.settings_value_bottom) + "High" -> stringResource(R.string.settings_value_high) + "None" -> stringResource(R.string.settings_value_none) + "Forced" -> stringResource(R.string.settings_value_forced) + "Auto (Original)" -> stringResource(R.string.settings_value_auto_original) + "Tablet" -> stringResource(R.string.settings_ui_mode_tablet) + "Phone" -> stringResource(R.string.settings_ui_mode_phone) + "System DNS" -> stringResource(R.string.settings_dns_system) + "12-hour" -> stringResource(R.string.settings_clock_12h) + "24-hour" -> stringResource(R.string.settings_clock_24h) + else -> value +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun SettingsRow( @@ -106,7 +149,7 @@ fun SettingsRow( contentAlignment = Alignment.Center ) { Text( - text = value.uppercase(), + text = localizeSettingValue(value).uppercase(), style = ArflixTypography.label.copy(fontSize = 11.sp, letterSpacing = 0.5.sp), color = Pink, maxLines = 1, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt index 8732fe5c8..0a719e32c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt @@ -26,10 +26,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.StreamSource import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.Pink @@ -182,7 +184,7 @@ fun PlayerInfoBar( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "LIVE", + text = stringResource(R.string.component_badge_live), style = ArflixTypography.badge, color = Color.White ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 73442aff9..e343e74cf 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -228,8 +228,9 @@ fun StreamSelector( } // Tab labels: "All sources" + addon labels - val tabLabels = remember(addonTabs) { - listOf("All sources") + addonTabs.map { it.label } + val allSourcesLabel = stringResource(R.string.stream_tab_all_sources) + val tabLabels = remember(addonTabs, allSourcesLabel) { + listOf(allSourcesLabel) + addonTabs.map { it.label } } val presentations = remember(streams) { streams.map(::presentSource) } @@ -463,7 +464,7 @@ fun StreamSelector( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = title.ifEmpty { "Select Source" }, + text = title.ifEmpty { stringResource(R.string.stream_title_select_source) }, style = ArflixTypography.body.copy( fontSize = 18.sp, fontWeight = FontWeight.Bold @@ -561,7 +562,7 @@ fun StreamSelector( if (elapsedSeconds > 0) append("${elapsedSeconds}s \u2022 ") if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) - else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...") + else if (totalAddons > 0) append(stringResource(R.string.stream_searching_addons, completedAddons, totalAddons)) else append(stringResource(R.string.finding_sources)) }, style = ArflixTypography.body.copy( @@ -587,7 +588,7 @@ fun StreamSelector( } Spacer(modifier = Modifier.height(12.dp)) Text( - text = if (!hasStreamingAddons) "No Streaming Addons" else "No sources found", + text = if (!hasStreamingAddons) stringResource(R.string.stream_no_streaming_addons) else stringResource(R.string.stream_no_sources_found), style = ArflixTypography.body.copy( fontSize = 14.sp, fontWeight = FontWeight.Medium @@ -597,9 +598,9 @@ fun StreamSelector( Spacer(modifier = Modifier.height(4.dp)) Text( text = if (!hasStreamingAddons) - "Go to Settings \u2192 Addons to add\na streaming addon" + stringResource(R.string.stream_no_addons_hint) else - "Try adding more addons", + stringResource(R.string.stream_try_adding_addons), style = ArflixTypography.caption.copy(fontSize = 12.sp), color = TextSecondary.copy(alpha = 0.6f), textAlign = androidx.compose.ui.text.style.TextAlign.Center @@ -788,7 +789,7 @@ private fun OledSourceSelectorTv( completedAddons = completedAddons, totalAddons = totalAddons, hasStreamingAddons = hasStreamingAddons, - message = "No sources match this filter" + message = stringResource(R.string.stream_no_sources_match) ) else -> Box(modifier = Modifier.fillMaxSize()) { TvLazyColumn( @@ -1441,7 +1442,7 @@ private fun BestMatchStrip( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "Best Match", + text = stringResource(R.string.stream_best_match), style = ArflixTypography.caption.copy( fontSize = 11.sp, fontWeight = FontWeight.Bold, @@ -1535,7 +1536,7 @@ private fun SourceAddonRail( .padding(start = 4.dp, top = 2.dp, bottom = 2.dp) ) { Text( - text = "ADDONS", + text = stringResource(R.string.stream_section_addons), style = ArflixTypography.caption.copy( fontSize = 10.sp, fontWeight = FontWeight.Bold, @@ -1610,11 +1611,11 @@ private fun SourceAddonRail( } Spacer(modifier = Modifier.height(10.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - RailMetric(label = "Total", value = totalSources.toString()) + RailMetric(label = stringResource(R.string.stream_metric_total), value = totalSources.toString()) RailMetric(label = "4K", value = count4K.toString()) RailMetric(label = "1080p", value = count1080.toString()) if (totalAddons > 0) { - RailMetric(label = "Checked", value = "$completedAddons/$totalAddons") + RailMetric(label = stringResource(R.string.stream_metric_checked), value = "$completedAddons/$totalAddons") } } } @@ -1718,7 +1719,7 @@ private fun SourceEmptyState( if (elapsedSeconds > 0) append("${elapsedSeconds}s \u2022 ") if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) - else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...") + else if (totalAddons > 0) append(stringResource(R.string.stream_searching_addons, completedAddons, totalAddons)) else append(stringResource(R.string.finding_sources)) }, style = ArflixTypography.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), @@ -1733,7 +1734,7 @@ private fun SourceEmptyState( ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = message ?: if (!hasStreamingAddons) "No streaming addons" else "No sources found", + text = message ?: if (!hasStreamingAddons) stringResource(R.string.stream_no_streaming_addons) else stringResource(R.string.stream_no_sources_found), style = ArflixTypography.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = TextSecondary ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt index eb2c45956..e79ace81c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -57,6 +58,7 @@ import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.itemsIndexed import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState import coil.compose.AsyncImage +import com.arflix.tv.R import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogKind import com.arflix.tv.data.model.CatalogSourceType @@ -90,6 +92,13 @@ import javax.inject.Inject enum class CollectionTab { MOVIES, SERIES } +/** + * Sentinel stored in [CollectionDetailsUiState.error] when a collection page fails to load. + * The ViewModel has no Context, so the human-readable message is resolved with + * [stringResource] at the @Composable display point (see CollectionDetailsScreen). + */ +private const val COLLECTION_LOAD_FAILED_ERROR = "__collection_load_failed__" + data class CollectionDetailsUiState( val catalog: CatalogConfig? = null, val movieItems: List = emptyList(), @@ -229,14 +238,14 @@ class CollectionDetailsViewModel @Inject constructor( isLoadingMovies = false, hasMoreMovies = page?.hasMore == true, loadedMovieOffset = pageItems.size, - error = _uiState.value.error ?: if (page == null) "Failed to load collection" else null + error = _uiState.value.error ?: if (page == null) COLLECTION_LOAD_FAILED_ERROR else null ) CollectionTab.SERIES -> _uiState.value.copy( seriesItems = pageItems, isLoadingSeries = false, hasMoreSeries = page?.hasMore == true, loadedSeriesOffset = pageItems.size, - error = _uiState.value.error ?: if (page == null) "Failed to load collection" else null + error = _uiState.value.error ?: if (page == null) COLLECTION_LOAD_FAILED_ERROR else null ) } preloadLogos(pageItems.take(2)) @@ -584,7 +593,11 @@ fun CollectionDetailsScreen( onNearEnd = { viewModel.loadMoreIfNeeded(activeTab) }, isLoading = isTabLoading, isLoadingMore = isTabLoadingMore, - emptyMessage = uiState.error ?: "Nothing to show here yet.", + emptyMessage = when (uiState.error) { + null -> stringResource(R.string.collection_empty) + COLLECTION_LOAD_FAILED_ERROR -> stringResource(R.string.collection_failed_load) + else -> uiState.error!! + }, topContentPadding = if (isMobile) 18.dp else if (usePosterCards) 22.dp else 10.dp ) } @@ -668,7 +681,7 @@ private fun CollectionTabBar( ) { if (showMovies) { CollectionTabChip( - label = "Movies", + label = stringResource(R.string.movies), isSelected = selectedTab == CollectionTab.MOVIES || onlyOne, focusRequester = moviesTabFocusRequester, onClick = { onTabSelected(CollectionTab.MOVIES) } @@ -676,7 +689,7 @@ private fun CollectionTabBar( } if (showSeries) { CollectionTabChip( - label = "Series", + label = stringResource(R.string.series), isSelected = selectedTab == CollectionTab.SERIES || onlyOne, focusRequester = seriesTabFocusRequester, onClick = { onTabSelected(CollectionTab.SERIES) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index f193ec3dc..826087c3b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -984,7 +984,7 @@ fun DetailsScreen( onSelect = { stream -> if (isPendingDebridStream(stream)) { viewModel.showToast( - "This debrid torrent is still being downloaded. Try another source or wait a bit.", + context.getString(R.string.details_toast_debrid_downloading), ToastType.ERROR ) return@StreamSelector @@ -1009,7 +1009,7 @@ fun DetailsScreen( EpisodeContextMenu( isVisible = showEpisodeContextMenu, episodeName = episode.name, - seasonEpisode = "S${episode.seasonNumber}:E${episode.episodeNumber}", + seasonEpisode = stringResource(R.string.details_episode_code_short, episode.seasonNumber, episode.episodeNumber), isWatched = episode.isWatched, onPlay = { showEpisodeContextMenu = false @@ -1195,8 +1195,10 @@ private fun DetailsContent( } } + val tvSeriesLabel = stringResource(R.string.details_label_tv_series) + val movieLabel = stringResource(R.string.movie) val genreText = genres.take(2).map(::formatGenreName).joinToString(" / ").ifBlank { - if (item.mediaType == MediaType.TV) "TV Series" else "Movie" + if (item.mediaType == MediaType.TV) tvSeriesLabel else movieLabel } val displayDate = item.year.takeIf { it.isNotBlank() } ?: item.releaseDate?.trim()?.takeIf { it.isNotEmpty() }?.let { date -> @@ -1392,7 +1394,7 @@ private fun DetailsContent( Spacer(modifier = Modifier.height(12.dp)) // Primary mobile actions - val playButtonLabel = if (!playLabel.isNullOrBlank()) playLabel else "Play" + val playButtonLabel = if (!playLabel.isNullOrBlank()) playLabel else stringResource(R.string.play) MobileActionButton( icon = Icons.Default.PlayArrow, text = playButtonLabel, @@ -1429,7 +1431,7 @@ private fun DetailsContent( ) MobileIconActionButton( icon = if (buttonWatched) Icons.Default.Check else Icons.Default.Visibility, - contentDescription = if (buttonWatched) "Watched" else "Mark watched", + contentDescription = if (buttonWatched) stringResource(R.string.watched) else stringResource(R.string.details_btn_mark_watched), isActive = buttonWatched, modifier = Modifier .weight(1f) @@ -1438,7 +1440,7 @@ private fun DetailsContent( ) MobileIconActionButton( icon = if (isInWatchlist) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, - contentDescription = if (isInWatchlist) "In watchlist" else "Add to watchlist", + contentDescription = if (isInWatchlist) stringResource(R.string.details_in_watchlist) else stringResource(R.string.add_to_watchlist), isActive = isInWatchlist, modifier = Modifier .weight(1f) @@ -1828,8 +1830,10 @@ private fun DetailsContent( Spacer(modifier = Modifier.height(4.dp)) + val tvSeriesLabel = stringResource(R.string.details_label_tv_series) + val movieLabel = stringResource(R.string.movie) val genreText = genres.take(2).map(::formatGenreName).joinToString(" / ").ifEmpty { - if (item.mediaType == MediaType.TV) "TV Series" else "Movie" + if (item.mediaType == MediaType.TV) tvSeriesLabel else movieLabel } val isCompactHeight = configuration.screenHeightDp < 720 val displayDate = item.releaseDate?.takeIf { it.isNotEmpty() } ?: item.year @@ -1918,7 +1922,7 @@ private fun DetailsContent( AsyncImage( model = networkLogoRequest, imageLoader = metadataLogoImageLoader, - contentDescription = "Primary streaming provider", + contentDescription = stringResource(R.string.details_cd_primary_provider), contentScale = ContentScale.Fit, modifier = Modifier .height(16.dp) @@ -2011,7 +2015,7 @@ private fun DetailsContent( val playButtonLabel = if (!playLabel.isNullOrBlank()) { playLabel } else { - "Play" + stringResource(R.string.play) } Box(modifier = Modifier.clickable { onButtonClick(0) }) { PremiumActionButton( @@ -2043,7 +2047,7 @@ private fun DetailsContent( Box(modifier = Modifier.clickable { onButtonClick(3) }) { PremiumActionButton( icon = if (buttonWatched) Icons.Default.Check else Icons.Default.Visibility, - text = if (buttonWatched) "Watched" else "Mark Watched", + text = if (buttonWatched) stringResource(R.string.watched) else stringResource(R.string.details_btn_mark_watched), isFocused = focusSectionForUi == FocusSection.BUTTONS && buttonIndex == 3, isActive = buttonWatched, isIconOnly = true @@ -3330,12 +3334,13 @@ private fun EpisodeCard( val episodeCode = "S${episode.seasonNumber} • E${String.format("%02d", episode.episodeNumber)}" val ratingLabel = episode.imdbRating.takeIf { parseRatingValue(it) > 0f } val isSpoilerBlurred = spoilerBlurEnabled && !episode.isWatched + val noSynopsisText = stringResource(R.string.details_no_synopsis) val previewText = if (isSpoilerBlurred) { "" } else { episode.overview .trim() - .ifEmpty { "No episode synopsis available." } + .ifEmpty { noSynopsisText } } val episodeAirDateLabel = remember(episode.airDate) { formatEpisodeAirDateLabel(episode.airDate) } val isEpisodeUnaired = remember(episode.airDate) { isFutureEpisodeAirDate(episode.airDate) } @@ -3400,7 +3405,7 @@ private fun EpisodeCard( contentAlignment = Alignment.Center ) { Text( - text = "Spoiler", + text = stringResource(R.string.details_spoiler), style = ArvioSkin.typography.caption.copy(fontWeight = FontWeight.Bold), color = Color.White.copy(alpha = 0.6f) ) @@ -3600,14 +3605,16 @@ private fun SeasonButton( val isFullyWatched = totalCount > 0 && watchedCount >= totalCount + val selectSeasonLabel = stringResource(R.string.details_cd_select_season, season) + val seasonOptionsLabel = stringResource(R.string.details_cd_season_options) val clickModifier = if (onLongClick != null) { @OptIn(ExperimentalFoundationApi::class) Modifier.combinedClickable( onClick = onClick, onLongClick = onLongClick, role = Role.Button, - onClickLabel = "Select season $season", - onLongClickLabel = "Show season options" + onClickLabel = selectSeasonLabel, + onLongClickLabel = seasonOptionsLabel ) } else { Modifier.clickable(onClick = onClick) @@ -4150,7 +4157,7 @@ private fun SimilarMediaCard( isFocused: Boolean, onClick: () -> Unit = {} ) { - val mediaTypeLabel = if (item.mediaType == MediaType.TV) "TV Series" else "Movie" + val mediaTypeLabel = if (item.mediaType == MediaType.TV) stringResource(R.string.details_label_tv_series) else stringResource(R.string.movie) val yearSuffix = item.year.takeIf { it.isNotBlank() }?.let { " | $it" }.orEmpty() MediaCard( item = item.copy(subtitle = "$mediaTypeLabel$yearSuffix"), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 12348e194..7a4fb08d8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -2,6 +2,7 @@ package com.arflix.tv.ui.screens.details import android.content.Context import android.util.Log +import com.arflix.tv.R import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.arflix.tv.data.model.CastMember @@ -305,7 +306,7 @@ class DetailsViewModel @Inject constructor( playSeason = initialSeason, playEpisode = initialEpisode, playLabel = if (mediaType == MediaType.TV && initialSeason != null && initialEpisode != null) { - "Continue S${initialSeason}E${initialEpisode}" + context.getString(R.string.continue_season_episode, initialSeason, initialEpisode) } else { null }, @@ -400,7 +401,7 @@ class DetailsViewModel @Inject constructor( if (item == null) { _uiState.value = _uiState.value.copy( isLoading = false, - error = "Failed to load details" + error = context.getString(R.string.details_error_load_failed) ) return@launch } @@ -656,7 +657,7 @@ class DetailsViewModel @Inject constructor( val hasWatchedEpisodes = decoratedEpisodes.any { it.isWatched } updateState { state -> val shouldUseEpisodeTarget = !hasExplicitEpisodeTarget && - (state.playLabel.isNullOrBlank() || state.playLabel == "Start S1E1") + (state.playLabel.isNullOrBlank() || state.playLabel == context.getString(R.string.play_start_s1e1)) state.copy( episodes = decoratedEpisodes, initialEpisodeIndex = initialEpisodeIndex, @@ -668,9 +669,9 @@ class DetailsViewModel @Inject constructor( } else state.playEpisode, playLabel = if (shouldUseEpisodeTarget) { if (nextUnwatchedEpisode != null) { - "Continue S${nextUnwatchedEpisode.seasonNumber}E${nextUnwatchedEpisode.episodeNumber}" + context.getString(R.string.continue_season_episode, nextUnwatchedEpisode.seasonNumber, nextUnwatchedEpisode.episodeNumber) } else if (hasWatchedEpisodes) { - "Start S1E1" + context.getString(R.string.play_start_s1e1) } else { state.playLabel } @@ -735,7 +736,7 @@ class DetailsViewModel @Inject constructor( state.copy( playSeason = initialSeason, playEpisode = initialEpisode, - playLabel = matchedResume?.label ?: "Continue S${initialSeason}E${initialEpisode}", + playLabel = matchedResume?.label ?: context.getString(R.string.continue_season_episode, initialSeason, initialEpisode), playPositionMs = matchedResume?.positionMs ) } @@ -856,14 +857,14 @@ class DetailsViewModel @Inject constructor( } else { // If no episodes returned, keep current and show error _uiState.value = _uiState.value.copy( - toastMessage = "No episodes found for Season $seasonNumber", + toastMessage = context.getString(R.string.details_no_episodes_season, seasonNumber), toastType = ToastType.ERROR ) } } catch (e: Exception) { // On error, keep showing current episodes _uiState.value = _uiState.value.copy( - toastMessage = "Failed to load Season $seasonNumber", + toastMessage = context.getString(R.string.details_failed_load_season, seasonNumber), toastType = ToastType.ERROR ) } @@ -884,7 +885,7 @@ class DetailsViewModel @Inject constructor( } _uiState.value = _uiState.value.copy( item = currentItem.copy(isWatched = newWatched), - toastMessage = if (newWatched) "Marked as watched" else "Marked as unwatched", + toastMessage = if (newWatched) context.getString(R.string.details_marked_watched) else context.getString(R.string.details_marked_unwatched), toastType = ToastType.SUCCESS ) runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } @@ -892,7 +893,7 @@ class DetailsViewModel @Inject constructor( val targetEpisode = _uiState.value.episodes.getOrNull(episodeIndex ?: 0) if (targetEpisode == null) { _uiState.value = _uiState.value.copy( - toastMessage = "No episode selected", + toastMessage = context.getString(R.string.details_no_episode_selected), toastType = ToastType.ERROR ) return@launch @@ -965,9 +966,9 @@ class DetailsViewModel @Inject constructor( item = currentItem.copy(isWatched = anyWatched), episodes = updatedEpisodes, toastMessage = if (episodeWatched) { - "S${targetEpisode.seasonNumber}E${targetEpisode.episodeNumber} marked as watched" + context.getString(R.string.details_episode_marked_watched, targetEpisode.seasonNumber, targetEpisode.episodeNumber) } else { - "S${targetEpisode.seasonNumber}E${targetEpisode.episodeNumber} marked as unwatched" + context.getString(R.string.details_episode_marked_unwatched, targetEpisode.seasonNumber, targetEpisode.episodeNumber) }, toastType = ToastType.SUCCESS ) @@ -976,7 +977,7 @@ class DetailsViewModel @Inject constructor( runCatching { cloudSyncRepository.pushToCloud() } } catch (e: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to update watched status", + toastMessage = context.getString(R.string.details_failed_update_watched), toastType = ToastType.ERROR ) } @@ -992,13 +993,13 @@ class DetailsViewModel @Inject constructor( val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) if (newInWatchlist) { if (traktConnected && !traktRepository.addToWatchlist(currentMediaType, currentMediaId)) { - throw IllegalStateException("Failed to add to Trakt watchlist") + throw IllegalStateException(context.getString(R.string.details_failed_trakt_watchlist_add)) } // Pass the full MediaItem so it appears instantly in watchlist watchlistRepository.addToWatchlist(currentMediaType, currentMediaId, currentItem) } else { if (traktConnected && !traktRepository.removeFromWatchlist(currentMediaType, currentMediaId)) { - throw IllegalStateException("Failed to remove from Trakt watchlist") + throw IllegalStateException(context.getString(R.string.details_failed_trakt_watchlist_remove)) } watchlistRepository.removeFromWatchlist(currentMediaType, currentMediaId) } @@ -1006,12 +1007,12 @@ class DetailsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isInWatchlist = newInWatchlist, - toastMessage = if (newInWatchlist) "Added to watchlist" else "Removed from watchlist", + toastMessage = if (newInWatchlist) context.getString(R.string.added_to_watchlist) else context.getString(R.string.watchlist_toast_removed), toastType = ToastType.SUCCESS ) } catch (e: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to update watchlist", + toastMessage = context.getString(R.string.details_failed_update_watchlist), toastType = ToastType.ERROR ) } @@ -1159,12 +1160,12 @@ class DetailsViewModel @Inject constructor( return PlayTarget( season = seasonNum, episode = firstUnwatched.episodeNumber, - label = "Continue S${seasonNum}E${firstUnwatched.episodeNumber}" + label = context.getString(R.string.continue_season_episode, seasonNum, firstUnwatched.episodeNumber) ) } } // All episodes watched — offer restart - PlayTarget(season = 1, episode = 1, label = "Start S1E1") + PlayTarget(season = 1, episode = 1, label = context.getString(R.string.play_start_s1e1)) } catch (_: Exception) { null } @@ -1708,7 +1709,7 @@ class DetailsViewModel @Inject constructor( if (seasonEpisodes.isEmpty()) { _uiState.value = _uiState.value.copy( - toastMessage = "No episodes found for Season $season", + toastMessage = context.getString(R.string.details_no_episodes_season, season), toastType = ToastType.ERROR ) return@launch @@ -1808,7 +1809,7 @@ class DetailsViewModel @Inject constructor( playEpisode = playTarget?.episode ?: _uiState.value.playEpisode, playLabel = playTarget?.label ?: _uiState.value.playLabel, playPositionMs = playTarget?.positionMs ?: _uiState.value.playPositionMs, - toastMessage = "Season $season marked as watched", + toastMessage = context.getString(R.string.details_season_marked_watched, season), toastType = ToastType.SUCCESS ) runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } @@ -1817,7 +1818,7 @@ class DetailsViewModel @Inject constructor( runCatching { cloudSyncRepository.pushToCloud() } } catch (_: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to mark season as watched", + toastMessage = context.getString(R.string.details_failed_mark_season_watched), toastType = ToastType.ERROR ) } @@ -1838,7 +1839,7 @@ class DetailsViewModel @Inject constructor( if (seasonEpisodes.isEmpty()) { _uiState.value = _uiState.value.copy( - toastMessage = "No episodes found for Season $season", + toastMessage = context.getString(R.string.details_no_episodes_season, season), toastType = ToastType.ERROR ) return@launch @@ -1887,7 +1888,7 @@ class DetailsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( episodes = updatedEpisodes, seasonProgress = optimisticProgress, - toastMessage = "Failed to sync Season $season as unwatched with Trakt", + toastMessage = context.getString(R.string.details_failed_sync_season_unwatched, season), toastType = ToastType.ERROR ) return@launch @@ -1899,14 +1900,14 @@ class DetailsViewModel @Inject constructor( item = currentItem.copy(isWatched = false), episodes = updatedEpisodes, seasonProgress = refreshedProgress?.progress ?: optimisticProgress, - toastMessage = "Season $season marked as unwatched", + toastMessage = context.getString(R.string.details_season_marked_unwatched, season), toastType = ToastType.SUCCESS ) runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } runCatching { cloudSyncRepository.pushToCloud() } } catch (_: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to mark season as unwatched", + toastMessage = context.getString(R.string.details_failed_mark_season_unwatched), toastType = ToastType.ERROR ) } @@ -2210,7 +2211,7 @@ class DetailsViewModel @Inject constructor( return if (mediaType == MediaType.MOVIE) { ResumeInfo( - label = "Continue at $timeLabel", + label = context.getString(R.string.continue_at, timeLabel), positionMs = seconds * 1000L ) } else { @@ -2219,7 +2220,7 @@ class DetailsViewModel @Inject constructor( ResumeInfo( season = s, episode = e, - label = "Continue S${s}E${e} at $timeLabel", + label = context.getString(R.string.continue_season_episode_at, s, e, timeLabel), positionMs = seconds * 1000L ) } @@ -2286,7 +2287,7 @@ class DetailsViewModel @Inject constructor( PlayTarget( season = 1, episode = 1, - label = "Start S1E1" + label = context.getString(R.string.play_start_s1e1) ) } else { val next = result.nextUnwatched @@ -2294,13 +2295,13 @@ class DetailsViewModel @Inject constructor( PlayTarget( season = next.first, episode = next.second, - label = "Continue S${next.first}E${next.second}" + label = context.getString(R.string.continue_season_episode, next.first, next.second) ) } else { PlayTarget( season = 1, episode = 1, - label = "Start S1E1" + label = context.getString(R.string.play_start_s1e1) ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 95cb824fa..264cd82fe 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -293,8 +293,8 @@ private fun localizedCategoryTitle(category: Category): String = when (category. "collection_row_franchise" -> stringResource(R.string.franchises) "collection_row_network" -> stringResource(R.string.networks) "collection_row_featured" -> stringResource(R.string.featured) - "top10_movies_today" -> if (androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl) "10 הסרטים הנצפים היום" else "Top 10 Movies Today" - "top10_shows_today" -> if (androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl) "10 הסדרות הנצפות היום" else "Top 10 Shows Today" + "top10_movies_today" -> if (androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl) "10 הסרטים הנצפים היום" else stringResource(R.string.home_top10_movies_today) + "top10_shows_today" -> if (androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl) "10 הסדרות הנצפות היום" else stringResource(R.string.home_top10_shows_today) else -> category.title } @@ -1190,7 +1190,7 @@ fun HomeScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = uiState.error ?: "Please check your connection", + text = uiState.error ?: stringResource(R.string.home_please_check_connection), style = ArflixTypography.body, color = TextSecondary ) @@ -1552,7 +1552,7 @@ private fun HeroSection( AsyncImage( model = networkLogoRequest, imageLoader = metadataLogoImageLoader, - contentDescription = "Primary streaming provider", + contentDescription = stringResource(R.string.home_cd_primary_provider), contentScale = ContentScale.Fit, modifier = Modifier .height(16.dp) @@ -1595,7 +1595,7 @@ private fun HeroSection( if (hasBudgetMetadata) { Text( - text = "Budget $budgetText", + text = "${stringResource(R.string.budget)} $budgetText", style = ArflixTypography.caption.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -1692,7 +1692,7 @@ private fun TopRankRibbon( .size(targetPx, targetPx) .allowHardware(true) .build(), - contentDescription = "Rank #$clamped", + contentDescription = stringResource(R.string.home_cd_rank, clamped), contentScale = ContentScale.Fit, modifier = modifier .width(width) @@ -2043,7 +2043,7 @@ private fun MobileHeroCarousel( } Icon( imageVector = Icons.Filled.Search, - contentDescription = "Search", + contentDescription = stringResource(R.string.search), tint = Color.White, modifier = Modifier .size(26.dp) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 02b1507db..c976fd9e7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -20,6 +20,7 @@ import com.arflix.tv.data.model.CatalogSourceType import com.arflix.tv.data.model.CollectionGroupKind import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType +import com.arflix.tv.R import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.TraktRepository import com.arflix.tv.data.repository.TraktSyncService @@ -1708,6 +1709,46 @@ class HomeViewModel @Inject constructor( return collectionCatalogByMediaId[item.id] } + private val collectionTitleResById: Map = mapOf( + "Featured" to R.string.featured, + "Services" to R.string.services, + "Genres" to R.string.genres, + "Decades" to R.string.decades, + "Franchises" to R.string.franchises, + "Networks" to R.string.networks, + "Latest Movies" to R.string.collections_latest_movies, + "Latest Shows" to R.string.collections_latest_shows, + "Trending Movies" to R.string.trending_movies, + "Trending Shows" to R.string.trending_in_shows, + "Action" to R.string.collections_genre_action, + "Comedy" to R.string.collections_genre_comedy, + "Sci-Fi" to R.string.collections_genre_sci_fi, + "Thriller" to R.string.collections_genre_thriller, + "Drama" to R.string.collections_genre_drama, + "Horror" to R.string.collections_genre_horror, + "Documentary" to R.string.collections_genre_documentary, + "Romance" to R.string.collections_genre_romance, + "Animation" to R.string.collections_genre_animation, + "Family" to R.string.collections_genre_family, + "Fantasy" to R.string.collections_genre_fantasy, + "Adventure" to R.string.collections_genre_adventure, + "Superhero" to R.string.collections_genre_superhero, + "War & Military" to R.string.collections_genre_war_military, + "20's Movies" to R.string.collections_decade_20s, + "10's Movies" to R.string.collections_decade_10s, + "00's Movies" to R.string.collections_decade_00s, + "90's Movies" to R.string.collections_decade_90s, + "80's Movies" to R.string.collections_decade_80s, + "70's Movies" to R.string.collections_decade_70s, + "60's Movies" to R.string.collections_decade_60s, + ) + + /** Display-only localization of preinstalled collection/rail titles. The persisted + * CatalogConfig keeps the English title (used for matching/dedup); only the + * ephemeral Category/MediaItem shown on the home screen is translated. */ + private fun localizedCollectionTitle(title: String): String = + collectionTitleResById[title]?.let { context.getString(it) } ?: title + private fun toCollectionCategory(row: HomeCollectionRow): Category { val items = row.items.mapIndexed { index, config -> val fakeId = (config.id.hashCode() and Int.MAX_VALUE).let { if (it == 0) index + 1 else it } @@ -1719,7 +1760,7 @@ class HomeViewModel @Inject constructor( // the home-row card. MediaItem( id = fakeId, - title = config.title, + title = localizedCollectionTitle(config.title), overview = "", mediaType = MediaType.MOVIE, image = config.collectionCoverImageUrl.orEmpty(), @@ -1734,7 +1775,7 @@ class HomeViewModel @Inject constructor( } return Category( id = row.id, - title = row.title, + title = localizedCollectionTitle(row.title), items = items ) } @@ -2445,7 +2486,7 @@ class HomeViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isLoading = false, isInitialLoad = false, - error = if (_uiState.value.categories.isEmpty()) e.message ?: "Failed to load content" else null + error = if (_uiState.value.categories.isEmpty()) e.message ?: context.getString(R.string.home_failed_load_content) else null ) } finally { } @@ -3922,7 +3963,7 @@ class HomeViewModel @Inject constructor( ) } _uiState.value = _uiState.value.copy( - toastMessage = if (isInWatchlist) "Removed from watchlist" else "Added to watchlist", + toastMessage = if (isInWatchlist) context.getString(R.string.watchlist_toast_removed) else context.getString(R.string.added_to_watchlist), toastType = ToastType.SUCCESS ) } catch (e: Exception) { @@ -3935,7 +3976,7 @@ class HomeViewModel @Inject constructor( ) ) _uiState.value = _uiState.value.copy( - toastMessage = "Failed to update watchlist", + toastMessage = context.getString(R.string.details_failed_update_watchlist), toastType = ToastType.ERROR ) } @@ -3949,13 +3990,13 @@ class HomeViewModel @Inject constructor( if (item.isWatched) { traktRepository.markMovieUnwatched(item.id) _uiState.value = _uiState.value.copy( - toastMessage = "Marked as unwatched", + toastMessage = context.getString(R.string.details_marked_unwatched), toastType = ToastType.SUCCESS ) } else { traktRepository.markMovieWatched(item.id) _uiState.value = _uiState.value.copy( - toastMessage = "Marked as watched", + toastMessage = context.getString(R.string.details_marked_watched), toastType = ToastType.SUCCESS ) } @@ -3975,7 +4016,7 @@ class HomeViewModel @Inject constructor( _uiState.value = _uiState.value.copy( categories = updatedCategories, - toastMessage = "S${nextEp.seasonNumber}E${nextEp.episodeNumber} marked as watched", + toastMessage = context.getString(R.string.details_episode_marked_watched, nextEp.seasonNumber, nextEp.episodeNumber), toastType = ToastType.SUCCESS ) @@ -4025,7 +4066,7 @@ class HomeViewModel @Inject constructor( } catch (_: Exception) {} } else { _uiState.value = _uiState.value.copy( - toastMessage = "No episode info available", + toastMessage = context.getString(R.string.home_no_episode_info), toastType = ToastType.ERROR ) } @@ -4038,7 +4079,7 @@ class HomeViewModel @Inject constructor( runCatching { cloudSyncRepository.pushToCloud() } } catch (e: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to update watched status", + toastMessage = context.getString(R.string.details_failed_update_watched), toastType = ToastType.ERROR ) } @@ -4052,12 +4093,12 @@ class HomeViewModel @Inject constructor( if (!item.isWatched) { traktRepository.markMovieWatched(item.id) _uiState.value = _uiState.value.copy( - toastMessage = "Marked as watched", + toastMessage = context.getString(R.string.details_marked_watched), toastType = ToastType.SUCCESS ) } else { _uiState.value = _uiState.value.copy( - toastMessage = "Already watched", + toastMessage = context.getString(R.string.home_already_watched), toastType = ToastType.INFO ) } @@ -4077,7 +4118,7 @@ class HomeViewModel @Inject constructor( _uiState.value = _uiState.value.copy( categories = updatedCategories, - toastMessage = "S${nextEp.seasonNumber}E${nextEp.episodeNumber} marked as watched", + toastMessage = context.getString(R.string.details_episode_marked_watched, nextEp.seasonNumber, nextEp.episodeNumber), toastType = ToastType.SUCCESS ) @@ -4132,7 +4173,7 @@ class HomeViewModel @Inject constructor( } catch (_: Exception) {} } else { _uiState.value = _uiState.value.copy( - toastMessage = "No episode info available", + toastMessage = context.getString(R.string.home_no_episode_info), toastType = ToastType.ERROR ) } @@ -4142,7 +4183,7 @@ class HomeViewModel @Inject constructor( runCatching { cloudSyncRepository.pushToCloud() } } catch (e: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to update watched status", + toastMessage = context.getString(R.string.details_failed_update_watched), toastType = ToastType.ERROR ) } @@ -4182,7 +4223,7 @@ class HomeViewModel @Inject constructor( _uiState.value = _uiState.value.copy( categories = updatedCategories, - toastMessage = "Removed from Continue Watching", + toastMessage = context.getString(R.string.home_removed_continue_watching), toastType = ToastType.SUCCESS ) runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } @@ -4195,7 +4236,7 @@ class HomeViewModel @Inject constructor( } } catch (e: Exception) { _uiState.value = _uiState.value.copy( - toastMessage = "Failed to remove from Continue Watching", + toastMessage = context.getString(R.string.home_failed_remove_continue_watching), toastType = ToastType.ERROR ) } @@ -4222,7 +4263,7 @@ class HomeViewModel @Inject constructor( } else { if (!silent) { _uiState.value = _uiState.value.copy( - toastMessage = "You already have the latest version", + toastMessage = context.getString(R.string.update_already_latest), toastType = ToastType.INFO ) } @@ -4231,7 +4272,7 @@ class HomeViewModel @Inject constructor( }.onFailure { error -> if (!silent) { _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to check for updates", + toastMessage = error.message ?: context.getString(R.string.update_check_failed), toastType = ToastType.ERROR ) } @@ -4274,7 +4315,7 @@ class HomeViewModel @Inject constructor( installAppUpdateOrRequestPermission() }.onFailure { error -> updateStatusManager.updateStatus( - com.arflix.tv.updater.UpdateStatus.Failure(error.message ?: "Download failed", update) + com.arflix.tv.updater.UpdateStatus.Failure(error.message ?: context.getString(R.string.update_download_failed), update) ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginScreen.kt index 4ae45dc90..d89692802 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -35,6 +36,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.repository.AuthState import com.arflix.tv.ui.components.* import com.arflix.tv.ui.theme.* @@ -186,7 +188,7 @@ fun LoginScreen( Spacer(modifier = Modifier.height(18.dp)) Text( - text = "Your library, tuned for TV.", + text = stringResource(R.string.login_tagline_main), fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.85f) @@ -195,7 +197,7 @@ fun LoginScreen( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Keep your watchlist, history, and Trakt sync tied to your account.", + text = stringResource(R.string.login_tagline_sub), fontSize = 14.sp, fontWeight = FontWeight.Normal, color = Color.White.copy(alpha = 0.6f), @@ -217,7 +219,7 @@ fun LoginScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = if (isSignUpMode) "Create your account" else "Sign in to continue", + text = if (isSignUpMode) stringResource(R.string.login_create_account) else stringResource(R.string.login_sign_in_continue), fontSize = 16.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.8f) @@ -247,7 +249,7 @@ fun LoginScreen( PremiumTextField( value = email, onValueChange = { email = it }, - placeholder = "Email", + placeholder = stringResource(R.string.login_email), keyboardType = KeyboardType.Email, imeAction = ImeAction.Next, keyboardActions = KeyboardActions( @@ -271,7 +273,7 @@ fun LoginScreen( PremiumTextField( value = password, onValueChange = { password = it }, - placeholder = "Password", + placeholder = stringResource(R.string.login_password), keyboardType = KeyboardType.Password, isPassword = true, imeAction = ImeAction.Done, @@ -304,7 +306,7 @@ fun LoginScreen( viewModel.signIn(email, password) } }, - text = if (isSignUpMode) "Sign Up" else "Sign In", + text = if (isSignUpMode) stringResource(R.string.login_sign_up) else stringResource(R.string.sign_in), isPrimary = true, isFocused = focusedField == "button", enabled = !uiState.isLoading, @@ -319,7 +321,7 @@ fun LoginScreen( // Toggle Sign In / Sign Up GradientButton( onClick = { isSignUpMode = !isSignUpMode }, - text = if (isSignUpMode) "Already have an account? Sign In" else "Don't have an account? Sign Up", + text = if (isSignUpMode) stringResource(R.string.login_have_account) else stringResource(R.string.login_no_account), isPrimary = false, isFocused = focusedField == "toggle", enabled = !uiState.isLoading, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt index d026538d9..2596ddc09 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt @@ -1,15 +1,18 @@ package com.arflix.tv.ui.screens.login +import android.content.Context import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialResponse import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.arflix.tv.R import com.arflix.tv.data.repository.AuthRepository import com.arflix.tv.data.repository.AuthState import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.StreamRepository import com.arflix.tv.util.AuthEmailValidator import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,6 +30,7 @@ data class LoginUiState( @HiltViewModel class LoginViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val authRepository: AuthRepository, private val streamRepository: StreamRepository, private val cloudSyncRepository: CloudSyncRepository @@ -47,12 +51,13 @@ class LoginViewModel @Inject constructor( fun signIn(email: String, password: String) { val normalizedEmail = AuthEmailValidator.normalize(email) - AuthEmailValidator.validate(normalizedEmail, rejectDisposable = false)?.let { message -> + AuthEmailValidator.validate(normalizedEmail, rejectDisposable = false)?.let { messageRes -> + val message = context.getString(messageRes) _uiState.update { it.copy(error = message) } return } if (password.isBlank()) { - _uiState.update { it.copy(error = "Please enter your password") } + _uiState.update { it.copy(error = context.getString(R.string.login_error_enter_password)) } return } @@ -83,24 +88,25 @@ class LoginViewModel @Inject constructor( fun signUp(email: String, password: String) { val normalizedEmail = AuthEmailValidator.normalize(email) - AuthEmailValidator.validate(normalizedEmail)?.let { message -> + AuthEmailValidator.validate(normalizedEmail)?.let { messageRes -> + val message = context.getString(messageRes) _uiState.update { it.copy(error = message) } return } if (password.isBlank()) { - _uiState.update { it.copy(error = "Please enter your password") } + _uiState.update { it.copy(error = context.getString(R.string.login_error_enter_password)) } return } if (password.length < 6) { - _uiState.update { it.copy(error = "Password must be at least 6 characters") } + _uiState.update { it.copy(error = context.getString(R.string.login_error_password_short)) } return } val now = System.currentTimeMillis() val remainingCooldownMs = 60_000L - (now - lastSignUpAttemptMs) if (remainingCooldownMs > 0L) { val seconds = ((remainingCooldownMs + 999L) / 1000L).coerceAtLeast(1L) - _uiState.update { it.copy(error = "Please wait ${seconds}s before creating another account") } + _uiState.update { it.copy(error = context.getString(R.string.login_error_wait_seconds, seconds)) } return } lastSignUpAttemptMs = now 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 591c03044..722bd89af 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 @@ -522,20 +522,23 @@ fun PlayerScreen( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } + val rewindLabel = context.getString(R.string.player_cd_rewind) + val forwardLabel = context.getString(R.string.player_cd_forward) + val playPauseLabel = if (isPlaying) context.getString(R.string.player_cd_pause) else context.getString(R.string.play) val actions = listOf( RemoteAction( vectorToDrawableIcon(pipRewindPainter), - "Rewind 10s", "Rewind 10s", makeIntent(PIP_ACTION_REWIND, 10) + rewindLabel, rewindLabel, makeIntent(PIP_ACTION_REWIND, 10) ), RemoteAction( vectorToDrawableIcon(if (isPlaying) pipPausePainter else pipPlayPainter), - if (isPlaying) "Pause" else "Play", - if (isPlaying) "Pause" else "Play", + playPauseLabel, + playPauseLabel, makeIntent(PIP_ACTION_PLAY_PAUSE, 11) ), RemoteAction( vectorToDrawableIcon(pipForwardPainter), - "Forward 10s", "Forward 10s", makeIntent(PIP_ACTION_FORWARD, 12) + forwardLabel, forwardLabel, makeIntent(PIP_ACTION_FORWARD, 12) ) ) return PictureInPictureParams.Builder() @@ -2716,7 +2719,7 @@ fun PlayerScreen( modifier = Modifier.size(12.dp) ) Text( - text = "AI Translating", + text = stringResource(R.string.player_ai_translating), style = androidx.compose.material3.MaterialTheme.typography.labelSmall, color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.85f) ) @@ -2820,7 +2823,7 @@ fun PlayerScreen( ) { Icon( imageVector = if (isCasting) Icons.Default.CastConnected else Icons.Default.Cast, - contentDescription = if (isCasting) "Stop casting" else "Cast to TV", + contentDescription = if (isCasting) stringResource(R.string.player_cd_stop_casting) else stringResource(R.string.player_cd_cast_to_tv), tint = if (isCasting) playerAccent else Color.White.copy(alpha = 0.85f), modifier = Modifier.size(22.dp) ) @@ -2967,7 +2970,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.width(gap)) // Subtitle settings (delay, size, vertical position) - PlayerIconButton(icon = Icons.Default.Tune, contentDescription = "Subtitle Settings", + PlayerIconButton(icon = Icons.Default.Tune, contentDescription = stringResource(R.string.subtitle_settings_title), focusRequester = subtitleSettingsBtnFocusRequester, size = smallBtn, iconSize = smallIcon, onFocusChanged = {}, onClick = { @@ -2999,7 +3002,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.width(wideGap)) // Rewind 10s - PlayerIconButton(icon = Icons.Default.Replay10, contentDescription = "Rewind 10s", + PlayerIconButton(icon = Icons.Default.Replay10, contentDescription = stringResource(R.string.player_cd_rewind), focusRequester = rewindButtonFocusRequester, size = midBtn, iconSize = midIcon, onFocusChanged = {}, onClick = { queueControlsSeek(-10_000L) }, @@ -3014,7 +3017,7 @@ fun PlayerScreen( // Play/Pause - center, largest PlayerIconButton(icon = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", + contentDescription = if (isPlaying) stringResource(R.string.player_cd_pause) else stringResource(R.string.play), focusRequester = playButtonFocusRequester, size = bigBtn, iconSize = bigIcon, onFocusChanged = { if (it) focusedButton = 0 }, onClick = { @@ -3034,7 +3037,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.width(gap)) // Forward 10s - own focus requester - PlayerIconButton(icon = Icons.Default.Forward10, contentDescription = "Forward 10s", + PlayerIconButton(icon = Icons.Default.Forward10, contentDescription = stringResource(R.string.player_cd_forward), focusRequester = forwardButtonFocusRequester, size = midBtn, iconSize = midIcon, onFocusChanged = {}, onClick = { queueControlsSeek(10_000L) }, @@ -3048,7 +3051,7 @@ fun PlayerScreen( } // Aspect Ratio - PlayerIconButton(icon = Icons.Default.AspectRatio, contentDescription = "Aspect: $aspectModeLabel", + PlayerIconButton(icon = Icons.Default.AspectRatio, contentDescription = stringResource(R.string.player_cd_aspect, aspectModeLabel), focusRequester = aspectButtonFocusRequester, size = smallBtn, iconSize = smallIcon, onFocusChanged = {}, onClick = cycleAspectRatio, @@ -3083,7 +3086,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.width(gap)) PlayerIconButton( icon = Icons.Default.PictureInPicture, - contentDescription = "Picture in Picture", + contentDescription = stringResource(R.string.player_cd_pip), focusRequester = pipButtonFocusRequester, size = smallBtn, iconSize = smallIcon, onFocusChanged = {}, @@ -3360,7 +3363,7 @@ fun PlayerScreen( currentVolume < maxVolume / 2 -> Icons.Default.VolumeDown else -> Icons.Default.VolumeUp }, - contentDescription = "Volume", + contentDescription = stringResource(R.string.player_cd_volume), tint = Color.White, modifier = Modifier.size(32.dp) ) @@ -3381,7 +3384,7 @@ fun PlayerScreen( } Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (isMuted) "Muted" else "${currentVolume * 100 / maxVolume}%", + text = if (isMuted) stringResource(R.string.player_muted) else "${currentVolume * 100 / maxVolume}%", style = ArflixTypography.caption, color = Color.White ) @@ -3517,7 +3520,7 @@ fun PlayerScreen( ) { Icon( imageVector = if (isSetup) Icons.Default.Settings else Icons.Default.ErrorOutline, - contentDescription = if (isSetup) "Setup" else "Error", + contentDescription = if (isSetup) stringResource(R.string.player_cd_setup) else stringResource(R.string.player_cd_error), tint = accentColor, modifier = Modifier.size(40.dp) ) @@ -3526,7 +3529,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.height(24.dp)) Text( - text = if (isSetup) "Addon Setup Required" else "Playback Error", + text = if (isSetup) stringResource(R.string.player_addon_setup_required) else stringResource(R.string.player_playback_error), style = ArflixTypography.sectionTitle, color = TextPrimary ) @@ -3534,7 +3537,7 @@ fun PlayerScreen( Spacer(modifier = Modifier.height(12.dp)) Text( - text = uiState.error ?: "An unknown error occurred", + text = uiState.error ?: stringResource(R.string.player_error_generic), style = ArflixTypography.body, color = TextSecondary, textAlign = androidx.compose.ui.text.style.TextAlign.Center, 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 67c4c629d..5817b50fd 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 com.arflix.tv.R import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.lifecycle.ViewModel @@ -214,10 +215,10 @@ class PlayerViewModel @Inject constructor( if (!success && !aiErrorToastShown) { aiErrorToastShown = true val msg = when { - errorMessage == "API key missing" -> "AI subtitles: no API key set. Add it in Settings → General." - errorMessage == "RATE_LIMITED" -> "AI subtitles: rate limit hit — translation paused." - errorMessage?.startsWith("HTTP 401") == true -> "AI subtitles: invalid API key." - else -> "AI subtitles: translation error — ${errorMessage.orEmpty()}" + 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()) } _uiState.value = _uiState.value.copy(aiErrorToast = msg) } @@ -622,7 +623,7 @@ class PlayerViewModel @Inject constructor( isLoading = false, isLoadingStreams = false, sourceSearchActive = false, - error = "Unable to resolve IMDB ID. Try again." + error = context.getString(R.string.player_error_imdb_resolve) ) return@launch } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt index 627d301d2..2b08ae1fd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -24,6 +25,7 @@ import androidx.tv.material3.Text import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.ClickableSurfaceDefaults +import com.arflix.tv.R @Composable fun PluginScreen( @@ -65,7 +67,7 @@ fun PluginScreen( false } ) { - Text("Plugins (Testing)", color = Color.White, style = MaterialTheme.typography.headlineLarge) + Text(stringResource(R.string.plugin_screen_title), color = Color.White, style = MaterialTheme.typography.headlineLarge) Spacer(modifier = Modifier.height(16.dp)) uiState.errorMessage?.let { msg -> Text(msg, color = Color.Red) @@ -87,14 +89,14 @@ fun PluginScreen( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - Text("Add Repository", color = Color.White, style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.plugin_screen_add_repo), color = Color.White, style = MaterialTheme.typography.titleMedium) } } Spacer(modifier = Modifier.height(24.dp)) if (uiState.repositories.isNotEmpty()) { - Text("Installed Repositories", color = Color.White, style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.plugin_screen_installed_repos), color = Color.White, style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) uiState.repositories.forEach { repo -> Row(verticalAlignment = Alignment.CenterVertically) { @@ -108,7 +110,7 @@ fun PluginScreen( ), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(4.dp)) ) { - Text("🗑️ Delete", color = Color.Red, modifier = Modifier.padding(4.dp)) + Text("🗑️ ${stringResource(R.string.delete)}", color = Color.Red, modifier = Modifier.padding(4.dp)) } } Spacer(modifier = Modifier.height(4.dp)) @@ -116,11 +118,11 @@ fun PluginScreen( Spacer(modifier = Modifier.height(24.dp)) } - Text("Installed Scrapers", color = Color.White, style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.plugin_screen_installed_scrapers), color = Color.White, style = MaterialTheme.typography.titleLarge) Spacer(modifier = Modifier.height(8.dp)) if (uiState.scrapers.isEmpty()) { - Text("No scrapers installed.", color = Color.Gray) + Text(stringResource(R.string.plugin_screen_no_scrapers), color = Color.Gray) } if (uiState.scrapers.isNotEmpty()) { @@ -220,14 +222,14 @@ fun AddRepoDialog( .clickable { /* absorb clicks */ } ) { Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { - Text("Add Plugin Repository", style = MaterialTheme.typography.titleLarge, color = Color.White) + Text(stringResource(R.string.plugin_screen_add_repo_dialog_title), style = MaterialTheme.typography.titleLarge, color = Color.White) Spacer(modifier = Modifier.height(16.dp)) androidx.compose.material3.OutlinedTextField( value = value, onValueChange = { value = it }, singleLine = true, - label = { androidx.compose.material3.Text("Repository URL") }, + label = { androidx.compose.material3.Text(stringResource(R.string.plugin_screen_repo_url)) }, modifier = Modifier.fillMaxWidth().focusRequester(inputFocusRequester), colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( focusedTextColor = Color.White, @@ -252,7 +254,7 @@ fun AddRepoDialog( shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)) ) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), modifier = Modifier.padding(vertical = 12.dp).fillMaxWidth(), textAlign = TextAlign.Center, color = Color.White @@ -269,7 +271,7 @@ fun AddRepoDialog( shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)) ) { Text( - text = "Add", + text = stringResource(R.string.add), modifier = Modifier.padding(vertical = 12.dp).fillMaxWidth(), textAlign = TextAlign.Center, color = Color.White diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/PinEntryDialog.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/PinEntryDialog.kt index 4de744b9d..78d1706c5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/PinEntryDialog.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/PinEntryDialog.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -41,13 +42,14 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.util.LocalDeviceType import com.arflix.tv.util.PinUtil @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun PinEntryDialog( - title: String = "Enter PIN", + title: String = stringResource(R.string.profile_enter_pin), onPinConfirmed: (String) -> Unit, onDismiss: () -> Unit, isSetup: Boolean = false, @@ -57,6 +59,8 @@ fun PinEntryDialog( var confirmPin by remember { mutableStateOf("") } var isConfirmingSetup by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(pinError) } + val pinInvalidMessage = stringResource(R.string.profile_pin_invalid) + val pinMismatchMessage = stringResource(R.string.profile_pin_mismatch) LaunchedEffect(pinError) { errorMessage = pinError @@ -84,15 +88,15 @@ fun PinEntryDialog( // Icon + Title Icon( imageVector = Icons.Default.Lock, - contentDescription = "PIN Entry", + contentDescription = stringResource(R.string.profile_pin_entry_cd), tint = Color.White, modifier = Modifier.size(32.dp) ) Text( text = when { - isSetup && !isConfirmingSetup -> "Set Profile PIN" - isSetup && isConfirmingSetup -> "Confirm PIN" + isSetup && !isConfirmingSetup -> stringResource(R.string.set_profile_pin) + isSetup && isConfirmingSetup -> stringResource(R.string.profile_confirm_pin) else -> title }, fontSize = 18.sp, @@ -103,9 +107,9 @@ fun PinEntryDialog( Text( text = when { - isSetup && !isConfirmingSetup -> "4-5 digits to lock this profile" - isSetup && isConfirmingSetup -> "Re-enter PIN to confirm" - else -> "Enter PIN to unlock" + isSetup && !isConfirmingSetup -> stringResource(R.string.profile_pin_setup_hint) + isSetup && isConfirmingSetup -> stringResource(R.string.profile_pin_reenter) + else -> stringResource(R.string.enter_pin_to_unlock) }, fontSize = 12.sp, color = Color(0xFFB0B0B0), @@ -204,7 +208,7 @@ fun PinEntryDialog( ) PinKeyButton( - label = "Clear", + label = stringResource(R.string.profile_clear), modifier = Modifier .weight(1f) .height(48.dp), @@ -255,7 +259,7 @@ fun PinEntryDialog( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { PinActionButton( - label = "Cancel", + label = stringResource(R.string.cancel), onClick = onDismiss, containerColor = Color(0xFF2A2A2A), modifier = Modifier @@ -264,17 +268,17 @@ fun PinEntryDialog( ) PinActionButton( - label = "OK", + label = stringResource(R.string.confirm), onClick = { val current = if (isSetup && isConfirmingSetup) confirmPin else pinInput if (!PinUtil.isValidPin(current)) { - errorMessage = "PIN must be 4-5 digits" + errorMessage = pinInvalidMessage } else if (isSetup) { if (!isConfirmingSetup) { isConfirmingSetup = true } else { if (pinInput != confirmPin) { - errorMessage = "PINs do not match" + errorMessage = pinMismatchMessage confirmPin = "" isConfirmingSetup = false } else { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileDialogs.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileDialogs.kt index f19a9fb16..77eab4351 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileDialogs.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileDialogs.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit @@ -69,10 +70,12 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.Profile import com.arflix.tv.data.model.ProfileColors import com.arflix.tv.ui.components.AvatarIcon import com.arflix.tv.ui.components.AvatarRegistry +import com.arflix.tv.ui.components.avatarCategoryLabel import com.arflix.tv.util.LocalDeviceType import com.arflix.tv.util.ProfileAvatarFiles import coil.compose.AsyncImage @@ -99,7 +102,7 @@ fun AddProfileDialog( onDismiss: () -> Unit ) { ProfileDialogContent( - title = "Add Profile", + title = stringResource(R.string.add_profile), autoFocusNameInput = true, name = name, onNameChange = onNameChange, @@ -111,7 +114,7 @@ fun AddProfileDialog( useCustomAvatarImage = useCustomAvatarImage, onAvatarImageSelected = onAvatarImageSelected, onRemoveAvatarImage = onRemoveAvatarImage, - confirmLabel = "Create", + confirmLabel = stringResource(R.string.confirm), onConfirm = onConfirm, onDismiss = onDismiss, onDelete = null @@ -143,7 +146,7 @@ fun EditProfileDialog( onRemovePin: () -> Unit = {} ) { ProfileDialogContent( - title = "Edit Profile", + title = stringResource(R.string.profile_edit_title), autoFocusNameInput = false, name = name, onNameChange = onNameChange, @@ -155,7 +158,7 @@ fun EditProfileDialog( useCustomAvatarImage = useCustomAvatarImage, onAvatarImageSelected = onAvatarImageSelected, onRemoveAvatarImage = onRemoveAvatarImage, - confirmLabel = "Save", + confirmLabel = stringResource(R.string.save), onConfirm = onConfirm, onDismiss = onDismiss, onDelete = onDelete, @@ -193,6 +196,7 @@ private fun ProfileDialogContent( onRemovePin: (() -> Unit)? = null ) { val context = LocalContext.current + val profileNameHint = stringResource(R.string.profile_name_hint) val configuration = LocalConfiguration.current val isTouchDevice = LocalDeviceType.current.isTouchDevice() val useMobileLayout = isTouchDevice && configuration.screenWidthDp < 700 @@ -325,7 +329,7 @@ private fun ProfileDialogContent( setText(name) setTextColor(android.graphics.Color.WHITE) setHintTextColor(android.graphics.Color.GRAY) - hint = "Profile name" + hint = profileNameHint textSize = 16f background = null setPadding(36, 32, 36, 32) @@ -372,7 +376,7 @@ private fun ProfileDialogContent( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "Profile Lock", + text = stringResource(R.string.profile_lock), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFFB0B0B0), @@ -384,7 +388,7 @@ private fun ProfileDialogContent( ) { if (profile.pin.isNullOrEmpty()) { DialogButton( - text = "Set PIN", + text = stringResource(R.string.profile_set_pin), isPrimary = false, onClick = { hideKeyboard() @@ -394,7 +398,7 @@ private fun ProfileDialogContent( ) } else { DialogButton( - text = "Change PIN", + text = stringResource(R.string.profile_change_pin), isPrimary = false, onClick = { hideKeyboard() @@ -403,7 +407,7 @@ private fun ProfileDialogContent( modifier = Modifier.weight(1f) ) DialogButton( - text = "Remove PIN", + text = stringResource(R.string.profile_remove_pin), isPrimary = false, onClick = { hideKeyboard() @@ -439,7 +443,7 @@ private fun ProfileDialogContent( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { DialogButton( - text = "Cancel", + text = stringResource(R.string.cancel), isPrimary = false, onClick = { hideKeyboard() @@ -449,7 +453,7 @@ private fun ProfileDialogContent( ) if (onDelete != null) { DialogButton( - text = "Delete", + text = stringResource(R.string.delete), isPrimary = false, isDestructive = true, onClick = { @@ -466,7 +470,7 @@ private fun ProfileDialogContent( AvatarRegistry.categories.forEachIndexed { rowIdx, (label, ids) -> Text( - text = label, + text = avatarCategoryLabel(label), fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.5f), @@ -569,7 +573,7 @@ private fun ProfileDialogContent( setText(name) setTextColor(android.graphics.Color.WHITE) setHintTextColor(android.graphics.Color.GRAY) - hint = "Profile name" + hint = profileNameHint textSize = 16f background = null setPadding(36, 32, 36, 32) @@ -617,7 +621,7 @@ private fun ProfileDialogContent( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "Profile Lock", + text = stringResource(R.string.profile_lock), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFFB0B0B0), @@ -629,7 +633,7 @@ private fun ProfileDialogContent( ) { if (profile.pin.isNullOrEmpty()) { DialogButton( - text = "Set PIN", + text = stringResource(R.string.profile_set_pin), isPrimary = false, onClick = { hideKeyboard() @@ -639,7 +643,7 @@ private fun ProfileDialogContent( ) } else { DialogButton( - text = "Change PIN", + text = stringResource(R.string.profile_change_pin), isPrimary = false, onClick = { hideKeyboard() @@ -648,7 +652,7 @@ private fun ProfileDialogContent( modifier = Modifier.weight(1f) ) DialogButton( - text = "Remove PIN", + text = stringResource(R.string.profile_remove_pin), isPrimary = false, onClick = { hideKeyboard() @@ -685,7 +689,7 @@ private fun ProfileDialogContent( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { DialogButton( - text = "Cancel", + text = stringResource(R.string.cancel), isPrimary = false, onClick = { hideKeyboard() @@ -695,7 +699,7 @@ private fun ProfileDialogContent( ) if (onDelete != null) { DialogButton( - text = "Delete", + text = stringResource(R.string.delete), isPrimary = false, isDestructive = true, onClick = { @@ -716,7 +720,7 @@ private fun ProfileDialogContent( // Avatar picker - 4 horizontal scrolling rows by category AvatarRegistry.categories.forEachIndexed { rowIdx, (label, ids) -> Text( - text = label, + text = avatarCategoryLabel(label), fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.5f), @@ -842,14 +846,14 @@ private fun AvatarImageButtons( verticalArrangement = Arrangement.spacedBy(8.dp) ) { DialogButton( - text = if (hasCustomAvatar) "Change Photo" else "Upload Photo", + text = if (hasCustomAvatar) stringResource(R.string.profile_change_photo) else stringResource(R.string.profile_upload_photo), isPrimary = false, onClick = onUpload, modifier = Modifier.fillMaxWidth() ) if (hasCustomAvatar) { DialogButton( - text = "Remove Photo", + text = stringResource(R.string.profile_remove_photo), isPrimary = false, onClick = onRemove, modifier = Modifier.fillMaxWidth() @@ -915,7 +919,7 @@ private fun AvatarGridItem( ) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Selected", + contentDescription = stringResource(R.string.selected), tint = Color.White, modifier = Modifier.size(22.dp) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileViewModel.kt index 769f49812..86405c309 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileViewModel.kt @@ -1,7 +1,9 @@ package com.arflix.tv.ui.screens.profile +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.arflix.tv.R import com.arflix.tv.data.model.Profile import com.arflix.tv.data.model.ProfileColors import com.arflix.tv.data.repository.CloudSyncRepository @@ -17,6 +19,7 @@ import com.arflix.tv.data.repository.IptvRepository import com.arflix.tv.ui.components.ToastType import com.arflix.tv.util.PinUtil import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -57,6 +60,7 @@ data class ProfileUiState( @HiltViewModel class ProfileViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val authRepository: AuthRepository, private val profileRepository: ProfileRepository, private val profileManager: ProfileManager, @@ -348,11 +352,11 @@ class ProfileViewModel @Inject constructor( ) ) }.onFailure { - showToast("Could not import avatar image", ToastType.ERROR) + showToast(context.getString(R.string.profile_avatar_import_failed), ToastType.ERROR) } } _uiState.value = _uiState.value.copy(showAddDialog = false) - showToast("Profile created successfully", ToastType.SUCCESS) + showToast(context.getString(R.string.profile_created_success), ToastType.SUCCESS) runCatching { cloudSyncRepository.pushToCloud(force = true) } } } @@ -408,7 +412,7 @@ class ProfileViewModel @Inject constructor( avatarImageStoragePath = imported.storagePath ) }.onFailure { - showToast("Could not import avatar image", ToastType.ERROR) + showToast(context.getString(R.string.profile_avatar_import_failed), ToastType.ERROR) return@launch } } else if (!state.useCustomAvatarImage) { @@ -421,7 +425,7 @@ class ProfileViewModel @Inject constructor( profileRepository.updateProfile(updatedProfile) _uiState.value = _uiState.value.copy(editingProfile = null) - showToast("Profile updated", ToastType.SUCCESS) + showToast(context.getString(R.string.profile_updated), ToastType.SUCCESS) runCatching { cloudSyncRepository.pushToCloud(force = true) } } } @@ -454,7 +458,7 @@ class ProfileViewModel @Inject constructor( viewModelScope.launch { val activeId = _uiState.value.activeProfile?.id profileRepository.deleteProfile(profile.id) - showToast("Profile deleted", ToastType.SUCCESS) + showToast(context.getString(R.string.profile_deleted), ToastType.SUCCESS) if (activeId == profile.id) { traktRepository.clearAllProfileCaches() watchHistoryRepository.clearProfileCaches() @@ -545,7 +549,7 @@ class ProfileViewModel @Inject constructor( profileRepository.updateProfile(updatedProfile) _uiState.value = _uiState.value.copy(editingProfile = updatedProfile) hidePinDialog() - showToast("Profile PIN set successfully", ToastType.SUCCESS) + showToast(context.getString(R.string.profile_pin_set_success), ToastType.SUCCESS) runCatching { cloudSyncRepository.pushToCloud(force = true) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 5cb4dafd4..53c15f391 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -179,7 +179,7 @@ fun SearchScreen( val quickFilters = listOfNotNull( DiscoverQuickFilter( key = "all", - label = "All", + label = stringResource(R.string.search_filter_all), isSelected = uiState.selectedType == DiscoverType.ALL && uiState.selectedGenre == null && uiState.selectedCountry == null, onSelect = { viewModel.setDiscoverFilters(DiscoverType.ALL, null, null) } ), @@ -197,7 +197,7 @@ fun SearchScreen( ), DiscoverQuickFilter( key = "anime", - label = "Anime", + label = stringResource(R.string.search_filter_anime), isSelected = uiState.selectedType == DiscoverType.ANIME && uiState.selectedGenre == null && uiState.selectedCountry == null, onSelect = { viewModel.setDiscoverFilters(DiscoverType.ANIME, null, null) } ), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 1f7b21ee7..91cf35e33 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -141,6 +141,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.arflix.tv.ui.components.SettingsRow import com.arflix.tv.ui.components.SettingsToggleRow +import com.arflix.tv.ui.components.localizeSettingValue import androidx.core.widget.doAfterTextChanged import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -243,8 +244,9 @@ private fun openExternalUrl(context: Context, url: String) { } } +@Composable private fun formatUserAgentPreview(value: String, maxLength: Int): String { - val displayValue = value.ifBlank { "Default" } + val displayValue = value.ifBlank { stringResource(R.string.settings_ua_default) } val preview = displayValue.take(maxLength) return if (displayValue.length > maxLength) "$preview..." else preview } @@ -1202,8 +1204,8 @@ fun SettingsScreen( "appearance" -> stringResource(R.string.interface_label) "profiles" -> stringResource(R.string.profiles) "network" -> stringResource(R.string.network) - "iptv" -> "TV" - "home_server" -> "Home Server" + "iptv" -> stringResource(R.string.iptv) + "home_server" -> stringResource(R.string.settings_home_server) "catalogs" -> stringResource(R.string.catalogs) "stremio" -> stringResource(R.string.addons) "accounts" -> stringResource(R.string.accounts) @@ -1227,7 +1229,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "ARVIO V${BuildConfig.VERSION_NAME}", + text = stringResource(R.string.settings_app_version_label, BuildConfig.VERSION_NAME), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f), modifier = Modifier.padding(start = 8.dp) @@ -1572,7 +1574,7 @@ fun SettingsScreen( InputModal( title = stringResource(R.string.add_addon), fields = listOf( - InputField(label = "URL", value = customAddonUrl, onValueChange = { customAddonUrl = it }) + InputField(label = stringResource(R.string.settings_label_url), value = customAddonUrl, onValueChange = { customAddonUrl = it }) ), onConfirm = { if (customAddonUrl.isNotBlank()) { @@ -1590,29 +1592,29 @@ fun SettingsScreen( if (showHomeServerInput) { InputModal( - title = "Home Server", - supportingText = "Use HTTPS where possible. HTTP server URLs are allowed for local networks but are not encrypted.", + title = stringResource(R.string.settings_home_server), + supportingText = stringResource(R.string.settings_home_server_https_note), fields = listOf( InputField( - label = "Server name", + label = stringResource(R.string.settings_label_server_name), value = homeServerDisplayName, - placeholder = "Optional, shown in sources", + placeholder = stringResource(R.string.settings_ph_server_name), onValueChange = { homeServerDisplayName = it } ), InputField( - label = "Server URL", + label = stringResource(R.string.settings_label_server_url), value = homeServerUrl, - placeholder = "http://server:8096 or http://server:32400", + placeholder = stringResource(R.string.settings_ph_server_url), onValueChange = { homeServerUrl = it } ), InputField( - label = "Username", + label = stringResource(R.string.settings_label_username), value = homeServerUsername, - placeholder = "Optional for token sign-in", + placeholder = stringResource(R.string.settings_ph_username_token), onValueChange = { homeServerUsername = it } ), InputField( - label = "Password / token", + label = stringResource(R.string.settings_label_password_token), value = homeServerPassword, isSecret = true, onValueChange = { homeServerPassword = it } @@ -1640,19 +1642,19 @@ fun SettingsScreen( } if (showPlexHomeServerInput) { InputModal( - title = "Connect with code", - supportingText = "Use HTTPS where possible. HTTP server URLs are allowed for local networks but are not encrypted.", + title = stringResource(R.string.settings_connect_with_code), + supportingText = stringResource(R.string.settings_home_server_https_note), fields = listOf( InputField( - label = "Server name", + label = stringResource(R.string.settings_label_server_name), value = plexHomeServerDisplayName, - placeholder = "Optional, shown in sources", + placeholder = stringResource(R.string.settings_ph_server_name), onValueChange = { plexHomeServerDisplayName = it } ), InputField( - label = "Server URL (optional)", + label = stringResource(R.string.settings_label_server_url_optional), value = plexHomeServerUrl, - placeholder = "Leave empty to discover automatically", + placeholder = stringResource(R.string.settings_ph_server_url_discover), onValueChange = { plexHomeServerUrl = it } ) ), @@ -1674,38 +1676,38 @@ fun SettingsScreen( } if (showIptvInput) { InputModal( - title = if (editingIptvIndex >= 0) "Edit TV Playlist" else "Add TV Playlist", - supportingText = "EPG supports multiple sources for this playlist. Add one URL per line; ARVIO will match them in order.", + title = if (editingIptvIndex >= 0) stringResource(R.string.settings_edit_tv_playlist) else stringResource(R.string.settings_add_tv_playlist), + supportingText = stringResource(R.string.settings_iptv_epg_note), fields = listOf( InputField( - label = "Playlist Name", + label = stringResource(R.string.settings_label_playlist_name), value = iptvEditName, onValueChange = { iptvEditName = it } ), InputField( - label = "M3U URL or Xtream Host", + label = stringResource(R.string.settings_label_m3u_or_xtream), value = iptvEditUrl, - placeholder = "https://provider.host:port", + placeholder = stringResource(R.string.settings_ph_provider_host), onValueChange = { iptvEditUrl = it } ), InputField( - label = "Xtream Username (Optional)", + label = stringResource(R.string.settings_label_xtream_user), value = iptvEditXtreamUser, - placeholder = "Leave empty for plain M3U", + placeholder = stringResource(R.string.settings_ph_plain_m3u), onValueChange = { iptvEditXtreamUser = it } ), InputField( - label = "Xtream Password (Optional)", + label = stringResource(R.string.settings_label_xtream_pass), value = iptvEditXtreamPass, - placeholder = "Leave empty for plain M3U", + placeholder = stringResource(R.string.settings_ph_plain_m3u), isSecret = true, onValueChange = { iptvEditXtreamPass = it } ), InputField( - label = "EPG Sources (Optional)", + label = stringResource(R.string.settings_label_epg_sources), value = iptvEditEpg, placeholder = "https://provider.com/xmltv.xml\nhttps://backup.com/epg.xml.gz", - helper = "One URL per line. Commas, semicolons, and pipes are also accepted.", + helper = stringResource(R.string.settings_helper_epg_one_per_line), singleLine = false, onValueChange = { iptvEditEpg = it } ) @@ -1777,7 +1779,7 @@ fun SettingsScreen( InputModal( title = stringResource(R.string.rename_catalog), fields = listOf( - InputField(label = "Title", value = renameCatalogTitle, onValueChange = { renameCatalogTitle = it }) + InputField(label = stringResource(R.string.settings_label_title), value = renameCatalogTitle, onValueChange = { renameCatalogTitle = it }) ), onConfirm = { if (renameCatalogTitle.isNotBlank()) { @@ -1814,7 +1816,7 @@ fun SettingsScreen( if (showQualityFilterEditor) { QualityFilterEditorModal( - title = if (editingQualityFilterId == null) "Add Quality Filter" else "Edit Quality Filter", + title = if (editingQualityFilterId == null) stringResource(R.string.settings_add_quality_filter) else stringResource(R.string.settings_edit_quality_filter), deviceName = qualityFilterDeviceName, regexPattern = qualityFilterRegexPattern, onDeviceNameChange = { qualityFilterDeviceName = it }, @@ -1983,11 +1985,11 @@ fun SettingsScreen( uiState.plexHomeServerAuth?.let { plexAuth -> TraktActivationModal( - title = "Connect with code", + title = stringResource(R.string.settings_connect_with_code), instruction = if (LocalDeviceType.current.isTouchDevice()) { - "Open the auth page, sign in, then return to ARVIO. This screen will finish automatically." + stringResource(R.string.settings_plex_auth_touch_instruction) } else { - "Scan the QR code or open the auth page and confirm this code" + stringResource(R.string.settings_plex_auth_tv_instruction) }, verificationUrl = plexAuth.verificationUrl, userCode = plexAuth.code, @@ -2197,13 +2199,13 @@ private fun QualityFiltersModal( } ) { Text( - text = "Quality Regex Filters", + text = stringResource(R.string.quality_filters), style = ArflixTypography.sectionTitle, color = TextPrimary ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Device-local filters. Matching streams are excluded.", + text = stringResource(R.string.settings_quality_filters_subtitle), style = ArflixTypography.caption, color = TextSecondary ) @@ -2211,7 +2213,7 @@ private fun QualityFiltersModal( if (filters.isEmpty()) { Text( - text = "No filters configured yet.", + text = stringResource(R.string.settings_no_filters_yet), style = ArflixTypography.body, color = TextSecondary ) @@ -2233,7 +2235,7 @@ private fun QualityFiltersModal( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = filter.deviceName.ifBlank { "Unnamed Device" }, + text = filter.deviceName.ifBlank { stringResource(R.string.settings_unnamed_device) }, style = ArflixTypography.body, color = TextPrimary, maxLines = 1, @@ -2278,14 +2280,14 @@ private fun QualityFiltersModal( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { SettingsChip( - label = "Close", + label = stringResource(R.string.close), enabled = true, onClick = onDismiss, isFocused = isFooterFocused && selectedFooterAction == 0, modifier = Modifier.weight(1f) ) SettingsChip( - label = "Add Filter", + label = stringResource(R.string.settings_add_filter), enabled = true, onClick = onAdd, isFocused = isFooterFocused && selectedFooterAction == 1, @@ -2427,7 +2429,7 @@ private fun QualityFilterEditorModal( value = deviceName, onValueChange = onDeviceNameChange, singleLine = true, - label = { Text("Device / Preset Name") }, + label = { Text(stringResource(R.string.settings_label_device_preset_name)) }, modifier = Modifier .fillMaxWidth() .focusRequester(deviceNameRequester) @@ -2441,7 +2443,7 @@ private fun QualityFilterEditorModal( onValueChange = onRegexPatternChange, singleLine = false, minLines = 3, - label = { Text("Regex Pattern") }, + label = { Text(stringResource(R.string.settings_label_regex_pattern)) }, modifier = Modifier .fillMaxWidth() .focusRequester(regexPatternRequester) @@ -2455,14 +2457,14 @@ private fun QualityFilterEditorModal( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { SettingsChip( - label = "Cancel", + label = stringResource(R.string.cancel), enabled = true, onClick = onDismiss, isFocused = focusedIndex == 2, modifier = Modifier.weight(1f) ) SettingsChip( - label = "Save", + label = stringResource(R.string.save), enabled = regexPattern.trim().isNotBlank(), onClick = onSave, isFocused = focusedIndex == 3, @@ -2568,7 +2570,7 @@ private fun CloudEmailPasswordModal( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "ARVIO Cloud Sign-in", + text = stringResource(R.string.settings_cloud_signin_title), style = ArflixTypography.sectionTitle, color = TextPrimary, modifier = Modifier.padding(bottom = 24.dp) @@ -2576,7 +2578,7 @@ private fun CloudEmailPasswordModal( Column(modifier = Modifier.fillMaxWidth()) { Text( - text = "Email", + text = stringResource(R.string.settings_label_email), style = ArflixTypography.caption, color = if (focusedIndex == 0) Pink else TextSecondary, modifier = Modifier.padding(bottom = 8.dp) @@ -2610,7 +2612,7 @@ private fun CloudEmailPasswordModal( Column(modifier = Modifier.fillMaxWidth()) { Text( - text = "Password", + text = stringResource(R.string.settings_label_password), style = ArflixTypography.caption, color = if (focusedIndex == 1) Pink else TextSecondary, modifier = Modifier.padding(bottom = 8.dp) @@ -2665,7 +2667,7 @@ private fun CloudEmailPasswordModal( contentAlignment = Alignment.Center ) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), style = ArflixTypography.button, color = if (isCancelFocused) TextPrimary else TextSecondary ) @@ -2689,7 +2691,7 @@ private fun CloudEmailPasswordModal( contentAlignment = Alignment.Center ) { Text( - text = "Sign In", + text = stringResource(R.string.sign_in), style = ArflixTypography.button, color = if (isSignInFocused) Color.White else Color.Black ) @@ -2713,7 +2715,7 @@ private fun CloudEmailPasswordModal( contentAlignment = Alignment.Center ) { Text( - text = "Create", + text = stringResource(R.string.settings_btn_create), style = ArflixTypography.button, color = Color.White ) @@ -2722,7 +2724,7 @@ private fun CloudEmailPasswordModal( Spacer(modifier = Modifier.height(12.dp)) Text( - text = if (LocalDeviceType.current.isTouchDevice()) "Enter your email and password to sign in." else "Tip: Use TV keyboard. D-pad to navigate.", + text = if (LocalDeviceType.current.isTouchDevice()) stringResource(R.string.settings_cloud_signin_hint_touch) else stringResource(R.string.settings_cloud_signin_hint_tv), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -2809,7 +2811,7 @@ private fun CloudPairModal( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "ARVIO Cloud Pairing", + text = stringResource(R.string.settings_cloud_pairing_title), style = ArflixTypography.sectionTitle, color = TextPrimary, modifier = Modifier.padding(bottom = 10.dp) @@ -2818,7 +2820,7 @@ private fun CloudPairModal( if (isMobile) { // On mobile, skip QR (can't scan own screen) and prompt email/password Text( - text = "Sign in with your email and password to link this device.", + text = stringResource(R.string.settings_pair_signin_link_device), style = ArflixTypography.body, color = TextSecondary, textAlign = TextAlign.Center, @@ -2826,7 +2828,7 @@ private fun CloudPairModal( ) } else { Text( - text = "Scan this QR code to sign in and link this TV.", + text = stringResource(R.string.settings_pair_scan_qr_link_tv), style = ArflixTypography.body, color = TextSecondary, textAlign = TextAlign.Center, @@ -2878,7 +2880,7 @@ private fun CloudPairModal( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Code: $userCode", + text = stringResource(R.string.settings_pair_code, userCode), style = ArflixTypography.body, color = TextPrimary ) @@ -2891,7 +2893,7 @@ private fun CloudPairModal( LoadingIndicator(size = 20.dp) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Waiting for approval...", + text = stringResource(R.string.settings_waiting_for_approval), style = ArflixTypography.body, color = TextSecondary ) @@ -2927,7 +2929,7 @@ private fun CloudPairModal( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Use Email & Password", + text = stringResource(R.string.settings_use_email_password), style = ArflixTypography.button, color = Color.White ) @@ -2947,7 +2949,7 @@ private fun CloudPairModal( contentAlignment = Alignment.Center ) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), style = ArflixTypography.button, color = TextSecondary ) @@ -2983,7 +2985,7 @@ private fun CloudPairModal( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Cancel", + text = stringResource(R.string.cancel), style = ArflixTypography.button, color = if (isCancelFocused) TextPrimary else TextSecondary ) @@ -3015,7 +3017,7 @@ private fun CloudPairModal( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Use Email/Password", + text = stringResource(R.string.settings_use_email_password_short), style = ArflixTypography.button, color = Color.White ) @@ -3035,10 +3037,12 @@ private fun TraktActivationModal( verificationUrl: String, userCode: String, onDismiss: () -> Unit, - title: String = "Connect Trakt.tv", - instruction: String = "Go to $verificationUrl and enter this code", + title: String? = null, + instruction: String? = null, onOpenUrl: (() -> Unit)? = null ) { + val resolvedTitle = title ?: stringResource(R.string.settings_connect_trakt) + val resolvedInstruction = instruction ?: stringResource(R.string.settings_trakt_instruction, verificationUrl) val focusRequester = remember { FocusRequester() } val isMobile = LocalDeviceType.current.isTouchDevice() val qrContainerSize = if (isMobile) 0.dp else 172.dp @@ -3085,13 +3089,13 @@ private fun TraktActivationModal( } ) { Text( - text = title, + text = resolvedTitle, style = ArflixTypography.sectionTitle, color = TextPrimary ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = instruction, + text = resolvedInstruction, style = ArflixTypography.body, color = TextSecondary ) @@ -3130,7 +3134,7 @@ private fun TraktActivationModal( ) Spacer(modifier = Modifier.height(10.dp)) Text( - text = "Waiting for authorization", + text = stringResource(R.string.settings_waiting_for_authorization), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.78f) ) @@ -3149,7 +3153,7 @@ private fun TraktActivationModal( contentAlignment = Alignment.Center ) { Text( - text = "Open auth page", + text = stringResource(R.string.settings_open_auth_page), style = ArflixTypography.button, color = Color.White ) @@ -3167,7 +3171,7 @@ private fun TraktActivationModal( contentAlignment = Alignment.Center ) { Text( - text = "Copy code", + text = stringResource(R.string.settings_copy_code), style = ArflixTypography.button, color = Color.White ) @@ -3188,7 +3192,7 @@ private fun TraktActivationModal( contentAlignment = Alignment.Center ) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), style = ArflixTypography.button, color = Color.White ) @@ -3290,7 +3294,7 @@ private fun MobileSettingsLayout( .size(28.dp) ) Text( - text = page, + text = mobileCategoryTitle(page), style = ArflixTypography.heroTitle.copy(fontSize = 24.sp), color = TextPrimary, modifier = Modifier.weight(1f) @@ -3321,6 +3325,23 @@ private fun MobileSettingsLayout( } } +/** + * Maps a stable mobile-settings page navigation key (kept in English so the + * `when (page)` routing stays stable) to its localized display title. + */ +@Composable +private fun mobileCategoryTitle(page: String): String = when (page) { + "Playback & Controls" -> stringResource(R.string.settings_cat_playback_controls) + "Audio & Subtitles" -> stringResource(R.string.settings_cat_audio_subtitles) + "Appearance" -> stringResource(R.string.interface_label) + "Addons" -> stringResource(R.string.addons) + "Plugins & Extensions" -> stringResource(R.string.settings_cat_plugins_extensions) + "Catalogs" -> stringResource(R.string.catalogs) + "TV" -> stringResource(R.string.iptv) + "Home Server" -> stringResource(R.string.settings_home_server) + else -> page +} + @Composable private fun MobileSettingsMainPage( uiState: SettingsUiState, @@ -3339,7 +3360,7 @@ private fun MobileSettingsMainPage( verticalArrangement = Arrangement.spacedBy(32.dp) ) { item { - MobileSettingsCategory(title = "LANGUAGES") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_languages)) { MobileSettingsRow( icon = Icons.Default.Language, title = stringResource(R.string.content_language), @@ -3380,7 +3401,7 @@ private fun MobileSettingsMainPage( } item { - MobileSettingsCategory(title = "CATEGORIES") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_categories)) { val categories = buildList { add("Playback & Controls" to Icons.Default.PlayArrow) add("Audio & Subtitles" to Icons.Default.Speaker) @@ -3404,7 +3425,7 @@ private fun MobileSettingsMainPage( ) { Icon(imageVector = icon, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(16.dp)) - Text(text = name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, modifier = Modifier.weight(1f)) + Text(text = mobileCategoryTitle(name), style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, modifier = Modifier.weight(1f)) Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(20.dp)) } if (index < categories.lastIndex) { @@ -3416,13 +3437,13 @@ private fun MobileSettingsMainPage( } item { - MobileSettingsCategory(title = "USER INFO & ACCOUNT") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_user_account)) { if (uiState.isLoggedIn) { MobileSettingsRow( icon = Icons.Default.Person, title = stringResource(R.string.cloud_account), subtitle = uiState.accountEmail ?: "", - value = "Force Sync", + value = stringResource(R.string.settings_force_sync), isFocused = false, onClick = { viewModel.forceCloudSyncNow() } ) @@ -3437,7 +3458,7 @@ private fun MobileSettingsMainPage( MobileSettingsRow( icon = Icons.Default.Person, title = stringResource(R.string.cloud_account), - value = "Sign In", + value = stringResource(R.string.sign_in), isFocused = false, onClick = { viewModel.openCloudEmailPasswordDialog() } ) @@ -3445,14 +3466,14 @@ private fun MobileSettingsMainPage( MobileSettingsRow( icon = Icons.Default.Movie, title = stringResource(R.string.trakt_account), - value = if (uiState.isTraktAuthenticated) "Disconnect" else "Connect", + value = if (uiState.isTraktAuthenticated) stringResource(R.string.settings_disconnect) else stringResource(R.string.connect), isFocused = false, onClick = { if (uiState.isTraktAuthenticated) viewModel.disconnectTrakt() else viewModel.startTraktAuth() } ) MobileSettingsRow( icon = Icons.Default.QrCode, title = "Telegram", - value = "Open", + value = stringResource(R.string.settings_open), isFocused = false, onClick = onNavigateToTelegram ) @@ -3460,7 +3481,7 @@ private fun MobileSettingsMainPage( icon = Icons.Default.SystemUpdate, title = stringResource(R.string.app_version), subtitle = "V${BuildConfig.VERSION_NAME}", - value = if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) "Update Available" else "Check Updates", + value = if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) stringResource(R.string.settings_update_available) else stringResource(R.string.settings_check_updates), isFocused = false, showDivider = false, onClick = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) } @@ -3502,7 +3523,7 @@ private fun MobileSettingsSubPage( ) { when (page) { "Playback & Controls" -> { - MobileSettingsCategory(title = "PLAYBACK") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_playback)) { MobileSettingsRow( icon = Icons.Default.PlayArrow, title = stringResource(R.string.auto_play_next_title), @@ -3568,7 +3589,7 @@ private fun MobileSettingsSubPage( onClick = openQualityFiltersModal ) } - MobileSettingsCategory(title = "CONTROLS") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_controls)) { MobileSettingsRow( icon = Icons.Default.Person, title = stringResource(R.string.skip_profile), @@ -3594,7 +3615,7 @@ private fun MobileSettingsSubPage( } } "Audio & Subtitles" -> { - MobileSettingsCategory(title = "SUBTITLES") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_subtitles)) { MobileSettingsRow( icon = Icons.Default.Subtitles, title = stringResource(R.string.subtitle_size), @@ -3708,7 +3729,7 @@ private fun MobileSettingsSubPage( ) } } - MobileSettingsCategory(title = "AUDIO") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_audio)) { MobileSettingsRow( icon = Icons.Default.VolumeUp, title = stringResource(R.string.volume_boost), @@ -3720,7 +3741,7 @@ private fun MobileSettingsSubPage( } } "Appearance" -> { - MobileSettingsCategory(title = "APPEARANCE") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_appearance)) { MobileSettingsRow( icon = Icons.Default.Palette, title = stringResource(R.string.ui_mode), @@ -3989,7 +4010,7 @@ private fun MobileSettingsRow( } } else { Text( - text = value, + text = localizeSettingValue(value), style = ArflixTypography.caption.copy( fontSize = 13.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium @@ -4057,17 +4078,17 @@ private fun UnknownSourcesModal( } } ) { - Text("Allow Unknown Sources", style = ArflixTypography.sectionTitle, color = TextPrimary) + Text(stringResource(R.string.settings_allow_unknown_sources), style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.height(12.dp)) Text( - "Allow installs from unknown sources for ARVIO so the downloaded update APK can be installed.", + stringResource(R.string.settings_allow_unknown_sources_desc), style = ArflixTypography.body, color = TextSecondary ) Spacer(modifier = Modifier.height(24.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - UpdateActionButton("Close", focusedIndex == 0, onDismiss) - UpdateActionButton("Open Settings", focusedIndex == 1, onOpenSettings, highlighted = true) + UpdateActionButton(stringResource(R.string.close), focusedIndex == 0, onDismiss) + UpdateActionButton(stringResource(R.string.settings_open_settings), focusedIndex == 1, onOpenSettings, highlighted = true) } } } @@ -4125,12 +4146,13 @@ private fun UpdateActionButton( } } +@Composable private fun tvSettingsSidebarGroup(section: String): String { return when (section) { - "accounts", "profiles" -> "Profile" - "playback", "language", "subtitles", "ai_subtitles" -> "Playback" - "iptv", "stremio", "catalogs", "home_server" -> "Sources" - else -> "System" + "accounts", "profiles" -> stringResource(R.string.settings_group_profile) + "playback", "language", "subtitles", "ai_subtitles" -> stringResource(R.string.playback) + "iptv", "stremio", "catalogs", "home_server" -> stringResource(R.string.sources) + else -> stringResource(R.string.settings_group_system) } } @@ -4301,7 +4323,7 @@ private fun TvSettingsInsightPanel( .padding(22.dp) ) { Text( - text = "Overview", + text = stringResource(R.string.settings_overview), style = ArflixTypography.sectionTitle.copy(fontSize = 22.sp), color = TextPrimary, maxLines = 1, @@ -4326,7 +4348,7 @@ private fun TvSettingsInsightPanel( ) { Column { Text( - text = if (focusedIndex >= 0) "Focused setting" else "Section", + text = if (focusedIndex >= 0) stringResource(R.string.settings_focused_setting) else stringResource(R.string.settings_section_label), style = ArflixTypography.caption, color = resolveAccentColor(fallback = Pink), maxLines = 1 @@ -4352,7 +4374,7 @@ private fun TvSettingsInsightPanel( Spacer(modifier = Modifier.height(22.dp)) Text( - text = "Status", + text = stringResource(R.string.settings_status), style = ArflixTypography.caption.copy(fontSize = 11.sp), color = TextSecondary.copy(alpha = 0.54f), maxLines = 1 @@ -4406,8 +4428,8 @@ private fun tvSettingsSectionTitle(section: String): String { "appearance" -> stringResource(R.string.interface_label) "profiles" -> stringResource(R.string.profiles) "network" -> stringResource(R.string.network) - "iptv" -> "TV" - "home_server" -> "Home Server" + "iptv" -> stringResource(R.string.iptv) + "home_server" -> stringResource(R.string.settings_home_server) "catalogs" -> stringResource(R.string.catalogs) "stremio" -> stringResource(R.string.addons) "accounts" -> stringResource(R.string.accounts) @@ -4415,24 +4437,26 @@ private fun tvSettingsSectionTitle(section: String): String { } } +@Composable private fun tvSettingsSectionDescription(section: String): String { return when (section) { - "language" -> "App text and preferred audio track." - "subtitles" -> "Subtitle language defaults, display style and filtering." - "ai_subtitles" -> "AI subtitle translation and cleanup options." - "playback" -> "Autoplay, trailers, source quality, frame-rate matching and audio boost." - "appearance" -> "Layout, OLED mode, focus styling and hero metadata." - "profiles" -> "Profile startup behavior for this device." - "network" -> "DNS and loading diagnostic preferences." - "iptv" -> "TV playlists, EPG refresh, channel loading and playlist management." - "home_server" -> "Connect personal media servers and use their libraries as sources." - "catalogs" -> "Discover, rename, order and remove home rows and list catalogs." - "stremio" -> "Manage third-party addons used for catalog and source discovery." - "accounts" -> "Cloud sync, Trakt connection, app updates and account controls." - else -> "Configure ARVIO for this profile." + "language" -> stringResource(R.string.settings_desc_language) + "subtitles" -> stringResource(R.string.settings_desc_subtitles) + "ai_subtitles" -> stringResource(R.string.settings_desc_ai_subtitles) + "playback" -> stringResource(R.string.settings_desc_playback) + "appearance" -> stringResource(R.string.settings_desc_appearance) + "profiles" -> stringResource(R.string.settings_desc_profiles) + "network" -> stringResource(R.string.settings_desc_network) + "iptv" -> stringResource(R.string.settings_desc_iptv) + "home_server" -> stringResource(R.string.settings_desc_home_server) + "catalogs" -> stringResource(R.string.settings_desc_catalogs) + "stremio" -> stringResource(R.string.settings_desc_stremio) + "accounts" -> stringResource(R.string.settings_desc_accounts) + else -> stringResource(R.string.settings_desc_default) } } +@Composable private fun tvSettingsSectionPills( section: String, uiState: SettingsUiState, @@ -4440,47 +4464,48 @@ private fun tvSettingsSectionPills( ): List { return when (section) { "language" -> listOf( - "App ${uiState.contentLanguage.uppercase()}", - "Audio ${uiState.defaultAudioLanguage}" + stringResource(R.string.settings_pill_app, uiState.contentLanguage.uppercase()), + stringResource(R.string.settings_pill_audio, localizeSettingValue(uiState.defaultAudioLanguage)) ) "subtitles" -> listOf( - "Default ${uiState.defaultSubtitle}", - "Size ${uiState.subtitleSize}" + stringResource(R.string.settings_pill_default, localizeSettingValue(uiState.defaultSubtitle)), + stringResource(R.string.settings_pill_size, localizeSettingValue(uiState.subtitleSize)) ) "ai_subtitles" -> listOf( - if (uiState.subtitleAiEnabled) "AI on" else "AI off", - if (uiState.subtitleAiAutoSelect) "Auto-select on" else "Auto-select off" + if (uiState.subtitleAiEnabled) stringResource(R.string.settings_pill_ai_on) else stringResource(R.string.settings_pill_ai_off), + if (uiState.subtitleAiAutoSelect) stringResource(R.string.settings_pill_autoselect_on) else stringResource(R.string.settings_pill_autoselect_off) ) "playback" -> listOf( - "Autoplay ${if (uiState.autoPlaySingleSource) "on" else "off"}", - "Trailers ${if (uiState.trailerAutoPlay) "on" else "off"}", - "Min ${uiState.autoPlayMinQuality}", + stringResource(R.string.settings_pill_autoplay, if (uiState.autoPlaySingleSource) stringResource(R.string.settings_inline_on) else stringResource(R.string.settings_inline_off)), + stringResource(R.string.settings_pill_trailers, if (uiState.trailerAutoPlay) stringResource(R.string.settings_inline_on) else stringResource(R.string.settings_inline_off)), + stringResource(R.string.settings_pill_min, localizeSettingValue(uiState.autoPlayMinQuality)), ) "appearance" -> listOf( - "OLED ${if (uiState.oledBlackBackground) "on" else "off"}" + stringResource(R.string.settings_pill_oled, if (uiState.oledBlackBackground) stringResource(R.string.settings_inline_on) else stringResource(R.string.settings_inline_off)) ) - "profiles" -> listOf(if (uiState.skipProfileSelection) "Skip on" else "Picker on") - "network" -> listOf("DNS ${uiState.dnsProvider}", if (uiState.showLoadingStats) "Stats on" else "Stats off") + "profiles" -> listOf(if (uiState.skipProfileSelection) stringResource(R.string.settings_pill_skip_on) else stringResource(R.string.settings_pill_picker_on)) + "network" -> listOf(stringResource(R.string.settings_pill_dns, uiState.dnsProvider), if (uiState.showLoadingStats) stringResource(R.string.settings_pill_stats_on) else stringResource(R.string.settings_pill_stats_off)) "iptv" -> listOf( - "${uiState.iptvPlaylists.size}/3 playlists", - "${formatCompactCount(uiState.iptvChannelCount)} channels", - if (uiState.isIptvLoading) "Refreshing" else "Ready" + stringResource(R.string.settings_pill_playlists, uiState.iptvPlaylists.size), + stringResource(R.string.settings_pill_channels, formatCompactCount(uiState.iptvChannelCount)), + if (uiState.isIptvLoading) stringResource(R.string.settings_refreshing) else stringResource(R.string.settings_ready) ) "home_server" -> listOf( - "${uiState.homeServerConnections.size} connected", - if (uiState.isHomeServerConnecting || uiState.isPlexHomeServerPolling) "Working" else "Idle" + stringResource(R.string.settings_pill_connected_count, uiState.homeServerConnections.size), + if (uiState.isHomeServerConnecting || uiState.isPlexHomeServerPolling) stringResource(R.string.settings_working) else stringResource(R.string.settings_idle) ) - "catalogs" -> listOf("${uiState.catalogs.size} catalogs", "Cloud synced") - "stremio" -> listOf("$addonCount installed", "Profile scoped") + "catalogs" -> listOf(stringResource(R.string.settings_pill_catalogs, uiState.catalogs.size), stringResource(R.string.settings_cloud_synced)) + "stremio" -> listOf(stringResource(R.string.settings_pill_installed, addonCount), stringResource(R.string.settings_profile_scoped)) "accounts" -> listOf( - if (uiState.isLoggedIn) "Cloud connected" else "Cloud off", - if (uiState.isTraktAuthenticated) "Trakt connected" else "Trakt off", - if (uiState.isForceCloudSyncing) "Syncing" else "Ready" + if (uiState.isLoggedIn) stringResource(R.string.settings_cloud_connected) else stringResource(R.string.settings_cloud_off), + if (uiState.isTraktAuthenticated) stringResource(R.string.settings_trakt_connected) else stringResource(R.string.settings_trakt_off), + if (uiState.isForceCloudSyncing) stringResource(R.string.syncing) else stringResource(R.string.settings_ready) ) else -> emptyList() } } +@Composable private fun tvSettingsPanelFacts( section: String, uiState: SettingsUiState, @@ -4488,112 +4513,113 @@ private fun tvSettingsPanelFacts( ): List> { return when (section) { "language" -> listOf( - "App language" to uiState.contentLanguage.uppercase(), - "Audio" to uiState.defaultAudioLanguage + stringResource(R.string.settings_fact_app_language) to uiState.contentLanguage.uppercase(), + stringResource(R.string.audio) to uiState.defaultAudioLanguage ) "subtitles" -> listOf( - "Default" to uiState.defaultSubtitle, - "Secondary" to uiState.secondarySubtitle, - "Style" to uiState.subtitleStyle + stringResource(R.string.settings_fact_default) to uiState.defaultSubtitle, + stringResource(R.string.settings_fact_secondary) to uiState.secondarySubtitle, + stringResource(R.string.settings_fact_style) to uiState.subtitleStyle ) "ai_subtitles" -> listOf( - "AI" to if (uiState.subtitleAiEnabled) "Enabled" else "Off", - "Auto-select" to if (uiState.subtitleAiAutoSelect) "On" else "Off" + stringResource(R.string.settings_fact_ai) to if (uiState.subtitleAiEnabled) stringResource(R.string.settings_enabled) else stringResource(R.string.off), + stringResource(R.string.settings_fact_autoselect) to if (uiState.subtitleAiAutoSelect) stringResource(R.string.on) else stringResource(R.string.off) ) "playback" -> listOf( - "Autoplay" to if (uiState.autoPlaySingleSource) "On" else "Off", - "Trailers" to if (uiState.trailerAutoPlay) "On" else "Off", - "Frame rate" to uiState.frameRateMatchingMode + stringResource(R.string.settings_fact_autoplay) to if (uiState.autoPlaySingleSource) stringResource(R.string.on) else stringResource(R.string.off), + stringResource(R.string.settings_fact_trailers) to if (uiState.trailerAutoPlay) stringResource(R.string.on) else stringResource(R.string.off), + stringResource(R.string.settings_fact_frame_rate) to uiState.frameRateMatchingMode ) "appearance" -> listOf( - "OLED" to if (uiState.oledBlackBackground) "On" else "Off", - "Accent color" to uiState.accentColor + stringResource(R.string.settings_fact_oled) to if (uiState.oledBlackBackground) stringResource(R.string.on) else stringResource(R.string.off), + stringResource(R.string.settings_fact_accent_color) to uiState.accentColor ) "profiles" -> listOf( - "Startup" to if (uiState.skipProfileSelection) "Skip picker" else "Show picker" + stringResource(R.string.settings_fact_startup) to if (uiState.skipProfileSelection) stringResource(R.string.settings_skip_picker) else stringResource(R.string.settings_show_picker) ) "network" -> listOf( - "DNS" to uiState.dnsProvider, - "Loading stats" to if (uiState.showLoadingStats) "On" else "Off", - "User-Agent" to formatUserAgentPreview(uiState.customUserAgent, 40) + stringResource(R.string.settings_fact_dns) to uiState.dnsProvider, + stringResource(R.string.settings_fact_loading_stats) to if (uiState.showLoadingStats) stringResource(R.string.on) else stringResource(R.string.off), + stringResource(R.string.settings_fact_user_agent) to formatUserAgentPreview(uiState.customUserAgent, 40) ) "iptv" -> listOf( - "Playlists" to "${uiState.iptvPlaylists.size}/3", - "Channels" to formatCompactCount(uiState.iptvChannelCount), - "EPG" to if (uiState.iptvPlaylists.any { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() }) "Configured" else "Optional", - "State" to if (uiState.isIptvLoading) "Refreshing" else "Ready" + stringResource(R.string.settings_fact_playlists) to "${uiState.iptvPlaylists.size}/3", + stringResource(R.string.settings_fact_channels) to formatCompactCount(uiState.iptvChannelCount), + stringResource(R.string.settings_fact_epg) to if (uiState.iptvPlaylists.any { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() }) stringResource(R.string.settings_configured) else stringResource(R.string.settings_optional), + stringResource(R.string.settings_fact_state) to if (uiState.isIptvLoading) stringResource(R.string.settings_refreshing) else stringResource(R.string.settings_ready) ) "home_server" -> listOf( - "Servers" to uiState.homeServerConnections.size.toString(), - "Status" to if (uiState.isHomeServerConnecting || uiState.isPlexHomeServerPolling) "Working" else "Ready" + stringResource(R.string.settings_fact_servers) to uiState.homeServerConnections.size.toString(), + stringResource(R.string.settings_fact_status) to if (uiState.isHomeServerConnecting || uiState.isPlexHomeServerPolling) stringResource(R.string.settings_working) else stringResource(R.string.settings_ready) ) "catalogs" -> listOf( - "Catalogs" to uiState.catalogs.size.toString(), - "Discovery" to if (uiState.isCatalogSearching) "Searching" else "Ready" + stringResource(R.string.catalogs) to uiState.catalogs.size.toString(), + stringResource(R.string.settings_fact_discovery) to if (uiState.isCatalogSearching) stringResource(R.string.settings_searching) else stringResource(R.string.settings_ready) ) "stremio" -> listOf( - "Addons" to addonCount.toString(), - "Scope" to "Current profile" + stringResource(R.string.addons) to addonCount.toString(), + stringResource(R.string.settings_fact_scope) to stringResource(R.string.settings_current_profile) ) "accounts" -> listOf( - "Cloud" to if (uiState.isLoggedIn) "Connected" else "Disconnected", - "Trakt" to if (uiState.isTraktAuthenticated) "Connected" else "Disconnected", - "Updates" to if (uiState.isSelfUpdateSupported) "Available" else "Play build" + stringResource(R.string.settings_fact_cloud) to if (uiState.isLoggedIn) stringResource(R.string.connected) else stringResource(R.string.settings_disconnected), + stringResource(R.string.settings_fact_trakt) to if (uiState.isTraktAuthenticated) stringResource(R.string.connected) else stringResource(R.string.settings_disconnected), + stringResource(R.string.settings_fact_updates) to if (uiState.isSelfUpdateSupported) stringResource(R.string.settings_available) else stringResource(R.string.settings_play_build) ) else -> emptyList() } } +@Composable private fun tvSettingsFocusedHelp(section: String, focusedIndex: Int): TvSettingsHelp { if (focusedIndex < 0) { return TvSettingsHelp( - title = "Choose a setting", - description = "Move into the center panel to edit the selected section." + title = stringResource(R.string.settings_help_choose_title), + description = stringResource(R.string.settings_help_choose_desc) ) } return when (section) { "language" -> when (focusedIndex) { - 0 -> TvSettingsHelp("App language", "Changes the interface and metadata language used by ARVIO.") - else -> TvSettingsHelp("Default audio", "Preferred audio language when multiple tracks exist.") + 0 -> TvSettingsHelp(stringResource(R.string.settings_fact_app_language), stringResource(R.string.settings_help_app_language_desc)) + else -> TvSettingsHelp(stringResource(R.string.default_audio), stringResource(R.string.settings_help_default_audio_desc)) } "subtitles" -> when (focusedIndex) { - 0 -> TvSettingsHelp("Default subtitle", "Preferred subtitle language when playback starts.") - 1 -> TvSettingsHelp("Secondary subtitle", "Optional second subtitle preference where available.") - else -> TvSettingsHelp("Subtitle preference", "Adjust subtitle size, color, position, style and filtering.") + 0 -> TvSettingsHelp(stringResource(R.string.default_subtitle), stringResource(R.string.settings_help_default_subtitle_desc)) + 1 -> TvSettingsHelp(stringResource(R.string.settings_help_secondary_subtitle), stringResource(R.string.settings_help_secondary_subtitle_desc)) + else -> TvSettingsHelp(stringResource(R.string.settings_help_subtitle_pref), stringResource(R.string.settings_help_subtitle_pref_desc)) } - "ai_subtitles" -> TvSettingsHelp("AI subtitles", "Configure optional AI subtitle translation and cleanup.") + "ai_subtitles" -> TvSettingsHelp(stringResource(R.string.ai_subtitles_section), stringResource(R.string.settings_help_ai_subtitles_desc)) "playback" -> when (focusedIndex) { - 0 -> TvSettingsHelp("Next episode autoplay", "Controls whether episodes continue automatically.") - 1 -> TvSettingsHelp("Source autoplay", "Controls whether a single source starts immediately or opens the picker.") - in 3..4 -> TvSettingsHelp("Trailers", "Tune trailer autoplay and sound behavior on hero banners.") - 7 -> TvSettingsHelp("Volume boost", "Boosts playback audio output when enabled.") - else -> TvSettingsHelp("Playback", "Tune source quality, frame-rate matching and playback behavior.") + 0 -> TvSettingsHelp(stringResource(R.string.settings_help_next_autoplay), stringResource(R.string.settings_help_next_autoplay_desc)) + 1 -> TvSettingsHelp(stringResource(R.string.settings_help_source_autoplay), stringResource(R.string.settings_help_source_autoplay_desc)) + in 3..4 -> TvSettingsHelp(stringResource(R.string.settings_help_trailers), stringResource(R.string.settings_help_trailers_desc)) + 7 -> TvSettingsHelp(stringResource(R.string.volume_boost), stringResource(R.string.settings_help_volume_boost_desc)) + else -> TvSettingsHelp(stringResource(R.string.playback), stringResource(R.string.settings_help_playback_desc)) } - "appearance" -> TvSettingsHelp("Interface", "Control layout, OLED mode, clock and focus styling.") - "profiles" -> TvSettingsHelp("Profiles", "Control whether this device opens the profile picker on startup.") - "network" -> TvSettingsHelp("Network", "DNS and loading diagnostic preferences.") + "appearance" -> TvSettingsHelp(stringResource(R.string.interface_label), stringResource(R.string.settings_help_interface_desc)) + "profiles" -> TvSettingsHelp(stringResource(R.string.profiles), stringResource(R.string.settings_help_profiles_desc)) + "network" -> TvSettingsHelp(stringResource(R.string.network), stringResource(R.string.settings_desc_network)) "iptv" -> when (focusedIndex) { - 0 -> TvSettingsHelp("Add playlist", "Add another IPTV playlist with M3U or Xtream details.") - else -> TvSettingsHelp("IPTV playlist", "Enable, edit, reorder, refresh or remove IPTV data.") + 0 -> TvSettingsHelp(stringResource(R.string.settings_help_add_playlist), stringResource(R.string.settings_help_add_playlist_desc)) + else -> TvSettingsHelp(stringResource(R.string.settings_help_iptv_playlist), stringResource(R.string.settings_help_iptv_playlist_desc)) } "home_server" -> when (focusedIndex) { - 0 -> TvSettingsHelp("Add server", "Connect a personal media server with URL and credentials.") - 1 -> TvSettingsHelp("Connect with code", "Use device-code auth for supported personal servers.") - else -> TvSettingsHelp("Server connection", "Edit, test or remove connected personal media servers.") + 0 -> TvSettingsHelp(stringResource(R.string.settings_help_add_server), stringResource(R.string.settings_help_add_server_desc)) + 1 -> TvSettingsHelp(stringResource(R.string.settings_connect_with_code), stringResource(R.string.settings_help_connect_code_desc)) + else -> TvSettingsHelp(stringResource(R.string.settings_help_server_connection), stringResource(R.string.settings_help_server_connection_desc)) } "catalogs" -> when (focusedIndex) { - 0 -> TvSettingsHelp("Discover catalog", "Search public list catalogs or add a direct URL.") - else -> TvSettingsHelp("Catalog row", "Rename, reorder, change layout or remove this catalog.") + 0 -> TvSettingsHelp(stringResource(R.string.settings_help_discover_catalog), stringResource(R.string.settings_help_discover_catalog_desc)) + else -> TvSettingsHelp(stringResource(R.string.settings_help_catalog_row), stringResource(R.string.settings_help_catalog_row_desc)) } - "stremio" -> TvSettingsHelp("Addon", "Enable, disable, add or remove third-party addon sources.") + "stremio" -> TvSettingsHelp(stringResource(R.string.settings_help_addon), stringResource(R.string.settings_help_addon_desc)) "accounts" -> when (focusedIndex) { - 0 -> TvSettingsHelp("Cloud account", "Connect or disconnect ARVIO cloud sync.") - 1 -> TvSettingsHelp("Trakt", "Connect or disconnect Trakt watch history and lists.") - 2 -> TvSettingsHelp("Force cloud sync", "Push/pull the latest synced profile data now.") - 3 -> TvSettingsHelp("App updates", "Check for sideload app updates or install a downloaded update.") - else -> TvSettingsHelp("Account data", "Open account and data deletion information.") + 0 -> TvSettingsHelp(stringResource(R.string.cloud_account), stringResource(R.string.settings_help_cloud_account_desc)) + 1 -> TvSettingsHelp(stringResource(R.string.settings_help_trakt), stringResource(R.string.settings_help_trakt_desc)) + 2 -> TvSettingsHelp(stringResource(R.string.force_cloud_sync), stringResource(R.string.settings_help_force_sync_desc)) + 3 -> TvSettingsHelp(stringResource(R.string.settings_help_app_updates), stringResource(R.string.settings_help_app_updates_desc)) + else -> TvSettingsHelp(stringResource(R.string.settings_help_account_data), stringResource(R.string.settings_help_account_data_desc)) } - else -> TvSettingsHelp("Setting", "Use OK to change this option.") + else -> TvSettingsHelp(stringResource(R.string.settings_help_setting), stringResource(R.string.settings_help_setting_desc)) } } @@ -5108,7 +5134,7 @@ private fun GeneralSettings( icon = Icons.Default.Schedule, title = stringResource(R.string.clock_format), subtitle = stringResource(R.string.clock_format_desc), - value = if (clockFormat == "12h") "12-hour" else "24-hour", + value = if (clockFormat == "12h") stringResource(R.string.settings_clock_12h) else stringResource(R.string.settings_clock_24h), isFocused = focusedIndex == 21, onClick = onClockFormatClick, modifier = Modifier.settingsFocusSlot(21) @@ -5617,7 +5643,7 @@ private fun AiKeyQrOverlay( if (qrBitmap != null) { androidx.compose.foundation.Image( bitmap = qrBitmap.asImageBitmap(), - contentDescription = "QR Code", + contentDescription = stringResource(R.string.settings_cd_qr_code), modifier = Modifier.size(220.dp), contentScale = ContentScale.Fit ) @@ -5667,20 +5693,20 @@ private fun HomeServerSettings( if (isMobile) { // Mobile UI: use MobileSettingsCategory/MobileSettingsRow style Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { - MobileSettingsCategory(title = "CONNECTIONS") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_connections)) { MobileSettingsRow( icon = Icons.Default.Cloud, - title = "Add server", - subtitle = "Personal media libraries as sources", - value = if (isWorking) "Working" else if (hasConnections) "Add another" else "", + title = stringResource(R.string.settings_add_server), + subtitle = stringResource(R.string.settings_add_server_subtitle), + value = if (isWorking) stringResource(R.string.settings_working) else if (hasConnections) stringResource(R.string.settings_add_another) else "", isFocused = false, onClick = onConnect ) MobileSettingsRow( icon = Icons.Default.QrCode, - title = "Connect with code", - subtitle = "Sign in with a server code", - value = if (isPlexWorking) "Waiting" else "", + title = stringResource(R.string.settings_connect_with_code), + subtitle = stringResource(R.string.settings_connect_code_subtitle), + value = if (isPlexWorking) stringResource(R.string.settings_waiting) else "", isFocused = false, showDivider = hasConnections, onClick = onConnectPlex @@ -5690,7 +5716,7 @@ private fun HomeServerSettings( val description = listOfNotNull( homeServerKindLabel(connection.serverKind).takeIf { it.isNotBlank() }, connection.userName.takeIf { it.isNotBlank() }, - if (libraries > 0) "$libraries collections" else null + if (libraries > 0) stringResource(R.string.settings_collections_count, libraries) else null ).joinToString(" | ").ifBlank { connection.serverUrl } MobileSettingsRow( icon = Icons.Default.Cloud, @@ -5703,19 +5729,19 @@ private fun HomeServerSettings( ) } } - MobileSettingsCategory(title = "ACTIONS") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_actions)) { MobileSettingsRow( icon = Icons.Default.Settings, - title = "Test connection", - subtitle = if (!hasConnections) "Connect a server first" else "Check that this profile can reach every server", + title = stringResource(R.string.settings_test_connection), + subtitle = if (!hasConnections) stringResource(R.string.settings_connect_server_first) else stringResource(R.string.settings_test_reach_servers), value = "", isFocused = false, onClick = { if (hasConnections) onTest() } ) MobileSettingsRow( icon = Icons.Default.Delete, - title = "Disconnect all", - subtitle = if (!hasConnections) "No server is connected" else "Remove all servers from the active profile", + title = stringResource(R.string.settings_disconnect_all), + subtitle = if (!hasConnections) stringResource(R.string.settings_no_server_connected) else stringResource(R.string.settings_remove_all_servers), value = "", isFocused = false, showDivider = false, @@ -5736,9 +5762,9 @@ private fun HomeServerSettings( Column { SettingsRow( icon = Icons.Default.Cloud, - title = "Add server", - subtitle = "Personal media libraries as sources", - value = if (isWorking) "Working" else if (hasConnections) "Add another" else "Connect", + title = stringResource(R.string.settings_add_server), + subtitle = stringResource(R.string.settings_add_server_subtitle), + value = if (isWorking) stringResource(R.string.settings_working) else if (hasConnections) stringResource(R.string.settings_add_another) else stringResource(R.string.connect), isFocused = focusedIndex == 0, onClick = onConnect, modifier = Modifier.settingsFocusSlot(0) @@ -5747,9 +5773,9 @@ private fun HomeServerSettings( Spacer(modifier = Modifier.height(16.dp)) SettingsActionRow( - title = "Connect with code", - description = "Sign in with a server code and use media libraries as sources", - actionLabel = if (isPlexWorking) "Waiting" else "Code", + title = stringResource(R.string.settings_connect_with_code), + description = stringResource(R.string.settings_connect_code_description), + actionLabel = if (isPlexWorking) stringResource(R.string.settings_waiting) else stringResource(R.string.settings_code), isFocused = focusedIndex == 1, onClick = onConnectPlex, modifier = Modifier.settingsFocusSlot(1) @@ -5761,13 +5787,13 @@ private fun HomeServerSettings( val description = listOfNotNull( homeServerKindLabel(connection.serverKind).takeIf { it.isNotBlank() }, connection.userName.takeIf { it.isNotBlank() }, - if (libraries > 0) "$libraries collections" else null + if (libraries > 0) stringResource(R.string.settings_collections_count, libraries) else null ).joinToString(" | ").ifBlank { connection.serverUrl } SettingsActionRow( title = connection.displayName.ifBlank { connection.serverName }.ifBlank { connection.serverUrl }, description = description, - actionLabel = "Change", + actionLabel = stringResource(R.string.settings_change), isFocused = focusedIndex == index + 2, onClick = { onEditConnection(connection) }, modifier = Modifier.settingsFocusSlot(index + 2) @@ -5780,9 +5806,9 @@ private fun HomeServerSettings( Spacer(modifier = Modifier.height(16.dp)) SettingsActionRow( - title = "Test connection", - description = if (!hasConnections) "Connect a server first" else "Check that this profile can reach every server", - actionLabel = if (isWorking) "Working" else "Test", + title = stringResource(R.string.settings_test_connection), + description = if (!hasConnections) stringResource(R.string.settings_connect_server_first) else stringResource(R.string.settings_test_reach_servers), + actionLabel = if (isWorking) stringResource(R.string.settings_working) else stringResource(R.string.settings_test), isFocused = focusedIndex == testIndex, onClick = { if (hasConnections) onTest() }, modifier = Modifier.settingsFocusSlot(testIndex) @@ -5791,9 +5817,9 @@ private fun HomeServerSettings( Spacer(modifier = Modifier.height(16.dp)) SettingsActionRow( - title = "Disconnect all", - description = if (!hasConnections) "No server is connected" else "Remove all servers from the active profile", - actionLabel = "Remove", + title = stringResource(R.string.settings_disconnect_all), + description = if (!hasConnections) stringResource(R.string.settings_no_server_connected) else stringResource(R.string.settings_remove_all_servers), + actionLabel = stringResource(R.string.settings_remove), isFocused = focusedIndex == disconnectIndex, onClick = { if (hasConnections) onDisconnect() }, modifier = Modifier.settingsFocusSlot(disconnectIndex) @@ -5811,11 +5837,12 @@ private fun HomeServerSettings( } } +@Composable private fun homeServerKindLabel(kind: HomeServerKind): String { return when (kind) { HomeServerKind.JELLYFIN, HomeServerKind.EMBY, - HomeServerKind.PLEX -> "Media Server" + HomeServerKind.PLEX -> stringResource(R.string.settings_media_server) HomeServerKind.UNKNOWN -> "" } } @@ -5854,7 +5881,7 @@ private fun IptvSettings( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - Text("${selectedIndices.size} ${stringResource(R.string.selected)}", style = ArflixTypography.sectionTitle, color = TextPrimary) + Text(stringResource(R.string.settings_n_selected, selectedIndices.size), style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.weight(1f)) if (selectedIndices.isNotEmpty()) { Box(modifier = Modifier.size(36.dp).clickable { selectedIndices.sortedDescending().forEach { onDeletePlaylist(it) }; selectionMode = false; selectedIndices = emptySet() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { @@ -5867,8 +5894,8 @@ private fun IptvSettings( } } } - MobileSettingsCategory(title = "PLAYLISTS") { - MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) "Add up to 3 M3U / Xtream TV lists" else "Create another TV list", value = if (playlists.size >= 3) "Full" else "", isFocused = false, showDivider = playlists.isNotEmpty(), onClick = onConfigure) + MobileSettingsCategory(title = stringResource(R.string.settings_section_playlists)) { + MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_tv_lists_hint) else stringResource(R.string.settings_create_another_tv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full_short) else "", isFocused = false, showDivider = playlists.isNotEmpty(), onClick = onConfigure) playlists.forEachIndexed { index, playlist -> val isSelected = selectedIndices.contains(index) val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } @@ -5895,14 +5922,14 @@ private fun IptvSettings( Text(buildString { append(playlist.m3uUrl.take(56)); when { epgSourceCount > 1 -> append(" • $epgSourceCount EPGs"); epgSourceCount == 1 -> append(" • EPG") } }, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) } if (selectionMode && selectedIndices.size == 1 && isSelected) { - Icon(imageVector = Icons.Default.DragHandle, contentDescription = "Drag to reorder", tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(index) { + Icon(imageVector = Icons.Default.DragHandle, contentDescription = stringResource(R.string.settings_cd_drag_reorder), tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(index) { var dragOffset = 0f; val itemHeight = 64.dp.toPx() detectVerticalDragGestures(onDragEnd = { dragOffset = 0f }, onDragCancel = { dragOffset = 0f }) { change, dragAmount -> change.consume(); dragOffset += dragAmount; if (dragOffset > itemHeight) { onMovePlaylistDown(index); dragOffset -= itemHeight } else if (dragOffset < -itemHeight) { onMovePlaylistUp(index); dragOffset += itemHeight } } }) } else if (!selectionMode) { Icon( imageVector = Icons.Default.List, - contentDescription = "Manage Categories", + contentDescription = stringResource(R.string.settings_cd_manage_categories), tint = TextSecondary, modifier = Modifier .size(36.dp) @@ -5922,13 +5949,13 @@ private fun IptvSettings( } } } - MobileSettingsCategory(title = "ACTIONS") { - val refreshSubtitle = when { isLoading -> "Refreshing channels and EPG..."; error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> "Reload playlists now"; else -> "Reload playlist and EPG now" } - MobileSettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) "Loading" else "", isFocused = false, onClick = onRefresh) - MobileSettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) "No playlists configured" else "Remove playlists, EPG and favorites", value = "", isFocused = false, showDivider = false, onClick = onDelete) + MobileSettingsCategory(title = stringResource(R.string.settings_section_actions)) { + val refreshSubtitle = when { isLoading -> stringResource(R.string.settings_refreshing_channels_epg); error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> stringResource(R.string.settings_reload_playlists_now); else -> stringResource(R.string.settings_reload_playlist_epg_now) } + MobileSettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) stringResource(R.string.loading_label) else "", isFocused = false, onClick = onRefresh) + MobileSettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = "", isFocused = false, showDivider = false, onClick = onDelete) } if (isLoading && !progressText.isNullOrBlank()) { - Text("$progressText (${progressPercent.coerceIn(0, 100)}%)", style = ArflixTypography.caption, color = TextSecondary) + Text(stringResource(R.string.settings_progress_format, progressText, progressPercent.coerceIn(0, 100)), style = ArflixTypography.caption, color = TextSecondary) Box(modifier = Modifier.fillMaxWidth().height(8.dp).background(Color.White.copy(alpha = 0.12f), RoundedCornerShape(999.dp))) { Box(modifier = Modifier.fillMaxHeight().fillMaxWidth(progressPercent.coerceIn(0, 100) / 100f).background(Pink, RoundedCornerShape(999.dp))) } @@ -5937,7 +5964,7 @@ private fun IptvSettings( } else { // TV UI Column { - SettingsRow(icon = Icons.Default.LiveTv, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) "Add up to 3 M3U / Xtream IPTV lists with names" else "Create another IPTV list", value = if (playlists.size >= 3) "FULL" else "ADD", isFocused = focusedIndex == 0, onClick = onConfigure, modifier = Modifier.settingsFocusSlot(0)) + SettingsRow(icon = Icons.Default.LiveTv, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_iptv_lists_hint) else stringResource(R.string.settings_create_another_iptv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onConfigure, modifier = Modifier.settingsFocusSlot(0)) Spacer(modifier = Modifier.height(16.dp)) playlists.forEachIndexed { index, playlist -> val rowIndex = index + 1 @@ -5989,13 +6016,13 @@ private fun IptvSettings( Spacer(modifier = Modifier.height(10.dp)) } Spacer(modifier = Modifier.height(6.dp)) - val refreshSubtitle = when { isLoading -> "Refreshing channels and EPG..."; error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> "Reload playlists now"; else -> "Reload playlist and EPG now" } - SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) "LOADING" else "REFRESH", isFocused = focusedIndex == playlists.size + 1, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 1)) + val refreshSubtitle = when { isLoading -> stringResource(R.string.settings_refreshing_channels_epg); error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> stringResource(R.string.settings_reload_playlists_now); else -> stringResource(R.string.settings_reload_playlist_epg_now) } + SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) stringResource(R.string.settings_badge_loading) else stringResource(R.string.settings_badge_refresh), isFocused = focusedIndex == playlists.size + 1, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 1)) Spacer(modifier = Modifier.height(16.dp)) - SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) "No playlists configured" else "Remove playlists, EPG and favorites", value = if (playlists.isEmpty()) "EMPTY" else "DELETE", isFocused = focusedIndex == playlists.size + 2, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 2)) + SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = if (playlists.isEmpty()) stringResource(R.string.settings_badge_empty) else stringResource(R.string.settings_badge_delete), isFocused = focusedIndex == playlists.size + 2, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 2)) if (isLoading && !progressText.isNullOrBlank()) { Spacer(modifier = Modifier.height(12.dp)) - Text("$progressText (${progressPercent.coerceIn(0, 100)}%)", style = ArflixTypography.caption, color = TextSecondary) + Text(stringResource(R.string.settings_progress_format, progressText, progressPercent.coerceIn(0, 100)), style = ArflixTypography.caption, color = TextSecondary) Spacer(modifier = Modifier.height(6.dp)) Box(modifier = Modifier.fillMaxWidth().height(8.dp).background(Color.White.copy(alpha = 0.12f), RoundedCornerShape(999.dp))) { Box(modifier = Modifier.fillMaxHeight().fillMaxWidth(progressPercent.coerceIn(0, 100) / 100f).background(Pink, RoundedCornerShape(999.dp))) } } @@ -6082,38 +6109,38 @@ private fun CatalogDiscoveryModal( .padding(10.dp) ) { CatalogDiscoveryInputButton( - label = "Search Lists", + label = stringResource(R.string.settings_search_lists), value = query, modifier = Modifier.fillMaxWidth(), - placeholder = "Search Lists", + placeholder = stringResource(R.string.settings_search_lists), onClick = { editingInput = CatalogDiscoveryInputTarget.Search } ) Spacer(modifier = Modifier.height(8.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { DiscoveryActionButton( - label = if (isSearching) "Searching" else "Search", + label = if (isSearching) stringResource(R.string.settings_searching) else stringResource(R.string.search), onClick = { submitSearch() }, highlighted = true, enabled = !isSearching && query.isNotBlank(), modifier = Modifier.weight(1f) ) DiscoveryActionButton( - label = "Close", + label = stringResource(R.string.close), onClick = onDismiss, modifier = Modifier.weight(1f) ) } Spacer(modifier = Modifier.height(8.dp)) CatalogDiscoveryInputButton( - label = "Paste URL", + label = stringResource(R.string.settings_paste_url), value = manualUrl, modifier = Modifier.fillMaxWidth(), - placeholder = "Trakt or MDBList URL", + placeholder = stringResource(R.string.settings_ph_trakt_mdblist), onClick = { editingInput = CatalogDiscoveryInputTarget.ManualUrl } ) Spacer(modifier = Modifier.height(8.dp)) DiscoveryActionButton( - label = "Add URL", + label = stringResource(R.string.settings_add_url), onClick = onManualAdd, enabled = manualUrl.isNotBlank(), modifier = Modifier.fillMaxWidth() @@ -6130,36 +6157,36 @@ private fun CatalogDiscoveryModal( verticalAlignment = Alignment.CenterVertically ) { CatalogDiscoveryInputButton( - label = "Search Lists", + label = stringResource(R.string.settings_search_lists), value = query, modifier = Modifier.weight(1f), - placeholder = "Search Lists", + placeholder = stringResource(R.string.settings_search_lists), onClick = { editingInput = CatalogDiscoveryInputTarget.Search } ) Spacer(modifier = Modifier.width(10.dp)) DiscoveryActionButton( - label = if (isSearching) "Searching" else "Search", + label = if (isSearching) stringResource(R.string.settings_searching) else stringResource(R.string.search), onClick = { submitSearch() }, highlighted = true, enabled = !isSearching && query.isNotBlank() ) Spacer(modifier = Modifier.width(10.dp)) CatalogDiscoveryInputButton( - label = "Paste URL", + label = stringResource(R.string.settings_paste_url), value = manualUrl, modifier = Modifier.weight(1f), - placeholder = "Trakt or MDBList URL", + placeholder = stringResource(R.string.settings_ph_trakt_mdblist), onClick = { editingInput = CatalogDiscoveryInputTarget.ManualUrl } ) Spacer(modifier = Modifier.width(10.dp)) DiscoveryActionButton( - label = "Add URL", + label = stringResource(R.string.settings_add_url), onClick = onManualAdd, enabled = manualUrl.isNotBlank() ) Spacer(modifier = Modifier.width(10.dp)) DiscoveryActionButton( - label = "Close", + label = stringResource(R.string.close), onClick = onDismiss ) } @@ -6171,15 +6198,15 @@ private fun CatalogDiscoveryModal( Column(modifier = Modifier.fillMaxWidth()) { Text( text = when { - isSearching -> "Searching both sources" - results.isNotEmpty() -> "${results.size} lists found" - else -> "Searches Trakt and MDBList automatically" + isSearching -> stringResource(R.string.settings_searching_both_sources) + results.isNotEmpty() -> stringResource(R.string.settings_lists_found, results.size) + else -> stringResource(R.string.settings_searches_trakt_mdblist) }, style = ArflixTypography.caption, color = TextSecondary ) Text( - text = "Tap a field to edit", + text = stringResource(R.string.settings_tap_field_to_edit), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.75f) ) @@ -6191,16 +6218,16 @@ private fun CatalogDiscoveryModal( ) { Text( text = when { - isSearching -> "Searching both sources" - results.isNotEmpty() -> "${results.size} lists found" - else -> "Searches Trakt and MDBList automatically" + isSearching -> stringResource(R.string.settings_searching_both_sources) + results.isNotEmpty() -> stringResource(R.string.settings_lists_found, results.size) + else -> stringResource(R.string.settings_searches_trakt_mdblist) }, style = ArflixTypography.caption, color = TextSecondary ) Spacer(modifier = Modifier.weight(1f)) Text( - text = "Press Select on a field to edit", + text = stringResource(R.string.settings_press_select_to_edit), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.75f) ) @@ -6226,7 +6253,7 @@ private fun CatalogDiscoveryModal( ) { LoadingIndicator(size = 30.dp) Spacer(modifier = Modifier.height(10.dp)) - Text("Searching lists", color = TextSecondary, style = ArflixTypography.body) + Text(stringResource(R.string.settings_searching_lists), color = TextSecondary, style = ArflixTypography.body) } } error != null -> { @@ -6243,13 +6270,13 @@ private fun CatalogDiscoveryModal( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Start with a search", + text = stringResource(R.string.settings_start_with_search), color = TextPrimary, style = ArflixTypography.bodyLarge ) Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Try top trending anime, best horror, Marvel chronological, or 2025 movies.", + text = stringResource(R.string.settings_search_examples), color = TextSecondary, style = ArflixTypography.body, textAlign = TextAlign.Center, @@ -6283,10 +6310,10 @@ private fun CatalogDiscoveryModal( editingInput?.let { target -> CatalogDiscoveryTextInputDialog( - title = if (target == CatalogDiscoveryInputTarget.Search) "Search Lists" else "Paste catalog URL", + title = if (target == CatalogDiscoveryInputTarget.Search) stringResource(R.string.settings_search_lists) else stringResource(R.string.settings_paste_catalog_url), initialValue = if (target == CatalogDiscoveryInputTarget.Search) query else manualUrl, - placeholder = if (target == CatalogDiscoveryInputTarget.Search) "Search Lists" else "https://trakt.tv/users/...", - confirmLabel = if (target == CatalogDiscoveryInputTarget.Search) "Use Search" else "Use URL", + placeholder = if (target == CatalogDiscoveryInputTarget.Search) stringResource(R.string.settings_search_lists) else "https://trakt.tv/users/...", + confirmLabel = if (target == CatalogDiscoveryInputTarget.Search) stringResource(R.string.settings_use_search) else stringResource(R.string.settings_use_url), onConfirm = { value -> if (target == CatalogDiscoveryInputTarget.Search) { onQueryChange(value) @@ -6413,7 +6440,7 @@ private fun CatalogDiscoveryTextInputDialog( modifier = Modifier.fillMaxWidth() ) DiscoveryActionButton( - label = "Cancel", + label = stringResource(R.string.cancel), onClick = onDismiss, modifier = Modifier.fillMaxWidth() ) @@ -6423,7 +6450,7 @@ private fun CatalogDiscoveryTextInputDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { - DiscoveryActionButton(label = "Cancel", onClick = onDismiss) + DiscoveryActionButton(label = stringResource(R.string.cancel), onClick = onDismiss) Spacer(modifier = Modifier.width(10.dp)) DiscoveryActionButton( label = confirmLabel, @@ -6449,10 +6476,10 @@ private fun CatalogDiscoveryResultRow( var isFocused by remember { mutableStateOf(false) } val compactFocusColor = resolveAccentColor(fallback = Color.White) val creator = result.creatorName ?: result.creatorHandle - val creatorMeta = creator?.let { "by $it" } - val itemCountMeta = result.itemCount?.let { "$it items" } - val likesMeta = result.likes?.let { "$it likes" } ?: "0 likes" - val updatedMeta = result.updatedAt?.let { "Updated ${formatCatalogDiscoveryDate(it)}" } + val creatorMeta = creator?.let { stringResource(R.string.settings_by, it) } + val itemCountMeta = result.itemCount?.let { stringResource(R.string.settings_items_count, it.toString()) } + val likesMeta = result.likes?.let { stringResource(R.string.settings_likes_count, it.toString()) } ?: stringResource(R.string.settings_likes_count, "0") + val updatedMeta = result.updatedAt?.let { stringResource(R.string.settings_updated_meta, formatCatalogDiscoveryDate(it)) } if (compact) { Column( @@ -6740,7 +6767,7 @@ private fun CatalogDiscoveryAddPill( Spacer(modifier = Modifier.width(6.dp)) } Text( - text = if (isAdded) "Added" else "Add", + text = if (isAdded) stringResource(R.string.settings_added) else stringResource(R.string.add), style = ArflixTypography.button, color = TextPrimary, maxLines = 1, @@ -6847,13 +6874,14 @@ private fun normalizeCatalogDiscoveryUrl(url: String): String { return url.trim().trimEnd('/').lowercase() } +@Composable private fun sourceLabel(sourceType: CatalogSourceType): String { return when (sourceType) { CatalogSourceType.TRAKT -> "Trakt" CatalogSourceType.MDBLIST -> "MDBList" - CatalogSourceType.PREINSTALLED -> "Built-in" - CatalogSourceType.ADDON -> "Addon" - CatalogSourceType.HOME_SERVER -> "Home Server" + CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_source_builtin) + CatalogSourceType.ADDON -> stringResource(R.string.settings_source_addon) + CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_home_server) } } @@ -6883,7 +6911,7 @@ private fun CatalogsSettings( Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { if (selectionMode) { Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Text("${selectedIds.size} ${stringResource(R.string.selected)}", style = ArflixTypography.sectionTitle, color = TextPrimary) + Text(stringResource(R.string.settings_n_selected, selectedIds.size), style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.weight(1f)) if (selectedIds.isNotEmpty()) { Box(modifier = Modifier.size(36.dp).clickable { selectedIds.forEach { id -> val cat = catalogs.find { it.id == id }; if (cat != null) onDeleteCatalog(cat) }; selectionMode = false; selectedIds = emptySet() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { @@ -6896,14 +6924,16 @@ private fun CatalogsSettings( } } } - MobileSettingsCategory(title = "ADD CATALOG") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_add_catalog)) { MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = "", isFocused = false, showDivider = false, onClick = onAddCatalog) } if (catalogs.isNotEmpty()) { - MobileSettingsCategory(title = "MY CATALOGS") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_my_catalogs)) { catalogs.forEachIndexed { index, catalog -> - val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> "${catalog.title} (Built-in Collection)"; CatalogKind.COLLECTION_RAIL -> "${catalog.title} (Built-in Rail)"; else -> "${catalog.title} (Built-in)" } } else catalog.title - val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: "Collection"; "$group rail" }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: "Collection"; "$group collection" }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> "Preinstalled catalog"; else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: "Addon"; "From $addonLabel" }; CatalogSourceType.HOME_SERVER -> "From Home Server"; else -> catalog.sourceUrl ?: "Custom catalog" } } + val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title + val collectionFallback = stringResource(R.string.settings_collection_fallback) + val addonFallback = stringResource(R.string.settings_source_addon) + val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_rail, group) }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_collection, group) }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog); else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback; stringResource(R.string.settings_from_source, addonLabel) }; CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server); else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } @@ -6934,7 +6964,7 @@ private fun CatalogsSettings( Spacer(modifier = Modifier.width(8.dp)) } if (selectionMode && selectedIds.size == 1 && isSelected) { - Icon(imageVector = Icons.Default.DragHandle, contentDescription = "Drag to reorder", tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(catalog.id) { + Icon(imageVector = Icons.Default.DragHandle, contentDescription = stringResource(R.string.settings_cd_drag_reorder), tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(catalog.id) { var dragOffset = 0f; val itemHeight = 64.dp.toPx() detectVerticalDragGestures(onDragEnd = { dragOffset = 0f }, onDragCancel = { dragOffset = 0f }) { change, dragAmount -> change.consume(); dragOffset += dragAmount; if (dragOffset > itemHeight) { onMoveCatalogDown(catalog); dragOffset -= itemHeight } else if (dragOffset < -itemHeight) { onMoveCatalogUp(catalog); dragOffset += itemHeight } } }) @@ -6953,19 +6983,21 @@ private fun CatalogsSettings( Column { if (selectionMode) { Row(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) { - Text("${selectedIds.size} ${stringResource(R.string.selected)}", style = ArflixTypography.sectionTitle, color = TextPrimary) + Text(stringResource(R.string.settings_n_selected, selectedIds.size), style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.weight(1f)) if (selectedIds.isNotEmpty()) { Box(modifier = Modifier.size(36.dp).clickable { selectedIds.forEach { id -> val cat = catalogs.find { it.id == id }; if (cat != null) onDeleteCatalog(cat) }; selectionMode = false; selectedIds = emptySet() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete), tint = Color.White, modifier = Modifier.size(20.dp)) }; Spacer(modifier = Modifier.width(12.dp)) } Box(modifier = Modifier.size(36.dp).clickable { selectionMode = false; selectedIds = emptySet() }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close), tint = Color.White, modifier = Modifier.size(20.dp)) } } } Text(text = stringResource(R.string.catalogs), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.65f), modifier = Modifier.padding(bottom = 20.dp)) - SettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = "ADD", isFocused = focusedIndex == 0, onClick = onAddCatalog, modifier = Modifier.settingsFocusSlot(0)) + SettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onAddCatalog, modifier = Modifier.settingsFocusSlot(0)) Spacer(modifier = Modifier.height(16.dp)) catalogs.forEachIndexed { index, catalog -> val rowFocusIndex = index + 1; val isRowFocused = focusedIndex == rowFocusIndex - val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> "${catalog.title} (Built-in Collection)"; CatalogKind.COLLECTION_RAIL -> "${catalog.title} (Built-in Rail)"; else -> "${catalog.title} (Built-in)" } } else catalog.title - val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: "Collection"; "$group rail" }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: "Collection"; "$group collection" }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> "Preinstalled catalog"; else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: "Addon"; "From $addonLabel" }; CatalogSourceType.HOME_SERVER -> "From Home Server"; else -> catalog.sourceUrl ?: "Custom catalog" } } + val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title + val collectionFallback = stringResource(R.string.settings_collection_fallback) + val addonFallback = stringResource(R.string.settings_source_addon) + val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_rail, group) }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_collection, group) }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog); else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback; stringResource(R.string.settings_from_source, addonLabel) }; CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server); else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } @@ -7080,12 +7112,12 @@ private fun StremioAddonsSettings( if (isMobile) { Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { - MobileSettingsCategory(title = "ADD ADDON") { - MobileSettingsRow(icon = Icons.Default.Add, title = "Add Addon", subtitle = "Install a custom Stremio addon by URL", value = "", isFocused = false, showDivider = false, onClick = onAddCustomAddon) + MobileSettingsCategory(title = stringResource(R.string.settings_section_add_addon)) { + MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_addon), subtitle = stringResource(R.string.settings_install_custom_addon), value = "", isFocused = false, showDivider = false, onClick = onAddCustomAddon) } - MobileSettingsCategory(title = "MY ADDONS") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_my_addons)) { if (addons.isEmpty()) { - MobileSettingsRow(icon = Icons.Default.Extension, title = "No addons installed", value = "", isFocused = false, showDivider = false, onClick = {}) + MobileSettingsRow(icon = Icons.Default.Extension, title = stringResource(R.string.settings_no_addons_installed), value = "", isFocused = false, showDivider = false, onClick = {}) } else { addons.forEachIndexed { index, addon -> val canDelete = !(addon.id == "opensubtitles" && addon.type == com.arflix.tv.data.model.AddonType.SUBTITLE) @@ -7116,7 +7148,7 @@ private fun StremioAddonsSettings( ) { Icon( Icons.Default.ArrowUpward, - contentDescription = "Move addon up", + contentDescription = stringResource(R.string.settings_cd_move_addon_up), tint = TextSecondary.copy(alpha = if (canMoveUp) 1f else 0.35f), modifier = Modifier.size(18.dp) ) @@ -7131,7 +7163,7 @@ private fun StremioAddonsSettings( ) { Icon( Icons.Default.ArrowDownward, - contentDescription = "Move addon down", + contentDescription = stringResource(R.string.settings_cd_move_addon_down), tint = TextSecondary.copy(alpha = if (canMoveDown) 1f else 0.35f), modifier = Modifier.size(18.dp) ) @@ -7139,7 +7171,7 @@ private fun StremioAddonsSettings( if (canDelete) { Spacer(modifier = Modifier.width(12.dp)) Box(modifier = Modifier.size(32.dp).clickable { onDeleteAddon(addon.id) }.background(Color.White.copy(alpha = 0.1f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.Delete, contentDescription = "Delete addon", tint = TextSecondary, modifier = Modifier.size(18.dp)) + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.settings_cd_delete_addon), tint = TextSecondary, modifier = Modifier.size(18.dp)) } } } @@ -7153,9 +7185,9 @@ private fun StremioAddonsSettings( } else { // TV UI Column { - Text("STREMIO ADDONS", style = ArflixTypography.caption.copy(fontSize = 12.sp, letterSpacing = 1.sp), color = TextSecondary, modifier = Modifier.padding(bottom = 10.dp)) + Text(stringResource(R.string.settings_section_stremio_addons), style = ArflixTypography.caption.copy(fontSize = 12.sp, letterSpacing = 1.sp), color = TextSecondary, modifier = Modifier.padding(bottom = 10.dp)) if (addons.isEmpty()) { - Text("No addons installed", style = ArflixTypography.body, color = TextSecondary) + Text(stringResource(R.string.settings_no_addons_installed), style = ArflixTypography.body, color = TextSecondary) } else { addons.forEachIndexed { index, addon -> val canDelete = !(addon.id == "opensubtitles" && addon.type == com.arflix.tv.data.model.AddonType.SUBTITLE) @@ -7177,7 +7209,7 @@ private fun StremioAddonsSettings( Row(modifier = Modifier.settingsFocusSlot(addons.size).fillMaxWidth().clickable(onClick = onAddCustomAddon).background(if (focusedIndex == addons.size) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == addons.size) 2.dp else 0.dp, color = if (focusedIndex == addons.size) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { Icon(Icons.Default.Widgets, contentDescription = null, tint = Pink, modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(12.dp)) - Text("Add Addon", style = ArflixTypography.button, color = Pink) + Text(stringResource(R.string.add_addon), style = ArflixTypography.button, color = Pink) } } } @@ -7278,12 +7310,12 @@ private fun AddonRow( Spacer(modifier = Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { AddonStatusChip( - text = "Installed", + text = stringResource(R.string.settings_status_installed), background = Color(0xFF2563EB).copy(alpha = 0.18f), textColor = Color(0xFF93C5FD) ) AddonStatusChip( - text = if (isEnabled) "Enabled" else "Disabled", + text = if (isEnabled) stringResource(R.string.settings_enabled) else stringResource(R.string.settings_disabled), background = if (isEnabled) SuccessGreen.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.08f), textColor = if (isEnabled) SuccessGreen else TextSecondary ) @@ -7390,7 +7422,7 @@ private fun AccountsSettings( AccountRow( name = "ARVIO Cloud", - description = cloudEmail ?: "Optional account for syncing profiles, addons, catalogs and IPTV settings", + description = cloudEmail ?: stringResource(R.string.settings_cloud_account_desc), isConnected = isCloudAuthenticated, isWorking = false, authCode = null, @@ -7409,7 +7441,7 @@ private fun AccountsSettings( // Trakt.tv AccountRow( name = "Trakt.tv", - description = "Sync watch history, progress, and watchlist", + description = stringResource(R.string.settings_trakt_desc), isConnected = isTraktAuthenticated, isWorking = isTraktAuthStarting || isTraktPolling, authCode = traktCode, @@ -7426,8 +7458,8 @@ private fun AccountsSettings( // Telegram SettingsActionRow( title = "Telegram", - description = "Search your channels and groups for video files", - actionLabel = "OPEN", + description = stringResource(R.string.settings_telegram_desc), + actionLabel = stringResource(R.string.settings_badge_open), isFocused = focusedIndex == 2, onClick = onNavigateToTelegram, modifier = Modifier.settingsFocusSlot(2) @@ -7438,15 +7470,15 @@ private fun AccountsSettings( SettingsActionRow( title = stringResource(R.string.force_cloud_sync), description = if (isForceCloudSyncing) { - "Syncing local and cloud state now" + stringResource(R.string.settings_sync_local_cloud_now) } else if (!lastCloudSyncStatus.isNullOrBlank()) { lastCloudSyncStatus } else if (isCloudAuthenticated) { - "Upload local state, then restore from cloud now" + stringResource(R.string.settings_upload_restore_now) } else { - "Sign in to ARVIO Cloud to force sync" + stringResource(R.string.settings_signin_to_force_sync) }, - actionLabel = if (isForceCloudSyncing) "SYNCING" else "SYNC", + actionLabel = if (isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), isFocused = focusedIndex == 3, onClick = { if (!isForceCloudSyncing) onForceCloudSync() }, modifier = Modifier.settingsFocusSlot(3) @@ -7457,19 +7489,19 @@ private fun AccountsSettings( SettingsActionRow( title = stringResource(R.string.app_update), description = when { - !isSelfUpdateSupported -> "This install is managed by the Play Store" - updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall -> "Latest update downloaded and ready to install" - updateStatus is com.arflix.tv.updater.UpdateStatus.Checking -> "Checking GitHub Releases for a newer APK" - updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> "Update available: ${updateStatus.update.title.ifBlank { updateStatus.update.tag }}" - updateStatus is com.arflix.tv.updater.UpdateStatus.Success -> "You already have the latest ARVIO version" - else -> "Check GitHub Releases for the latest ARVIO APK" + !isSelfUpdateSupported -> stringResource(R.string.settings_update_managed_play) + updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall -> stringResource(R.string.settings_update_ready_install) + updateStatus is com.arflix.tv.updater.UpdateStatus.Checking -> stringResource(R.string.settings_update_checking) + updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_update_available_format, updateStatus.update.title.ifBlank { updateStatus.update.tag }) + updateStatus is com.arflix.tv.updater.UpdateStatus.Success -> stringResource(R.string.settings_update_latest) + else -> stringResource(R.string.settings_update_check_releases) }, actionLabel = when { - !isSelfUpdateSupported -> "PLAY" - updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall -> "INSTALL" - updateStatus is com.arflix.tv.updater.UpdateStatus.Checking -> "CHECKING" - updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> "UPDATE" - else -> "CHECK" + !isSelfUpdateSupported -> stringResource(R.string.settings_badge_play) + updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall -> stringResource(R.string.settings_badge_install) + updateStatus is com.arflix.tv.updater.UpdateStatus.Checking -> stringResource(R.string.settings_badge_checking) + updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_badge_update) + else -> stringResource(R.string.settings_badge_check) }, isFocused = focusedIndex == 4, onClick = { @@ -7481,9 +7513,9 @@ private fun AccountsSettings( Spacer(modifier = Modifier.height(16.dp)) SettingsActionRow( - title = "Privacy and data deletion", - description = "Open privacy and ARVIO Cloud account deletion instructions", - actionLabel = "OPEN", + title = stringResource(R.string.settings_privacy_data_deletion), + description = stringResource(R.string.settings_privacy_data_deletion_desc), + actionLabel = stringResource(R.string.settings_badge_open), isFocused = focusedIndex == 5, onClick = onOpenDataDeletion, modifier = Modifier.settingsFocusSlot(5) @@ -7739,7 +7771,7 @@ private fun AccountRow( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Go to: $authUrl", + text = stringResource(R.string.settings_go_to, authUrl), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.9f) ) @@ -7940,7 +7972,7 @@ private fun InputModalLegacy( singleLine = true, placeholder = { Text( - text = "Enter ${field.label.lowercase()}...", + text = stringResource(R.string.settings_enter_field, field.label.lowercase()), color = TextSecondary.copy(alpha = 0.5f) ) }, @@ -7993,13 +8025,13 @@ private fun InputModalLegacy( ) { Icon( imageVector = Icons.Default.ContentPaste, - contentDescription = "Paste", + contentDescription = stringResource(R.string.settings_cd_paste), tint = if (isPasteFocused) Pink else TextSecondary, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Paste from Clipboard", + text = stringResource(R.string.settings_paste_from_clipboard), style = ArflixTypography.button, color = if (isPasteFocused) Pink else TextSecondary ) @@ -8064,7 +8096,7 @@ private fun InputModalLegacy( // Hint text Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Press Enter to select • Navigate with D-pad", + text = stringResource(R.string.settings_hint_enter_dpad), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -8274,9 +8306,9 @@ private fun InputModal( ) Text( text = if (LocalDeviceType.current.isTouchDevice()) { - "Tap a field to edit, then tap Confirm" + stringResource(R.string.settings_modal_hint_touch) } else { - "Use D-pad to move, press OK to edit a field" + stringResource(R.string.settings_modal_hint_tv) }, style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.75f), @@ -8474,12 +8506,13 @@ private fun InputModal( Spacer(modifier = Modifier.height(12.dp)) val isPasteFocused = focusedIndex == fields.size + val fieldFallbackLabel = stringResource(R.string.settings_field_fallback) val pasteTargetLabel = fields.getOrNull(pasteTargetIndex()) ?.label ?.substringBefore("(") ?.trim() - ?.ifBlank { "field" } - ?: "field" + ?.ifBlank { fieldFallbackLabel } + ?: fieldFallbackLabel Row( modifier = Modifier .fillMaxWidth() @@ -8502,13 +8535,13 @@ private fun InputModal( ) { Icon( imageVector = Icons.Default.ContentPaste, - contentDescription = "Paste", + contentDescription = stringResource(R.string.settings_cd_paste), tint = if (isPasteFocused) Color.Black else Color.White, modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Paste into $pasteTargetLabel", + text = stringResource(R.string.settings_paste_into, pasteTargetLabel), style = ArflixTypography.button, color = if (isPasteFocused) Color.Black else Color.White, maxLines = 1, @@ -8581,7 +8614,7 @@ private fun InputModal( Spacer(modifier = Modifier.height(10.dp)) Text( - text = if (LocalDeviceType.current.isTouchDevice()) "Tap a field to edit, tap Confirm when done" else "OK: edit/select \u2022 Back: close keyboard first", + text = if (LocalDeviceType.current.isTouchDevice()) stringResource(R.string.settings_modal_footer_touch) else stringResource(R.string.settings_modal_footer_tv), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.56f) ) @@ -8713,7 +8746,7 @@ private fun SubtitlePickerModal( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Press Enter to select", + text = stringResource(R.string.settings_press_enter_to_select), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.6f), textAlign = TextAlign.Center, @@ -8782,7 +8815,7 @@ private fun UiModeWarningDialog( } ) { Text( - text = "Change UI Mode", + text = stringResource(R.string.settings_change_ui_mode), style = ArflixTypography.sectionTitle, color = TextPrimary ) @@ -8790,14 +8823,14 @@ private fun UiModeWarningDialog( Spacer(modifier = Modifier.height(16.dp)) val modeString = when (nextMode) { - "tv" -> "TV" - "tablet" -> "Tablet" - "phone" -> "Phone" - else -> "Auto" + "tv" -> stringResource(R.string.settings_mode_tv) + "tablet" -> stringResource(R.string.settings_mode_tablet) + "phone" -> stringResource(R.string.settings_mode_phone) + else -> stringResource(R.string.auto) } Text( - text = "Are you sure you want to change the UI mode to $modeString?", + text = stringResource(R.string.settings_change_ui_mode_confirm, modeString), style = ArflixTypography.body, color = TextSecondary ) @@ -8948,7 +8981,7 @@ private fun IptvCategoriesSettings( Column { if (!isMobile) { Text( - text = "IPTV CATEGORIES", + text = stringResource(R.string.settings_iptv_categories), style = ArflixTypography.sectionTitle, color = TextPrimary, modifier = Modifier.padding(bottom = 12.dp) @@ -8957,9 +8990,9 @@ private fun IptvCategoriesSettings( SettingsRow( icon = Icons.Default.Refresh, - title = "Reset Order", - subtitle = "Restore default category order", - value = "RESET", + title = stringResource(R.string.settings_reset_order), + subtitle = stringResource(R.string.settings_reset_order_desc), + value = stringResource(R.string.settings_badge_reset), isFocused = focusedIndex == 0, onClick = onReset, modifier = Modifier.settingsFocusSlot(0) @@ -8968,10 +9001,10 @@ private fun IptvCategoriesSettings( Spacer(modifier = Modifier.height(16.dp)) if (isMobile) { - MobileSettingsCategory(title = "CATEGORIES") { + MobileSettingsCategory(title = stringResource(R.string.settings_section_categories)) { if (orderedGroups.isEmpty()) { Text( - text = "No categories available", + text = stringResource(R.string.settings_no_categories_available), style = ArflixTypography.body, color = TextSecondary, modifier = Modifier.padding(16.dp) @@ -8983,7 +9016,7 @@ private fun IptvCategoriesSettings( MobileSettingsRow( icon = if (isHidden) Icons.Default.VisibilityOff else Icons.Default.Check, title = group, - subtitle = if (isHidden) "Hidden" else "Visible", + subtitle = if (isHidden) stringResource(R.string.settings_hidden) else stringResource(R.string.settings_visible), value = "", onClick = { onToggleHidden(group) }, showDivider = index < orderedGroups.lastIndex @@ -8994,7 +9027,7 @@ private fun IptvCategoriesSettings( } else { if (orderedGroups.isEmpty()) { Text( - text = "No categories available", + text = stringResource(R.string.settings_no_categories_available), style = ArflixTypography.body, color = TextSecondary, modifier = Modifier.padding(16.dp) @@ -9040,7 +9073,7 @@ private fun IptvCategoriesSettings( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = if (isHidden) "Hidden" else "Visible", + text = if (isHidden) stringResource(R.string.settings_hidden) else stringResource(R.string.settings_visible), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.7f) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 07a62a7af..014b381ce 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.arflix.tv.R import com.arflix.tv.server.AiKeyConfigServer import com.arflix.tv.ui.screens.player.SubtitleAiModel import com.arflix.tv.util.DeviceIpAddress @@ -741,7 +742,7 @@ class SettingsViewModel @Inject constructor( is SyncResult.Error -> { if (!silent) { _uiState.value = _uiState.value.copy( - toastMessage = "Sync failed: ${result.message}", + toastMessage = context.getString(R.string.sync_failed, result.message), toastType = ToastType.ERROR ) } @@ -771,7 +772,7 @@ class SettingsViewModel @Inject constructor( } is SyncResult.Error -> { _uiState.value = _uiState.value.copy( - toastMessage = "Sync failed: ${result.message}", + toastMessage = context.getString(R.string.sync_failed, result.message), toastType = ToastType.ERROR ) } @@ -1582,7 +1583,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message?.takeIf { it.isNotBlank() } ?: "Failed to add addon", + toastMessage = error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.addon_failed_add), toastType = ToastType.ERROR ) } @@ -1719,7 +1720,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to add catalog", + toastMessage = error.message ?: context.getString(R.string.catalog_failed_add), toastType = ToastType.ERROR ) } @@ -1761,7 +1762,7 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( catalogSearchResults = emptyList(), isCatalogSearching = false, - catalogSearchError = error.message ?: "Failed to search catalogs" + catalogSearchError = error.message ?: context.getString(R.string.catalog_failed_search) ) } } @@ -1789,7 +1790,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to add catalog", + toastMessage = error.message ?: context.getString(R.string.catalog_failed_add), toastType = ToastType.ERROR ) } @@ -1807,7 +1808,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to update catalog", + toastMessage = error.message ?: context.getString(R.string.catalog_failed_update), toastType = ToastType.ERROR ) } @@ -1828,7 +1829,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to remove catalog", + toastMessage = error.message ?: context.getString(R.string.catalog_failed_remove), toastType = ToastType.ERROR ) } @@ -2001,7 +2002,7 @@ class SettingsViewModel @Inject constructor( iptvError = null, iptvStatusMessage = doneMsg, iptvStatusType = if (snapshot.epgWarning != null) ToastType.INFO else ToastType.SUCCESS, - iptvProgressText = "Done", + iptvProgressText = context.getString(R.string.done), iptvProgressPercent = 100, toastMessage = if (showToast) { if (configured) "IPTV configured (${snapshot.channels.size} channels)" else "IPTV refreshed (${snapshot.channels.size} channels)" @@ -2091,7 +2092,7 @@ class SettingsViewModel @Inject constructor( clearCloudAuthSession() _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = error.message ?: "Failed to start cloud login", + toastMessage = error.message ?: context.getString(R.string.cloud_login_failed_start), toastType = ToastType.ERROR ) } @@ -2130,7 +2131,7 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( showCloudEmailPasswordDialog = false, isCloudAuthWorking = false, - toastMessage = error.message ?: "Failed to start cloud sign-in", + toastMessage = error.message ?: context.getString(R.string.cloud_signin_failed_start), toastType = ToastType.ERROR ) } @@ -2147,7 +2148,8 @@ class SettingsViewModel @Inject constructor( createAccount: Boolean ) { val trimmedEmail = AuthEmailValidator.normalize(email) - AuthEmailValidator.validate(trimmedEmail, rejectDisposable = createAccount)?.let { message -> + AuthEmailValidator.validate(trimmedEmail, rejectDisposable = createAccount)?.let { messageRes -> + val message = context.getString(messageRes) _uiState.value = _uiState.value.copy( toastMessage = message, toastType = ToastType.ERROR @@ -2168,7 +2170,7 @@ class SettingsViewModel @Inject constructor( if (sessionReady.isFailure) { clearCloudAuthSession() _uiState.value = _uiState.value.copy( - toastMessage = sessionReady.exceptionOrNull()?.message ?: "Cloud sign-in could not start. Try again.", + toastMessage = sessionReady.exceptionOrNull()?.message ?: context.getString(R.string.cloud_signin_could_not_start), toastType = ToastType.ERROR, isCloudAuthWorking = false ) @@ -2201,7 +2203,7 @@ class SettingsViewModel @Inject constructor( startCloudPolling() }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to link TV", + toastMessage = error.message ?: context.getString(R.string.tv_link_failed), toastType = ToastType.ERROR, isCloudAuthWorking = false ) @@ -2233,7 +2235,7 @@ class SettingsViewModel @Inject constructor( if (access.isNullOrBlank() || refresh.isNullOrBlank()) { _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = status.message ?: "Approved, but tokens were missing. Try again.", + toastMessage = status.message ?: context.getString(R.string.tv_link_approved_no_tokens), toastType = ToastType.ERROR ) return@launch @@ -2285,7 +2287,7 @@ class SettingsViewModel @Inject constructor( } else { _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = tokenImport.exceptionOrNull()?.message ?: "Failed to import session tokens", + toastMessage = tokenImport.exceptionOrNull()?.message ?: context.getString(R.string.cloud_failed_import_tokens), toastType = ToastType.ERROR ) return@launch @@ -2298,7 +2300,7 @@ class SettingsViewModel @Inject constructor( showCloudEmailPasswordDialog = false, cloudUserCode = null, cloudVerificationUrl = null, - toastMessage = status.message ?: "Cloud sign-in expired. Try again.", + toastMessage = status.message ?: context.getString(R.string.cloud_signin_expired), toastType = ToastType.ERROR ) clearCloudAuthSession(cancelPolling = false) @@ -2307,7 +2309,7 @@ class SettingsViewModel @Inject constructor( TvDeviceAuthStatusType.ERROR -> { _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = status.message ?: "Cloud sign-in failed. Try again.", + toastMessage = status.message ?: context.getString(R.string.cloud_signin_failed), toastType = ToastType.ERROR ) return@launch @@ -2395,8 +2397,8 @@ class SettingsViewModel @Inject constructor( }.onFailure { error -> _uiState.value = _uiState.value.copy( isHomeServerConnecting = false, - homeServerError = error.message ?: "Home Server connection failed", - toastMessage = error.message ?: "Home Server connection failed", + homeServerError = error.message ?: context.getString(R.string.homeserver_connection_failed), + toastMessage = error.message ?: context.getString(R.string.homeserver_connection_failed), toastType = ToastType.ERROR ) } @@ -2437,8 +2439,8 @@ class SettingsViewModel @Inject constructor( isHomeServerConnecting = false, plexHomeServerAuth = null, isPlexHomeServerPolling = false, - homeServerError = error.message ?: "Code sign in failed", - toastMessage = error.message ?: "Code sign in failed", + homeServerError = error.message ?: context.getString(R.string.homeserver_code_signin_failed), + toastMessage = error.message ?: context.getString(R.string.homeserver_code_signin_failed), toastType = ToastType.ERROR ) } @@ -2495,8 +2497,8 @@ class SettingsViewModel @Inject constructor( isHomeServerConnecting = false, plexHomeServerAuth = null, isPlexHomeServerPolling = false, - homeServerError = error.message ?: "Server connection failed", - toastMessage = error.message ?: "Server connection failed", + homeServerError = error.message ?: context.getString(R.string.homeserver_server_connection_failed), + toastMessage = error.message ?: context.getString(R.string.homeserver_server_connection_failed), toastType = ToastType.ERROR ) return@launch @@ -2552,8 +2554,8 @@ class SettingsViewModel @Inject constructor( }.onFailure { error -> _uiState.value = _uiState.value.copy( isHomeServerConnecting = false, - homeServerError = error.message ?: "Home Server test failed", - toastMessage = error.message ?: "Home Server test failed", + homeServerError = error.message ?: context.getString(R.string.homeserver_test_failed), + toastMessage = error.message ?: context.getString(R.string.homeserver_test_failed), toastType = ToastType.ERROR ) } @@ -2608,7 +2610,7 @@ class SettingsViewModel @Inject constructor( ) } else if (!silent && result.isFailure) { _uiState.value = _uiState.value.copy( - toastMessage = result.exceptionOrNull()?.message ?: "Cloud sync failed", + toastMessage = result.exceptionOrNull()?.message ?: context.getString(R.string.cloud_sync_failed), toastType = ToastType.ERROR ) } @@ -2665,7 +2667,7 @@ class SettingsViewModel @Inject constructor( } } if (pushResult == null || pushResult.isFailure) { - val uploadError = pushResult?.exceptionOrNull()?.message ?: "Cloud sync failed while uploading" + val uploadError = pushResult?.exceptionOrNull()?.message ?: context.getString(R.string.cloud_sync_failed_upload) _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, lastCloudSyncStatus = "Upload failed: ${uploadError.take(120)}", @@ -2800,7 +2802,7 @@ class SettingsViewModel @Inject constructor( }.onFailure { error -> if (showNoUpdateFeedback) { _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to check for updates", + toastMessage = error.message ?: context.getString(R.string.update_check_failed), toastType = ToastType.ERROR ) } @@ -2858,7 +2860,7 @@ class SettingsViewModel @Inject constructor( installAppUpdateOrRequestPermission() }.onFailure { error -> updateStatusManager.updateStatus( - com.arflix.tv.updater.UpdateStatus.Failure(error.message ?: "Download failed", update) + com.arflix.tv.updater.UpdateStatus.Failure(error.message ?: context.getString(R.string.update_download_failed), update) ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt index 4c676170d..c05465f23 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -59,6 +60,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.telegram.TelegramAuthState import com.arflix.tv.ui.components.LoadingIndicator import com.arflix.tv.ui.theme.ArflixTypography @@ -102,7 +104,7 @@ fun TelegramSettingsScreen( ) { Icon( imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), tint = TextPrimary, modifier = Modifier.size(20.dp) ) @@ -119,13 +121,13 @@ fun TelegramSettingsScreen( when (val state = authState) { is TelegramAuthState.Idle -> IdleContent(onConnect = { viewModel.startAuth() }) - is TelegramAuthState.Initializing -> LoadingContent("Connecting...") + is TelegramAuthState.Initializing -> LoadingContent(stringResource(R.string.telegram_connecting)) is TelegramAuthState.WaitPhone -> { if (isMobile) { PhoneContent(onSubmit = { viewModel.submitPhone(it) }) } else { LaunchedEffect(Unit) { viewModel.startQrAuth() } - LoadingContent("Preparing QR code...") + LoadingContent(stringResource(R.string.telegram_preparing_qr)) } } is TelegramAuthState.WaitQr -> QrContent(link = state.link) @@ -171,13 +173,13 @@ private fun IdleContent(onConnect: () -> Unit) { ) { Spacer(modifier = Modifier.height(40.dp)) Text( - text = "Connect Telegram", + text = stringResource(R.string.telegram_connect_title), style = ArflixTypography.cardTitle.copy(fontSize = 22.sp), color = TextPrimary ) Spacer(modifier = Modifier.height(10.dp)) Text( - text = "Search your channels and groups for video files whenever you open a movie or show.", + text = stringResource(R.string.telegram_connect_desc), style = ArflixTypography.caption.copy(fontSize = 14.sp), color = TextSecondary, textAlign = TextAlign.Center, @@ -192,15 +194,14 @@ private fun IdleContent(onConnect: () -> Unit) { .padding(horizontal = 16.dp, vertical = 12.dp) ) { Text( - text = "Arvio is not responsible for the content streamed through this feature. " + - "Only connect your own account and use it fairly — respect copyright and applicable laws.", + text = stringResource(R.string.telegram_disclaimer), style = ArflixTypography.caption.copy(fontSize = 11.sp), color = TextSecondary.copy(alpha = 0.7f), textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(24.dp)) - ActionButton(label = "CONNECT", onClick = onConnect) + ActionButton(label = stringResource(R.string.connect).uppercase(), onClick = onConnect) } } @@ -228,13 +229,13 @@ private fun QrContent(link: String) { ) { Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Scan with Telegram", + text = stringResource(R.string.telegram_scan_title), style = ArflixTypography.cardTitle.copy(fontSize = 20.sp), color = TextPrimary ) Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Open Telegram on your phone → Settings → Devices → Link Desktop Device", + text = stringResource(R.string.telegram_scan_instructions), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, textAlign = TextAlign.Center, @@ -250,7 +251,7 @@ private fun QrContent(link: String) { ) { Image( bitmap = qrBitmap.asImageBitmap(), - contentDescription = "Telegram QR code", + contentDescription = stringResource(R.string.telegram_qr_code_desc), modifier = Modifier.fillMaxSize() ) } @@ -259,7 +260,7 @@ private fun QrContent(link: String) { } Spacer(modifier = Modifier.height(16.dp)) Text( - text = "QR code expires in 30 seconds — refreshes automatically", + text = stringResource(R.string.telegram_qr_expires), style = ArflixTypography.caption.copy(fontSize = 11.sp), color = TextSecondary.copy(alpha = 0.6f) ) @@ -304,13 +305,13 @@ private fun PhoneContent(onSubmit: (String) -> Unit) { ) { Spacer(modifier = Modifier.height(40.dp)) Text( - text = "Enter Phone Number", + text = stringResource(R.string.telegram_phone_title), style = ArflixTypography.cardTitle.copy(fontSize = 22.sp), color = TextPrimary ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Use the same number registered with your Telegram account.", + text = stringResource(R.string.telegram_phone_desc), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, textAlign = TextAlign.Center, @@ -320,13 +321,13 @@ private fun PhoneContent(onSubmit: (String) -> Unit) { TextField( value = phone, onValueChange = { phone = it }, - label = { Text("Phone number") }, + label = { Text(stringResource(R.string.telegram_phone_label)) }, placeholder = { Text("+1 650 555 1234", color = TextSecondary.copy(alpha = 0.35f)) }, supportingText = { if (showError) { - Text("Must start with + and include country code, e.g. +1 for US, +44 for UK", color = Pink) + Text(stringResource(R.string.telegram_phone_error), color = Pink) } else { - Text("International format: +[country code][number]", color = TextSecondary.copy(alpha = 0.5f)) + Text(stringResource(R.string.telegram_phone_format), color = TextSecondary.copy(alpha = 0.5f)) } }, isError = showError, @@ -344,12 +345,12 @@ private fun PhoneContent(onSubmit: (String) -> Unit) { LoadingIndicator(color = Pink, size = 32.dp, strokeWidth = 2.5.dp) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Sending code...", + text = stringResource(R.string.telegram_sending_code), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary ) } else { - ActionButton(label = "SEND CODE", onClick = ::trySubmit) + ActionButton(label = stringResource(R.string.telegram_send_code), onClick = ::trySubmit) } } } @@ -373,20 +374,20 @@ private fun CodeContent(codeLength: Int, onSubmit: (String) -> Unit) { ) { Text(text = "✓ ", style = ArflixTypography.caption.copy(fontSize = 14.sp), color = SuccessGreen) Text( - text = "Code sent to your Telegram app", + text = stringResource(R.string.telegram_code_sent), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = SuccessGreen ) } Spacer(modifier = Modifier.height(20.dp)) Text( - text = "Enter Code", + text = stringResource(R.string.telegram_code_title), style = ArflixTypography.cardTitle.copy(fontSize = 22.sp), color = TextPrimary ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Check your Telegram app for a $codeLength-digit code.", + text = stringResource(R.string.telegram_code_desc, codeLength), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, textAlign = TextAlign.Center, @@ -396,7 +397,7 @@ private fun CodeContent(codeLength: Int, onSubmit: (String) -> Unit) { TextField( value = code, onValueChange = { if (it.length <= codeLength) code = it }, - label = { Text("Verification code") }, + label = { Text(stringResource(R.string.telegram_code_label)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done @@ -407,7 +408,7 @@ private fun CodeContent(codeLength: Int, onSubmit: (String) -> Unit) { modifier = Modifier.fillMaxWidth(0.55f) ) Spacer(modifier = Modifier.height(20.dp)) - ActionButton(label = "CONFIRM") { if (code.isNotBlank()) onSubmit(code) } + ActionButton(label = stringResource(R.string.confirm).uppercase()) { if (code.isNotBlank()) onSubmit(code) } } } @@ -421,13 +422,13 @@ private fun PasswordContent(onSubmit: (String) -> Unit) { ) { Spacer(modifier = Modifier.height(40.dp)) Text( - text = "Two-Step Verification", + text = stringResource(R.string.telegram_2fa_title), style = ArflixTypography.cardTitle.copy(fontSize = 20.sp), color = TextPrimary ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Your account has an additional password.", + text = stringResource(R.string.telegram_2fa_desc), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary ) @@ -435,7 +436,7 @@ private fun PasswordContent(onSubmit: (String) -> Unit) { TextField( value = password, onValueChange = { password = it }, - label = { Text("Password") }, + label = { Text(stringResource(R.string.telegram_password_label)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, @@ -447,7 +448,7 @@ private fun PasswordContent(onSubmit: (String) -> Unit) { modifier = Modifier.fillMaxWidth(0.55f) ) Spacer(modifier = Modifier.height(20.dp)) - ActionButton(label = "CONFIRM", onClick = { onSubmit(password) }) + ActionButton(label = stringResource(R.string.confirm).uppercase(), onClick = { onSubmit(password) }) } } @@ -475,12 +476,12 @@ private fun ConnectedContent( ) { Column { Text( - text = "Connected", + text = stringResource(R.string.connected), style = ArflixTypography.cardTitle.copy(fontSize = 15.sp), color = SuccessGreen ) Text( - text = "Signed in as $firstName", + text = stringResource(R.string.telegram_signed_in_as, firstName), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary ) @@ -492,7 +493,7 @@ private fun ConnectedContent( .padding(horizontal = 12.dp, vertical = 8.dp) ) { Text( - text = "DISCONNECT", + text = stringResource(R.string.telegram_disconnect_btn), style = ArflixTypography.label.copy(fontSize = 11.sp), color = TextSecondary ) @@ -522,7 +523,7 @@ private fun ConnectedContent( ) { Column { Text( - text = "Video Cache", + text = stringResource(R.string.telegram_video_cache), style = ArflixTypography.cardTitle.copy(fontSize = 14.sp), color = if (cacheFocused) Pink else TextPrimary ) @@ -534,7 +535,7 @@ private fun ConnectedContent( } if (cacheSizeBytes > 0L) { Text( - text = "CLEAR", + text = stringResource(R.string.telegram_clear_cache_btn), style = ArflixTypography.label.copy(fontSize = 11.sp), color = Pink ) @@ -553,11 +554,11 @@ private fun ErrorContent(message: String, onRetry: () -> Unit) { Spacer(modifier = Modifier.height(40.dp)) Icon(imageVector = Icons.Default.Close, contentDescription = null, tint = Pink, modifier = Modifier.size(40.dp)) Spacer(modifier = Modifier.height(12.dp)) - Text(text = "Connection Failed", style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), color = TextPrimary) + Text(text = stringResource(R.string.telegram_connection_failed), style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), color = TextPrimary) Spacer(modifier = Modifier.height(8.dp)) Text(text = message, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary) Spacer(modifier = Modifier.height(24.dp)) - ActionButton(label = "TRY AGAIN", onClick = onRetry) + ActionButton(label = stringResource(R.string.telegram_try_again), onClick = onRetry) } } @@ -620,13 +621,13 @@ private fun DisconnectConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Disconnect Telegram?", + text = stringResource(R.string.telegram_disconnect_confirm_title), style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), color = TextPrimary ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "You'll need to sign in again to use Telegram as a source.", + text = stringResource(R.string.telegram_disconnect_confirm_desc), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, textAlign = TextAlign.Center @@ -650,7 +651,7 @@ private fun DisconnectConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit contentAlignment = Alignment.Center ) { Text( - text = "CANCEL", + text = stringResource(R.string.cancel).uppercase(), style = ArflixTypography.label.copy(fontSize = 12.sp), color = TextPrimary ) @@ -672,7 +673,7 @@ private fun DisconnectConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit contentAlignment = Alignment.Center ) { Text( - text = "DISCONNECT", + text = stringResource(R.string.telegram_disconnect_btn), style = ArflixTypography.label.copy(fontSize = 12.sp), color = Pink ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index e86f394d7..6f182c720 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -1,7 +1,9 @@ package com.arflix.tv.ui.screens.tv +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvSnapshot @@ -11,6 +13,7 @@ import com.arflix.tv.data.repository.IptvRepository import com.arflix.tv.data.repository.IptvTvSessionState import com.arflix.tv.util.AppLogger import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -75,6 +78,7 @@ data class TvUiState( @HiltViewModel class TvViewModel @Inject constructor( + @ApplicationContext private val context: Context, val iptvRepository: IptvRepository, private val cloudSyncRepository: CloudSyncRepository ) : ViewModel() { @@ -433,7 +437,7 @@ class TvViewModel @Inject constructor( pendingForcedReload = true _uiState.value = currentState.copy( isLoading = false, - error = error.message ?: "Failed to load IPTV", + error = error.message ?: context.getString(R.string.tv_failed_load_iptv), loadingMessage = null, loadingPercent = 0 ) @@ -441,7 +445,7 @@ class TvViewModel @Inject constructor( } _uiState.value = _uiState.value.copy( isLoading = false, - error = error.message ?: "Failed to load IPTV", + error = error.message ?: context.getString(R.string.tv_failed_load_iptv), loadingMessage = null, loadingPercent = 0 ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index ee17d35d3..9715f3b55 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -79,6 +80,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -264,7 +266,7 @@ fun CategorySidebar( val isAllGroup = cat.id == "all" && cat.children.isNotEmpty() val isOpen = isAllGroup && expandedAll SidebarRow( - label = cat.label, + label = liveCategoryLabel(cat.label), count = cat.count, icon = iconFor(cat), active = selectedId == cat.id, @@ -283,7 +285,7 @@ fun CategorySidebar( if (isOpen && expanded) { cat.children.forEach { child -> SidebarRow( - label = child.label, + label = liveCategoryLabel(child.label), count = child.count, icon = iconFor(child), flagEmoji = child.flagEmoji, @@ -300,7 +302,7 @@ fun CategorySidebar( if (child.containsId(selectedId)) { child.children.forEach { grandchild -> SidebarRow( - label = grandchild.label, + label = liveCategoryLabel(grandchild.label), count = grandchild.count, icon = iconFor(grandchild), active = selectedId == grandchild.id, @@ -317,10 +319,10 @@ fun CategorySidebar( } } if (tree.global.categories.isNotEmpty()) { - item { SectionHeader(tree.global.label, expanded) } + item { SectionHeader(liveSectionLabel(tree.global.label), expanded) } itemsIndexed(tree.global.categories, key = { index, cat -> "global:${cat.id}:$index" }) { _, cat -> SidebarRow( - label = cat.label, + label = liveCategoryLabel(cat.label), count = cat.count, icon = iconFor(cat), active = selectedId == cat.id, @@ -335,10 +337,10 @@ fun CategorySidebar( } } if (tree.hidden.categories.isNotEmpty()) { - item { SectionHeader(tree.hidden.label, expanded) } + item { SectionHeader(liveSectionLabel(tree.hidden.label), expanded) } itemsIndexed(tree.hidden.categories, key = { index, cat -> "hidden:${cat.id}:$index" }) { _, cat -> SidebarRow( - label = cat.label, + label = liveCategoryLabel(cat.label), count = cat.count, icon = Icons.Filled.VisibilityOff, active = false, @@ -356,11 +358,11 @@ fun CategorySidebar( } } if (tree.countries.categories.isNotEmpty()) { - item { SectionHeader(tree.countries.label, expanded) } + item { SectionHeader(liveSectionLabel(tree.countries.label), expanded) } itemsIndexed(tree.countries.categories, key = { index, country -> "country:${country.id}:$index" }) { _, country -> val isExpanded = expandedCountry == country.id SidebarRow( - label = country.label, + label = liveCategoryLabel(country.label), count = country.count, icon = null, leadingCode = country.id, @@ -386,7 +388,7 @@ fun CategorySidebar( if (isExpanded && expanded) { country.children.forEach { child -> SidebarRow( - label = child.label, + label = liveCategoryLabel(child.label), count = child.count, icon = null, active = selectedId == child.id, @@ -402,10 +404,10 @@ fun CategorySidebar( } } if (tree.adult.categories.isNotEmpty()) { - item { SectionHeader(tree.adult.label, expanded) } + item { SectionHeader(liveSectionLabel(tree.adult.label), expanded) } itemsIndexed(tree.adult.categories, key = { index, cat -> "adult:${cat.id}:$index" }) { _, cat -> SidebarRow( - label = cat.label, + label = liveCategoryLabel(cat.label), count = cat.count, icon = Icons.Filled.Lock, active = selectedId == cat.id, @@ -506,13 +508,13 @@ private fun SearchEntry( ) { Icon( imageVector = Icons.Filled.Search, - contentDescription = "Search", + contentDescription = stringResource(R.string.search), tint = LiveColors.FgDim, modifier = Modifier.size(14.dp), ) if (expanded) { Text( - text = "Search", + text = stringResource(R.string.search), style = LiveType.CatLabel.copy(color = LiveColors.FgDim), ) Spacer(Modifier.weight(1f)) @@ -766,20 +768,20 @@ private fun buildCategoryMenuActions( onMoveDown: () -> Unit, ): List = buildList { if (canMove) { - add(CategoryMenuAction("Move to top", Icons.Filled.KeyboardArrowUp, onMoveToTop)) - add(CategoryMenuAction("Move up", Icons.Filled.KeyboardArrowUp, onMoveUp)) - add(CategoryMenuAction("Move down", Icons.Filled.KeyboardArrowDown, onMoveDown)) + add(CategoryMenuAction(R.string.live_menu_move_top, Icons.Filled.KeyboardArrowUp, onMoveToTop)) + add(CategoryMenuAction(R.string.live_menu_move_up, Icons.Filled.KeyboardArrowUp, onMoveUp)) + add(CategoryMenuAction(R.string.live_menu_move_down, Icons.Filled.KeyboardArrowDown, onMoveDown)) } if (canHide) { - add(CategoryMenuAction("Hide category", Icons.Filled.VisibilityOff, onHide)) + add(CategoryMenuAction(R.string.live_menu_hide_category, Icons.Filled.VisibilityOff, onHide)) } if (canUnhide) { - add(CategoryMenuAction("Unhide category", Icons.Filled.Visibility, onUnhide)) + add(CategoryMenuAction(R.string.live_menu_unhide_category, Icons.Filled.Visibility, onUnhide)) } } private data class CategoryMenuAction( - val label: String, + val labelRes: Int, val icon: ImageVector, val onClick: () -> Unit, ) @@ -820,7 +822,7 @@ private fun CategoryMenuItem( modifier = Modifier.size(16.dp), ) Text( - text = action.label, + text = stringResource(action.labelRes), style = LiveType.CatLabel.copy( color = if (focused) Color.Black else LiveColors.Fg, fontSize = 11.sp, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index 989ab647d..9bd21f18c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt @@ -42,11 +42,13 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvNowNext /** @@ -207,7 +209,7 @@ fun ChannelRow( Spacer(Modifier.width(4.dp)) Icon( imageVector = Icons.Filled.History, - contentDescription = "Catchup available", + contentDescription = stringResource(R.string.live_cd_catchup_available), tint = LiveColors.Accent.copy(alpha = 0.8f), modifier = Modifier.size(11.dp), ) @@ -233,7 +235,7 @@ fun ChannelRow( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.End, ) { - SmallPillBadge(if (variantCount > 1) "${channel.quality.label} ${variantCount}x" else channel.quality.label) + SmallPillBadge(if (variantCount > 1) stringResource(R.string.live_label_quality_variants, channel.quality.label, variantCount) else channel.quality.label) SmallPillBadge(channel.lang) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index dfd95f760..11c06b70a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -45,11 +45,13 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.ui.focus.arvioDpadFocusGroup @@ -352,12 +354,12 @@ fun EpgGrid( horizontalArrangement = Arrangement.SpaceBetween, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("CHANNELS", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) + Text(stringResource(R.string.live_label_channels), style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) Text(safeTotalChannelCount.toString(), style = LiveType.NumberMono.copy(color = LiveColors.FgDim)) } Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text("CH", style = LiveType.SectionTag.copy(color = LiveColors.Accent)) + Text(stringResource(R.string.live_badge_ch), style = LiveType.SectionTag.copy(color = LiveColors.Accent)) Text( selectedChannel?.number?.toString() ?: "—", style = LiveType.NumberMono.copy(color = LiveColors.Accent), @@ -405,7 +407,7 @@ fun EpgGrid( .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( - text = "NOW " + formatClock(clockTickMillis), + text = stringResource(R.string.live_label_now_time, formatClock(clockTickMillis)), style = LiveType.Badge.copy(color = LiveColors.Bg), ) } @@ -545,16 +547,17 @@ fun EpgGrid( val rowHasGuideIdentity = !ch.source.epgId.isNullOrBlank() || !ch.source.tvgName.isNullOrBlank() val placeholderTitle = when { - isGuideLoading -> "Loading guide..." - !rowHasGuideIdentity -> "No programme data" - hasGuideSource && guideAttempted -> "No guide data matched" - hasGuideSource -> "Guide pending..." - else -> "No guide source" + isGuideLoading -> stringResource(R.string.live_placeholder_loading_guide) + !rowHasGuideIdentity -> stringResource(R.string.live_empty_no_programme) + hasGuideSource && guideAttempted -> stringResource(R.string.live_placeholder_no_guide_matched) + hasGuideSource -> stringResource(R.string.live_placeholder_guide_pending) + else -> stringResource(R.string.live_placeholder_no_guide_source) } ProgramsRow( channel = ch, programs = rowPrograms, placeholderTitle = placeholderTitle, + noProgrammeData = stringResource(R.string.live_empty_no_programme), clockTickMillis = clockTickMillis, windowStartMillis = windowStartMillis, windowEndMillis = windowEndMillis, @@ -625,6 +628,7 @@ private fun ProgramsRow( channel: EnrichedChannel, programs: List, placeholderTitle: String, + noProgrammeData: String, clockTickMillis: Long, windowStartMillis: Long, windowEndMillis: Long, @@ -662,8 +666,8 @@ private fun ProgramsRow( // storm that made dpad navigation hitch. The now/past state is derived cheaply // per render from `nowMillis` via ProgramPlacement.isNow()/isPast(), and the // placeholder anchor refreshes whenever `windowStartMillis` rounds forward. - val placements = remember(programs, placeholderTitle, windowStartMillis, windowEndMillis) { - buildProgramPlacements(programs, windowStartMillis, windowEndMillis, nowMillis, placeholderTitle) + val placements = remember(programs, placeholderTitle, noProgrammeData, windowStartMillis, windowEndMillis) { + buildProgramPlacements(programs, windowStartMillis, windowEndMillis, nowMillis, placeholderTitle, noProgrammeData) } val focusablePlacementIndices = remember(placements, channel.catchupDays, nowMillis, epgMode) { if (!epgMode) return@remember emptyList() @@ -872,11 +876,12 @@ private fun buildProgramPlacements( windowStartMillis: Long, windowEndMillis: Long, nowMillis: Long, - placeholderTitle: String = "No Information", + placeholderTitle: String = "", + noProgrammeData: String = placeholderTitle, ): List { val placements = mutableListOf() var cursor = windowStartMillis - val gapTitle = if (programs.isEmpty()) placeholderTitle else "No programme data" + val gapTitle = if (programs.isEmpty()) placeholderTitle else noProgrammeData programs.forEach { program -> // 1. Fill gap before this program diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt index 8cd289c1f..1fb01d332 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt @@ -64,11 +64,13 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import kotlinx.coroutines.delay @@ -305,7 +307,7 @@ private fun FullscreenGuideContent( ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Close guide", + contentDescription = stringResource(R.string.live_cd_close_guide), tint = LiveColors.Fg, modifier = Modifier.size(21.dp), ) @@ -323,10 +325,10 @@ private fun FullscreenGuideContent( overflow = TextOverflow.Ellipsis, ) Row(horizontalArrangement = Arrangement.spacedBy(if (isTouchDevice) 5.dp else 6.dp)) { - GuideChip("CH ${channel.number}", LiveColors.FgDim, Color.White.copy(alpha = 0.08f)) + GuideChip(stringResource(R.string.live_label_ch, channel.number), LiveColors.FgDim, Color.White.copy(alpha = 0.08f)) GuideChip(channel.quality.label, LiveColors.FgDim, Color.White.copy(alpha = 0.08f)) if (catchupSupported) { - GuideChip("Catchup", LiveColors.Bg, LiveColors.Accent) + GuideChip(stringResource(R.string.live_label_catchup), LiveColors.Bg, LiveColors.Accent) } } } @@ -430,19 +432,19 @@ private fun GuideTimelineSummary( horizontalArrangement = Arrangement.spacedBy(if (isTouchDevice) 5.dp else 7.dp), ) { GuideTimelinePill( - label = "Aired", + label = stringResource(R.string.live_label_aired), value = pastCount.toString(), accent = if (catchupSupported) LiveColors.Accent else LiveColors.FgMute, modifier = Modifier.weight(1f), ) GuideTimelinePill( - label = "Live", + label = stringResource(R.string.live), value = liveCount.coerceAtMost(1).toString(), accent = LiveColors.LiveRed, modifier = Modifier.weight(1f), ) GuideTimelinePill( - label = "Later", + label = stringResource(R.string.later), value = futureCount.toString(), accent = LiveColors.FgDim, modifier = Modifier.weight(1f), @@ -598,9 +600,9 @@ private fun GuideProgramRow( ) { GuideChip( label = when (item.state) { - GuideProgramState.PastPlayable -> "AIRED" - GuideProgramState.PastUnavailable -> "AIRED" - GuideProgramState.Live -> "LIVE" + GuideProgramState.PastPlayable -> stringResource(R.string.live_badge_aired) + GuideProgramState.PastUnavailable -> stringResource(R.string.live_badge_aired) + GuideProgramState.Live -> stringResource(R.string.live_badge_live) GuideProgramState.Future -> startsLabel(item.program, nowMillis) }, fg = when (item.state) { @@ -663,9 +665,9 @@ private fun GuideEmptyState( ) { val text = when { !catchupSupported -> - "No programme timeline is available for this channel." + stringResource(R.string.live_empty_no_timeline_channel) else -> - "No guide timeline is available yet." + stringResource(R.string.live_empty_no_timeline_yet) } Box( modifier = Modifier @@ -702,14 +704,15 @@ private fun GuideChip(label: String, fg: Color, bg: Color) { private val timelineDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM", Locale.getDefault()) +@Composable private fun timelineDateLabel(program: IptvProgram, nowMillis: Long): String { val zone = ZoneId.systemDefault() val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate() val programDate = Instant.ofEpochMilli(program.startUtcMillis).atZone(zone).toLocalDate() return when (programDate) { - today.minusDays(1) -> "Yesterday" - today -> "Today" - today.plusDays(1) -> "Tomorrow" + today.minusDays(1) -> stringResource(R.string.live_label_yesterday) + today -> stringResource(R.string.live_label_today) + today.plusDays(1) -> stringResource(R.string.live_label_tomorrow) else -> timelineDateFormatter.format(programDate) } } @@ -723,11 +726,12 @@ private fun EnrichedChannel.supportsFullscreenCatchup(): Boolean { || channelSource.streamUrl.contains("/live/", ignoreCase = true) } +@Composable private fun startsLabel(program: IptvProgram, nowMillis: Long): String { val minutes = ((program.startUtcMillis - nowMillis) / 60_000L).coerceAtLeast(0L) return when { - minutes < 60 -> "in ${minutes}m" - minutes < 24 * 60 -> "in ${minutes / 60}h ${minutes % 60}m" - else -> "later" + minutes < 60 -> stringResource(R.string.live_label_starts_in_min, minutes.toInt()) + minutes < 24 * 60 -> stringResource(R.string.live_label_starts_in_hm, (minutes / 60).toInt(), (minutes % 60).toInt()) + else -> stringResource(R.string.live_label_starts_later) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt index 073e852fa..a775d7211 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt @@ -33,11 +33,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvNowNext import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.CircleShape @@ -131,7 +133,7 @@ fun FullscreenHud( ChannelLogo(channel = channel, size = 40.dp) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = "CH ${channel.number}", + text = stringResource(R.string.live_label_ch, channel.number), style = LiveType.SectionTag.copy(color = LiveColors.FgMute), ) Text( @@ -189,7 +191,7 @@ fun FullscreenHud( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = if (isCatchupMode) "CATCHUP" else "NOW", + text = if (isCatchupMode) stringResource(R.string.live_badge_catchup) else stringResource(R.string.live_badge_now), style = LiveType.SectionTag.copy(color = LiveColors.Accent), ) Text( @@ -209,13 +211,13 @@ fun FullscreenHud( ) } if (onGuideClick != null) { - HudActionButton("GUIDE", onGuideClick) + HudActionButton(stringResource(R.string.live_btn_guide), onGuideClick) } } Text( text = now?.title ?: channel?.name - ?: "No programme data", + ?: stringResource(R.string.live_empty_no_programme), style = LiveType.ProgramTitle.copy( color = LiveColors.Fg, fontSize = 18.sp, @@ -258,12 +260,12 @@ fun FullscreenHud( ) { HudIconButton( icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", + contentDescription = if (isPlaying) stringResource(R.string.live_cd_pause) else stringResource(R.string.play), emphasis = true, onClick = { onPlayPauseClick?.invoke() }, ) Spacer(Modifier.width(14.dp)) - HudActionButton("LIVE", onClick = { onGoLiveClick?.invoke() }) + HudActionButton(stringResource(R.string.live_badge_live), onClick = { onGoLiveClick?.invoke() }) } } else if (next != null) { Box( @@ -276,7 +278,7 @@ fun FullscreenHud( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("NEXT", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) + Text(stringResource(R.string.live_badge_next), style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) Text( text = formatClock(next.startUtcMillis), style = LiveType.TimeMono.copy(color = LiveColors.FgDim), @@ -308,7 +310,7 @@ fun FullscreenHud( ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Go Back", + contentDescription = stringResource(R.string.back), tint = Color.White, modifier = Modifier.size(24.dp), ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt index 26d1a1fe7..2be466ab1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt @@ -1,6 +1,9 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel /** Broad channel genre derived from M3U group name. */ @@ -313,6 +316,30 @@ data class LiveCategory( enum class CategoryIcon { Favorite, Recent, All, Grid, Sport, Movie, News, Kids, Docs, Music, Lock, Country, SubEntry } +/** + * Display-localization for category labels. The stored [LiveCategory.label] stays in English + * so grouping/comparison logic keeps working; only the rendered text is localized here. + * Dynamic labels (country names, "4K | Ultra HD", playlist group names, …) pass through. + */ +@Composable +fun liveCategoryLabel(raw: String): String = when (raw) { + "Favorites" -> stringResource(R.string.live_cat_favorites) + "Recently Watched" -> stringResource(R.string.live_cat_recently_watched) + "All Channels" -> stringResource(R.string.live_label_all_channels) + "Adult" -> stringResource(R.string.live_cat_adult) + "Ungrouped" -> stringResource(R.string.live_cat_ungrouped) + else -> raw +} + +/** Display-localization for [LiveSection.label] section headers. Unknown headers pass through. */ +@Composable +fun liveSectionLabel(raw: String): String = when (raw) { + "PLAYLIST" -> stringResource(R.string.live_section_playlist) + "ADULT" -> stringResource(R.string.live_section_adult) + "HIDDEN" -> stringResource(R.string.live_section_hidden) + else -> raw +} + data class LiveSection(val id: String, val label: String, val categories: List) data class LiveCategoryTree( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt index 2686c71cb..d8fb703c7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt @@ -48,11 +48,13 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.repository.IptvConfig data class TvProviderFilter( @@ -295,9 +297,9 @@ fun EpgStatusStrip( if (!visible) return val text = when { !warning.isNullOrBlank() -> warning - isLoading -> "Loading visible guide..." - !hasGuideSource -> "No EPG source configured" - else -> "Guide pending" + isLoading -> stringResource(R.string.live_status_loading_guide) + !hasGuideSource -> stringResource(R.string.live_status_no_epg_source) + else -> stringResource(R.string.live_status_guide_pending) } Box( modifier = modifier @@ -319,7 +321,7 @@ fun EpgStatusStrip( modifier = Modifier.size(14.dp), ) Text( - text = "$text | matched $matchedCount/$totalChannels visible", + text = stringResource(R.string.live_status_matched, text ?: "", matchedCount, totalChannels), style = LiveType.SectionTag.copy(color = LiveColors.FgDim), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -356,7 +358,7 @@ fun ChannelNumberOverlay( style = LiveType.NumberMono.copy(color = LiveColors.Fg, fontSize = 24.sp), ) Text( - text = exactChannelName ?: if (matchCount > 0) "$matchCount matches" else "No channel", + text = exactChannelName ?: if (matchCount > 0) stringResource(R.string.live_label_matches, matchCount) else stringResource(R.string.live_empty_no_channel), style = LiveType.SectionTag.copy(color = if (matchCount > 0) LiveColors.FgDim else Color(0xFFFF8A9A)), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -458,7 +460,7 @@ fun VariantPickerOverlay( modifier = Modifier.size(22.dp), ) Column { - Text("Choose source", style = LiveType.ChannelName.copy(color = LiveColors.Fg, fontSize = 18.sp)) + Text(stringResource(R.string.live_label_choose_source), style = LiveType.ChannelName.copy(color = LiveColors.Fg, fontSize = 18.sp)) Text( channel.name, style = LiveType.SectionTag.copy(color = LiveColors.FgDim), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 24eebcf58..8681025c5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -78,6 +79,7 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram @@ -1856,8 +1858,8 @@ fun LiveTvScreen( if (resetRetry) playerRetryCount = 0 if (resetRetry) { playbackDiagnostic = PlaybackDiagnostic( - title = if (playingCatchupProgram != null && initialPositionMs > 0L) "Seeking catch-up" else "Starting live stream", - detail = playingChannel?.name ?: "Preparing source", + title = if (playingCatchupProgram != null && initialPositionMs > 0L) context.getString(R.string.live_diag_seeking_catchup) else context.getString(R.string.live_diag_starting_stream), + detail = playingChannel?.name ?: context.getString(R.string.live_diag_preparing_source), severity = PlaybackDiagnosticSeverity.Info, ) } @@ -1960,8 +1962,8 @@ fun LiveTvScreen( } }.getOrElse { error -> playbackDiagnostic = PlaybackDiagnostic( - title = if (playingCatchupProgram != null) "Catch-up unavailable" else "Playback failed", - detail = error.message ?: "Provider did not return a playable stream.", + title = if (playingCatchupProgram != null) context.getString(R.string.live_diag_catchup_unavailable) else context.getString(R.string.live_diag_playback_failed), + detail = error.message ?: context.getString(R.string.live_diag_no_playable_stream), severity = PlaybackDiagnosticSeverity.Error, ) System.err.println( @@ -2038,7 +2040,7 @@ fun LiveTvScreen( } if (nextAttempt > maxRetryCount) { playbackDiagnostic = PlaybackDiagnostic( - title = "Playback failed", + title = context.getString(R.string.live_diag_playback_failed), detail = "${error.errorCodeName}: ${classifyPlaybackError(error)}", severity = PlaybackDiagnosticSeverity.Error, ) @@ -2066,7 +2068,7 @@ fun LiveTvScreen( } }.getOrElse { resolveError -> playbackDiagnostic = PlaybackDiagnostic( - title = if (retryProgram != null) "Catch-up unavailable" else "Playback failed", + title = if (retryProgram != null) context.getString(R.string.live_diag_catchup_unavailable) else context.getString(R.string.live_diag_playback_failed), detail = resolveError.message ?: classifyPlaybackError(error), severity = PlaybackDiagnosticSeverity.Error, ) @@ -2082,7 +2084,7 @@ fun LiveTvScreen( "candidates=$catchupCandidateCount url=${redactPlaybackUrl(retryStream)}" ) playbackDiagnostic = PlaybackDiagnostic( - title = "Retrying source", + title = context.getString(R.string.live_diag_retrying_source), detail = "Attempt $nextAttempt/$maxRetryCount after ${classifyPlaybackError(error)}", severity = PlaybackDiagnosticSeverity.Warning, ) @@ -2218,8 +2220,8 @@ fun LiveTvScreen( // PlayerView owns ExoPlayer. } else if (!state.isConfigured && state.snapshot.channels.isEmpty()) { EmptyStatePane( - message = "No IPTV playlist configured.", - actionLabel = "Open settings", + message = stringResource(R.string.live_empty_no_playlist), + actionLabel = stringResource(R.string.live_btn_open_settings), onAction = onNavigateToIptvSettings ?: onNavigateToSettings, isFocused = focusZone != LiveTvFocusZone.TOPBAR, focusRequester = emptyStateButtonFocus, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt index c3a80fc62..35c4801dd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -44,6 +45,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.util.formatGenreName @@ -202,7 +204,7 @@ private fun VideoCard( ) { Icon( imageVector = Icons.Default.FitScreen, - contentDescription = "Fullscreen", + contentDescription = stringResource(R.string.live_cd_fullscreen), tint = Color.White, modifier = Modifier.size(21.dp), ) @@ -236,7 +238,7 @@ private fun LiveBug(modifier: Modifier = Modifier) { .background(LiveColors.LiveRed, CircleShape), ) Text( - text = "LIVE", + text = stringResource(R.string.live_badge_live), style = LiveType.Badge.copy(color = Color.White), ) } @@ -283,7 +285,7 @@ private fun ChannelIdentityRow( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "CH " + channel.number, + text = stringResource(R.string.live_label_ch, channel.number), style = LiveType.SectionTag.copy(color = LiveColors.FgMute), ) Text( @@ -331,7 +333,7 @@ private fun SourceBadge(count: Int, onOpenVariants: (() -> Unit)?) { .then(if (onOpenVariants != null) Modifier.clickable { onOpenVariants() } else Modifier) .padding(horizontal = 6.dp, vertical = 2.dp), ) { - Text("$count sources", style = LiveType.Badge.copy(color = LiveColors.Accent)) + Text(stringResource(R.string.live_label_sources, count), style = LiveType.Badge.copy(color = LiveColors.Accent)) } } @@ -377,7 +379,7 @@ private fun NowCard(channel: EnrichedChannel?, clockTickMillis: Long, nowNext: I verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("NOW", style = LiveType.SectionTag.copy(color = LiveColors.Accent)) + Text(stringResource(R.string.live_badge_now), style = LiveType.SectionTag.copy(color = LiveColors.Accent)) Text( text = formatTimeWindow(now), style = LiveType.TimeMono.copy(color = LiveColors.Fg), @@ -392,7 +394,7 @@ private fun NowCard(channel: EnrichedChannel?, clockTickMillis: Long, nowNext: I } } Text( - text = now?.title ?: channel?.name ?: "No programme data", + text = now?.title ?: channel?.name ?: stringResource(R.string.live_empty_no_programme), style = LiveType.ProgramTitle.copy(color = LiveColors.Fg), maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -425,7 +427,7 @@ private fun NextRow(nowNext: IptvNowNext?) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("NEXT", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) + Text(stringResource(R.string.live_badge_next), style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) Text( text = formatClock(next.startUtcMillis), style = LiveType.TimeMono.copy(color = LiveColors.FgDim), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index d65a26fa8..b30d1595e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -40,11 +40,13 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.util.LocalDeviceType @@ -205,16 +207,16 @@ fun ProgramCell( Row(verticalAlignment = Alignment.CenterVertically) { val nowMs = clockTickMillis if (isNow) { - Badge("LIVE", Color.White, LiveColors.LiveRed) + Badge(stringResource(R.string.live_badge_live), Color.White, LiveColors.LiveRed) Spacer(Modifier.size(6.dp)) } else if (isPast && isCatchupSupported) { - Badge("ARCHIVE", LiveColors.Bg, LiveColors.Accent) + Badge(stringResource(R.string.live_badge_archive), LiveColors.Bg, LiveColors.Accent) Spacer(Modifier.size(6.dp)) } else if (!isPast) { val isNewTag = (nowMs - program.startUtcMillis) in 0..24L * 60 * 60 * 1000L && !program.isLive(nowMs) if (isNewTag) { - Badge("NEW", LiveColors.Bg, LiveColors.Accent) + Badge(stringResource(R.string.live_badge_new), LiveColors.Bg, LiveColors.Accent) Spacer(Modifier.size(6.dp)) } } @@ -246,7 +248,7 @@ fun ProgramCell( .coerceAtLeast(0L) if (mins > 0) { Text( - text = "· ${mins}min", + text = stringResource(R.string.live_label_duration_min, mins), style = LiveType.TimeMono.copy(color = LiveColors.FgMute, fontSize = 9.sp), ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt index 42e8dbc20..33d19b8e3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt @@ -247,7 +247,7 @@ fun QuickZapOverlay( ) ) Text( - text = (categories.getOrNull(selectedCategoryIndex)?.label ?: "All Channels").uppercase(), + text = (categories.getOrNull(selectedCategoryIndex)?.label?.let { liveCategoryLabel(it) } ?: stringResource(R.string.live_label_all_channels)).uppercase(), style = LiveType.ChannelName.copy( color = LiveColors.Fg, fontSize = 16.sp, @@ -320,20 +320,20 @@ private fun CategorySidebarPanel( for (offset in -3..-1) { val index = (selectedIndex + offset + categories.size * 10) % categories.size categories.getOrNull(index)?.let { cat -> - NonFocusedCategoryRow(label = cat.label) + NonFocusedCategoryRow(label = liveCategoryLabel(cat.label)) } } // Centered Focused Category categories.getOrNull(selectedIndex)?.let { cat -> - FocusedCategoryRow(label = cat.label, isFocused = isFocused) + FocusedCategoryRow(label = liveCategoryLabel(cat.label), isFocused = isFocused) } // Render 3 slots below focused category for (offset in 1..3) { val index = (selectedIndex + offset) % categories.size categories.getOrNull(index)?.let { cat -> - NonFocusedCategoryRow(label = cat.label) + NonFocusedCategoryRow(label = liveCategoryLabel(cat.label)) } } } @@ -462,7 +462,7 @@ private fun ChannelColumnPanel( } } else { Text( - text = "No channels in this category", + text = stringResource(R.string.live_empty_no_channels_category), style = LiveType.CellTitle.copy(color = LiveColors.FgMute), modifier = Modifier.padding(vertical = 48.dp) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt index 4e34dd533..f3b426e32 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.util.formatGenreName @@ -81,6 +83,16 @@ fun SearchOverlay( val firstResultFocus = remember { FocusRequester() } LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + // Resolved up front so they can be used inside the non-composable search LaunchedEffect. + val nowFormat = stringResource(R.string.live_search_now) + val guideLabels = GuideMatchLabels( + now = stringResource(R.string.now), + next = stringResource(R.string.next), + later = stringResource(R.string.later), + guide = stringResource(R.string.live_program_guide), + nowFormat = nowFormat, + ) + // Debounce input for 150ms per spec §7. LaunchedEffect(query) { delay(150) @@ -92,7 +104,7 @@ fun SearchOverlay( if (q.isEmpty()) { // Show the first 60 by default — gives a preview list users can scroll. results = channels.take(60).map { channel -> - SearchResult(channel, nowNext[channel.id]?.now?.let { "Now: ${it.title}" }) + SearchResult(channel, nowNext[channel.id]?.now?.let { nowFormat.format(it.title) }) } return@LaunchedEffect } @@ -104,7 +116,7 @@ fun SearchOverlay( .distinctBy { it.id } .take(200) .map { channel -> - SearchResult(channel, nowNext[channel.id]?.now?.let { "Now: ${it.title}" }) + SearchResult(channel, nowNext[channel.id]?.now?.let { nowFormat.format(it.title) }) } return@LaunchedEffect } @@ -138,7 +150,7 @@ fun SearchOverlay( ch.country?.lowercase() == q -> 200 else -> 0 } - SearchResult(ch, guideMatch?.let { labelProgramMatch(it, nowNext[ch.id]) }) to score + SearchResult(ch, guideMatch?.let { labelProgramMatch(it, nowNext[ch.id], guideLabels) }) to score } .filter { it.second > 0 } .sortedByDescending { it.second } @@ -214,7 +226,7 @@ fun SearchOverlay( decorationBox = { inner -> if (query.isEmpty()) { Text( - "Channel name, number, or category…", + stringResource(R.string.live_hint_search), style = TextStyle(color = LiveColors.FgMute, fontSize = 18.sp), ) } @@ -275,12 +287,24 @@ private fun IptvNowNext.bestProgramMatch(query: String): IptvProgram? { ?: candidates.firstOrNull { it.title.lowercase().contains(query) } } -private fun labelProgramMatch(program: IptvProgram, guide: IptvNowNext?): String { +private data class GuideMatchLabels( + val now: String, + val next: String, + val later: String, + val guide: String, + val nowFormat: String, +) + +private fun labelProgramMatch( + program: IptvProgram, + guide: IptvNowNext?, + labels: GuideMatchLabels, +): String { val prefix = when (program) { - guide?.now -> "Now" - guide?.next -> "Next" - guide?.later -> "Later" - else -> "Guide" + guide?.now -> labels.now + guide?.next -> labels.next + guide?.later -> labels.later + else -> labels.guide } return "$prefix: ${program.title}" } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt index b03ee9d2d..27a4e1dec 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt @@ -21,9 +21,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.R private data class TouchCategoryRailItem( val id: String, @@ -60,11 +62,11 @@ fun TouchCategoryRail( ) { Icon( imageVector = Icons.Filled.Search, - contentDescription = "Search", + contentDescription = stringResource(R.string.search), tint = LiveColors.FgDim, ) Text( - text = "Search channels", + text = stringResource(R.string.live_label_search_channels), style = LiveType.CatLabel.copy(color = LiveColors.Fg), ) } @@ -112,15 +114,15 @@ private fun rememberTouchRailItems( selectedId: String, ): List { val base = buildList { - tree.top.forEach { add(TouchCategoryRailItem(it.id, it.label, it.count)) } - tree.global.categories.forEach { add(TouchCategoryRailItem(it.id, it.label, it.count)) } - tree.countries.categories.forEach { add(TouchCategoryRailItem(it.id, it.label, it.count)) } - tree.adult.categories.forEach { add(TouchCategoryRailItem(it.id, it.label, it.count)) } + tree.top.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } + tree.global.categories.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } + tree.countries.categories.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } + tree.adult.categories.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } }.distinctBy { it.id }.toMutableList() val selected = tree.byId(selectedId) if (selected != null && base.none { it.id == selectedId }) { - base.add(0, TouchCategoryRailItem(selected.id, selected.label, selected.count)) + base.add(0, TouchCategoryRailItem(selected.id, liveCategoryLabel(selected.label), selected.count)) } return base diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt index bdf7432e0..146f7b683 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt @@ -1,7 +1,9 @@ package com.arflix.tv.ui.screens.watchlist +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.arflix.tv.R import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType.MOVIE import com.arflix.tv.data.model.MediaType.TV @@ -11,6 +13,7 @@ import com.arflix.tv.data.repository.TraktRepository import com.arflix.tv.data.repository.WatchlistRepository import com.arflix.tv.util.AppLogger import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,6 +39,7 @@ data class WatchlistUiState( @HiltViewModel class WatchlistViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val watchlistRepository: WatchlistRepository, private val cloudSyncRepository: CloudSyncRepository, private val traktRepository: TraktRepository, @@ -168,7 +172,7 @@ class WatchlistViewModel @Inject constructor( } else { _uiState.value = WatchlistUiState( isLoading = false, - error = "Failed to load Trakt watchlist" + error = context.getString(R.string.watchlist_error_load_trakt) ) } } else { @@ -219,7 +223,7 @@ class WatchlistViewModel @Inject constructor( val items = watchlistRepository.refreshWatchlistItems().watchlistDisplayOrder() _uiState.value = items.toSplitState(isLoading = false) } else if (!syncedFromTrakt) { - showLocalWatchlistOrError("Failed to load Trakt watchlist") + showLocalWatchlistOrError(context.getString(R.string.watchlist_error_load_trakt)) } else { enrichLocalWatchlistInBackground() } @@ -230,7 +234,7 @@ class WatchlistViewModel @Inject constructor( ) _uiState.value = _uiState.value.copy( isLoading = false, - toastMessage = "Failed to refresh", + toastMessage = context.getString(R.string.watchlist_error_refresh), toastType = ToastType.ERROR ) } @@ -265,7 +269,7 @@ class WatchlistViewModel @Inject constructor( try { val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) if (traktConnected && !traktRepository.removeFromWatchlist(item.mediaType, item.id)) { - throw IllegalStateException("Failed to remove from Trakt watchlist") + throw IllegalStateException(context.getString(R.string.watchlist_failed_remove_trakt)) } watchlistRepository.removeFromWatchlist(item.mediaType, item.id) @@ -275,7 +279,7 @@ class WatchlistViewModel @Inject constructor( _uiState.value = current.copy( movies = current.movies.filter { it.id != item.id || it.mediaType != item.mediaType }, series = current.series.filter { it.id != item.id || it.mediaType != item.mediaType }, - toastMessage = "Removed from watchlist", + toastMessage = context.getString(R.string.watchlist_toast_removed), toastType = ToastType.SUCCESS ) runCatching { cloudSyncRepository.pushToCloud() } @@ -294,7 +298,7 @@ class WatchlistViewModel @Inject constructor( ) ) _uiState.value = _uiState.value.copy( - toastMessage = "Failed to remove from watchlist", + toastMessage = context.getString(R.string.watchlist_failed_remove), toastType = ToastType.ERROR ) } diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkDownloader.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkDownloader.kt index be3a552c8..639c30300 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/ApkDownloader.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkDownloader.kt @@ -1,5 +1,8 @@ package com.arflix.tv.updater +import android.content.Context +import com.arflix.tv.R +import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.File @@ -9,6 +12,7 @@ import javax.inject.Singleton @Singleton class ApkDownloader @Inject constructor( + @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient ) { suspend fun download( @@ -29,10 +33,10 @@ class ApkDownloader @Inject constructor( val request = Request.Builder().url(url).build() downloadClient.newCall(request).execute().use { response -> if (!response.isSuccessful) { - error("Download failed: HTTP ${response.code}") + error(context.getString(R.string.update_error_download_http, response.code)) } - val body = response.body ?: error("Empty download body") + val body = response.body ?: error(context.getString(R.string.update_error_empty_download_body)) val total = body.contentLength().takeIf { it > 0L } body.byteStream().use { input -> FileOutputStream(destinationFile).use { output -> diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt index 7062e609e..f05bd1476 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import android.widget.Toast +import com.arflix.tv.R import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -62,7 +63,7 @@ class ApkInstallReceiver : BroadcastReceiver() { // Some Android TV forks (particularly Chinese AOSP variants) don't // handle the system confirm intent correctly. Log but don't crash. Log.e(TAG, "Failed to launch install confirmation Activity: ${e.message}", e) - showToast(context, "Update install requires manual confirmation. Please install from Downloads.") + showToast(context, context.getString(R.string.update_install_manual_confirm)) } } @@ -81,13 +82,13 @@ class ApkInstallReceiver : BroadcastReceiver() { PackageInstaller.STATUS_FAILURE_STORAGE -> { Log.e(TAG, "Update install failed: status=$status message=$message") val userMessage = when (status) { - PackageInstaller.STATUS_FAILURE_ABORTED -> "Update cancelled." - PackageInstaller.STATUS_FAILURE_BLOCKED -> "Update blocked by system policy." - PackageInstaller.STATUS_FAILURE_CONFLICT -> "Update conflicts with installed version. Try uninstalling first." - PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Update not compatible with this device." - PackageInstaller.STATUS_FAILURE_INVALID -> "Update package is invalid or corrupted." - PackageInstaller.STATUS_FAILURE_STORAGE -> "Not enough storage to install update." - else -> message ?: "Update install failed." + PackageInstaller.STATUS_FAILURE_ABORTED -> context.getString(R.string.update_install_cancelled) + PackageInstaller.STATUS_FAILURE_BLOCKED -> context.getString(R.string.update_install_blocked) + PackageInstaller.STATUS_FAILURE_CONFLICT -> context.getString(R.string.update_install_conflict) + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> context.getString(R.string.update_install_incompatible) + PackageInstaller.STATUS_FAILURE_INVALID -> context.getString(R.string.update_install_invalid) + PackageInstaller.STATUS_FAILURE_STORAGE -> context.getString(R.string.update_install_storage) + else -> message ?: context.getString(R.string.update_install_failed) } updateStatusManager.updateStatus(UpdateStatus.Failure(userMessage)) showToast(context, userMessage) diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt index c2eb9745c..d1d9fd876 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstaller.kt @@ -11,6 +11,7 @@ import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider import com.arflix.tv.BuildConfig +import com.arflix.tv.R import java.io.File import java.io.FileInputStream @@ -80,10 +81,10 @@ object ApkInstaller { } if (installedSigs.isNotEmpty() && apkSigs.isNotEmpty() && installedSigs != apkSigs) { - return "Signature conflict detected. The installed version was signed with a different key.\n\n" + - "To update, please uninstall the current version first:\n" + - "Settings > Apps > ${context.applicationInfo.loadLabel(pm)} > Uninstall\n\n" + - "Then install the new version. Your cloud-synced data (watchlist, progress) will be restored after login." + return context.getString( + R.string.update_error_signature_conflict, + context.applicationInfo.loadLabel(pm) + ) } } catch (_: Exception) { // Can't check — proceed with install attempt diff --git a/app/src/main/kotlin/com/arflix/tv/updater/AppUpdateRepository.kt b/app/src/main/kotlin/com/arflix/tv/updater/AppUpdateRepository.kt index 89694a669..ee5bfc63e 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/AppUpdateRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/AppUpdateRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import com.arflix.tv.BuildConfig +import com.arflix.tv.R import com.google.gson.Gson import com.google.gson.annotations.SerializedName import dagger.hilt.android.qualifiers.ApplicationContext @@ -63,21 +64,21 @@ class AppUpdateRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) { - error("GitHub API error: ${response.code}") + error(context.getString(R.string.update_error_github_api, response.code)) } val body = response.body?.string().orEmpty() val dto = gson.fromJson(body, GitHubReleaseDto::class.java) - ?: error("Empty GitHub release response") + ?: error(context.getString(R.string.update_error_empty_release_response)) if (dto.draft || dto.prerelease) { - error("Latest release is draft/prerelease") + error(context.getString(R.string.update_error_draft_prerelease)) } val tag = dto.tagName?.takeIf { it.isNotBlank() } ?: dto.name?.takeIf { it.isNotBlank() } - ?: error("Release has no tag/name") + ?: error(context.getString(R.string.update_error_missing_release_tag)) val asset = AbiSelector.chooseBestApkAsset(dto.assets) - ?: error("No APK asset found in release") + ?: error(context.getString(R.string.update_error_no_apk_asset)) AppUpdate( tag = tag, diff --git a/app/src/main/kotlin/com/arflix/tv/util/AuthEmailValidator.kt b/app/src/main/kotlin/com/arflix/tv/util/AuthEmailValidator.kt index c84c3fa77..2329c5b37 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/AuthEmailValidator.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/AuthEmailValidator.kt @@ -1,5 +1,8 @@ package com.arflix.tv.util +import androidx.annotation.StringRes +import com.arflix.tv.R + object AuthEmailValidator { private val emailRegex = Regex( pattern = "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,63}$", @@ -23,26 +26,27 @@ object AuthEmailValidator { fun normalize(email: String): String = email.trim().lowercase() - fun validate(email: String, rejectDisposable: Boolean = true): String? { + @StringRes + fun validate(email: String, rejectDisposable: Boolean = true): Int? { val normalized = normalize(email) - if (normalized.isBlank()) return "Email is required" + if (normalized.isBlank()) return R.string.auth_email_required if (normalized.length > 254 || !emailRegex.matches(normalized)) { - return "Enter a valid email address" + return R.string.auth_email_invalid } val localPart = normalized.substringBefore("@") val domain = normalized.substringAfter("@", missingDelimiterValue = "") if (localPart.isBlank() || domain.isBlank()) { - return "Use a real email address" + return R.string.auth_email_real_required } if (rejectDisposable && domain in blockedDomains) { - return "Use a real email address" + return R.string.auth_email_real_required } if (rejectDisposable && (domain.endsWith(".invalid") || domain.endsWith(".test") || domain.endsWith(".local"))) { - return "Use a real email address" + return R.string.auth_email_real_required } if (domain.split(".").any { it.isBlank() }) { - return "Enter a valid email address" + return R.string.auth_email_invalid } return null } diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ae41a37ae..1bdd89427 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,9 +1,17 @@ + ARVIO + LECTURE EN COURS + + Accueil Recherche Liste Paramètres + TV + Ma liste + + Général Films Film @@ -12,53 +20,1117 @@ Série Tous les genres Toutes les langues - Langue de l\'app + + + Continuer à regarder + Films tendances + Séries tendances + Anime tendances + + + Langue de l’app Langue et sous-titres - Sous-titres + Langue et audio Audio Lecture Interface Réseau Catalogues Comptes + + Sources Détails Lire Bande-annonce Saisons Saison + Saison %d Épisodes + Épisode %d Distribution Avis Similaire + Budget + En cours + + + Ajouter à la liste + Retirer de la liste + Votre liste est vide + Ajoutez des films et séries à regarder plus tard + + + Sous-titres + Sous-titre par défaut + Langue de sous-titres secondaire + Langue de sous-titres de secours quand la principale est indisponible + Filtrer les sous-titres par langue + N’affiche que les sous-titres dans votre langue préférée + + + Lecture auto. du suivant + Épisode suivant + Suivant + Direct + Maintenant + Plus tard + Se termine à + + + Recherche des sources… + Sources disponibles + sources disponibles + + Fermer Retour Annuler Confirmer Réessayer - Chargement - Vide Supprimer Ajouter Actualiser sélectionné Connexion Déconnexion - Suivant - Direct - Maintenant - Plus tard + Vu + Non vu + + Désactivé Activé Auto - Budget - En cours - Recherche des sources... - Sources disponibles + + + Chargement… + Chargement + Vide Aucun résultat trouvé Aucun résultat trouvé pour - Votre liste est vide - Ajoutez des films et séries à regarder plus tard - sources disponibles - Se termine à + Erreur de chargement du contenu + + + Langue du contenu + Audio par défaut + Taille des sous-titres + Couleur des sous-titres + Style des sous-titres + Position des sous-titres + Lecture auto. du suivant + Lecture automatique + Qualité min. lecture auto. + Lecture auto. des bandes-annonces + Adapter la fréquence d’images + Filtres regex de qualité + Disposition des cartes + Mode d’interface + Ignorer la sélection de profil + Fond noir OLED + Format de l’heure + Afficher le budget sur l’accueil + Afficher les stats de chargement + Amplification du volume + Fournisseur DNS + User-Agent personnalisé + Laissez vide pour utiliser les valeurs par défaut d’ARVIO. Les valeurs personnalisées s’appliquent quand une source ne fournit pas son propre User-Agent. + Ajouter une playlist + Actualiser les données TV + Supprimer les playlists TV + Ajouter un catalogue + Renommer le catalogue + Addons + Services + Genres + Décennies + Franchises + Chaînes + À la une + Séries tendances + Ajouter un addon + Compte Trakt + Compte Cloud + Forcer la synchronisation Cloud + Changer de profil + Version de l’app + + + Texte de l’app, titres, descriptions et métadonnées + Sélection auto. de la langue des sous-titres + Piste audio préférée + Taille du texte des sous-titres + Couleur du texte des sous-titres + Style gras, normal ou avec fond pour les sous-titres + Position verticale des sous-titres + Sous-titres stylisés + Respectez les styles intégrés des sous-titres ASS/SSA. Désactivé, tous les sous-titres utilisent vos réglages de taille, couleur et style. + Démarre automatiquement l’épisode suivant + Désactivé, le sélecteur de source s’ouvre à la lecture + Qualité minimale pour la lecture auto. + Lire les bandes-annonces dans la bannière hero + Son des bandes-annonces + Lire l’audio des bandes-annonces dans la bannière hero + Délai des bandes-annonces + Secondes avant le démarrage de la bande-annonce + Bandes-annonces dans les cartes + TV : lit les bandes-annonces dans la carte sélectionnée au lieu du plein écran + Désactivé, fluide ou toujours + Exclure des niveaux de qualité sur cet appareil + Cartes paysage ou affiche + Forcer TV, tablette ou téléphone + Charge auto. le dernier profil utilisé au redémarrage + Utilisez des fonds noir pur pour les écrans OLED + Choisissez le format 12 h ou 24 h + Affiche le budget du film dans la bannière hero de l’accueil + Flou anti-spoiler + Floutez les cartes d’épisodes non vus pour éviter les spoilers + Couleur d’accentuation + Choisissez la couleur d’accentuation des contours de focus, boutons et éléments sélectionnés + Affiche la progression de résolution du stream + Amplifie les sources peu sonores (via le LoudnessEnhancer système) + Résout les requêtes API et de stream + Importez une URL de catalogue Trakt ou MDBList + + + Piste audio + Aucune piste audio disponible + Appuyez sur RETOUR pour fermer + + + Au cinéma + Inclus avec Prime + + + Mise à jour de l’app + TV + La TV n’est pas configurée + Saisissez le code : + Synchronisation + Synchroniser + Connecté + Connecter + Qui regarde ? + Profils + Gérer les profils + Ajouter un profil + Chargement du profil… + Saisissez le code PIN pour déverrouiller + Définir le code PIN du profil + Terminé + Enregistrer + Se connecter à ARVIO Cloud + Voir la collection + + + Réglages des sous-titres + Délai + Taille + Position verticale + + + Sous-titres IA + Traduction IA des sous-titres + Traduis les sous-titres en temps réel avec Groq ou Gemini + Modèle d’IA + Choisissez le fournisseur et le modèle de traduction + Sélection auto. de la traduction IA + Activez automatiquement l’IA quand une langue intégrée est disponible + Retirer le texte pour malentendants + Supprimez les marqueurs pour malentendants [entre crochets] avant la traduction + Clé API + Saisissez manuellement votre clé API Groq ou Gemini + Scanner un QR code pour définir la clé + Ouvrez un navigateur sur votre téléphone pour saisir la clé API + Offre gratuite Groq : ~30 requêtes/min + Nécessite un compte Google avec facturation activée. Coût : moins de 0,01 $ par film + Non définie + Choisissez le fournisseur et le modèle d’IA pour la traduction des sous-titres + Offre gratuite : 30 RPM / 500K RPD + Meilleure qualité · Nécessite un compte Google avec facturation activée + Saisissez votre clé API Groq + Saisissez votre clé API Gemini + Clé API enregistrée + La traduction IA est prête à l’emploi + Scannez avec votre téléphone — choisissez Groq ou Gemini + Défilement plus fluide + Utilisez des transitions fluides premium dans les rangées d’affiches + + %1$d fournisseurs + Mis à jour : %1$s + Actualiser le dépôt + Retirer le dépôt + Version : %1$s + Tester le plugin + Tester + Masquer le diagnostic + Afficher le diagnostic + Résultats : %1$d + + %1$d de plus + Erreur inconnue + URL de dépôt invalide + Dépôt ajouté avec %1$d fournisseurs + Erreur lors de l’ajout du dépôt + Dépôt retiré + Dépôt actualisé avec succès + Erreur lors de l’actualisation du dépôt + Aucun résultat trouvé + %1$d streams trouvés + Erreur lors du test du plugin + + Confirmer + Avertissement + Ce plugin peut être risqué. + Annuler + Activer + Plugin : + Plugins : %1$s + + + Choisir une catégorie + Catégories de chaînes + Guide des chaînes + + + + Serveur personnel + ARVIO V%1$s + URL + Utilisez HTTPS quand c’est possible. Les URL de serveur HTTP sont autorisées pour les réseaux locaux mais ne sont pas chiffrées. + Nom du serveur + Optionnel, affiché dans les sources + URL du serveur + http://server:8096 ou http://server:32400 + Nom d’utilisateur + Optionnel pour la connexion par jeton + Mot de passe / jeton + Connecter avec un code + URL du serveur (optionnel) + Laissez vide pour détecter automatiquement + Modifier la playlist TV + Ajouter une playlist TV + L’EPG prend en charge plusieurs sources pour cette playlist. Ajoutez une URL par ligne ; ARVIO les associera dans l’ordre. + Nom de la playlist + URL M3U ou hôte Xtream + https://fournisseur.hote:port + Nom d’utilisateur Xtream (optionnel) + Laissez vide pour du M3U simple + Mot de passe Xtream (optionnel) + Sources EPG (optionnel) + Une URL par ligne. Les virgules, points-virgules et barres verticales sont aussi acceptés. + Titre + Ajouter un filtre de qualité + Modifier le filtre de qualité + Ouvrez la page d’authentification, connectez-vous, puis revenez dans ARVIO. Cet écran se terminera automatiquement. + Scannez le QR code ou ouvrez la page d’authentification et confirmez ce code + Filtres locaux à l’appareil. Les streams correspondants sont exclus. + Aucun filtre configuré pour l’instant. + Appareil sans nom + Ajouter un filtre + Nom de l’appareil / du préréglage + Motif regex + Connexion ARVIO Cloud + E-mail + Mot de passe + Créer + Saisissez votre e-mail et votre mot de passe pour vous connecter. + Astuce : utilisez le clavier TV. D-pad pour naviguer. + Appairage ARVIO Cloud + Connectez-vous avec votre e-mail et votre mot de passe pour lier cet appareil. + Scannez ce QR code pour vous connecter et lier cette TV. + Code : %1$s + En attente d’approbation… + Utiliser e-mail et mot de passe + E-mail/mot de passe + Connecter Trakt.tv + Allez sur %1$s et saisissez ce code + En attente d’autorisation + Ouvrir la page d’authentification + Copier le code + Lecture et commandes + Audio et sous-titres + Plugins et extensions + CATÉGORIES + LANGUES + INFOS ET COMPTE + Forcer la sync + Déconnecter + Ouvrir + Mise à jour disponible + Vérifier les mises à jour + LECTURE + COMMANDES + SOUS-TITRES + AUDIO + APPARENCE + Autoriser les sources inconnues + Autorise l’installation depuis des sources inconnues pour ARVIO afin que l’APK de mise à jour téléchargé puisse être installé. + Ouvrir les paramètres + Aperçu + Réglage ciblé + Section + État + Profil + Système + Texte de l’appli et piste audio préférée. + Langue par défaut des sous-titres, style d’affichage et filtrage. + Options de traduction et de nettoyage des sous-titres par IA. + Lecture auto, bandes-annonces, qualité des sources, synchro de fréquence d’images et amplification audio. + Mise en page, mode OLED, style de focus et métadonnées du hero. + Comportement des profils au démarrage de cet appareil. + Préférences DNS et de diagnostic de chargement. + Playlists TV, actualisation EPG, chargement des chaînes et gestion des playlists. + Connectez des serveurs multimédias personnels et utilisez leurs bibliothèques comme sources. + Découvrez, renommez, ordonnez et supprimez les rangées d’accueil et les catalogues de listes. + Gère les addons tiers utilisés pour la découverte de catalogues et de sources. + Sync Cloud, connexion Trakt, mises à jour de l’appli et contrôles du compte. + Configure ARVIO pour ce profil. + Appli %1$s + Audio %1$s + Défaut %1$s + Taille %1$s + IA activée + IA désactivée + Sélection auto activée + Sélection auto désactivée + Lecture auto %1$s + Bandes-annonces %1$s + Min %1$s + activé + désactivé + OLED %1$s + Ignorer activé + Sélecteur activé + DNS %1$s + Stats activées + Stats désactivées + %1$d/3 playlists + %1$s chaînes + Actualisation + Prêt + %1$d connecté + En cours + Inactif + %1$d catalogues + Synchro Cloud + %1$d installé + Limité au profil + Cloud connecté + Cloud désactivé + Trakt connecté + Trakt désactivé + Langue de l’app + Défaut + Secondaire + Style + IA + Activé + Sélection auto + Lecture auto + Bandes-annonces + Fréquence d’images + OLED + Couleur d’accent + Démarrage + Ignorer le sélecteur + Afficher le sélecteur + DNS + Stats de chargement + User-Agent + Playlists + Chaînes + EPG + Configuré + Optionnel + État + Serveurs + État + Découverte + Recherche + Portée + Profil actuel + Cloud + Déconnecté + Trakt + Mises à jour + Disponible + Build Play + Choisissez un réglage + Déplacez-vous vers le panneau central pour modifier la section sélectionnée. + Changez la langue de l’interface et des métadonnées utilisée par ARVIO. + Langue audio préférée quand plusieurs pistes existent. + Langue de sous-titres préférée au lancement de la lecture. + Sous-titre secondaire + Préférence de second sous-titre optionnelle quand elle est disponible. + Préférence de sous-titres + Ajuste la taille, la couleur, la position, le style et le filtrage des sous-titres. + Configure la traduction et le nettoyage optionnels des sous-titres par IA. + Lecture auto de l’épisode suivant + Détermine si les épisodes s’enchaînent automatiquement. + Lecture auto de la source + Détermine si une source unique démarre immédiatement ou ouvre le sélecteur. + Bandes-annonces + Réglez la lecture auto et le son des bandes-annonces sur les bannières hero. + Amplifie la sortie audio de lecture quand c’est activé. + Réglez la qualité des sources, la synchro de fréquence d’images et le comportement de lecture. + Contrôle la mise en page, le mode OLED, l’horloge et le style de focus. + Détermine si cet appareil ouvre le sélecteur de profil au démarrage. + Ajouter une playlist + Ajoutez une autre playlist IPTV avec des informations M3U ou Xtream. + Playlist IPTV + Activez, modifiez, réordonnez, actualisez ou supprimez les données IPTV. + Ajouter un serveur + Connectez un serveur multimédia personnel avec une URL et des identifiants. + Utilisez l’authentification par code d’appareil pour les serveurs personnels pris en charge. + Connexion au serveur + Modifiez, testez ou supprimez les serveurs multimédias personnels connectés. + Découvrir un catalogue + Recherchez des catalogues de listes publics ou ajoutez une URL directe. + Rangée de catalogue + Renommez, réordonnez, changez la disposition ou supprimez ce catalogue. + Addon + Activez, désactivez, ajoutez ou supprimez des sources d’addons tiers. + Connectez ou déconnectez la sync Cloud d’ARVIO. + Trakt + Connectez ou déconnectez l’historique et les listes Trakt. + Envoie/récupère maintenant les dernières données de profil synchronisées. + Mises à jour de l’appli + Vérifiez les mises à jour en sideload ou installez une mise à jour téléchargée. + Données du compte + Ouvrez les informations sur le compte et la suppression des données. + Réglage + Appuyez sur OK pour changer cette option. + Serveur multimédia + CONNEXIONS + Ajouter un serveur + Bibliothèques multimédias personnelles comme sources + En ajouter un autre + Connectez-vous avec un code de serveur + En attente + %1$d collections + ACTIONS + Tester la connexion + Connectez d’abord un serveur + Vérifiez que ce profil peut joindre chaque serveur + Tout déconnecter + Aucun serveur n’est connecté + Retirez tous les serveurs du profil actif + Connectez-vous avec un code de serveur et utilisez les bibliothèques multimédias comme sources + Code + Modifier + Retirer + Tester + QR code + Glisser pour réordonner + Gérer les catégories + %1$s (%2$d%%) + Actualisation des chaînes et de l’EPG… + Recharger les playlists maintenant + Recharger la playlist et l’EPG maintenant + Aucune playlist configurée + Retirez les playlists, l’EPG et les favoris + Ajoutez jusqu’à 3 listes IPTV M3U / Xtream avec des noms + Créer une autre liste IPTV + PLEIN + AJOUTER + CHARGEMENT + ACTUALISER + VIDE + SUPPRIMER + %1$d sélectionné(s) + PLAYLISTS + Ajoutez jusqu’à 3 listes TV M3U / Xtream + Créer une autre liste TV + Plein + Rechercher des listes + Coller une URL + URL Trakt ou MDBList + Ajouter l’URL + Recherche dans les deux sources + %1$d listes trouvées + Recherche automatiquement dans Trakt et MDBList + Appuyez sur un champ pour le modifier + Appuyez sur Sélectionner sur un champ pour le modifier + Recherche des listes + Commencez par une recherche + Essayez les animes les plus tendance, les meilleurs films d’horreur, Marvel dans l’ordre chronologique, ou les films de 2025. + Coller l’URL du catalogue + Utiliser la recherche + Utiliser l’URL + par %1$s + %1$s éléments + %1$s likes + Mis à jour %1$s + Ajouté + Intégré + Addon + AJOUTER UN CATALOGUE + MES CATALOGUES + %1$s (collection intégrée) + %1$s (rangée intégrée) + %1$s (intégré) + Collection + rangée %1$s + collection %1$s + Catalogue préinstallé + De %1$s + Du serveur personnel + Catalogue personnalisé + AJOUTER UN ADDON + Installez un addon Stremio personnalisé par URL + MES ADDONS + Aucun addon installé + Monter l’addon + Descendre l’addon + Supprimer l’addon + ADDONS STREMIO + Installé + Désactivé + Compte optionnel pour synchroniser les profils, addons, catalogues et réglages IPTV + Synchronise l’historique de visionnage, la progression et la liste + Recherche des fichiers vidéo dans vos chaînes et groupes + OUVRIR + Synchronisation de l’état local et Cloud en cours + Envoie l’état local, puis restaure depuis le Cloud maintenant + Connectez-vous à ARVIO Cloud pour forcer la sync + SYNCHRO + SYNC + Cette installation est gérée par le Play Store + Dernière mise à jour téléchargée et prête à installer + Recherche d’un APK plus récent dans les GitHub Releases + Mise à jour disponible : %1$s + Vous avez déjà la dernière version d’ARVIO + Vérifiez les GitHub Releases pour le dernier APK ARVIO + PLAY + INSTALLER + VÉRIF + METTRE À JOUR + VÉRIFIER + Confidentialité et suppression des données + Ouvrez les instructions de confidentialité et de suppression du compte ARVIO Cloud + Allez sur : %1$s + Coller + Coller depuis le presse-papiers + Appuyez sur Entrée pour sélectionner • Naviguez avec le D-pad + Appuyez sur un champ pour le modifier, puis appuyez sur Confirmer + Utilisez le D-pad pour vous déplacer, appuyez sur OK pour modifier un champ + champ + Coller dans %1$s + Appuyez sur un champ pour le modifier, appuyez sur Confirmer une fois terminé + OK : modifier/sélectionner • Retour : ferme d’abord le clavier + Appuyez sur Entrée pour sélectionner + Changer le mode d’interface + TV + Tablette + Téléphone + Voulez-vous vraiment changer le mode d’interface vers %1$s ? + CATÉGORIES IPTV + Réinitialiser l’ordre + Restaure l’ordre par défaut des catégories + RÉINIT + Aucune catégorie disponible + Masqué + Visible + Défaut + Saisissez %1$s… + 12 heures + 24 heures + Tablette + Téléphone + DNS système + + Animaux + Personnages + Médias + Nature + + Appuyez sur Retour pour annuler + + + Connexion en cours… + Préparation du QR code… + Connecter Telegram + Cherche dans vos chaînes et groupes les fichiers vidéo dès que vous ouvrez un film ou une série. + ARVIO n’est pas responsable du contenu diffusé via cette fonctionnalité. Connectez uniquement votre propre compte et utilisez-la de façon loyale — respectez le droit d’auteur et les lois applicables. + Scannez avec Telegram + Ouvrez Telegram sur votre téléphone → Paramètres → Appareils → Lier un appareil de bureau + QR code Telegram + Le QR code expire dans 30 secondes — il se rafraîchit automatiquement + Saisissez votre numéro de téléphone + Utilisez le même numéro que celui associé à votre compte Telegram. + Numéro de téléphone + Doit commencer par + et inclure l’indicatif du pays, par ex. +1 pour les US, +44 pour le UK + Format international : +[indicatif pays][numéro] + Envoi du code… + ENVOYER LE CODE + Code envoyé sur votre application Telegram + Saisissez le code + Consultez votre application Telegram pour un code à %1$d chiffres. + Code de vérification + Vérification en deux étapes + Votre compte possède un mot de passe supplémentaire. + Mot de passe + Connecté en tant que %1$s + DÉCONNECTER + Cache vidéo + VIDER + Échec de la connexion + RÉESSAYER + Déconnecter Telegram ? + Vous devrez vous reconnecter pour utiliser Telegram comme source. + Plugins (test) + Ajouter un dépôt + Dépôts installés + Scrapers installés + Aucun scraper installé. + Ajouter un dépôt de plugins + URL du dépôt + + + Modifier le profil + Nom du profil + Verrouillage du profil + Définir un PIN + Modifier le PIN + Supprimer le PIN + Changer la photo + Importer une photo + Supprimer la photo + Saisissez votre PIN + Saisie du PIN + Confirmez le PIN + 4 à 5 chiffres pour verrouiller ce profil + Ressaisissez le PIN pour confirmer + Effacer + Le PIN doit comporter 4 à 5 chiffres + Les PIN ne correspondent pas + Votre bibliothèque, pensée pour la TV. + Gardez votre liste, votre historique et la sync Trakt liés à votre compte. + Créez votre compte + Connectez-vous pour continuer + E-mail + Mot de passe + S’inscrire + Vous avez déjà un compte ? Connexion + Vous n’avez pas de compte ? Inscrivez-vous + Rien à afficher ici pour l’instant. + + + Ce torrent debrid est encore en cours de téléchargement. Essayez une autre source ou patientez un peu. + S%1$d:E%2$d + Série TV + Marquer comme vu + Dans la liste + Fournisseur de streaming principal + Aucun synopsis disponible pour cet épisode. + Spoiler + Choisir la saison %1$d + Afficher les options de saison + Top 10 des films du jour + Top 10 des séries du jour + Vérifiez votre connexion + Fournisseur de streaming principal + Rang n°%1$d + Tous + Anime + Reculer de 10 s + Avancer de 10 s + Picture-in-picture + Volume + Format : %1$s + Pause + Traduction par IA + Arrêter la diffusion + Diffuser sur la TV + Coupé + Configuration + Erreur + Configuration de l’addon requise + Erreur de lecture + Une erreur inconnue est survenue + + + DIRECT + MAINTENANT + SUIVANT + RATTRAPAGE + DIFFUSÉ + ARCHIVE + NOUVEAU + CH + CH %1$d + %1$d sources + %1$d résultats + Choisir la source + Rattrapage + Diffusé + CHAÎNES + MAINTENANT %1$s + · %1$dmin + %1$s %2$dx + Toutes les chaînes + Rechercher des chaînes + Favoris + Vus récemment + Adulte + Non classé + PLAYLIST + ADULTE + MASQUÉ + Hier + Aujourd\'hui + Demain + dans %1$dmin + dans %1$dh %2$dmin + plus tard + Guide + Maintenant : %1$s + Chargement du guide visible… + Aucune source EPG configurée + Guide en attente + %1$s | %2$d/%3$d correspondances visibles + Aucune donnée de programme + Aucune chaîne + Aucune chronologie de programme n’est disponible pour cette chaîne. + Aucune chronologie de guide n’est encore disponible. + Aucune chaîne dans cette catégorie + Aucune playlist IPTV configurée. + Chargement du guide… + Aucune donnée de guide trouvée + Guide en attente… + Aucune source de guide + GUIDE + Ouvrir les paramètres + Nom de chaîne, numéro ou catégorie… + Plein écran + Fermer le guide + Rattrapage disponible + Pause + Recherche du rattrapage + Démarrage du flux en direct + Préparation de la source + Rattrapage indisponible + Échec de la lecture + Le fournisseur n’a pas renvoyé de flux lisible. + Nouvelle tentative + + + QR code + Avatar + Appuyez sur une touche pour continuer + DIRECT + TV + FILM + Marquer comme vu + Retirer de la reprise + Série TV + Média + Logo %1$s + Nouvel épisode + 1 ép restant + %1$d ép restants + S%1$d • E%2$d + Biographie + Connu pour + dans le rôle de %1$s + Chargement des sources… + Résolution depuis %1$s + Recherche du meilleur stream… + Ignorer + Télécharger + Installer + Masquer + Réessayer l’installation + Vérification des releases GitHub… + Mise à jour disponible : %1$s (%2$s) + Téléchargement de la mise à jour… + %1$s est prête à être installée. + Installation de la mise à jour… Suivez les instructions du système. + Échec de la mise à jour. + Vous avez déjà la dernière version installée. + Aucune information de release disponible. + Version actuelle %1$s -> dernière %2$s + La version actuelle %1$s est à jour + Préparation… + La dernière mise à jour ARVIO a été téléchargée et est prête à être installée. + L’installateur de paquets Android devrait apparaître. Si ce n’est pas le cas, vous pouvez appuyer à nouveau sur Installer. + Toutes les sources + Choisir une source + Aucun addon de streaming + Aucune source trouvée + Allez dans Paramètres → Addons pour ajouter\nun addon de streaming + Essayez d’ajouter d’autres addons + Recherche dans les addons (%1$d/%2$d)… + Aucune source ne correspond à ce filtre + Meilleure correspondance + ADDONS + Total + Vérifiés + + + L’installation de la mise à jour nécessite une confirmation manuelle. Installez-la depuis Téléchargements. + Mise à jour annulée. + Mise à jour bloquée par la politique du système. + La mise à jour entre en conflit avec la version installée. Essayez d’abord de la désinstaller. + Mise à jour incompatible avec cet appareil. + Le paquet de mise à jour est invalide ou corrompu. + Espace de stockage insuffisant pour installer la mise à jour. + Échec de l’installation de la mise à jour. + Sous-titres IA : aucune clé API définie. Ajoutez-la dans Paramètres → Général. + Sous-titres IA : limite de débit atteinte — traduction en pause. + Sous-titres IA : clé API invalide. + Sous-titres IA : erreur de traduction — %1$s + Impossible de résoudre l’identifiant IMDB. Réessayez. + Échec du chargement des détails + + + + Saisissez votre mot de passe + Le mot de passe doit comporter au moins 6 caractères + Patientez %1$d s avant de créer un autre compte + Échec du chargement de la liste Trakt + Échec de l’actualisation + Retiré de la liste + + + + Reprendre S%1$dE%2$d + Reprendre S%1$dE%2$d à %3$s + Reprendre à %1$s + Reprendre depuis %1$s + Reprendre + Commencer S1E1 + %1$s depuis %2$s + Ajouté à la liste + Échec de la vérification des mises à jour + Échec du téléchargement + Échec de la recherche de catalogues + Échec de l’ajout du catalogue + Échec de la mise à jour du catalogue + Échec de la suppression du catalogue + Échec de la lecture des métadonnées du catalogue + Catalogue déjà ajouté + Catalogue introuvable + Les catalogues préinstallés ne peuvent pas être modifiés + Échec de la liaison de la TV + Échec de la synchronisation : %1$s + Échec de la connexion au Home Server + Échec de la connexion par code + Échec de la connexion au serveur + Échec du test du Home Server + + + Échec de l’ajout de l’addon + Échec du démarrage de la connexion Cloud + Échec du démarrage de la connexion Cloud + La connexion Cloud n’a pas pu démarrer. Réessayez. + Approuvé, mais des jetons manquaient. Réessayez. + Échec de l’import des jetons de session + Connexion Cloud expirée. Réessayez. + Échec de la connexion Cloud. Réessayez. + Échec de la synchronisation Cloud + Échec de la synchronisation Cloud lors de l’envoi + + + Aucun épisode trouvé pour la saison %1$d + Échec du chargement de la saison %1$d + Aucun épisode sélectionné + Échec de la mise à jour du statut de visionnage + Échec de l’ajout à la liste Trakt + Échec de la suppression de la liste Trakt + Échec de la mise à jour de la liste + Échec du marquage de la saison comme vue + Échec de la synchronisation de la saison %1$d comme non vue avec Trakt + Échec du marquage de la saison comme non vue + Marqué comme vu + Marqué comme non vu + S%1$dE%2$d marqué comme vu + S%1$dE%2$d marqué comme non vu + Saison %1$d marquée comme vue + Saison %1$d marquée comme non vue + + + Catalogue MDBList + Chargement des chaînes depuis le portail… + Chargement du guide complet (%1$d/%2$d)… + Chargement des flux en direct… + Chargement du fournisseur EPG %1$d/%2$d… + Chargement du guide Xtream complet %1$d/%2$d… + Chargement du guide Xtream complet… + Chargement du guide complet… %1$d/%2$d flux + Aucun flux catchup lisible renvoyé par le fournisseur + La playlist est chargée mais ne contient aucune chaîne. + Échec du chargement de la playlist M3U. + + + Saisissez une URL de serveur valide + Saisissez un mot de passe ou un jeton + Saisissez un nom d’utilisateur + Aucun Home Server connecté + Le serveur n’a pas renvoyé de code d’activation + Aucune URL de serveur joignable pour ce compte + L’identité du serveur ne correspond pas au serveur du compte sélectionné + Aucune bibliothèque accessible pour ce compte + Le Home Server est désactivé ou incomplet + Réponse d’authentification incomplète + Échec de l’import de la session d’authentification + Échec de la connexion Google + Type d’identifiant inattendu + Échec de l’analyse des identifiants Google + Non authentifié avec Trakt + Échec de l’authentification Supabase + %1$s - %2$s + La recherche Telegram a expiré + + + Impossible d’importer l’image de l’avatar + Profil créé + Profil mis à jour + Profil supprimé + Code PIN du profil défini + Échec du chargement de l’IPTV + Échec du démarrage de l’authentification TV + Échec de la vérification du statut d’authentification TV + + + Échec du chargement du contenu + Échec de la suppression de la liste Trakt + Échec de la suppression de la liste + Échec du chargement de la collection + + + Aucune info d’épisode disponible + Déjà vu + Retiré de Continuer à regarder + Échec du retrait de Continuer à regarder + Vous avez déjà la dernière version + + + + Un compte existe déjà. Connectez-vous plutôt. + Échec de la connexion. Réessayez. + E-mail ou mot de passe invalide. + Vérifiez votre e-mail pour continuer. + Échec de la connexion + Échec de l’inscription + Impossible de créer le compte + URL de connexion par code invalide + Échec de la connexion par code (%1$d) + Échec du suivi de la connexion par code (%1$d) + URL de serveur invalide + Échec de la requête serveur (%1$d) + Échec de la connexion au serveur (%1$d) + La connexion au serveur n’a pas renvoyé de compte exploitable + Jeton de compte manquant + Échec de la recherche Telegram + Trop de recherches — patientez %1$d s avant de réessayer + Telegram : %1$s + + + + L’e-mail est requis + Saisissez une adresse e-mail valide + Utilisez une vraie adresse e-mail + Sous-titres d’OpenSubtitles + L’URL de l’addon est vide + Bundle de scrapers HTTP locaux (%1$d fournisseurs HTTP) + Échec du téléchargement : HTTP %1$d + Corps de téléchargement vide + Conflit de signature détecté. La version installée a été signée avec une clé différente.\n\nPour mettre à jour, désinstallez d’abord la version actuelle :\nParamètres > Applications > %1$s > Désinstaller\n\nPuis installez la nouvelle version. Vos données synchronisées dans le cloud (liste, progression) seront restaurées après connexion. + Erreur de l’API GitHub : %1$d + Réponse de release GitHub vide + La dernière release est un brouillon/préversion + La release n’a pas de tag/nom + Aucun fichier APK trouvé dans la release + + + + Derniers films + Dernières séries + Action + Comédie + Science-fiction + Thriller + Drame + Horreur + Documentaire + Romance + Animation + Famille + Fantastique + Aventure + Super-héros + Guerre et militaire + Films des années 20 + Films des années 10 + Films des années 2000 + Films des années 90 + Films des années 80 + Films des années 70 + Films des années 60 + + + + Toutes + Toujours + Fluide uniquement + Petite + Moyenne + Grande + Très grande + Blanc + Rouge + Orange + Jaune + Vert + Bleu + Indigo + Violet + Cyan + Normal + Gras + Avec fond + Basse + En bas + Haute + Aucune + Forcés + Auto (original) + + + + Impossible de décoder l’image d’avatar sélectionnée + Échec du téléchargement de l’avatar : HTTP %1$d + Réponse d’avatar vide + Échec de l’envoi de l’avatar : HTTP %1$d + Réponse EPG vide + Échec de la requête EPG (HTTP %1$d). + Aucune URL EPG configurée + Réponse EPG vide à la nouvelle tentative + Délai de téléchargement EPG dépassé pour %1$s + EPG en cache utilisé + Vous n’êtes pas connecté + Connexion au portail Stalker… + %1$d chaînes en direct chargées via l’API + %1$d chaînes chargées + Réponse M3U vide. + Échec de la requête M3U (HTTP %1$d). + Délai de chargement de la playlist dépassé. Actualisez ou utilisez les identifiants Xtream du fournisseur. + Démarrage du chargement IPTV… + Fournisseur Xtream détecté. Chargement des chaînes en direct… + Délai dépassé pour le fournisseur Xtream lors du chargement. + Bibliothèque + Bibliothèque %1$s + Sans titre + Programme inconnu + Synchronisation déjà en cours + Échec du démarrage de TDLib : %1$s + TDLib non disponible sur cet appareil + + + + Déplacer en haut + Monter + Descendre + Masquer la catégorie + Afficher la catégorie diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c0799529..64312ea91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,4 +289,848 @@ Select Category Channel Categories Channel Guide + + + + Home Server + ARVIO V%1$s + URL + Use HTTPS where possible. HTTP server URLs are allowed for local networks but are not encrypted. + Server name + Optional, shown in sources + Server URL + http://server:8096 or http://server:32400 + Username + Optional for token sign-in + Password / token + Connect with code + Server URL (optional) + Leave empty to discover automatically + Edit TV Playlist + Add TV Playlist + EPG supports multiple sources for this playlist. Add one URL per line; ARVIO will match them in order. + Playlist Name + M3U URL or Xtream Host + https://provider.host:port + Xtream Username (Optional) + Leave empty for plain M3U + Xtream Password (Optional) + EPG Sources (Optional) + One URL per line. Commas, semicolons, and pipes are also accepted. + Title + Add Quality Filter + Edit Quality Filter + Open the auth page, sign in, then return to ARVIO. This screen will finish automatically. + Scan the QR code or open the auth page and confirm this code + Device-local filters. Matching streams are excluded. + No filters configured yet. + Unnamed Device + Add Filter + Device / Preset Name + Regex Pattern + ARVIO Cloud Sign-in + Email + Password + Create + Enter your email and password to sign in. + Tip: Use TV keyboard. D-pad to navigate. + ARVIO Cloud Pairing + Sign in with your email and password to link this device. + Scan this QR code to sign in and link this TV. + Code: %1$s + Waiting for approval... + Use Email & Password + Use Email/Password + Connect Trakt.tv + Go to %1$s and enter this code + Waiting for authorization + Open auth page + Copy code + Playback & Controls + Audio & Subtitles + Plugins & Extensions + CATEGORIES + LANGUAGES + USER INFO & ACCOUNT + Force Sync + Disconnect + Open + Update Available + Check Updates + PLAYBACK + CONTROLS + SUBTITLES + AUDIO + APPEARANCE + Allow Unknown Sources + Allow installs from unknown sources for ARVIO so the downloaded update APK can be installed. + Open Settings + Overview + Focused setting + Section + Status + Profile + System + App text and preferred audio track. + Subtitle language defaults, display style and filtering. + AI subtitle translation and cleanup options. + Autoplay, trailers, source quality, frame-rate matching and audio boost. + Layout, OLED mode, focus styling and hero metadata. + Profile startup behavior for this device. + DNS and loading diagnostic preferences. + TV playlists, EPG refresh, channel loading and playlist management. + Connect personal media servers and use their libraries as sources. + Discover, rename, order and remove home rows and list catalogs. + Manage third-party addons used for catalog and source discovery. + Cloud sync, Trakt connection, app updates and account controls. + Configure ARVIO for this profile. + App %1$s + Audio %1$s + Default %1$s + Size %1$s + AI on + AI off + Auto-select on + Auto-select off + Autoplay %1$s + Trailers %1$s + Min %1$s + on + off + OLED %1$s + Skip on + Picker on + DNS %1$s + Stats on + Stats off + %1$d/3 playlists + %1$s channels + Refreshing + Ready + %1$d connected + Working + Idle + %1$d catalogs + Cloud synced + %1$d installed + Profile scoped + Cloud connected + Cloud off + Trakt connected + Trakt off + App language + Default + Secondary + Style + AI + Enabled + Auto-select + Autoplay + Trailers + Frame rate + OLED + Accent color + Startup + Skip picker + Show picker + DNS + Loading stats + User-Agent + Playlists + Channels + EPG + Configured + Optional + State + Servers + Status + Discovery + Searching + Scope + Current profile + Cloud + Disconnected + Trakt + Updates + Available + Play build + Choose a setting + Move into the center panel to edit the selected section. + Changes the interface and metadata language used by ARVIO. + Preferred audio language when multiple tracks exist. + Preferred subtitle language when playback starts. + Secondary subtitle + Optional second subtitle preference where available. + Subtitle preference + Adjust subtitle size, color, position, style and filtering. + Configure optional AI subtitle translation and cleanup. + Next episode autoplay + Controls whether episodes continue automatically. + Source autoplay + Controls whether a single source starts immediately or opens the picker. + Trailers + Tune trailer autoplay and sound behavior on hero banners. + Boosts playback audio output when enabled. + Tune source quality, frame-rate matching and playback behavior. + Control layout, OLED mode, clock and focus styling. + Control whether this device opens the profile picker on startup. + Add playlist + Add another IPTV playlist with M3U or Xtream details. + IPTV playlist + Enable, edit, reorder, refresh or remove IPTV data. + Add server + Connect a personal media server with URL and credentials. + Use device-code auth for supported personal servers. + Server connection + Edit, test or remove connected personal media servers. + Discover catalog + Search public list catalogs or add a direct URL. + Catalog row + Rename, reorder, change layout or remove this catalog. + Addon + Enable, disable, add or remove third-party addon sources. + Connect or disconnect ARVIO cloud sync. + Trakt + Connect or disconnect Trakt watch history and lists. + Push/pull the latest synced profile data now. + App updates + Check for sideload app updates or install a downloaded update. + Account data + Open account and data deletion information. + Setting + Use OK to change this option. + Media Server + CONNECTIONS + Add server + Personal media libraries as sources + Add another + Sign in with a server code + Waiting + %1$d collections + ACTIONS + Test connection + Connect a server first + Check that this profile can reach every server + Disconnect all + No server is connected + Remove all servers from the active profile + Sign in with a server code and use media libraries as sources + Code + Change + Remove + Test + QR Code + Drag to reorder + Manage Categories + %1$s (%2$d%%) + Refreshing channels and EPG... + Reload playlists now + Reload playlist and EPG now + No playlists configured + Remove playlists, EPG and favorites + Add up to 3 M3U / Xtream IPTV lists with names + Create another IPTV list + FULL + ADD + LOADING + REFRESH + EMPTY + DELETE + %1$d selected + PLAYLISTS + Add up to 3 M3U / Xtream TV lists + Create another TV list + Full + Search Lists + Paste URL + Trakt or MDBList URL + Add URL + Searching both sources + %1$d lists found + Searches Trakt and MDBList automatically + Tap a field to edit + Press Select on a field to edit + Searching lists + Start with a search + Try top trending anime, best horror, Marvel chronological, or 2025 movies. + Paste catalog URL + Use Search + Use URL + by %1$s + %1$s items + %1$s likes + Updated %1$s + Added + Built-in + Addon + ADD CATALOG + MY CATALOGS + %1$s (Built-in Collection) + %1$s (Built-in Rail) + %1$s (Built-in) + Collection + %1$s rail + %1$s collection + Preinstalled catalog + From %1$s + From Home Server + Custom catalog + ADD ADDON + Install a custom Stremio addon by URL + MY ADDONS + No addons installed + Move addon up + Move addon down + Delete addon + STREMIO ADDONS + Installed + Disabled + Optional account for syncing profiles, addons, catalogs and IPTV settings + Sync watch history, progress, and watchlist + Search your channels and groups for video files + OPEN + Syncing local and cloud state now + Upload local state, then restore from cloud now + Sign in to ARVIO Cloud to force sync + SYNCING + SYNC + This install is managed by the Play Store + Latest update downloaded and ready to install + Checking GitHub Releases for a newer APK + Update available: %1$s + You already have the latest ARVIO version + Check GitHub Releases for the latest ARVIO APK + PLAY + INSTALL + CHECKING + UPDATE + CHECK + Privacy and data deletion + Open privacy and ARVIO Cloud account deletion instructions + Go to: %1$s + Paste + Paste from Clipboard + Press Enter to select • Navigate with D-pad + Tap a field to edit, then tap Confirm + Use D-pad to move, press OK to edit a field + field + Paste into %1$s + Tap a field to edit, tap Confirm when done + OK: edit/select • Back: close keyboard first + Press Enter to select + Change UI Mode + TV + Tablet + Phone + Are you sure you want to change the UI mode to %1$s? + IPTV CATEGORIES + Reset Order + Restore default category order + RESET + No categories available + Hidden + Visible + Default + Enter %1$s... + 12-hour + 24-hour + Tablet + Phone + System DNS + + Animals + Characters + Media + Nature + + Press Back to cancel + + + Connecting... + Preparing QR code... + Connect Telegram + Search your channels and groups for video files whenever you open a movie or show. + Arvio is not responsible for the content streamed through this feature. Only connect your own account and use it fairly — respect copyright and applicable laws. + Scan with Telegram + Open Telegram on your phone → Settings → Devices → Link Desktop Device + Telegram QR code + QR code expires in 30 seconds — refreshes automatically + Enter Phone Number + Use the same number registered with your Telegram account. + Phone number + Must start with + and include country code, e.g. +1 for US, +44 for UK + International format: +[country code][number] + Sending code... + SEND CODE + Code sent to your Telegram app + Enter Code + Check your Telegram app for a %1$d-digit code. + Verification code + Two-Step Verification + Your account has an additional password. + Password + Signed in as %1$s + DISCONNECT + Video Cache + CLEAR + Connection Failed + TRY AGAIN + Disconnect Telegram? + You\'ll need to sign in again to use Telegram as a source. + Plugins (Testing) + Add Repository + Installed Repositories + Installed Scrapers + No scrapers installed. + Add Plugin Repository + Repository URL + + + Edit Profile + Profile name + Profile Lock + Set PIN + Change PIN + Remove PIN + Change Photo + Upload Photo + Remove Photo + Enter PIN + PIN Entry + Confirm PIN + 4-5 digits to lock this profile + Re-enter PIN to confirm + Clear + PIN must be 4-5 digits + PINs do not match + Your library, tuned for TV. + Keep your watchlist, history, and Trakt sync tied to your account. + Create your account + Sign in to continue + Email + Password + Sign Up + Already have an account? Sign In + Don\'t have an account? Sign Up + Nothing to show here yet. + + + This debrid torrent is still being downloaded. Try another source or wait a bit. + S%1$d:E%2$d + TV Series + Mark Watched + In watchlist + Primary streaming provider + No episode synopsis available. + Spoiler + Select season %1$d + Show season options + Top 10 Movies Today + Top 10 Shows Today + Please check your connection + Primary streaming provider + Rank #%1$d + All + Anime + Rewind 10s + Forward 10s + Picture in Picture + Volume + Aspect: %1$s + Pause + AI Translating + Stop casting + Cast to TV + Muted + Setup + Error + Addon Setup Required + Playback Error + An unknown error occurred + + + LIVE + NOW + NEXT + CATCHUP + AIRED + ARCHIVE + NEW + CH + CH %1$d + %1$d sources + %1$d matches + Choose source + Catchup + Aired + CHANNELS + NOW %1$s + · %1$dmin + %1$s %2$dx + All Channels + Search channels + Favorites + Recently Watched + Adult + Ungrouped + PLAYLIST + ADULT + HIDDEN + Yesterday + Today + Tomorrow + in %1$dm + in %1$dh %2$dm + later + Guide + Now: %1$s + Loading visible guide... + No EPG source configured + Guide pending + %1$s | matched %2$d/%3$d visible + No programme data + No channel + No programme timeline is available for this channel. + No guide timeline is available yet. + No channels in this category + No IPTV playlist configured. + Loading guide... + No guide data matched + Guide pending... + No guide source + GUIDE + Open settings + Channel name, number, or category… + Fullscreen + Close guide + Catchup available + Pause + Seeking catch-up + Starting live stream + Preparing source + Catch-up unavailable + Playback failed + Provider did not return a playable stream. + Retrying source + + + QR code + Avatar + Press any key to continue + LIVE + TV + MOVIE + Mark Watched + Remove Continue Watching + TV Series + Media + %1$s logo + New Episode + 1 ep left + %1$d eps left + S%1$d • E%2$d + Biography + Known For + as %1$s + Loading sources... + Resolving from %1$s + Finding best quality stream... + Ignore + Download + Install + Hide + Retry Install + Checking GitHub Releases... + Update available: %1$s (%2$s) + Downloading update... + %1$s is ready to install. + Installing update... Please follow the system prompt. + Update failed. + You already have the latest version installed. + No release information available. + Current version %1$s -> latest %2$s + Current version %1$s is up to date + Preparing... + The latest ARVIO update has been downloaded and is ready to install. + The Android package installer should appear. If it does not, you can try pressing Install again. + All sources + Select Source + No Streaming Addons + No sources found + Go to Settings → Addons to add\na streaming addon + Try adding more addons + Searching addons (%1$d/%2$d)... + No sources match this filter + Best Match + ADDONS + Total + Checked + + + Update install requires manual confirmation. Please install from Downloads. + Update cancelled. + Update blocked by system policy. + Update conflicts with installed version. Try uninstalling first. + Update not compatible with this device. + Update package is invalid or corrupted. + Not enough storage to install update. + Update install failed. + AI subtitles: no API key set. Add it in Settings → General. + AI subtitles: rate limit hit — translation paused. + AI subtitles: invalid API key. + AI subtitles: translation error — %1$s + Unable to resolve IMDB ID. Try again. + Failed to load details + + + + Please enter your password + Password must be at least 6 characters + Please wait %1$ds before creating another account + Failed to load Trakt watchlist + Failed to refresh + Removed from watchlist + + + + Continue S%1$dE%2$d + Continue S%1$dE%2$d at %3$s + Continue at %1$s + Continue from %1$s + Continue + Start S1E1 + %1$s from %2$s + Added to watchlist + Failed to check for updates + Download failed + Failed to search catalogs + Failed to add catalog + Failed to update catalog + Failed to remove catalog + Failed to read catalog metadata + Catalog already added + Catalog not found + Preinstalled catalogs cannot be edited + Failed to link TV + Sync failed: %1$s + Home Server connection failed + Code sign in failed + Server connection failed + Home Server test failed + + + Failed to add addon + Failed to start cloud login + Failed to start cloud sign-in + Cloud sign-in could not start. Try again. + Approved, but tokens were missing. Try again. + Failed to import session tokens + Cloud sign-in expired. Try again. + Cloud sign-in failed. Try again. + Cloud sync failed + Cloud sync failed while uploading + + + No episodes found for Season %1$d + Failed to load Season %1$d + No episode selected + Failed to update watched status + Failed to add to Trakt watchlist + Failed to remove from Trakt watchlist + Failed to update watchlist + Failed to mark season as watched + Failed to sync Season %1$d as unwatched with Trakt + Failed to mark season as unwatched + Marked as watched + Marked as unwatched + S%1$dE%2$d marked as watched + S%1$dE%2$d marked as unwatched + Season %1$d marked as watched + Season %1$d marked as unwatched + + + No episode info available + Already watched + Removed from Continue Watching + Failed to remove from Continue Watching + You already have the latest version + + + MDBList Catalog + Loading channels from portal... + Loading full EPG (%1$d/%2$d)... + Loading live streams... + Loading EPG provider %1$d/%2$d... + Loading full Xtream guide %1$d/%2$d... + Loading full Xtream EPG... + Loading full EPG... %1$d/%2$d streams + No playable catchup stream returned by provider + Playlist loaded but contains no channels. + Failed to load M3U playlist. + + + Enter a valid server URL + Enter a password or token + Enter a username + No Home Server connected + Server did not return an activation code + No reachable server URL found for this account + Server identity did not match the selected account server + No accessible libraries found for this account + Home Server is disabled or incomplete + Auth response incomplete + Failed to import auth session + Google Sign-In failed + Unexpected credential type + Failed to parse Google credentials + Not authenticated with Trakt + Supabase auth failed + %1$s - %2$s + Telegram search timed out + + + Could not import avatar image + Profile created successfully + Profile updated + Profile deleted + Profile PIN set successfully + Failed to load IPTV + Failed to start TV auth + Failed to poll TV auth status + + + Failed to load content + Failed to remove from Trakt watchlist + Failed to remove from watchlist + Failed to load collection + + + + Account already exists. Sign in instead. + Sign in failed. Please try again. + Invalid email or password. + Please verify your email to continue. + Sign in failed + Sign up failed + Unable to create account + Invalid code sign-in URL + Code sign in failed (%1$d) + Code sign in polling failed (%1$d) + Invalid server URL + Server request failed (%1$d) + Server sign in failed (%1$d) + Server sign in did not return a playable account + Missing account token + Telegram search failed + Too many searches — please wait %1$ds before retrying + Telegram: %1$s + + + + Email is required + Enter a valid email address + Use a real email address + Subtitles from OpenSubtitles + Addon URL is empty + HTTP local scraper bundle (%1$d HTTP providers) + Download failed: HTTP %1$d + Empty download body + Signature conflict detected. The installed version was signed with a different key.\n\nTo update, please uninstall the current version first:\nSettings > Apps > %1$s > Uninstall\n\nThen install the new version. Your cloud-synced data (watchlist, progress) will be restored after login. + GitHub API error: %1$d + Empty GitHub release response + Latest release is draft/prerelease + Release has no tag/name + No APK asset found in release + + + + Latest Movies + Latest Shows + Action + Comedy + Sci-Fi + Thriller + Drama + Horror + Documentary + Romance + Animation + Family + Fantasy + Adventure + Superhero + War & Military + 20\'s Movies + 10\'s Movies + 00\'s Movies + 90\'s Movies + 80\'s Movies + 70\'s Movies + 60\'s Movies + + + + Any + Always + Seamless only + Small + Medium + Large + Extra Large + White + Red + Orange + Yellow + Green + Blue + Indigo + Violet + Cyan + Normal + Bold + Background + Low + Bottom + High + None + Forced + Auto (Original) + + + + Could not decode selected avatar image + Avatar download failed: HTTP %1$d + Empty avatar response + Avatar upload failed: HTTP %1$d + Empty EPG response + EPG request failed (HTTP %1$d). + No EPG URL configured + Empty EPG retry response + EPG download timed out for %1$s + Using cached EPG + Not logged in + Connecting to Stalker portal... + Loaded %1$d live channels from provider API + Loaded %1$d channels + M3U response was empty. + M3U request failed (HTTP %1$d). + Playlist loading timed out. Try refreshing or using the provider\'s Xtream credentials. + Starting IPTV load... + Detected Xtream provider. Loading live channels... + Xtream provider timed out while loading live channels. + Library + Library %1$s + No Title + Unknown program + Sync already in progress + TDLib failed to start: %1$s + TDLib not available on this device + + + + Move to top + Move up + Move down + Hide category + Unhide category