From 23080c4344641e43ef830e7b479bfb5d3f65fbe3 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:25:24 +0530 Subject: [PATCH 1/2] feat: localization --- composeApp/build.gradle.kts | 1 + .../src/androidMain/AndroidManifest.xml | 1 + .../kotlin/com/nuvio/app/MainActivity.kt | 5 +- .../DownloadsLiveStatusPlatform.android.kt | 25 +- .../DownloadsPlatformDownloader.android.kt | 25 +- ...sodeReleaseNotificationPlatform.android.kt | 9 +- .../features/player/PlayerEngine.android.kt | 10 +- .../settings/ThemeSettingsStorage.android.kt | 25 +- .../src/androidMain/res/values-es/strings.xml | 4 + .../src/androidMain/res/values-v31/themes.xml | 4 +- .../src/androidMain/res/values/themes.xml | 2 +- .../src/androidMain/res/xml/locale_config.xml | 5 + .../composeResources/values-es/strings.xml | 1043 +++++++++++++++++ .../composeResources/values/strings.xml | 1043 +++++++++++++++++ .../commonMain/kotlin/com/nuvio/app/App.kt | 77 +- .../com/nuvio/app/core/auth/AuthRepository.kt | 10 +- .../app/core/format/ReleaseDateDisplay.kt | 17 +- .../nuvio/app/core/i18n/LocalizedUiText.kt | 141 +++ .../kotlin/com/nuvio/app/core/ui/AppTheme.kt | 37 +- .../nuvio/app/core/ui/ContinueWatchingText.kt | 26 + .../com/nuvio/app/core/ui/NuvioComponents.kt | 10 +- .../ui/NuvioContinueWatchingActionSheet.kt | 18 +- .../nuvio/app/core/ui/NuvioFloatingPrompt.kt | 5 +- .../app/core/ui/NuvioNetworkOfflineCard.kt | 7 +- .../app/core/ui/NuvioPosterActionSheet.kt | 22 +- .../nuvio/app/core/ui/NuvioShelfComponents.kt | 8 +- .../app/core/ui/TraktListPickerDialog.kt | 16 +- .../nuvio/app/features/addons/AddonModels.kt | 9 +- .../app/features/addons/AddonRepository.kt | 12 +- .../nuvio/app/features/addons/AddonsScreen.kt | 98 +- .../com/nuvio/app/features/auth/AuthScreen.kt | 50 +- .../app/features/catalog/CatalogRepository.kt | 6 +- .../app/features/catalog/CatalogScreen.kt | 6 +- .../collection/CollectionEditorRepository.kt | 11 +- .../collection/CollectionEditorScreen.kt | 142 ++- .../collection/CollectionManagementScreen.kt | 62 +- .../features/collection/CollectionModels.kt | 2 +- .../collection/CollectionRepository.kt | 69 +- .../collection/FolderDetailRepository.kt | 27 +- .../features/collection/FolderDetailScreen.kt | 15 +- .../app/features/details/MetaDetailsParser.kt | 11 +- .../features/details/MetaDetailsRepository.kt | 6 +- .../app/features/details/MetaDetailsScreen.kt | 41 +- .../details/MetaScreenSettingsRepository.kt | 55 +- .../features/details/PersonDetailScreen.kt | 37 +- .../details/TmdbEntityBrowseScreen.kt | 35 +- .../details/components/CommentDetailSheet.kt | 16 +- .../details/components/DetailActionButtons.kt | 8 +- .../components/DetailAdditionalInfoSection.kt | 26 +- .../details/components/DetailCastSection.kt | 4 +- .../components/DetailCommentsSection.kt | 18 +- .../components/DetailFloatingHeader.kt | 10 +- .../features/details/components/DetailHero.kt | 4 +- .../details/components/DetailMetaInfo.kt | 19 +- .../components/DetailProductionSection.kt | 8 +- .../details/components/DetailSeriesContent.kt | 35 +- .../components/DetailTrailersSection.kt | 14 +- .../components/EpisodeWatchedActionSheet.kt | 27 +- .../details/components/TrailerPlayerPopup.kt | 10 +- .../app/features/downloads/DownloadsModels.kt | 45 +- .../features/downloads/DownloadsRepository.kt | 5 +- .../app/features/downloads/DownloadsScreen.kt | 64 +- .../features/home/HomeCatalogDefinitions.kt | 18 +- .../home/HomeCatalogSettingsRepository.kt | 5 +- .../com/nuvio/app/features/home/HomeScreen.kt | 41 +- .../components/HomeContinueWatchingSection.kt | 24 +- .../home/components/HomeHeroSection.kt | 4 +- .../app/features/library/LibraryRepository.kt | 8 +- .../app/features/library/LibraryScreen.kt | 36 +- .../EpisodeReleaseNotificationsModels.kt | 37 +- .../EpisodeReleaseNotificationsRepository.kt | 16 +- .../app/features/player/AudioTrackModal.kt | 10 +- .../app/features/player/PlayerControls.kt | 41 +- .../features/player/PlayerEpisodesPanel.kt | 46 +- .../player/PlayerLanguagePreferences.kt | 285 +++-- .../nuvio/app/features/player/PlayerLayout.kt | 13 +- .../app/features/player/PlayerOverlays.kt | 34 +- .../nuvio/app/features/player/PlayerScreen.kt | 46 +- .../app/features/player/PlayerSourcesPanel.kt | 16 +- .../features/player/SubtitleAudioModels.kt | 106 +- .../app/features/player/SubtitleModal.kt | 24 +- .../app/features/player/SubtitleRepository.kt | 7 +- .../app/features/player/SubtitleStylePanel.kt | 19 +- .../features/player/skip/NextEpisodeCard.kt | 34 +- .../features/player/skip/SkipIntroButton.kt | 22 +- .../app/features/profiles/PinEntryDialog.kt | 14 +- .../features/profiles/ProfileEditScreen.kt | 86 +- .../features/profiles/ProfileRepository.kt | 21 +- .../profiles/ProfileSelectionScreen.kt | 16 +- .../features/profiles/ProfileSwitcherTab.kt | 23 +- .../features/search/SearchDiscoverContent.kt | 51 +- .../app/features/search/SearchRepository.kt | 11 +- .../nuvio/app/features/search/SearchScreen.kt | 54 +- .../features/settings/AccountSettingsPage.kt | 53 +- .../app/features/settings/AppLanguage.kt | 20 + .../settings/AppearanceSettingsPage.kt | 145 ++- .../settings/ContentDiscoverySettingsPage.kt | 38 +- .../settings/ContinueWatchingSettingsPage.kt | 57 +- .../settings/HomescreenSettingsPage.kt | 68 +- .../settings/IntegrationsSettingsPage.kt | 17 +- .../features/settings/MdbListSettingsPage.kt | 58 +- .../settings/MetaScreenSettingsPage.kt | 129 +- .../settings/NotificationsSettingsPage.kt | 44 +- .../features/settings/PlaybackSettingsPage.kt | 444 +++---- .../PosterCustomizationSettingsPage.kt | 77 +- .../features/settings/SettingsComponents.kt | 44 +- .../settings/SettingsFullScreenPages.kt | 20 +- .../app/features/settings/SettingsModels.kt | 66 +- .../app/features/settings/SettingsRootPage.kt | 87 +- .../app/features/settings/SettingsScreen.kt | 28 +- .../settings/SupportersContributorsPage.kt | 86 +- .../settings/ThemeSettingsRepository.kt | 15 + .../features/settings/ThemeSettingsStorage.kt | 3 + .../app/features/settings/TmdbSettingsPage.kt | 112 +- .../features/settings/TraktSettingsPage.kt | 59 +- .../app/features/streams/StreamModels.kt | 6 +- .../app/features/streams/StreamsRepository.kt | 4 +- .../app/features/streams/StreamsScreen.kt | 61 +- .../features/streams/StreamsTabletLayout.kt | 14 +- .../app/features/tmdb/TmdbMetadataService.kt | 38 +- .../app/features/trakt/TraktAuthRepository.kt | 21 +- .../features/trakt/TraktCommentsRepository.kt | 5 +- .../features/trakt/TraktLibraryRepository.kt | 9 +- .../features/trakt/TraktProgressRepository.kt | 7 +- .../nuvio/app/features/updater/AppUpdater.kt | 86 +- .../watching/domain/SeriesContinuity.kt | 22 +- .../watchprogress/WatchProgressModels.kt | 33 +- .../settings/ThemeSettingsStorage.ios.kt | 14 +- 128 files changed, 5205 insertions(+), 1556 deletions(-) create mode 100644 composeApp/src/androidMain/res/values-es/strings.xml create mode 100644 composeApp/src/androidMain/res/xml/locale_config.xml create mode 100644 composeApp/src/commonMain/composeResources/values-es/strings.xml create mode 100644 composeApp/src/commonMain/composeResources/values/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ca333a1c..51f0d145 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -219,6 +219,7 @@ kotlin { } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.work.runtime) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index a4b48672..dc8f0964 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -10,6 +10,7 @@ android:label="@string/app_name" android:usesCleartextTraffic="true" android:roundIcon="@mipmap/ic_launcher_round" + android:localeConfig="@xml/locale_config" android:supportsRtl="true" android:theme="@style/Theme.Nuvio"> "Paused $detail" - DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed" - DownloadStatus.Completed -> "Download completed" + DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) } + DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) } + DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) } } } @@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform { if (manager.getNotificationChannel(channelId) != null) return manager.createNotificationChannel( - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply { - description = channelDescription + NotificationChannel( + channelId, + runBlocking { getString(Res.string.downloads_channel_name) }, + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = runBlocking { getString(Res.string.downloads_channel_description) } }, ) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index e1e3b60d..502c14b1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -8,9 +8,12 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Request +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import java.io.File import java.io.FileOutputStream import java.net.URI @@ -44,7 +47,7 @@ internal actual object DownloadsPlatformDownloader { scope.launch { val context = appContext if (context == null) { - onFailure("Download system is not initialized") + onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) }) return@launch } @@ -69,7 +72,9 @@ internal actual object DownloadsPlatformDownloader { var attemptedRangeRequest = resumeFromBytes > 0L var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null) call = downloadHttpClient.newCall(httpRequest) - var response = call?.execute() ?: error("Download request failed") + var response = call?.execute() ?: error( + runBlocking { getString(Res.string.downloads_error_request_failed) }, + ) if (attemptedRangeRequest && response.code == 416) { response.close() @@ -78,12 +83,18 @@ internal actual object DownloadsPlatformDownloader { attemptedRangeRequest = false httpRequest = buildRequest(null) call = downloadHttpClient.newCall(httpRequest) - response = call?.execute() ?: error("Download request failed") + response = call?.execute() ?: error( + runBlocking { getString(Res.string.downloads_error_request_failed) }, + ) } response.use { response -> if (!response.isSuccessful) { - error("Request failed with HTTP ${response.code}") + error( + runBlocking { + getString(Res.string.downloads_error_http_failed, response.code) + }, + ) } val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L @@ -94,7 +105,9 @@ internal actual object DownloadsPlatformDownloader { tempFile.delete() } - val body = response.body ?: error("Empty response body") + val body = response.body ?: error( + runBlocking { getString(Res.string.downloads_error_empty_body) }, + ) val totalBytes = resolveTotalBytes( startingBytes = startingBytes, isPartialResume = isPartialResume, @@ -131,7 +144,7 @@ internal actual object DownloadsPlatformDownloader { onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) } } catch (error: Throwable) { - onFailure(error.message ?: "Download failed") + onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) }) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt index cee05234..9fe424a1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt @@ -23,6 +23,9 @@ import androidx.work.WorkManager import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.android.Android +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import java.time.LocalDate @@ -285,13 +288,13 @@ internal actual object EpisodeReleaseNotificationPlatform { val channel = NotificationChannel( channelId, - "Episode Releases", + runBlocking { getString(Res.string.notifications_channel_episode_releases_name) }, NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = "Alerts when a saved show's new episode is released." + description = runBlocking { getString(Res.string.notifications_channel_episode_releases_description) } } notificationManager.createNotificationChannel(channel) } private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId" -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index bcf964c8..b6f7be0c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.C @@ -184,7 +187,7 @@ actual fun PlatformPlayerSurface( val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { - latestOnError.value(error.localizedMessage ?: "Unable to play this stream.") + latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) }) } override fun onPlaybackStateChanged(playbackState: Int) { @@ -585,7 +588,10 @@ private fun ExoPlayer.extractAudioTracks(): List { else -> null } val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } } - val baseName = format.label?.takeIf { it.isNotBlank() } ?: resolvedLanguage ?: format.language ?: "Track ${idx + 1}" + val baseName = format.label?.takeIf { it.isNotBlank() } + ?: resolvedLanguage + ?: format.language + ?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) } val suffix = listOfNotNull(channelLabel, codecLabel) .joinToString(" ") .let { if (it.isNotBlank()) " ($it)" else "" } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index aa0002f9..32e659c1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.settings import android.content.Context import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat import com.nuvio.app.core.sync.decodeSyncBoolean import com.nuvio.app.core.sync.decodeSyncString import com.nuvio.app.core.sync.encodeSyncBoolean @@ -15,12 +17,14 @@ actual object ThemeSettingsStorage { private const val preferencesName = "nuvio_theme_settings" private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" - private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey) + private const val selectedAppLanguageKey = "selected_app_language" + private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey, selectedAppLanguageKey) private var preferences: SharedPreferences? = null fun initialize(context: Context) { preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } actual fun loadSelectedTheme(): String? = @@ -46,9 +50,26 @@ actual object ThemeSettingsStorage { ?.apply() } + actual fun loadSelectedAppLanguage(): String? = + preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null) + + actual fun saveSelectedAppLanguage(languageCode: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(selectedAppLanguageKey), languageCode) + ?.apply() + } + + actual fun applySelectedAppLanguage(languageCode: String) { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(languageCode), + ) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -58,5 +79,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/composeApp/src/androidMain/res/values-es/strings.xml b/composeApp/src/androidMain/res/values-es/strings.xml new file mode 100644 index 00000000..f5bd09e2 --- /dev/null +++ b/composeApp/src/androidMain/res/values-es/strings.xml @@ -0,0 +1,4 @@ + + + Nuvio + diff --git a/composeApp/src/androidMain/res/values-v31/themes.xml b/composeApp/src/androidMain/res/values-v31/themes.xml index 28ee2797..97bab989 100644 --- a/composeApp/src/androidMain/res/values-v31/themes.xml +++ b/composeApp/src/androidMain/res/values-v31/themes.xml @@ -1,6 +1,6 @@ - @@ -9,4 +9,4 @@ @drawable/ic_splash_logo @style/Theme.Nuvio - \ No newline at end of file + diff --git a/composeApp/src/androidMain/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml index 309123d3..91536bd1 100644 --- a/composeApp/src/androidMain/res/values/themes.xml +++ b/composeApp/src/androidMain/res/values/themes.xml @@ -1,6 +1,6 @@ - diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml new file mode 100644 index 00000000..98f6f9a3 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml new file mode 100644 index 00000000..421b1531 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -0,0 +1,1043 @@ + + Reconocimiento abierto y créditos del proyecto + Atrás + Cancelar + Cerrar + Eliminar + Listo + Editar + Importar + Siguiente + Aceptar + Reproducir + Anterior + Eliminar + Reordenar + Restablecer + Reanudar + Reintentar + Guardar + Instalando + Complementos + Activo + %1$d catálogos + Configurable + Actualizando + %1$d recursos + No disponible + Configurar complemento + Eliminar complemento + Agrega una URL de manifiesto para empezar a cargar catálogos, metadatos, streams o subtítulos en Nuvio. + Aún no hay complementos instalados. + Introduce una URL de complemento. + URL del complemento + Instalar complemento + Cargando detalles del manifiesto... + Validando la URL del manifiesto y cargando los detalles del complemento antes de instalar. + Comprobando complemento + Error de instalación + %1$s se validó y añadió correctamente. + Complemento instalado + Mover complemento abajo + Mover complemento arriba + Activo + Complementos + Catálogos + Actualizar complemento + Añadir complemento + Complementos instalados + Resumen + %1$d reglas de ID + Versión %1$s + Seleccionado + Copiar JSON + %1$d colección(es), %2$d carpeta(s) + ¿Eliminar "%1$s"? Esto no se puede deshacer. + Eliminar colección + Añadir catálogo + Añadir carpeta + Todos los géneros + Añade catálogos de tus complementos instalados para definir qué muestra esta carpeta. + Aún no hay fuentes de catálogo + Elegir + Emoji + URL de imagen + Ninguna + Portada + Crear colección + Listo + Editar colección + Editar carpeta + Configura la identidad, presentación y fuentes de catálogo de la carpeta con la misma estructura que el editor principal de colecciones. + Añade una para empezar. + Aún no hay carpetas + Carpetas + Filtro de género + Mostrar solo la imagen de portada + Ocultar título + Nueva carpeta + Muestra esta colección por encima de todos los catálogos normales del inicio. Varias colecciones fijadas siguen el orden de creación. + Fijar sobre los catálogos + URL de imagen de fondo (opcional) + Nombre de la carpeta + URL de GIF animado (solo se reproduce al enfocarse) + Nombre de la colección + Guardar cambios + Guardar + Apariencia + Básicos + Fuentes de catálogo + Elige los catálogos del complemento que debe agrupar esta carpeta. + Seleccionar catálogos + Seleccionar género + Póster + Cuadrado + Panorámico + Combinar todos los catálogos en una sola pestaña + Mostrar pestaña "Todo" + Reproducir el GIF configurado en lugar de la portada estática cuando esté disponible. + Mostrar GIF cuando esté configurado + %1$d fuente(s) · %2$s + Forma del mosaico + Filas + Pestañas + Modo de vista + Crea una para organizar tus catálogos. + Aún no hay colecciones + %1$d carpeta(s) + No se encontraron elementos + Carpeta no encontrada + Colecciones + Importar colecciones + JSON + Pega abajo el JSON de tus colecciones. + Importar + Nueva colección + Fijado + Todo + Tus colecciones + Hecho con ❤️ por Tapframe y amigos + Versión %1$s (%2$s) + Desactivado + Activado + Pausar + Recargar + ¿Ya tienes una cuenta? + Continuar sin cuenta + Crear cuenta + ¿No tienes una cuenta? + Correo electrónico + o + Contraseña + Inicia sesión para acceder a tu biblioteca y progreso + Iniciar sesión + Regístrate para sincronizar tus datos entre dispositivos + Registrarse + Tus datos solo se almacenarán localmente + Transmite todo, en todas partes + Bienvenido de nuevo + Biblioteca + Biblioteca de Trakt + Inicio + Biblioteca + Perfil + Buscar + Pistas de audio + Audio + Integrado + Desplazamiento inferior + Cerrar reproductor + Color + Reproduciendo ahora + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episodios + Tamaño de fuente + %1$dsp + Bloquear controles del reproductor + No hay pistas de audio disponibles + No hay episodios disponibles + No se encontraron streams + Ninguno + Contorno + Episodios + Fuentes + Streams + Error de reproducción + Reproduciendo + Toca para buscar subtítulos + Volver + Restablecer valores predeterminados + Rellenar + Ajustar + Zoom + Retroceder 10 segundos + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avanzar 10 segundos + Fuentes + Estilo + Subs + Subtítulos + Brillo %1$d%% + Volumen %1$d%% + Silenciado + Descargado + Se emite + Por confirmar + Toca para desbloquear + Pista %1$d + Desbloquear controles del reproductor + Estás viendo + Añadir perfil + Borrar búsqueda + Descubrir + Los complementos instalados no devolvieron resultados de búsqueda válidos. + La búsqueda falló + Instala y valida al menos un complemento antes de buscar. + No hay complementos activos + Los catálogos instalados no devolvieron coincidencias para esta consulta. + No se encontraron resultados + Tus complementos instalados no exponen búsqueda de catálogo. + No hay catálogos de búsqueda + Buscar películas, series... + Búsquedas recientes + Eliminar búsqueda reciente + Acerca de + General + Cuenta + Complementos + Apariencia + Contenido y descubrimiento + Seguir viendo + Pantalla de inicio + Integraciones + Calificaciones de MDBList + Pantalla meta + Notificaciones + Reproducción + Plugins + Personalización de póster + Configuración + Patrocinadores y colaboradores + Enriquecimiento TMDB + Trakt + ACERCA DE + Administra tu cuenta, cierra sesión o elimínala. + CUENTA + Ajusta la presentación de inicio y las preferencias visuales. + Buscar nuevas versiones de la app. + Buscar actualizaciones + Administra complementos y fuentes de descubrimiento. + Administra tus películas y episodios descargados. + Descargas + GENERAL + Conecta los servicios TMDB y MDBList. + Administra alertas de estreno de episodios y envía una notificación de prueba. + Cambiar a un perfil diferente. + Cambiar perfil + Conecta Trakt, sincroniza listas y guarda títulos directamente en Trakt. + Cargando tus listas de Trakt… + Elige dónde guardar este título en Trakt + Donar + Ir a detalles + Eliminar + Empezar desde el principio + Reproducir + %1$d/10 + Reseña + Spoiler + Aún no hay reseñas de Trakt. + %1$d me gusta + Este comentario contiene spoilers. + Este comentario contiene spoilers y se ha ocultado. + Comentarios + Tráiler + %1$s (%2$d) + Tráilers + No hay episodios completados + Aún no hay descargas + %1$d episodio(s) descargado(s) + Activas + Películas + Series + Mostrar descargas + Completado • %1$s + Descargando • %1$s + Falló + Pausado • %1$s + Visto + Temporada %1$d + Especiales + Continuar donde lo dejaste + Añadir a la biblioteca + Marcar como no visto + Marcar como visto + Eliminar de la biblioteca + Ver todo + Reproducir manualmente + Logo de %1$s + Cuenta + Eliminar cuenta + Esto eliminará permanentemente tu cuenta y todos los datos asociados. + Esta acción no se puede deshacer. Todos tus datos, perfiles e historial de sincronización se eliminarán permanentemente. + ¿Eliminar cuenta? + Correo electrónico + Sin iniciar sesión + Cerrar sesión + Volverás a la pantalla de inicio de sesión. + ¿Cerrar sesión? + Estado + Anónimo + Sesión iniciada + Negro AMOLED + Usa fondos negros puros para pantallas OLED. + Idioma de la app + Elegir idioma + Muestra, oculta y ajusta el estante de Seguir viendo. + Ajusta el ancho compartido de las tarjetas de póster y los radios de esquina. + PANTALLA + INICIO + TEMA + Colección • %1$s + Nombre visible + Instala un complemento con catálogos compatibles con tableros para configurar las filas de la pantalla de inicio. + No hay catálogos de inicio + Fuente del Hero + Oculto + Mantener Inicio enfocado + %1$s • Límite alcanzado (máx. %2$d) + No hay fuentes Hero seleccionadas + No está en Hero + Quita fijar arriba de la colección para moverla + Fijado + Fijado arriba + Reordenar + CATÁLOGOS + CATÁLOGOS Y COLECCIONES + COLECCIONES + HERO + FUENTES DEL HERO + %1$d de %2$d seleccionados + Mostrar Hero + Mostrar un carrusel Hero destacado en la parte superior del inicio. Elige hasta 2 catálogos fuente abajo. + %1$d de %2$d catálogos visibles • %3$d fuentes Hero seleccionadas + Abre un catálogo solo cuando necesites cambiarle el nombre o reordenarlo. + Visible + Reproductor, subtítulos y reproducción automática + Radio de tarjeta + ESTILO DE TARJETA DE PÓSTER + Ancho de tarjeta + Personalizado + Personaliza el ancho de la tarjeta y el radio de las esquinas para las tarjetas de póster compartidas en toda la app. + Ocultar etiquetas + Modo horizontal para pósters en estantes + Vista previa en vivo + %1$s (%2$s) + Radio de esquina: %1$ddp + Altura: %1$ddp + Ancho: %1$ddp + Clásico + Píldora + Redondeado + Marcado + Sutil + Equilibrado + Cómodo + Compacto + Denso + Grande + Estándar + Mostrar un aviso para continuar donde lo dejaste al abrir la app después de salir del reproductor. + Aviso para reanudar al iniciar + ESTILO DE TARJETA + AL INICIAR + COMPORTAMIENTO DE SIGUIENTE + VISIBILIDAD + Mostrar el estante de Seguir viendo en la pantalla de inicio. + Mostrar Seguir viendo + Póster + Tarjeta de póster centrada en la portada + Panorámica + Tarjeta horizontal rica en información + Cuando está activado, Siguiente siempre continúa desde el episodio más avanzado visto. Cuando está desactivado, sigue el episodio visto más recientemente. Útil si vuelves a ver episodios anteriores. + Siguiente desde el episodio más avanzado + INICIO + FUENTES + Instala, elimina, actualiza y ordena tus fuentes de contenido. + Instala repositorios de scrapers en JavaScript y prueba proveedores internamente. + Controla qué catálogos aparecen en Inicio y en qué orden. + Desactiva secciones de detalles y reordena todo debajo del Hero. + Crea agrupaciones de catálogos personalizadas con carpetas mostradas en Inicio. + INTEGRACIONES + Mejora las páginas de detalles con arte, créditos, metadatos de episodios y más desde TMDB. + Añade calificaciones externas de IMDb, Rotten Tomatoes, Metacritic y otras a las páginas de detalles. + Añade tu clave API de MDBList abajo antes de activar las calificaciones. + Obtén una clave en https://mdblist.com/preferences y pégala aquí. + Clave API + Clave API de MDBList + Activar calificaciones de MDBList + Mostrar calificaciones externas de MDBList en páginas de metadatos cuando haya un ID de IMDb disponible. + CLAVE API + PROVEEDORES DE CALIFICACIÓN + MDBLIST + Acciones + Controles de reproducción y guardado. + Reparto + Lista principal de reparto. + Fondo cinematográfico + Fondo desenfocado detrás del contenido, similar a la pantalla de streams. + Colección + Carril de colección o franquicia relacionada. + Comentarios + Sección de comentarios de Trakt. + Detalles + Duración, estado, estreno, idioma e información relacionada. + Tarjetas de episodios + Elige cómo se muestran los episodios en la pantalla de metadatos. + Horizontal + Tarjetas en fila estilo fondo + Lista + Tarjetas apiladas centradas en detalles + Episodios + Temporadas y lista de episodios para series. + Grupo %1$d + Más como esto + Carril de recomendaciones. + Ninguno + Resumen + Sinopsis, calificaciones, géneros y créditos principales. + Producción + Estudios y cadenas. + APARIENCIA + SECCIONES + Grupo de pestañas %1$d + Diseño de pestañas + Agrupa secciones en pestañas como en la app de TV. Asigna hasta 3 secciones por grupo de pestañas. + Tráilers + Carril de tráilers y atajos de reproducción. + Las notificaciones están actualmente desactivadas en Nuvio. + Alertas de estreno de episodios + Programa notificaciones locales cuando haya disponible un nuevo episodio de una serie guardada. + Las notificaciones del sistema están desactivadas para Nuvio. Actívalas para recibir alertas y notificaciones de prueba. + Actualmente hay %1$d alertas de estreno programadas en este dispositivo. + ALERTAS + PRUEBA + Enviar notificación de prueba + Enviando notificación de prueba... + Enviar una notificación local de prueba para %1$s. + Guarda primero una serie en tu biblioteca para probar las notificaciones. + Notificación de prueba + Comunidad + Mira a las personas que construyen y apoyan Nuvio en Mobile, TV y Web. + La API de patrocinadores no está configurada. Agrega DONATIONS_BASE_URL a local.properties. + Colaboradores + Patrocinadores + Abrir GitHub + Perfil de GitHub no disponible + No hay mensaje adjunto. + Cargando colaboradores... + Cargando patrocinadores... + No se pudieron cargar los colaboradores + No se pudieron cargar los patrocinadores + No se encontraron colaboradores. + No se encontraron patrocinadores. + No se pudieron cargar los colaboradores. + No se pudieron cargar los patrocinadores. + No se pudieron cargar los colaboradores en este momento. + No se pudieron cargar los patrocinadores en este momento. + %1$d commits totales + Ene + Feb + Mar + Abr + May + Jun + Jul + Ago + Sep + Oct + Nov + Dic + %2$s de %1$s de %3$s + Todos los complementos + Todos los plugins + Complementos permitidos + Plugins permitidos + Anime Skip + ID de cliente de AnimeSkip + Introduce tu ID de cliente API de AnimeSkip. Obtén uno en anime-skip.com. + Buscar también marcas de salto en AnimeSkip (requiere ID de cliente). + Reproducción automática del siguiente episodio + Buscar y reproducir automáticamente el siguiente episodio cuando se alcance el umbral. + Solo dispositivo + Preferir app (FFmpeg) + Preferir dispositivo + Prioridad del decodificador + Toca afuera para cerrar + Toca afuera para guardar y cerrar + %1$d día + %1$d días + %1$d hora + %1$d horas + Activar libass + Usar libass para renderizar subtítulos ASS/SSA en lugar del renderizador predeterminado. + Velocidad al mantener + Mantener para acelerar + Mantén pulsado en cualquier parte de la superficie del reproductor para aumentar temporalmente la velocidad. + Patrón regex no válido + Duración de caché del último enlace + Mapear DV7 a HEVC + Usar Dolby Vision Perfil 7 a HEVC como alternativa para dispositivos no compatibles. + Minutos antes del final + Mostrar la tarjeta del siguiente episodio esta cantidad de minutos antes del final. + %1$d min + No hay elementos disponibles + No establecido + Predeterminado + Idioma del dispositivo + Forzado + Ninguno + Preferir grupo binge + Al reproducir automáticamente, preferir un stream del mismo grupo binge que el actual. + Idioma de audio preferido + Idioma de subtítulos preferido + Preajustes + Coincide con nombre del stream, etiqueta, descripción, complemento y URL. + Patrón regex + 4K|2160p|Remux + Cualquier 1080p+ + AVC / x264 + Calidad BluRay + Dolby Atmos / DTS + Inglés + HDR / Dolby Vision + HEVC / x265 + Sin CAM/TS + Sin REMUX/HDR + 1080p estándar + 4K / Remux + 720p / más pequeño + Fuentes WEB + Tipo de renderizado + Estándar (Cues) + Canvas con efectos + OpenGL con efectos + Canvas superpuesto + OpenGL superpuesto + Reutilizar último enlace + Reproducir automáticamente tu último stream funcional para esta misma película/episodio cuando la caché siga siendo válida. + Idioma de audio secundario + Idioma de subtítulos secundario + DECODIFICADOR + SIGUIENTE EPISODIO + REPRODUCTOR + SALTAR SEGMENTOS + REPRODUCCIÓN AUTOMÁTICA DE STREAMS + SELECCIÓN DE STREAM + SUBTÍTULOS Y AUDIO + RENDERIZADO DE SUBTÍTULOS + %1$d seleccionados + Mostrar superposición de carga + Mostrar la superposición de carga inicial mientras empieza a reproducirse un stream. + Saltar intro/outro/resumen + Mostrar botón de salto durante segmentos detectados de intro, outro y resumen. + Ámbito de fuentes + Todos los complementos + Considerar streams de todos los complementos instalados. + Todas las fuentes + Considerar streams tanto de complementos como de plugins. + Solo plugins habilitados + Considerar solo streams de plugins habilitados. + Solo complementos instalados + Considerar solo streams de complementos instalados. + Modo de selección de stream + Primer stream disponible + Reproducir automáticamente el primer stream encontrado. + Manual + Seleccionar streams manualmente cada vez. + Coincidencia regex + Seleccionar automáticamente un stream que coincida con un patrón regex. + Tiempo de espera del stream + Cuánto esperar por los streams antes de autoseleccionar. + Minutos antes del final + Modo de umbral + Minutos antes del final + Porcentaje + Porcentaje de umbral + Mostrar la tarjeta del siguiente episodio cuando la reproducción alcance este porcentaje. + %1$d%% + Instantáneo + %1$ds + Ilimitado + Reproducción tunneled + Activa la reproducción tunneled para una menor latencia en la sincronización de audio/video. + Añade tu propia clave API de TMDB abajo antes de activar el enriquecimiento. + Clave API de TMDB + Activar enriquecimiento TMDB + Usar tu clave API de TMDB para enriquecer metadatos del complemento en la pantalla de detalles cuando haya un ID de TMDB o IMDb disponible. + Introduce tu clave API v3 de TMDB. + Código de idioma + Arte + Reemplazar fondo, póster y logo con arte de TMDB. + Información básica + Usar título, sinopsis, géneros y calificación de TMDB. + Colecciones + Mostrar carriles de franquicia y colección para películas cuando TMDB los proporcione. + Créditos + Usar creadores, directores, guionistas y fotos del reparto de TMDB. + Detalles + Usar información de estreno, duración, clasificación por edad, estado, país e idioma de TMDB. + Episodios + Usar títulos, miniaturas, descripciones y duraciones de episodios de TMDB para series. + Más como esto + Mostrar recomendaciones de TMDB al final de las páginas de detalles. + Cadenas + Usar metadatos de cadenas de TMDB para títulos de TV. + Productoras + Usar metadatos de productoras de TMDB en la pantalla de detalles. + Pósters de temporada + Usar pósters de temporada de TMDB en el selector de temporadas de la pantalla de metadatos para series. + Tráilers + Obtener y mostrar la sección de vídeos de tráiler de TMDB en páginas de detalles. + Clave API personal + Idioma preferido + Configura el código de idioma de TMDB usado para metadatos localizados, por ejemplo `en`, `en-US` o `pt-BR`. + CREDENCIALES + LOCALIZACIÓN + MÓDULOS + TMDB + Después de aprobar, serás redirigido automáticamente. + AUTENTICACIÓN + Comentarios + Mostrar comentarios de Trakt en detalles de películas y series + Conectar Trakt + Conectado como %1$s + Usuario de Trakt + Desconectar + No se pudo abrir el navegador + FUNCIONES + Completa el inicio de sesión de Trakt en tu navegador + Haz seguimiento de lo que ves, guarda en tu lista o listas personalizadas y mantén tu biblioteca sincronizada con Trakt. + Faltan credenciales de Trakt en local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Abrir inicio de sesión de Trakt + Tus acciones de Guardar ahora pueden apuntar a la watchlist de Trakt y a listas personales. + Inicia sesión con Trakt para habilitar el guardado basado en listas y el modo biblioteca de Trakt. + Calificación del público + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Desconocido + Ámbar + Carmesí + Esmeralda + Océano + Rosa + Violeta + Blanco + Siguiente episodio + Buscando fuente… + Reproduciendo vía %1$s en %2$d… + Miniatura del siguiente episodio + No emitido + Saltar + Saltar intro + Saltar outro + Saltar resumen + No se encontraron subtítulos + Afrikáans + Albanés + Amhárico + Árabe + Armenio + Azerbaiyano + Euskera + Bielorruso + Bengalí + Bosnio + Búlgaro + Birmano + Catalán + Chino + Chino (simplificado) + Chino (tradicional) + Croata + Checo + Danés + Neerlandés + Inglés + Estonio + Filipino + Finés + Francés + Gallego + Georgiano + Alemán + Griego + Guyaratí + Hebreo + Hindi + Húngaro + Islandés + Indonesio + Irlandés + Italiano + Japonés + Kannada + Kazajo + Jemer + Coreano + Lao + Letón + Lituano + Macedonio + Malayo + Malayalam + Maltés + Maratí + Mongol + Nepalí + Noruego + Persa + Polaco + Portugués (Portugal) + Portugués (Brasil) + Punyabí + Rumano + Ruso + Serbio + Cingalés + Eslovaco + Esloveno + Español + Español (Latinoamérica) + Suajili + Sueco + Tamil + Telugu + Tailandés + Turco + Ucraniano + Urdu + Uzbeko + Vietnamita + Galés + Zulú + Limpiar + Continuar + Ignorar + Instalar + Más tarde + No + Actualizar + + ¿Quieres salir de la app? + Salir de la app + Este catálogo no devolvió ningún elemento. + No se encontraron títulos + Comprueba tu conexión Wi‑Fi o de datos móviles e inténtalo de nuevo. + Director + No se pudo cargar + Más como esto + Temporadas + Este addon devolvió videos para la serie, pero ninguno incluía números de temporada o episodio. + Este addon no proporcionó metadatos de episodios para esta serie. + Este addon aún no ha publicado episodios. + Tu dispositivo está en línea, pero Nuvio no pudo conectarse a los servidores necesarios. + Mostrar menos + Mostrar más ▾ + Guionista + Todos los géneros + Catálogo + %1$s • %2$s + El catálogo seleccionado no devolvió elementos de descubrimiento. + No se pudo cargar Descubrir + Los addons instalados no exponen catálogos compatibles con el tablero para Descubrir. + No hay catálogos de descubrir + El catálogo y los filtros seleccionados no devolvieron ningún elemento. + No se encontraron títulos + Instala y valida al menos un addon antes de explorar catálogos en Descubrir. + Seleccionar catálogo + Seleccionar género + Seleccionar tipo + Tipo + Marcar anteriores como no vistos + Marcar anteriores como vistos + Marcar %1$s como no vista + Marcar %1$s como vista + Marcar como no visto + Marcar como visto + Siguiente + %1$d%% visto + Instala y valida al menos un addon antes de cargar filas de catálogo en Inicio. + Los addons instalados no exponen actualmente catálogos compatibles con el tablero sin extras requeridos. + No hay filas de inicio disponibles + Ver detalles + Controles para reproducir y guardar. + Acciones + Lista principal del reparto. + Fila de colección o franquicia relacionada. + Colección + Sección de comentarios de Trakt. + Duración, estado, fecha de estreno, idioma e información relacionada. + Detalles + Temporadas y lista de episodios para series. + Fila de recomendaciones. + Más como esto + Sinopsis, calificaciones, géneros y créditos principales. + Resumen + Estudios y cadenas. + Producción + Fila de tráilers y accesos rápidos de reproducción. + De nuevo en línea + No se puede acceder a los servidores + Sin conexión a internet + (edad %1$d) + Nació %1$s%2$s + Murió %1$s + Conocido por: %1$s + Más reciente + No se pudieron cargar los detalles de %1$s + Popular + Algo salió mal + Próximamente + Borrar + Cancelar + Introducir PIN + Introducir PIN para %1$s + ¿Olvidaste el PIN? + PIN incorrecto + Bloqueado. Inténtalo de nuevo en %1$ds + Las opciones de avatar aparecerán aquí cuando se cargue el catálogo. + Avatar: %1$s + Elige un avatar + Elige un avatar abajo. + Crear perfil + Todos los datos de "%1$s" se eliminarán permanentemente. + Eliminar perfil + Añadir perfil + Editar perfil + Introducir PIN actual + Introducir PIN nuevo + Perfil %1$d + Cargando avatares... + Gestionar perfiles + Nombre del perfil + Perfil nuevo + Addons principales desactivados + Addons principales activados + Quitar PIN para %1$s + Quitar bloqueo PIN + Guardando... + Seguridad + Añade un PIN si quieres que este perfil quede bloqueado antes de cambiar a él. + Este perfil está protegido con un PIN. + Selecciona un avatar para este perfil. + Configurar bloqueo PIN + Perfil sin nombre + Usar addons principales + Comparte la configuración de addons del perfil principal en lugar de gestionar una lista separada. + ¿Quién está viendo? + Descargado + Reanudar + Scrapers activos + Comprobando más addons… + Copiar enlace del stream + Descargar archivo + Los addons de streams instalados no devolvieron una respuesta válida. + No se pudieron cargar los streams + Instala primero un addon para cargar streams de este título. + Tus addons instalados no proporcionan streams para este tipo de título. + No hay addon de streams disponible + Ninguno de tus addons instalados devolvió streams para este título. + T%1$d E%2$d + Episodio + T%1$dE%2$d - %3$s + Obteniendo… + Buscando fuente… + Buscando streams… + Enlace del stream copiado + No hay enlace directo del stream disponible + No hay metadatos disponibles + Actualizar streams + Reanudar desde %1$d%% + Reanudar desde %1$s + TAMAÑO %1$s + Cerrar tráiler + No se puede reproducir el tráiler + No se pudieron cargar las listas de Trakt + No se pudieron actualizar las listas de Trakt + %1$s • %2$s + Falló la comprobación de actualizaciones + La descarga falló + Descargando %1$d%% + No se pudo iniciar la instalación + Estás usando la versión más reciente. + Activa la instalación de apps para Nuvio y luego vuelve para continuar. + Descargando actualización... + No se encontraron actualizaciones. + Hay una nueva versión lista para instalar. + Las actualizaciones dentro de la app no están disponibles en esta compilación. + Preparando descarga + Notas de la versión + Permitir instalaciones para continuar + Actualización disponible + Estado de la actualización + Ese complemento ya está instalado. + Introduce una URL de complemento válida + No se pudo cargar el manifiesto + Nuvio + No se pudo eliminar la cuenta + Error al iniciar sesión + Error al cerrar sesión + Error al registrarse + No se pudieron cargar los elementos del catálogo. + A continuación + A continuación • T%1$dE%2$d + logotipo de %1$s + No se pudieron cargar los comentarios + No se pudieron cargar los detalles desde ningún complemento. + Redes + Ningún complemento proporciona metadatos para este contenido. + Descarga fallida + Muestra el progreso en vivo y los controles de descarga. + Descargas + Descarga completada + Descargando %1$s • %2$s + Descargando %1$s • %2$s / %3$s + Descarga fallida + En pausa %1$s + Eliminar + ¿Eliminar %1$s de tu biblioteca? + ¿Eliminar de la biblioteca? + Película + Alertas cuando se estrena un nuevo episodio de una serie guardada. + Vista previa de la alerta de estreno de episodio. + No se pudo enviar una notificación de prueba. + Notificación de prueba enviada para %1$s. + No se puede reproducir esta transmisión. + El PIN de este perfil cambió. Conéctate una vez para actualizar el bloqueo en este dispositivo. + No se pudo quitar el bloqueo por PIN. Inténtalo de nuevo. + Conéctate a internet para quitar el bloqueo por PIN. + Este PIN aún no puede verificarse sin conexión en este dispositivo. Conéctate una vez y desbloquéalo en línea primero. + No se pudo establecer el PIN. Inténtalo de nuevo. + Conéctate a internet para establecer un PIN. + Este perfil usa los complementos principales. + No se pudo cargar %1$s + Fuente + Integrado + Autorización denegada + Completa el inicio de sesión de Trakt en tu navegador + Callback de Trakt no válido + Estado de callback de Trakt no válido + Respuesta de token de Trakt no válida + No se pudo cargar la biblioteca de Trakt + Lista %1$d + Trakt no devolvió un código de autorización + Faltan credenciales de Trakt + No se pudo cargar el progreso de Trakt + No se pudo completar el inicio de sesión de Trakt + Usuario de Trakt + Lista de seguimiento + Tráiler + Desconocido + Complemento + Guardado + Reproducir %1$s + Reanudar %1$s + El JSON está vacío. + La colección %1$d tiene el id vacío. + La colección '%1$s' tiene el título vacío. + La carpeta %1$d en '%2$s' tiene el id vacío. + La carpeta '%1$s' en '%2$s' tiene el título vacío. + La fuente %1$d en la carpeta '%2$s' tiene campos vacíos. + JSON inválido: %1$s + Complemento no encontrado: %1$s + Enero + Febrero + Marzo + Abril + Mayo + Junio + Julio + Agosto + Septiembre + Octubre + Noviembre + Diciembre + Ene + Feb + Mar + Abr + May + Jun + Jul + Ago + Sep + Oct + Nov + Dic + Productora + Cadena + No se pudo cargar %1$s + Popular + Reciente + %1$s • %2$s + Mejor valorado + Clasificación + Detalles de la película + Idioma original + País de origen + Información de estreno + Duración + Pósteres + Texto + Detalles de la serie + Estado + Videos + ARCHIVO + No hay enlace directo del stream disponible + Se reemplazó la descarga anterior + Descarga iniciada + Formato de stream no compatible para descargas + Cuerpo de respuesta vacío + La solicitud falló con HTTP %1$d + El sistema de descargas no está inicializado + La solicitud de descarga falló + %1$s - %2$s + Los títulos guardados aparecerán aquí después de tocar Guardar en una pantalla de detalles. + Tu biblioteca está vacía + No se pudo cargar la biblioteca + Otro + Biblioteca + Conecta Trakt y guarda títulos en tu lista de seguimiento o listas personales. + Tu biblioteca de Trakt está vacía + No se pudo cargar la biblioteca de Trakt + Biblioteca de Trakt + Anime + Canales + Películas + Series + TV + %1$s ya está disponible + %1$s • %2$s ya está disponible + Ya hay un episodio nuevo disponible + %1$s ya está disponible + Estrenos de episodios + Creador + Director + Guionista + Puntuación del público + No se encontró un stream de tráiler reproducible. + Temporada %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..842bef80 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,1043 @@ + + Open recognition and project credits + Back + Cancel + Close + Delete + Done + Edit + Import + Next + OK + Play + Previous + Remove + Reorder + Reset + Resume + Retry + Save + Installing + Addons + Active + %1$d catalogs + Configurable + Refreshing + %1$d resources + Unavailable + Configure addon + Delete addon + Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio. + No addons installed yet. + Enter an addon URL. + Addon URL + Install Addon + Loading manifest details... + Validating the manifest URL and loading addon details before install. + Checking Addon + Install Failed + %1$s was validated and added successfully. + Addon Installed + Move addon down + Move addon up + Active + Addons + Catalogs + Refresh addon + Add Addon + Installed Addons + Overview + %1$d id rules + Version %1$s + Selected + Copy JSON + %1$d collection(s), %2$d folder(s) + Delete "%1$s"? This cannot be undone. + Delete Collection + Add Catalog + Add Folder + All genres + Add catalogs from your installed addons to define what this folder shows. + No catalog sources yet + Choose + Emoji + Image URL + None + Cover + Create Collection + Done + Edit Collection + Edit Folder + Set the folder identity, presentation, and catalog sources with the same structure as the main collections editor. + Add one to get started. + No folders yet + Folders + Genre Filter + Only show the cover image + Hide Title + New Folder + Show this collection above all regular home catalogs. Multiple pinned collections follow collection creation order. + Pin Above Catalogs + Backdrop image URL (optional) + Folder name + Animated GIF URL (plays only while focused) + Collection name + Save Changes + Save + Appearance + Basics + Catalog Sources + Choose the addon catalogs this folder should aggregate. + Select Catalogs + Select genre + Poster + Square + Wide + Combine all catalogs into one tab + Show \"All\" Tab + Play the configured GIF instead of the static cover when available. + Show GIF When Configured + %1$d source(s) · %2$s + Tile Shape + Rows + Tabs + View Mode + Create one to organize your catalogs. + No collections yet + %1$d folder(s) + No items found + Folder not found + Collections + Import Collections + JSON + Paste your collections JSON below. + Import + New Collection + Pinned + All + Your Collections + Made with ❤️ by Tapframe and friends + Version %1$s (%2$s) + Off + On + Pause + Reload + Already have an account? + Continue Without Account + Create Account + Don't have an account? + Email + or + Password + Sign in to access your library and progress + Sign In + Sign up to sync your data across devices + Sign Up + Your data will only be stored locally + Stream everything, everywhere + Welcome Back + Library + Trakt Library + Home + Library + Profile + Search + Audio Tracks + Audio + Built-in + Bottom Offset + Close player + Color + Currently playing + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episodes + Font Size + %1$dsp + Lock player controls + No audio tracks available + No episodes available + No streams found + None + Outline + Episodes + Sources + Streams + Playback error + Playing + Tap to fetch subtitles + Go back + Reset Defaults + Fill + Fit + Zoom + Seek backward 10 seconds + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Seek forward 10 seconds + Sources + Style + Subs + Subtitles + Brightness %1$d%% + Volume %1$d%% + Muted + Downloaded + Airs + TBA + Tap to unlock + Track %1$d + Unlock player controls + You're watching + Add Profile + Clear search + Discover + Installed addons failed to return valid search results. + Search failed + Install and validate at least one addon before searching. + No active addons + Installed searchable catalogs did not return any matches for this query. + No results found + Your installed addons do not expose catalog search. + No searchable catalogs + Search movies, shows... + Recent Searches + Remove recent search + About + General + Account + Addons + Appearance + Content & Discovery + Continue Watching + Homescreen + Integrations + MDBList Ratings + Meta Screen + Notifications + Playback + Plugins + Poster Customization + Settings + Supporters & Contributors + TMDB Enrichment + Trakt + ABOUT + Manage your account, sign out, or delete. + ACCOUNT + Tune home presentation and visual preferences. + Check for new versions of the app. + Check for updates + Manage addons and discovery sources. + Manage your downloaded movies and episodes. + Downloads + GENERAL + Connect TMDB and MDBList services. + Manage episode release alerts and send a test notification. + Change to a different profile. + Switch Profile + Connect Trakt, sync watchlist lists, and save titles directly to Trakt. + Loading your Trakt lists… + Choose where to save this title on Trakt + Donate + Go to details + Remove + Start from beginning + Play + %1$d/10 + Review + Spoiler + No Trakt reviews available yet. + %1$d likes + This comment contains spoilers. + This comment contains spoilers and has been hidden. + Comments + Trailer + %1$s (%2$d) + Trailers + No completed episodes + No downloads yet + %1$d downloaded episode(s) + Active + Movies + Shows + Show Downloads + Completed • %1$s + Downloading • %1$s + Failed + Paused • %1$s + Watched + Season %1$d + Specials + Continue where you left off + Add to library + Mark as unwatched + Mark as watched + Remove from library + View All + Play manually + %1$s logo + Account + Delete Account + This will permanently delete your account and all associated data. + This action cannot be undone. All your data, profiles, and sync history will be permanently removed. + Delete Account? + Email + Not signed in + Sign Out + You will be returned to the login screen. + Sign Out? + Status + Anonymous + Signed In + AMOLED Black + Use pure black backgrounds for OLED screens. + App Language + Choose Language + Show, hide, and style the Continue Watching shelf. + Adjust shared poster card width and corner radius presets. + DISPLAY + HOME + THEME + Collection • %1$s + Display Name + Install an addon with board-compatible catalogs to configure Homescreen rows. + No home catalogs + Hero source + Hidden + Keep Home focused + %1$s • Limit reached (max %2$d) + No hero sources selected + Not in hero + Remove pin to top from collection to move + Pinned + Pinned to top + Reorder + CATALOGS + CATALOGS & COLLECTIONS + COLLECTIONS + HERO + HERO SOURCES + %1$d of %2$d selected + Show Hero + Display a featured hero carousel at the top of Home. Choose up to 2 source catalogs below. + %1$d of %2$d catalogs visible • %3$d hero sources selected + Open a catalog only when you need to rename or reorder it. + Visible + Player, subtitles, and auto-play + Card Radius + POSTER CARD STYLE + Card Width + Custom + Customize card width and corner radius for shared poster cards across the app. + Hide labels + Landscape mode for shelf posters + Live Preview + %1$s (%2$s) + Corner radius: %1$ddp + Height: %1$ddp + Width: %1$ddp + Classic + Pill + Rounded + Sharp + Subtle + Balanced + Comfort + Compact + Dense + Large + Standard + Show a popup to continue where you left off when opening the app after leaving from the player. + Resume prompt on launch + CARD STYLE + ON LAUNCH + UP NEXT BEHAVIOR + VISIBILITY + Display the Continue Watching shelf on the Home screen. + Show Continue Watching + Poster + Artwork-first poster card + Wide + Info-dense horizontal card + When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode. Useful if you rewatch earlier episodes. + Up Next from furthest episode + HOME + SOURCES + Install, remove, refresh, and sort your content sources. + Install JavaScript scraper repositories and test providers internally. + Control which catalogs appear on Home and in what order. + Disable detail sections and reorder everything below Hero. + Create custom catalog groupings with folders shown on Home. + INTEGRATIONS + Enhance detail pages with TMDB artwork, credits, episode metadata, and more. + Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages. + Add your MDBList API key below before turning ratings on. + Get a key from https://mdblist.com/preferences and paste it here. + API key + MDBList API key + Enable MDBList ratings + Show external ratings from MDBList on metadata pages when an IMDb ID is available. + API KEY + RATING PROVIDERS + MDBLIST + Actions + Play and save controls. + Cast + Principal cast list. + Cinematic Background + Blurred backdrop behind content, similar to stream screen. + Collection + Related collection or franchise rail. + Comments + Trakt comments section. + Details + Runtime, status, release, language, and related info. + Episode Cards + Choose how episodes are rendered on the metadata screen. + Horizontal + Backdrop-style row cards + List + Detail-first stacked cards + Episodes + Seasons and episode list for series. + Group %1$d + More Like This + Recommendation rail. + None + Overview + Synopsis, ratings, genres, and core credits. + Production + Studios and networks. + APPEARANCE + SECTIONS + Tab Group %1$d + Tab Layout + Group sections into tabs like the TV app. Assign up to 3 sections per tab group. + Trailers + Trailer rail and playback shortcuts. + Notifications are currently disabled in Nuvio. + Episode release alerts + Schedule local notifications when a new episode for a saved show becomes available. + System notifications are disabled for Nuvio. Enable them to receive alerts and test notifications. + %1$d release alerts are currently scheduled on this device. + ALERTS + TEST + Send Test Notification + Sending Test Notification... + Send a local test notification for %1$s. + Save a show to your library first to test notifications. + Test notification + Community + See the people building and supporting Nuvio across Mobile, TV, and Web. + Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties. + Contributors + Supporters + Open GitHub + GitHub profile unavailable + No message attached. + Loading contributors... + Loading supporters... + Couldn't load contributors + Couldn't load supporters + No contributors found. + No supporters found. + Unable to load contributors. + Unable to load supporters. + Couldn't load contributors right now. + Couldn't load supporters right now. + %1$d total commits + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + %1$s %2$s, %3$s + All Addons + All Plugins + Allowed Addons + Allowed Plugins + Anime Skip + AnimeSkip Client ID + Enter your AnimeSkip API client ID. Get one at anime-skip.com. + Also search AnimeSkip for skip timestamps (requires client ID). + Auto-Play Next Episode + Automatically find and play the next episode when the threshold is reached. + Device Only + Prefer App (FFmpeg) + Prefer Device + Decoder Priority + Tap outside to close + Tap outside to save & close + %1$d day + %1$d days + %1$d hour + %1$d hours + Enable libass + Use libass for ASS/SSA subtitle rendering instead of the default renderer. + Hold Speed + Hold To Speed + Long-press anywhere on the player surface to temporarily boost playback speed. + Invalid regex pattern + Last Link Cache Duration + Map DV7 to HEVC + Dolby Vision Profile 7 to HEVC fallback for unsupported devices. + Minutes Before End + Show next episode card this many minutes before the end. + %1$d min + No items available + Not set + Default + Device Language + Forced + None + Prefer Binge Group + When auto-playing, prefer a stream from the same binge group as the current one. + Preferred Audio Language + Preferred Subtitle Language + Presets + Matches against stream name, label, description, addon, and URL. + Regex Pattern + 4K|2160p|Remux + Any 1080p+ + AVC / x264 + BluRay Quality + Dolby Atmos / DTS + English + HDR / Dolby Vision + HEVC / x265 + No CAM/TS + No REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Smaller + WEB Sources + Render Type + Standard (Cues) + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL + Reuse Last Link + Auto-play your last working stream for this same movie/episode when cache is still valid. + Secondary Audio Language + Secondary Subtitle Language + DECODER + NEXT EPISODE + PLAYER + SKIP SEGMENTS + STREAM AUTO-PLAY + STREAM SELECTION + SUBTITLE AND AUDIO + SUBTITLE RENDERING + %1$d selected + Show Loading Overlay + Show the opening loading overlay while a stream starts playing. + Skip Intro/Outro/Recap + Show skip button during detected intro, outro, and recap segments. + Source Scope + All Addons + Consider streams from all installed addons. + All Sources + Consider streams from both addons and plugins. + Enabled Plugins Only + Only consider streams from enabled plugins. + Installed Addons Only + Only consider streams from installed addons. + Stream Selection Mode + First Available Stream + Automatically play the first stream found. + Manual + Select streams manually each time. + Regex Match + Auto-select a stream matching a regex pattern. + Stream Timeout + How long to wait for streams before auto-selecting. + Minutes Before End + Threshold Mode + Minutes Before End + Percentage + Threshold Percentage + Show next episode card when playback reaches this percentage. + %1$d%% + Instant + %1$ds + Unlimited + Tunneled Playback + Enable tunneled playback for lower latency audio/video sync. + Add your own TMDB API key below before turning enrichment on. + TMDB API key + Enable TMDB enrichment + Use your TMDB API key to enrich addon metadata on the details screen when a TMDB or IMDb ID is available. + Enter your TMDB v3 API key. + Language code + Artwork + Replace backdrop, poster, and logo with TMDB artwork. + Basic info + Use TMDB title, synopsis, genres, and rating. + Collections + Show franchise and collection rails for movies when TMDB provides them. + Credits + Use TMDB creators, directors, writers, and cast photos. + Details + Use TMDB release info, runtime, age rating, status, country, and language. + Episodes + Use TMDB episode titles, thumbnails, descriptions, and runtimes for series. + More like this + Show TMDB recommendations at the bottom of detail pages. + Networks + Use TMDB network metadata for TV titles. + Production companies + Use TMDB production company metadata on the details screen. + Season posters + Use TMDB season posters in the metadata screen season selector for series. + Trailers + Fetch and show TMDB trailer videos section on detail pages. + Personal API key + Preferred language + Set the TMDB language code used for localized metadata, for example `en`, `en-US`, or `pt-BR`. + CREDENTIALS + LOCALIZATION + MODULES + TMDB + After approval, you will be redirected back automatically. + AUTHENTICATION + Comments + Show Trakt comments on movie and show details + Connect Trakt + Connected as %1$s + Trakt user + Disconnect + Failed to open browser + FEATURES + Finish Trakt sign in in your browser + Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt. + Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Open Trakt Login + Your Save actions can now target Trakt watchlist and personal lists. + Sign in with Trakt to enable list-based saving and Trakt library mode. + Audience Score + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Unknown + Amber + Crimson + Emerald + Ocean + Rose + Violet + White + Next Episode + Finding source… + Playing via %1$s in %2$d… + Next episode thumbnail + Unaired + Skip + Skip Intro + Skip Outro + Skip Recap + No subtitles found + Afrikaans + Albanian + Amharic + Arabic + Armenian + Azerbaijani + Basque + Belarusian + Bengali + Bosnian + Bulgarian + Burmese + Catalan + Chinese + Chinese (Simplified) + Chinese (Traditional) + Croatian + Czech + Danish + Dutch + English + Estonian + Filipino + Finnish + French + Galician + Georgian + German + Greek + Gujarati + Hebrew + Hindi + Hungarian + Icelandic + Indonesian + Irish + Italian + Japanese + Kannada + Kazakh + Khmer + Korean + Lao + Latvian + Lithuanian + Macedonian + Malay + Malayalam + Maltese + Marathi + Mongolian + Nepali + Norwegian + Persian + Polish + Portuguese (Portugal) + Portuguese (Brazil) + Punjabi + Romanian + Russian + Serbian + Sinhala + Slovak + Slovenian + Spanish + Spanish (Latin America) + Swahili + Swedish + Tamil + Telugu + Thai + Turkish + Ukrainian + Urdu + Uzbek + Vietnamese + Welsh + Zulu + Clear + Continue + Ignore + Install + Later + No + Update + Yes + Do you want to exit the app? + Exit app + This catalog did not return any items. + No titles found + Check your Wi-Fi or mobile data connection and try again. + Director + Failed to load + More Like This + Seasons + This addon returned videos for the series, but none included season or episode numbers. + This addon did not provide episode metadata for this series. + Episodes have not been published by this addon yet. + Your device is online, but Nuvio could not reach required servers. + Show Less + Show More ▾ + Writer + All Genres + Catalog + %1$s • %2$s + The selected catalog failed to return discover items. + Could not load discover + Installed addons do not expose board-compatible catalogs for discover. + No discover catalogs + The selected catalog and filters did not return any items. + No titles found + Install and validate at least one addon before browsing discover catalogs. + Select Catalog + Select Genre + Select Type + Type + Mark previous as unwatched + Mark previous as watched + Mark %1$s as unwatched + Mark %1$s as watched + Mark as unwatched + Mark as watched + Up next + %1$d%% watched + Install and validate at least one addon before loading catalog rows on Home. + Installed addons do not currently expose board-compatible catalogs without required extras. + No home rows available + View Details + Play and save controls. + Actions + Principal cast list. + Related collection or franchise rail. + Collection + Trakt comments section. + Runtime, status, release, language, and related info. + Details + Seasons and episode list for series. + Recommendation rail. + More Like This + Synopsis, ratings, genres, and core credits. + Overview + Studios and networks. + Production + Trailer rail and playback shortcuts. + Back online + Cannot reach servers + No internet connection + (age %1$d) + Born %1$s%2$s + Died %1$s + Known for: %1$s + Latest + Could not load details for %1$s + Popular + Something went wrong + Upcoming + Backspace + Cancel + Enter PIN + Enter PIN for %1$s + Forgot PIN? + Incorrect PIN + Locked. Try again in %1$ds + Avatar options will appear here when the catalog loads. + Avatar: %1$s + Choose an avatar + Choose an avatar below. + Create Profile + All data for "%1$s" will be permanently deleted. + Delete Profile + Add Profile + Edit Profile + Enter current PIN + Enter new PIN + Profile %1$d + Loading avatars... + Manage Profiles + Profile name + New profile + Primary addons off + Primary addons on + Remove PIN for %1$s + Remove PIN Lock + Saving... + Security + Add a PIN if you want this profile locked before switching into it. + This profile is protected with a PIN. + Select an avatar for this profile. + Set PIN Lock + Unnamed profile + Use Primary Addons + Share the main profile's addon setup instead of managing a separate list. + Who's watching? + Downloaded + Resume + Active scrapers + Checking more addons… + Copy stream link + Download file + The installed stream addons failed to return a valid stream response. + Could not load streams + Install an addon first to load streams for this title. + Your installed addons do not provide streams for this type of title. + No stream addon available + None of your installed addons returned streams for this title. + S%1$d E%2$d + Episode + S%1$dE%2$d - %3$s + Fetching… + Finding source… + Finding streams… + Stream link copied + No direct stream link available + No metadata available + Refresh streams + Resume from %1$d%% + Resume from %1$s + SIZE %1$s + Close trailer + Unable to play trailer + Failed to load Trakt lists + Failed to update Trakt lists + %1$s • %2$s + Update check failed + Download failed + Downloading %1$d%% + Unable to start installation + You're using the latest version. + Enable app installs for Nuvio, then come back and continue. + Downloading update... + No updates found. + A new version is ready to install. + In-app updates are not available on this build. + Preparing download + Release notes + Allow installs to continue + Update available + Update status + That addon is already installed. + Enter a valid addon URL + Unable to load manifest + Nuvio + Account deletion failed + Sign-in failed + Sign-out failed + Sign-up failed + Unable to load catalog items. + Up Next + Up Next • S%1$dE%2$d + %1$s logo + Failed to load comments + Could not load details from any addon. + Networks + No addon provides meta for this content. + Download failed + Shows live download progress and controls. + Downloads + Download completed + Downloading %1$s • %2$s + Downloading %1$s • %2$s / %3$s + Download failed + Paused %1$s + Remove + Remove %1$s from your library? + Remove from Library? + Movie + Alerts when a saved show's new episode is released. + Preview episode release alert. + Failed to send a test notification. + Test notification sent for %1$s. + Unable to play this stream. + This profile PIN changed. Connect once to refresh the lock on this device. + Couldn't remove PIN lock. Try again. + Connect to the internet to remove the PIN lock. + This PIN can't be verified offline on this device yet. Connect once and unlock it online first. + Couldn't set PIN. Try again. + Connect to the internet to set a PIN. + This profile uses primary addons. + Failed to load %1$s + Stream + Embedded + Authorization denied + Complete Trakt sign in in your browser + Invalid Trakt callback + Invalid Trakt callback state + Invalid Trakt token response + Failed to load Trakt library + List %1$d + Trakt did not return an authorization code + Missing Trakt credentials + Failed to load Trakt progress + Failed to complete Trakt sign in + Trakt user + Watchlist + Trailer + Unknown + Addon + Saved + Play %1$s + Resume %1$s + JSON is empty. + Collection %1$d has blank id. + Collection '%1$s' has blank title. + Folder %1$d in '%2$s' has blank id. + Folder '%1$s' in '%2$s' has blank title. + Source %1$d in folder '%2$s' has blank fields. + Invalid JSON: %1$s + Addon not found: %1$s + January + February + March + April + May + June + July + August + September + October + November + December + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + Production Company + Network + Could not load %1$s + Popular + Recent + %1$s • %2$s + Top Rated + Certification + Movie Details + Original Language + Origin Country + Release Info + Runtime + Posters + Text + Show Details + Status + Videos + FILE + No direct stream link available + Replaced previous download + Download started + Unsupported stream format for downloads + Empty response body + Request failed with HTTP %1$d + Download system is not initialized + Download request failed + %1$s - %2$s + Saved titles will appear here after you tap Save on a details screen. + Your library is empty + Couldn't load library + Other + Library + Connect Trakt and save titles to your watchlist or personal lists. + Your Trakt library is empty + Couldn't load Trakt library + Trakt Library + Anime + Channels + Movies + Series + TV + %1$s is out now + %1$s • %2$s is out now + A new episode is out now + %1$s is out now + Episode Releases + Creator + Director + Writer + Audience Score + No playable trailer stream found. + Season %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f3b35366..4e68adf2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -92,6 +92,7 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme +import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository @@ -167,12 +168,20 @@ import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark +import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library +import nuvio.composeapp.generated.resources.compose_catalog_subtitle_trakt_library +import nuvio.composeapp.generated.resources.compose_nav_home +import nuvio.composeapp.generated.resources.compose_nav_library +import nuvio.composeapp.generated.resources.compose_nav_profile +import nuvio.composeapp.generated.resources.compose_nav_search import nuvio.composeapp.generated.resources.sidebar_library import nuvio.composeapp.generated.resources.sidebar_search import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Serializable object TabsRoute @@ -278,7 +287,6 @@ fun App() { ThemeSettingsRepository.selectedTheme }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() - NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { LaunchedEffect(Unit) { AuthRepository.initialize() @@ -499,6 +507,7 @@ private fun MainAppContent( val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() + val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -542,11 +551,11 @@ private fun MainAppContent( when (condition) { NetworkCondition.NoInternet -> { - NuvioToastController.show("No internet connection") + NuvioToastController.show(getString(Res.string.network_no_internet_connection)) } NetworkCondition.ServersUnreachable -> { - NuvioToastController.show("Cannot reach servers") + NuvioToastController.show(getString(Res.string.network_cannot_reach_servers)) } NetworkCondition.Online -> { @@ -554,7 +563,7 @@ private fun MainAppContent( previousConditionName == NetworkCondition.NoInternet.name || previousConditionName == NetworkCondition.ServersUnreachable.name ) { - NuvioToastController.show("Back online") + NuvioToastController.show(getString(Res.string.network_back_online)) } } @@ -698,7 +707,7 @@ private fun MainAppContent( streamTitle = downloadedItem.streamTitle.ifBlank { title }, streamSubtitle = downloadedItem.streamSubtitle, pauseDescription = pauseDescription, - providerName = downloadedItem.providerName.ifBlank { "Downloaded" }, + providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel }, providerAddonId = downloadedItem.providerAddonId, contentType = type, videoId = videoId, @@ -798,15 +807,17 @@ private fun MainAppContent( ) } + val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { + stringResource(Res.string.compose_catalog_subtitle_trakt_library) + } else { + stringResource(Res.string.compose_catalog_subtitle_library) + } + val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> navController.navigate( CatalogRoute( title = section.displayTitle, - subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { - "Trakt Library" - } else { - "Library" - }, + subtitle = librarySectionSubtitle, manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, type = section.items.firstOrNull()?.type ?: "movie", catalogId = section.type, @@ -899,19 +910,19 @@ private fun MainAppContent( selected = selectedTab == AppScreenTab.Home, onClick = { selectedTab = AppScreenTab.Home }, icon = Icons.Filled.Home, - contentDescription = "Home", + contentDescription = stringResource(Res.string.compose_nav_home), ) NavItem( selected = selectedTab == AppScreenTab.Search, onClick = { selectedTab = AppScreenTab.Search }, icon = Res.drawable.sidebar_search, - contentDescription = "Search", + contentDescription = stringResource(Res.string.compose_nav_search), ) NavItem( selected = selectedTab == AppScreenTab.Library, onClick = { selectedTab = AppScreenTab.Library }, icon = Res.drawable.sidebar_library, - contentDescription = "Library", + contentDescription = stringResource(Res.string.compose_nav_library), ) NavItem( selected = selectedTab == AppScreenTab.Settings, @@ -994,6 +1005,9 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val directorRole = stringResource(Res.string.person_role_director) + val writerRole = stringResource(Res.string.person_role_writer) + val creatorRole = stringResource(Res.string.person_role_creator) MetaDetailsScreen( type = route.type, id = route.id, @@ -1034,8 +1048,11 @@ private fun MainAppContent( castAvatarTransitionKey = avatarTransitionKey, preferCrew = person.role?.let { it.equals("Director", ignoreCase = true) || + it.equals(directorRole, ignoreCase = true) || it.equals("Writer", ignoreCase = true) || + it.equals(writerRole, ignoreCase = true) || it.equals("Creator", ignoreCase = true) + || it.equals(creatorRole, ignoreCase = true) } ?: false, ), ) @@ -1662,7 +1679,7 @@ private fun MainAppContent( tab.key to (snapshot[tab.key] == true) } }.onFailure { error -> - pickerError = error.message ?: "Failed to load Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } pickerPending = false } @@ -1748,7 +1765,7 @@ private fun MainAppContent( pickerItem = null pickerError = null }.onFailure { error -> - pickerError = error.message ?: "Failed to update Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) } pickerPending = false } @@ -1756,11 +1773,11 @@ private fun MainAppContent( ) NuvioStatusModal( - title = "Exit app", - message = "Do you want to exit the app?", + title = stringResource(Res.string.app_exit_title), + message = stringResource(Res.string.app_exit_message), isVisible = showExitConfirmation, - confirmText = "Yes", - dismissText = "No", + confirmText = stringResource(Res.string.action_yes), + dismissText = stringResource(Res.string.action_no), onConfirm = { showExitConfirmation = false platformExitApp() @@ -1791,9 +1808,9 @@ private fun MainAppContent( visible = resumePromptItem != null, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, title = resumePromptItem?.title.orEmpty(), - subtitle = resumePromptItem?.subtitle.orEmpty(), + subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(), progressFraction = resumePromptItem?.progressFraction ?: 0f, - actionLabel = "Resume", + actionLabel = stringResource(Res.string.resume_prompt_action), onAction = { val item = resumePromptItem ?: return@NuvioFloatingPrompt resumePromptItem = null @@ -1948,13 +1965,13 @@ private fun TabletFloatingTopBar( verticalAlignment = Alignment.CenterVertically, ) { TabletTopPillItem( - label = "Home", + label = stringResource(Res.string.compose_nav_home), selected = selectedTab == AppScreenTab.Home, onClick = { onTabSelected(AppScreenTab.Home) }, icon = { Icon( imageVector = Icons.Filled.Home, - contentDescription = "Home", + contentDescription = stringResource(Res.string.compose_nav_home), modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Home) { MaterialTheme.colorScheme.onPrimaryContainer @@ -1965,13 +1982,13 @@ private fun TabletFloatingTopBar( }, ) TabletTopPillItem( - label = "Search", + label = stringResource(Res.string.compose_nav_search), selected = selectedTab == AppScreenTab.Search, onClick = { onTabSelected(AppScreenTab.Search) }, icon = { Icon( painter = painterResource(Res.drawable.sidebar_search), - contentDescription = "Search", + contentDescription = stringResource(Res.string.compose_nav_search), modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Search) { MaterialTheme.colorScheme.onPrimaryContainer @@ -1982,13 +1999,13 @@ private fun TabletFloatingTopBar( }, ) TabletTopPillItem( - label = "Library", + label = stringResource(Res.string.compose_nav_library), selected = selectedTab == AppScreenTab.Library, onClick = { onTabSelected(AppScreenTab.Library) }, icon = { Icon( painter = painterResource(Res.drawable.sidebar_library), - contentDescription = "Library", + contentDescription = stringResource(Res.string.compose_nav_library), modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Library) { MaterialTheme.colorScheme.onPrimaryContainer @@ -2018,7 +2035,7 @@ private fun TabletFloatingTopBar( onAddProfileRequested = onAddProfileRequested, ) Text( - text = "Profile", + text = stringResource(Res.string.compose_nav_profile), modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) }, style = MaterialTheme.typography.labelLarge, color = if (selectedTab == AppScreenTab.Settings) { @@ -2081,7 +2098,7 @@ private fun AppLaunchOverlay( ) { Image( painter = painterResource(Res.drawable.app_logo_wordmark), - contentDescription = "Nuvio", + contentDescription = stringResource(Res.string.app_brand_name), modifier = Modifier .fillMaxWidth(0.48f) .height(44.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index d17dc078..7e5f9d85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object AuthRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -89,7 +91,7 @@ object AuthRepository { Unit }.onFailure { e -> log.e(e) { "Email sign-up failed" } - _error.value = e.message ?: "Sign-up failed" + _error.value = e.message ?: getString(Res.string.auth_sign_up_failed) } suspend fun signInWithEmail(email: String, password: String): Result = runCatching { @@ -100,7 +102,7 @@ object AuthRepository { } }.onFailure { e -> log.e(e) { "Email sign-in failed" } - _error.value = e.message ?: "Sign-in failed" + _error.value = e.message ?: getString(Res.string.auth_sign_in_failed) } suspend fun signOut(): Result = runCatching { @@ -114,7 +116,7 @@ object AuthRepository { LocalAccountDataCleaner.wipe() }.onFailure { e -> log.e(e) { "Sign-out failed" } - _error.value = e.message ?: "Sign-out failed" + _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) } suspend fun deleteAccount(): Result = runCatching { @@ -124,7 +126,7 @@ object AuthRepository { LocalAccountDataCleaner.wipe() }.onFailure { e -> log.e(e) { "Account deletion failed" } - _error.value = e.message ?: "Account deletion failed" + _error.value = e.message ?: getString(Res.string.auth_account_deletion_failed) } fun clearError() { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt index 87616ba5..9af52b9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt @@ -1,19 +1,6 @@ package com.nuvio.app.core.format -private val MONTH_NAMES = listOf( - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -) +import com.nuvio.app.core.i18n.localizedMonthName /** * Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1". @@ -28,7 +15,7 @@ fun formatReleaseDateForDisplay(raw: String): String { val year = parts[0].toIntOrNull() ?: return raw val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw - return "$year ${MONTH_NAMES[month - 1]} $day" + return "$year ${localizedMonthName(month)} $day" } /** diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt new file mode 100644 index 00000000..fe9a7297 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt @@ -0,0 +1,141 @@ +package com.nuvio.app.core.i18n + +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_play +import nuvio.composeapp.generated.resources.action_play_episode +import nuvio.composeapp.generated.resources.action_resume +import nuvio.composeapp.generated.resources.action_resume_episode +import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.continue_watching_up_next +import nuvio.composeapp.generated.resources.continue_watching_up_next_episode +import nuvio.composeapp.generated.resources.date_month_april +import nuvio.composeapp.generated.resources.date_month_august +import nuvio.composeapp.generated.resources.date_month_december +import nuvio.composeapp.generated.resources.date_month_february +import nuvio.composeapp.generated.resources.date_month_january +import nuvio.composeapp.generated.resources.date_month_july +import nuvio.composeapp.generated.resources.date_month_june +import nuvio.composeapp.generated.resources.date_month_march +import nuvio.composeapp.generated.resources.date_month_may +import nuvio.composeapp.generated.resources.date_month_november +import nuvio.composeapp.generated.resources.date_month_october +import nuvio.composeapp.generated.resources.date_month_september +import nuvio.composeapp.generated.resources.date_month_short_apr +import nuvio.composeapp.generated.resources.date_month_short_aug +import nuvio.composeapp.generated.resources.date_month_short_dec +import nuvio.composeapp.generated.resources.date_month_short_feb +import nuvio.composeapp.generated.resources.date_month_short_jan +import nuvio.composeapp.generated.resources.date_month_short_jul +import nuvio.composeapp.generated.resources.date_month_short_jun +import nuvio.composeapp.generated.resources.date_month_short_mar +import nuvio.composeapp.generated.resources.date_month_short_may +import nuvio.composeapp.generated.resources.date_month_short_nov +import nuvio.composeapp.generated.resources.date_month_short_oct +import nuvio.composeapp.generated.resources.date_month_short_sep +import nuvio.composeapp.generated.resources.media_anime +import nuvio.composeapp.generated.resources.media_channels +import nuvio.composeapp.generated.resources.media_movie +import nuvio.composeapp.generated.resources.media_movies +import nuvio.composeapp.generated.resources.media_series +import nuvio.composeapp.generated.resources.media_tv +import nuvio.composeapp.generated.resources.unit_bytes_b +import nuvio.composeapp.generated.resources.unit_bytes_gb +import nuvio.composeapp.generated.resources.unit_bytes_kb +import nuvio.composeapp.generated.resources.unit_bytes_mb +import org.jetbrains.compose.resources.getString + +fun localizedMediaTypeLabel(type: String): String = runBlocking { + when (type.trim().lowercase()) { + "movie" -> getString(Res.string.media_movies) + "series" -> getString(Res.string.media_series) + "anime" -> getString(Res.string.media_anime) + "channel" -> getString(Res.string.media_channels) + "tv" -> getString(Res.string.media_tv) + else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } +} + +fun localizedMovieTypeLabel(): String = runBlocking { getString(Res.string.media_movie) } + +fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? = runBlocking { + when { + seasonNumber != null && episodeNumber != null -> + getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + episodeNumber != null -> + getString(Res.string.compose_player_episode_code_episode_only, episodeNumber) + else -> null + } +} + +fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { + val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber) + if (episodeCode != null) { + getString(Res.string.action_play_episode, episodeCode) + } else { + getString(Res.string.action_play) + } +} + +fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { + val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber) + if (episodeCode != null) { + getString(Res.string.action_resume_episode, episodeCode) + } else { + getString(Res.string.action_resume) + } +} + +fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { + if (seasonNumber != null && episodeNumber != null) { + getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) + } else { + getString(Res.string.continue_watching_up_next) + } +} + +fun localizedMonthName(month: Int): String = runBlocking { + when (month) { + 1 -> getString(Res.string.date_month_january) + 2 -> getString(Res.string.date_month_february) + 3 -> getString(Res.string.date_month_march) + 4 -> getString(Res.string.date_month_april) + 5 -> getString(Res.string.date_month_may) + 6 -> getString(Res.string.date_month_june) + 7 -> getString(Res.string.date_month_july) + 8 -> getString(Res.string.date_month_august) + 9 -> getString(Res.string.date_month_september) + 10 -> getString(Res.string.date_month_october) + 11 -> getString(Res.string.date_month_november) + 12 -> getString(Res.string.date_month_december) + else -> month.toString() + } +} + +fun localizedShortMonthName(month: Int): String = runBlocking { + when (month) { + 1 -> getString(Res.string.date_month_short_jan) + 2 -> getString(Res.string.date_month_short_feb) + 3 -> getString(Res.string.date_month_short_mar) + 4 -> getString(Res.string.date_month_short_apr) + 5 -> getString(Res.string.date_month_short_may) + 6 -> getString(Res.string.date_month_short_jun) + 7 -> getString(Res.string.date_month_short_jul) + 8 -> getString(Res.string.date_month_short_aug) + 9 -> getString(Res.string.date_month_short_sep) + 10 -> getString(Res.string.date_month_short_oct) + 11 -> getString(Res.string.date_month_short_nov) + 12 -> getString(Res.string.date_month_short_dec) + else -> month.toString() + } +} + +fun localizedByteUnit(unit: String): String = runBlocking { + when (unit) { + "GB" -> getString(Res.string.unit_bytes_gb) + "MB" -> getString(Res.string.unit_bytes_mb) + "KB" -> getString(Res.string.unit_bytes_kb) + else -> getString(Res.string.unit_bytes_b) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt index 23321cf8..081f1756 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt @@ -1,11 +1,32 @@ package com.nuvio.app.core.ui -enum class AppTheme(val displayName: String) { - CRIMSON("Crimson"), - OCEAN("Ocean"), - VIOLET("Violet"), - EMERALD("Emerald"), - AMBER("Amber"), - ROSE("Rose"), - WHITE("White"), +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.theme_amber +import nuvio.composeapp.generated.resources.theme_crimson +import nuvio.composeapp.generated.resources.theme_emerald +import nuvio.composeapp.generated.resources.theme_ocean +import nuvio.composeapp.generated.resources.theme_rose +import nuvio.composeapp.generated.resources.theme_violet +import nuvio.composeapp.generated.resources.theme_white +import org.jetbrains.compose.resources.StringResource + +enum class AppTheme { + CRIMSON, + OCEAN, + VIOLET, + EMERALD, + AMBER, + ROSE, + WHITE, } + +val AppTheme.labelRes: StringResource + get() = when (this) { + AppTheme.CRIMSON -> Res.string.theme_crimson + AppTheme.OCEAN -> Res.string.theme_ocean + AppTheme.VIOLET -> Res.string.theme_violet + AppTheme.EMERALD -> Res.string.theme_emerald + AppTheme.AMBER -> Res.string.theme_amber + AppTheme.ROSE -> Res.string.theme_rose + AppTheme.WHITE -> Res.string.theme_white + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt new file mode 100644 index 00000000..8c122e2c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String { + val seasonNumber = item.seasonNumber + val episodeNumber = item.episodeNumber + val episodeTitle = item.episodeTitle?.takeIf { it.isNotBlank() } + + val base = when { + seasonNumber != null && episodeNumber != null && item.isNextUp -> + stringResource(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) + seasonNumber != null && episodeNumber != null -> + stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + item.isNextUp -> + stringResource(Res.string.continue_watching_up_next) + else -> + stringResource(Res.string.media_movie) + } + + return episodeTitle?.let { "$base • $it" } ?: base +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index 53830e66..365b1a84 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -65,6 +65,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_back +import nuvio.composeapp.generated.resources.action_ok +import org.jetbrains.compose.resources.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -141,7 +145,7 @@ fun NuvioScreenHeader( IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(Res.string.action_back), tint = MaterialTheme.colorScheme.onBackground, ) } @@ -233,7 +237,7 @@ fun NuvioBackButton( contentColor: Color = MaterialTheme.colorScheme.onSurface, buttonSize: Dp = 40.dp, iconSize: Dp = 22.dp, - contentDescription: String = "Back", + contentDescription: String = stringResource(Res.string.action_back), ) { Box( modifier = modifier @@ -375,7 +379,7 @@ fun NuvioStatusModal( modifier: Modifier = Modifier, isVisible: Boolean, isBusy: Boolean = false, - confirmText: String = "OK", + confirmText: String = stringResource(Res.string.action_ok), dismissText: String? = null, onConfirm: () -> Unit, onDismiss: (() -> Unit)? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index 1d326687..b85173d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -30,6 +30,12 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.nuvio.app.features.watchprogress.ContinueWatchingItem import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.cw_action_go_to_details +import nuvio.composeapp.generated.resources.cw_action_remove +import nuvio.composeapp.generated.resources.cw_action_start_from_beginning +import nuvio.composeapp.generated.resources.play_manually +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,14 +76,14 @@ fun NuvioContinueWatchingActionSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Default.Info, - title = "Go to details", + title = stringResource(Res.string.cw_action_go_to_details), onClick = { dismissAfter(onOpenDetails) }, ) if (showManualPlayOption && onPlayManually != null) { NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Default.PlayArrow, - title = "Play manually", + title = stringResource(Res.string.play_manually), onClick = { dismissAfter(onPlayManually) }, ) } @@ -85,14 +91,14 @@ fun NuvioContinueWatchingActionSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Default.Replay, - title = "Start from beginning", + title = stringResource(Res.string.cw_action_start_from_beginning), onClick = { dismissAfter(onStartFromBeginning) }, ) } NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Default.DeleteOutline, - title = "Remove", + title = stringResource(Res.string.cw_action_remove), onClick = { dismissAfter(onRemove) }, ) } @@ -152,7 +158,7 @@ private fun ContinueWatchingSheetHeader( overflow = TextOverflow.Ellipsis, ) Text( - text = item.subtitle, + text = localizedContinueWatchingSubtitle(item), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -160,4 +166,4 @@ private fun ContinueWatchingSheetHeader( ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt index ca57e37a..b7b15ef1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt @@ -52,6 +52,9 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.floating_prompt_continue_where_left_off +import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt private const val AutoDismissDelayMs = 15_000L @@ -202,7 +205,7 @@ fun NuvioFloatingPrompt( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "Continue where you left off", + text = stringResource(Res.string.floating_prompt_continue_where_left_off), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt index 958ccd0c..f751e6df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt @@ -10,6 +10,9 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.messageForEmptyState import com.nuvio.app.core.network.titleForEmptyState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_retry +import org.jetbrains.compose.resources.stringResource @Composable fun NuvioNetworkOfflineCard( @@ -32,9 +35,9 @@ fun NuvioNetworkOfflineCard( if (onRetry != null) { Spacer(modifier = Modifier.height(16.dp)) NuvioPrimaryButton( - text = "Retry", + text = stringResource(Res.string.action_retry), onClick = onRetry, ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index 8a1081eb..e226f637 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -37,6 +37,13 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.episodes_cd_watched +import nuvio.composeapp.generated.resources.hero_add_to_library +import nuvio.composeapp.generated.resources.hero_mark_unwatched +import nuvio.composeapp.generated.resources.hero_mark_watched +import nuvio.composeapp.generated.resources.hero_remove_from_library +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,7 +79,11 @@ fun NuvioPosterActionSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, - title = if (isSaved) "Remove from Library" else "Add to Library", + title = if (isSaved) { + stringResource(Res.string.hero_remove_from_library) + } else { + stringResource(Res.string.hero_add_to_library) + }, onClick = { onToggleLibrary() coroutineScope.launch { @@ -86,7 +97,11 @@ fun NuvioPosterActionSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, - title = if (isWatched) "Mark as Unwatched" else "Mark as Watched", + title = if (isWatched) { + stringResource(Res.string.hero_mark_unwatched) + } else { + stringResource(Res.string.hero_mark_watched) + }, onClick = { onToggleWatched() coroutineScope.launch { @@ -114,7 +129,7 @@ fun NuvioWatchedBadge( ) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Watched", + contentDescription = stringResource(Res.string.episodes_cd_watched), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(12.dp), ) @@ -200,4 +215,3 @@ private fun PosterSheetHeader( } } } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index b1139b86..b1b99312 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -33,6 +33,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.home_view_all +import nuvio.composeapp.generated.resources.poster_logo_content_description +import org.jetbrains.compose.resources.stringResource enum class NuvioPosterShape { Poster, @@ -156,7 +160,7 @@ fun NuvioPosterCard( if (!bottomLeftLogoUrl.isNullOrBlank()) { AsyncImage( model = bottomLeftLogoUrl, - contentDescription = "$title logo", + contentDescription = stringResource(Res.string.poster_logo_content_description, title), modifier = Modifier .width(catalogLogoOverlaySize.width) .height(catalogLogoOverlaySize.height), @@ -280,7 +284,7 @@ private fun NuvioViewAllPill( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "View All", + text = stringResource(Res.string.home_view_all), style = textStyle, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt index 6a39e445..8684d5ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt @@ -28,6 +28,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.nuvio.app.features.trakt.TraktListTab +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading +import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,7 +68,7 @@ fun TraktListPickerDialog( color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Choose where to save this title on Trakt", + text = stringResource(Res.string.compose_trakt_list_picker_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -91,7 +97,7 @@ fun TraktListPickerDialog( modifier = Modifier.size(24.dp), ) Text( - text = "Loading your Trakt lists…", + text = stringResource(Res.string.compose_trakt_list_picker_loading), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -151,7 +157,7 @@ fun TraktListPickerDialog( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } Button( onClick = onSave, @@ -164,11 +170,11 @@ fun TraktListPickerDialog( modifier = Modifier.size(16.dp), ) } else { - Text("Save") + Text(stringResource(Res.string.action_save)) } } } } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index 163861fc..6b73ffe6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -1,5 +1,10 @@ package com.nuvio.app.features.addons +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.generic_addon +import org.jetbrains.compose.resources.getString + data class AddonManifest( val id: String, val name: String, @@ -54,7 +59,9 @@ data class ManagedAddon( val displayTitle: String get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name } ?: manifest?.name - ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" } + ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { + runBlocking { getString(Res.string.generic_addon) } + } } data class AddonsUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index 4433d65a..bf4b1a4c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString @Serializable private data class AddonRow( @@ -198,17 +200,17 @@ object AddonRepository { suspend fun addAddon(rawUrl: String): AddAddonResult { if (isUsingPrimaryAddonsFromSecondaryProfile()) { - return AddAddonResult.Error("This profile uses primary addons.") + return AddAddonResult.Error(getString(Res.string.profile_primary_addons_required)) } log.i { "addAddon() — rawUrl=$rawUrl" } val manifestUrl = try { normalizeManifestUrl(rawUrl) } catch (error: IllegalArgumentException) { - return AddAddonResult.Error(error.message ?: "Enter a valid addon URL") + return AddAddonResult.Error(error.message ?: getString(Res.string.addon_invalid_url)) } if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) { - return AddAddonResult.Error("That addon is already installed.") + return AddAddonResult.Error(getString(Res.string.addon_already_installed)) } val manifest = try { @@ -220,7 +222,7 @@ object AddonRepository { ) } } catch (error: Throwable) { - return AddAddonResult.Error(error.message ?: "Unable to load manifest") + return AddAddonResult.Error(error.message ?: getString(Res.string.addon_load_manifest_failed)) } _uiState.update { current -> @@ -310,7 +312,7 @@ object AddonRepository { onFailure = { error -> addon.copy( isRefreshing = false, - errorMessage = error.message ?: "Unable to load manifest", + errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed), ) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 91c85d88..32c9554e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -53,17 +53,19 @@ import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun AddonsScreen( modifier: Modifier = Modifier, - title: String = "Addons", + title: String? = null, onBack: (() -> Unit)? = null, ) { NuvioScreen(modifier = modifier) { stickyHeader { NuvioScreenHeader( - title = title, + title = title ?: stringResource(Res.string.addon_title), onBack = onBack, ) { } @@ -88,6 +90,7 @@ internal fun AddonsSettingsPageContent( var addonUrl by rememberSaveable { mutableStateOf("") } var formMessage by rememberSaveable { mutableStateOf(null) } var installModalState by remember { mutableStateOf(null) } + val enterAddonUrlMessage = stringResource(Res.string.addons_error_enter_url) val overview = remember(uiState.addons) { uiState.addons.toOverview() } @@ -95,10 +98,10 @@ internal fun AddonsSettingsPageContent( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SectionHeader("OVERVIEW") + SectionHeader(stringResource(Res.string.addons_section_overview)) OverviewCard(overview = overview) - SectionHeader("ADD ADDON") + SectionHeader(stringResource(Res.string.addons_section_add_addon)) AddAddonCard( addonUrl = addonUrl, formMessage = formMessage, @@ -109,7 +112,7 @@ internal fun AddonsSettingsPageContent( onAddClick = { val requestedUrl = addonUrl.trim() if (requestedUrl.isBlank()) { - formMessage = "Enter an addon URL." + formMessage = enterAddonUrlMessage return@AddAddonCard } @@ -131,7 +134,7 @@ internal fun AddonsSettingsPageContent( }, ) - SectionHeader("INSTALLED ADDONS") + SectionHeader(stringResource(Res.string.addons_section_installed)) if (uiState.addons.isEmpty()) { EmptyStateCard() } else { @@ -171,12 +174,30 @@ internal fun AddonsSettingsPageContent( val modalState = installModalState if (modalState != null) { + val modalTitle = when (modalState) { + AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_title) + is AddonInstallModalState.Success -> stringResource(Res.string.addons_modal_success_title) + is AddonInstallModalState.Error -> stringResource(Res.string.addons_modal_failure_title) + } + val modalMessage = when (modalState) { + AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_message) + is AddonInstallModalState.Success -> stringResource( + Res.string.addons_modal_success_message, + modalState.addonName, + ) + is AddonInstallModalState.Error -> modalState.reason + } + val modalConfirmText = when (modalState) { + AddonInstallModalState.Checking -> stringResource(Res.string.addon_installing) + is AddonInstallModalState.Success -> stringResource(Res.string.action_done) + is AddonInstallModalState.Error -> stringResource(Res.string.action_close) + } NuvioStatusModal( - title = modalState.title, - message = modalState.message, + title = modalTitle, + message = modalMessage, isVisible = true, isBusy = modalState.isBusy, - confirmText = modalState.confirmText, + confirmText = modalConfirmText, onConfirm = { if (!modalState.isBusy) { installModalState = null @@ -200,19 +221,19 @@ private fun OverviewCard(overview: AddonOverview) { ) { OverviewStat( value = overview.totalAddons.toString(), - label = "Addons", + label = stringResource(Res.string.addons_overview_addons), modifier = Modifier.weight(1f), ) VerticalSeparator() OverviewStat( value = overview.activeAddons.toString(), - label = "Active", + label = stringResource(Res.string.addons_overview_active), modifier = Modifier.weight(1f), ) VerticalSeparator() OverviewStat( value = overview.totalCatalogs.toString(), - label = "Catalogs", + label = stringResource(Res.string.addons_overview_catalogs), modifier = Modifier.weight(1f), ) } @@ -264,11 +285,11 @@ private fun AddAddonCard( NuvioInputField( value = addonUrl, onValueChange = onAddonUrlChange, - placeholder = "Addon URL", + placeholder = stringResource(Res.string.addons_input_placeholder), ) Spacer(modifier = Modifier.height(18.dp)) NuvioPrimaryButton( - text = "Install Addon", + text = stringResource(Res.string.addons_install_button), enabled = addonUrl.isNotBlank(), onClick = onAddClick, ) @@ -284,33 +305,21 @@ private fun AddAddonCard( } private sealed interface AddonInstallModalState { - val title: String - val message: String - val confirmText: String val isBusy: Boolean data object Checking : AddonInstallModalState { - override val title: String = "Checking Addon" - override val message: String = "Validating the manifest URL and loading addon details before install." - override val confirmText: String = "Installing" override val isBusy: Boolean = true } data class Success( - private val addonName: String, + val addonName: String, ) : AddonInstallModalState { - override val title: String = "Addon Installed" - override val message: String = "$addonName was validated and added successfully." - override val confirmText: String = "Done" override val isBusy: Boolean = false } data class Error( - private val reason: String, + val reason: String, ) : AddonInstallModalState { - override val title: String = "Install Failed" - override val message: String = reason - override val confirmText: String = "Close" override val isBusy: Boolean = false } } @@ -319,13 +328,13 @@ private sealed interface AddonInstallModalState { private fun EmptyStateCard() { NuvioSurfaceCard { Text( - text = "No addons installed yet.", + text = stringResource(Res.string.addons_empty_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.", + text = stringResource(Res.string.addons_empty_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -365,7 +374,7 @@ private fun InstalledAddonCard( manifest?.version?.let { version -> Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Version $version", + text = stringResource(Res.string.addons_version_format, version), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -383,7 +392,7 @@ private fun InstalledAddonCard( onMoveUpClick?.let { onMoveUp -> NuvioIconActionButton( icon = Icons.Rounded.ArrowUpward, - contentDescription = "Move addon up", + contentDescription = stringResource(Res.string.addons_move_up), tint = MaterialTheme.colorScheme.onSurfaceVariant, onClick = onMoveUp, ) @@ -391,28 +400,28 @@ private fun InstalledAddonCard( onMoveDownClick?.let { onMoveDown -> NuvioIconActionButton( icon = Icons.Rounded.ArrowDownward, - contentDescription = "Move addon down", + contentDescription = stringResource(Res.string.addons_move_down), tint = MaterialTheme.colorScheme.onSurfaceVariant, onClick = onMoveDown, ) } NuvioIconActionButton( icon = Icons.Rounded.Refresh, - contentDescription = "Refresh addon", + contentDescription = stringResource(Res.string.addons_refresh), tint = MaterialTheme.colorScheme.primary, onClick = onRefreshClick, ) onConfigureClick?.let { onConfigure -> NuvioIconActionButton( icon = Icons.Rounded.Settings, - contentDescription = "Configure addon", + contentDescription = stringResource(Res.string.addons_configure), tint = MaterialTheme.colorScheme.tertiary, onClick = onConfigure, ) } NuvioIconActionButton( icon = Icons.Rounded.Delete, - contentDescription = "Delete addon", + contentDescription = stringResource(Res.string.addons_delete), tint = MaterialTheme.colorScheme.error, onClick = onDeleteClick, ) @@ -429,16 +438,16 @@ private fun InstalledAddonCard( ) { NuvioInfoBadge( text = when { - addon.isRefreshing -> "Refreshing" - manifest != null -> "Active" - else -> "Unavailable" + addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing) + manifest != null -> stringResource(Res.string.addons_badge_active) + else -> stringResource(Res.string.addons_badge_unavailable) }, ) manifest?.let { - NuvioInfoBadge(text = "${it.resources.size} resources") - NuvioInfoBadge(text = "${it.catalogs.size} catalogs") + NuvioInfoBadge(text = stringResource(Res.string.addons_badge_resources, it.resources.size)) + NuvioInfoBadge(text = stringResource(Res.string.addons_badge_catalogs, it.catalogs.size)) if (it.behaviorHints.configurable) { - NuvioInfoBadge(text = "Configurable") + NuvioInfoBadge(text = stringResource(Res.string.addons_badge_configurable)) } } } @@ -447,7 +456,7 @@ private fun InstalledAddonCard( addon.isRefreshing -> { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Loading manifest details...", + text = stringResource(Res.string.addons_loading_manifest_details), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -524,6 +533,7 @@ private fun AddonIconBadge( } } +@Composable private fun manifestSummary(manifest: AddonManifest): String { val resources = manifest.resources.joinToString(separator = ", ") { it.name } val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) } @@ -533,7 +543,7 @@ private fun manifestSummary(manifest: AddonManifest): String { append(resources) if (manifest.idPrefixes.isNotEmpty()) { append(" • ") - append("${manifest.idPrefixes.size} id rules") + append(stringResource(Res.string.addons_summary_id_rules, manifest.idPrefixes.size)) } if (manifest.behaviorHints.p2p) { append(" • P2P") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt index 2a24f2e1..c6362e85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt @@ -62,7 +62,22 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.app_logo_wordmark +import nuvio.composeapp.generated.resources.compose_auth_already_have_account +import nuvio.composeapp.generated.resources.compose_auth_continue_without_account +import nuvio.composeapp.generated.resources.compose_auth_create_account +import nuvio.composeapp.generated.resources.compose_auth_dont_have_account +import nuvio.composeapp.generated.resources.compose_auth_email +import nuvio.composeapp.generated.resources.compose_auth_or_separator +import nuvio.composeapp.generated.resources.compose_auth_password +import nuvio.composeapp.generated.resources.compose_auth_sign_in +import nuvio.composeapp.generated.resources.compose_auth_sign_in_subtitle +import nuvio.composeapp.generated.resources.compose_auth_sign_up +import nuvio.composeapp.generated.resources.compose_auth_sign_up_subtitle +import nuvio.composeapp.generated.resources.compose_auth_store_locally +import nuvio.composeapp.generated.resources.compose_auth_tagline +import nuvio.composeapp.generated.resources.compose_auth_welcome_back import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Composable fun AuthScreen( @@ -97,7 +112,7 @@ fun AuthScreen( ) { Image( painter = painterResource(Res.drawable.app_logo_wordmark), - contentDescription = "Nuvio", + contentDescription = null, modifier = Modifier .fillMaxWidth(0.6f) .height(48.dp), @@ -105,7 +120,7 @@ fun AuthScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Stream everything, everywhere", + text = stringResource(Res.string.compose_auth_tagline), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -119,7 +134,8 @@ fun AuthScreen( label = "heading", ) { signUp -> Text( - text = if (signUp) "Create Account" else "Welcome Back", + text = if (signUp) stringResource(Res.string.compose_auth_create_account) + else stringResource(Res.string.compose_auth_welcome_back), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -131,8 +147,8 @@ fun AuthScreen( label = "subtitle", ) { signUp -> Text( - text = if (signUp) "Sign up to sync your data across devices" - else "Sign in to access your library and progress", + text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle) + else stringResource(Res.string.compose_auth_sign_in_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -150,7 +166,7 @@ fun AuthScreen( singleLine = true, placeholder = { Text( - text = "Email", + text = stringResource(Res.string.compose_auth_email), color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, @@ -183,7 +199,7 @@ fun AuthScreen( singleLine = true, placeholder = { Text( - text = "Password", + text = stringResource(Res.string.compose_auth_password), color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, @@ -240,7 +256,13 @@ fun AuthScreen( Spacer(modifier = Modifier.height(24.dp)) NuvioPrimaryButton( - text = if (isLoading) "" else if (isSignUp) "Create Account" else "Sign In", + text = if (isLoading) { + "" + } else if (isSignUp) { + stringResource(Res.string.compose_auth_create_account) + } else { + stringResource(Res.string.compose_auth_sign_in) + }, enabled = email.isNotBlank() && password.length >= 6 && !isLoading, onClick = { isLoading = true @@ -279,7 +301,8 @@ fun AuthScreen( label = "togglePrompt", ) { signUp -> Text( - text = if (signUp) "Already have an account? " else "Don't have an account? ", + text = if (signUp) stringResource(Res.string.compose_auth_already_have_account) + else stringResource(Res.string.compose_auth_dont_have_account), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -290,7 +313,8 @@ fun AuthScreen( label = "toggleAction", ) { signUp -> Text( - text = if (signUp) "Sign In" else "Sign Up", + text = if (signUp) stringResource(Res.string.compose_auth_sign_in) + else stringResource(Res.string.compose_auth_sign_up), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold, @@ -317,7 +341,7 @@ fun AuthScreen( .background(MaterialTheme.colorScheme.outline), ) Text( - text = " or ", + text = stringResource(Res.string.compose_auth_or_separator), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -346,7 +370,7 @@ fun AuthScreen( ), ) { Text( - text = "Continue Without Account", + text = stringResource(Res.string.compose_auth_continue_without_account), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) @@ -354,7 +378,7 @@ fun AuthScreen( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Your data will only be stored locally", + text = stringResource(Res.string.compose_auth_store_locally), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index d1df68d4..a46ddcbf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library" @@ -92,7 +94,7 @@ object CatalogRepository { items = emptyList(), isLoading = false, nextSkip = null, - errorMessage = error.message ?: "Unable to load catalog items.", + errorMessage = error.message ?: getString(Res.string.catalog_load_failed), ) }, ) @@ -148,7 +150,7 @@ object CatalogRepository { items = if (reset) emptyList() else current.items, isLoading = false, nextSkip = null, - errorMessage = error.message ?: "Unable to load catalog items.", + errorMessage = error.message ?: getString(Res.string.catalog_load_failed), ) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index ec60a08d..9e53063e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -56,6 +56,8 @@ import com.nuvio.app.features.home.stableKey import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun CatalogScreen( @@ -329,12 +331,12 @@ private fun CatalogEmptyState( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = "No titles found", + text = stringResource(Res.string.catalog_empty_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) Text( - text = errorMessage ?: "This catalog did not return any items.", + text = errorMessage ?: stringResource(Res.string.catalog_empty_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index cf2172df..e5c09ab7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -93,10 +93,10 @@ object CollectionEditorRepository { } @OptIn(ExperimentalUuidApi::class) - fun addFolder() { + fun addFolder(defaultTitle: String) { val newFolder = CollectionFolder( id = Uuid.random().toString(), - title = "New Folder", + title = defaultTitle, ) _uiState.value = _uiState.value.copy( editingFolder = newFolder, @@ -177,13 +177,8 @@ object CollectionEditorRepository { fun updateFolderTileShape(shape: PosterShape) { val folder = _uiState.value.editingFolder ?: return - val shapeStr = when (shape) { - PosterShape.Poster -> "Poster" - PosterShape.Landscape -> "Landscape" - PosterShape.Square -> "Square" - } _uiState.value = _uiState.value.copy( - editingFolder = folder.copy(tileShape = shapeStr), + editingFolder = folder.copy(tileShape = shape.name.lowercase()), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 8f50790c..3fb927c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -64,6 +64,8 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.home.PosterShape +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -141,7 +143,11 @@ fun CollectionEditorScreen( ) { stickyHeader { NuvioScreenHeader( - title = if (state.isNew) "New Collection" else "Edit Collection", + title = if (state.isNew) { + stringResource(Res.string.collections_new) + } else { + stringResource(Res.string.collections_editor_edit_collection) + }, onBack = onBack, ) } @@ -150,7 +156,7 @@ fun CollectionEditorScreen( NuvioInputField( value = state.title, onValueChange = { CollectionEditorRepository.setTitle(it) }, - placeholder = "Collection Title", + placeholder = stringResource(Res.string.collections_editor_placeholder_name), ) } @@ -158,7 +164,7 @@ fun CollectionEditorScreen( NuvioInputField( value = state.backdropImageUrl, onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) }, - placeholder = "Backdrop Image URL (optional)", + placeholder = stringResource(Res.string.collections_editor_placeholder_backdrop), ) } @@ -173,13 +179,13 @@ fun CollectionEditorScreen( ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Pin to Top", + text = stringResource(Res.string.collections_editor_pin_above), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Display this collection above regular catalog rows.", + text = stringResource(Res.string.collections_editor_pin_above_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -204,7 +210,7 @@ fun CollectionEditorScreen( item { NuvioSurfaceCard { Text( - text = "View Mode", + text = stringResource(Res.string.collections_editor_view_mode), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -223,9 +229,9 @@ fun CollectionEditorScreen( label = { Text( when (mode) { - FolderViewMode.TABBED_GRID -> "Tabbed Grid" - FolderViewMode.ROWS -> "Rows" - FolderViewMode.FOLLOW_LAYOUT -> "Rows" + FolderViewMode.TABBED_GRID -> stringResource(Res.string.collections_editor_view_mode_tabs) + FolderViewMode.ROWS -> stringResource(Res.string.collections_editor_view_mode_rows) + FolderViewMode.FOLLOW_LAYOUT -> stringResource(Res.string.collections_editor_view_mode_rows) } ) }, @@ -256,13 +262,13 @@ fun CollectionEditorScreen( ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Show \"All\" Tab", + text = stringResource(Res.string.collections_editor_show_all_tab), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Combine all folder catalogs into a single tab.", + text = stringResource(Res.string.collections_editor_show_all_tab_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -283,20 +289,23 @@ fun CollectionEditorScreen( // Folders Section Header item { + val newFolderTitle = stringResource(Res.string.collections_editor_new_folder) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - NuvioSectionLabel(text = "FOLDERS") - TextButton(onClick = { CollectionEditorRepository.addFolder() }) { + NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders)) + TextButton( + onClick = { CollectionEditorRepository.addFolder(newFolderTitle) }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(4.dp)) - Text("Add Folder") + Text(stringResource(Res.string.collections_editor_add_folder)) } } } @@ -318,13 +327,13 @@ fun CollectionEditorScreen( modifier = Modifier.padding(top = 8.dp), ) { Text( - text = "No folders yet", + text = stringResource(Res.string.collections_editor_folder_empty_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Add one to get started.", + text = stringResource(Res.string.collections_editor_folder_empty_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -352,7 +361,11 @@ fun CollectionEditorScreen( .padding(bottom = bottomInset), ) { NuvioPrimaryButton( - text = if (state.isNew) "Create Collection" else "Save Changes", + text = if (state.isNew) { + stringResource(Res.string.collections_editor_create_collection) + } else { + stringResource(Res.string.collections_editor_save_changes) + }, enabled = state.title.isNotBlank(), onClick = { if (CollectionEditorRepository.save()) { @@ -436,6 +449,11 @@ private fun FolderListItem( Spacer(modifier = Modifier.width(12.dp)) } Column(modifier = Modifier.weight(1f)) { + val summary = stringResource( + Res.string.collections_editor_source_count, + folder.catalogSources.size, + posterShapeLabel(folder.posterShape), + ) Text( text = folder.title, style = MaterialTheme.typography.bodyLarge, @@ -445,7 +463,7 @@ private fun FolderListItem( overflow = TextOverflow.Ellipsis, ) Text( - text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}", + text = summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -471,7 +489,7 @@ private fun FolderListItem( ) { Icon( imageVector = Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.action_reorder), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -480,7 +498,7 @@ private fun FolderListItem( IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Rounded.Edit, - contentDescription = "Edit", + contentDescription = stringResource(Res.string.action_edit), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -488,7 +506,7 @@ private fun FolderListItem( IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Rounded.Delete, - contentDescription = "Delete", + contentDescription = stringResource(Res.string.action_delete), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -514,7 +532,11 @@ private fun FolderEditorPage( NuvioScreen(modifier = Modifier.fillMaxSize()) { stickyHeader { NuvioScreenHeader( - title = if (state.folders.any { it.id == folder.id }) "Edit Folder" else "New Folder", + title = if (state.folders.any { it.id == folder.id }) { + stringResource(Res.string.collections_editor_edit_folder) + } else { + stringResource(Res.string.collections_editor_new_folder) + }, onBack = onBack, ) } @@ -522,7 +544,7 @@ private fun FolderEditorPage( item { NuvioSurfaceCard { Text( - text = "Set the folder identity, presentation, and catalog sources with the same structure as the main collections editor.", + text = stringResource(Res.string.collections_editor_folder_editor_help), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -530,24 +552,24 @@ private fun FolderEditorPage( } item { - FolderEditorSection(title = "BASICS") { + FolderEditorSection(title = stringResource(Res.string.collections_editor_section_basics)) { NuvioSurfaceCard { NuvioInputField( value = folder.title, onValueChange = { CollectionEditorRepository.updateFolderTitle(it) }, - placeholder = "Folder Title", + placeholder = stringResource(Res.string.collections_editor_placeholder_folder), ) } } } item { - FolderEditorSection(title = "APPEARANCE") { + FolderEditorSection(title = stringResource(Res.string.collections_editor_section_appearance)) { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Cover", + text = stringResource(Res.string.collections_editor_cover), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -559,7 +581,7 @@ private fun FolderEditorPage( FilterChip( selected = folder.coverEmoji == null && folder.coverImageUrl == null, onClick = { CollectionEditorRepository.clearFolderCover() }, - label = { Text("None") }, + label = { Text(stringResource(Res.string.collections_editor_cover_none)) }, ) FilterChip( selected = folder.coverEmoji != null, @@ -568,7 +590,7 @@ private fun FolderEditorPage( CollectionEditorRepository.updateFolderCoverEmoji("📁") } }, - label = { Text("Emoji") }, + label = { Text(stringResource(Res.string.collections_editor_cover_emoji)) }, ) FilterChip( selected = folder.coverImageUrl != null, @@ -577,7 +599,7 @@ private fun FolderEditorPage( CollectionEditorRepository.updateFolderCoverImage("") } }, - label = { Text("Image") }, + label = { Text(stringResource(Res.string.collections_editor_cover_image_url)) }, ) } } @@ -586,7 +608,7 @@ private fun FolderEditorPage( NuvioInputField( value = folder.coverEmoji, onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) }, - placeholder = "Emoji", + placeholder = stringResource(Res.string.collections_editor_cover_emoji), modifier = Modifier.width(100.dp), ) } @@ -595,14 +617,14 @@ private fun FolderEditorPage( NuvioInputField( value = folder.coverImageUrl, onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) }, - placeholder = "Image URL", + placeholder = stringResource(Res.string.collections_editor_cover_image_url), ) } NuvioInputField( value = folder.focusGifUrl.orEmpty(), onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) }, - placeholder = "Always-play GIF URL (optional)", + placeholder = stringResource(Res.string.collections_editor_placeholder_gif), ) } } @@ -611,7 +633,7 @@ private fun FolderEditorPage( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Tile Shape", + text = stringResource(Res.string.collections_editor_tile_shape), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -624,7 +646,7 @@ private fun FolderEditorPage( FilterChip( selected = folder.posterShape == shape, onClick = { CollectionEditorRepository.updateFolderTileShape(shape) }, - label = { Text(shape.name) }, + label = { Text(posterShapeLabel(shape)) }, leadingIcon = if (folder.posterShape == shape) { { Icon( @@ -640,15 +662,15 @@ private fun FolderEditorPage( } FolderEditorToggleRow( - title = "Show GIF When Configured", - subtitle = "Play the configured GIF instead of the static cover when available.", + title = stringResource(Res.string.collections_editor_show_gif_when_configured), + subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc), checked = folder.focusGifEnabled, onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) }, ) FolderEditorToggleRow( - title = "Hide Title", - subtitle = "Only show the artwork or emoji for this folder tile.", + title = stringResource(Res.string.collections_editor_hide_title), + subtitle = stringResource(Res.string.collections_editor_hide_title_desc), checked = folder.hideTitle, onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) }, ) @@ -659,7 +681,7 @@ private fun FolderEditorPage( item { FolderEditorSection( - title = "CATALOG SOURCES", + title = stringResource(Res.string.collections_editor_section_catalog_sources), actions = { TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { Icon( @@ -668,7 +690,7 @@ private fun FolderEditorPage( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(4.dp)) - Text("Add") + Text(stringResource(Res.string.collections_editor_add_catalog)) } }, ) { @@ -676,13 +698,13 @@ private fun FolderEditorPage( NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "No catalog sources yet", + text = stringResource(Res.string.collections_editor_catalog_sources_empty_title), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Add catalogs from your installed addons to define what this folder shows.", + text = stringResource(Res.string.collections_editor_catalog_sources_empty_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -725,7 +747,7 @@ private fun FolderEditorPage( .padding(bottom = bottomInset), ) { NuvioPrimaryButton( - text = "Save Folder", + text = stringResource(Res.string.collections_editor_save), enabled = folder.title.isNotBlank(), onClick = { CollectionEditorRepository.saveFolderEdit() }, ) @@ -762,17 +784,17 @@ private fun CatalogPickerSheet( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Select Catalog Sources", + text = stringResource(Res.string.collections_editor_select_catalogs), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) TextButton(onClick = onDismiss) { - Text("Done") + Text(stringResource(Res.string.collections_editor_done)) } } Text( - text = "Choose the addon catalogs this folder should aggregate.", + text = stringResource(Res.string.collections_editor_select_catalogs_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 8.dp), @@ -831,7 +853,7 @@ private fun CatalogPickerSheet( if (isSelected) { Icon( imageVector = Icons.Rounded.Check, - contentDescription = "Selected", + contentDescription = stringResource(Res.string.cd_selected), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) @@ -872,7 +894,7 @@ private fun GenrePickerSheet( item { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "Genre Filter", + text = stringResource(Res.string.collections_editor_genre_filter), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -888,7 +910,7 @@ private fun GenrePickerSheet( if (allowAll) { item { GenrePickerOptionRow( - title = "All genres", + title = stringResource(Res.string.collections_editor_all_genres), selected = selectedGenre == null, onClick = { onSelect(null) }, ) @@ -991,7 +1013,11 @@ private fun FolderCatalogSourceCard( append(" · ${source.catalogId}") } val genreOptions = matchingCatalog?.genreOptions.orEmpty() - val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) "Select genre" else "All genres" + val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) { + stringResource(Res.string.collections_editor_select_genre) + } else { + stringResource(Res.string.collections_editor_all_genres) + } NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -1019,7 +1045,7 @@ private fun FolderCatalogSourceCard( ) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Remove", + contentDescription = stringResource(Res.string.action_remove), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -1045,7 +1071,7 @@ private fun FolderCatalogSourceCard( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = "Genre Filter", + text = stringResource(Res.string.collections_editor_genre_filter), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -1057,7 +1083,7 @@ private fun FolderCatalogSourceCard( ) } TextButton(onClick = onOpenGenrePicker) { - Text("Choose") + Text(stringResource(Res.string.collections_editor_choose_genre)) } } } @@ -1065,6 +1091,14 @@ private fun FolderCatalogSourceCard( } } +@Composable +private fun posterShapeLabel(shape: PosterShape): String = + when (shape) { + PosterShape.Poster -> stringResource(Res.string.collections_editor_shape_poster) + PosterShape.Square -> stringResource(Res.string.collections_editor_shape_square) + PosterShape.Landscape -> stringResource(Res.string.collections_editor_shape_wide) + } + @Composable private fun GenrePickerOptionRow( title: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt index d1649ba0..74deba81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -75,7 +77,7 @@ fun CollectionManagementScreen( NuvioScreen { stickyHeader { NuvioScreenHeader( - title = "Collections", + title = stringResource(Res.string.collections_header), onBack = onBack, ) { IconButton(onClick = { @@ -84,14 +86,14 @@ fun CollectionManagementScreen( }) { Icon( imageVector = Icons.Rounded.ContentCopy, - contentDescription = "Copy JSON", + contentDescription = stringResource(Res.string.collections_copy_json), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } IconButton(onClick = { showImportDialog = true }) { Icon( imageVector = Icons.Rounded.ContentPaste, - contentDescription = "Import", + contentDescription = stringResource(Res.string.collections_import), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -100,8 +102,11 @@ fun CollectionManagementScreen( item { NuvioSurfaceCard { Text( - text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " + - "${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}", + text = stringResource( + Res.string.collections_count_summary, + collections.size, + collections.sumOf { it.folders.size }, + ), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -110,13 +115,13 @@ fun CollectionManagementScreen( item { NuvioPrimaryButton( - text = "New Collection", + text = stringResource(Res.string.collections_new), onClick = { onNavigateToEditor(null) }, ) } if (collections.isNotEmpty()) { - item { NuvioSectionLabel(text = "YOUR COLLECTIONS") } + item { NuvioSectionLabel(text = stringResource(Res.string.collections_your_collections)) } } if (collections.isNotEmpty()) { @@ -142,13 +147,13 @@ fun CollectionManagementScreen( ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = "No collections yet", + text = stringResource(Res.string.collections_empty_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Create one to organize your catalogs.", + text = stringResource(Res.string.collections_empty_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -187,11 +192,11 @@ fun CollectionManagementScreen( val deleteId = showDeleteConfirm val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } } NuvioStatusModal( - title = "Delete Collection", - message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.", + title = stringResource(Res.string.collections_delete_title), + message = stringResource(Res.string.collections_delete_message, deleteCollection?.title.orEmpty()), isVisible = deleteId != null, - confirmText = "Delete", - dismissText = "Cancel", + confirmText = stringResource(Res.string.action_delete), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { if (deleteId != null) { CollectionRepository.removeCollection(deleteId) @@ -261,6 +266,13 @@ private fun CollectionListItem( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { + val summary = buildString { + append(stringResource(Res.string.collections_folder_count, collection.folders.size)) + if (collection.pinToTop) { + append(" · ") + append(stringResource(Res.string.collections_pinned)) + } + } Text( text = collection.title, style = MaterialTheme.typography.bodyLarge, @@ -271,8 +283,7 @@ private fun CollectionListItem( ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" + - if (collection.pinToTop) " · Pinned" else "", + text = summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -298,7 +309,7 @@ private fun CollectionListItem( ) { Icon( imageVector = Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.action_reorder), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -310,7 +321,7 @@ private fun CollectionListItem( ) { Icon( imageVector = Icons.Rounded.Edit, - contentDescription = "Edit", + contentDescription = stringResource(Res.string.action_edit), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -321,7 +332,7 @@ private fun CollectionListItem( ) { Icon( imageVector = Icons.Rounded.Delete, - contentDescription = "Delete", + contentDescription = stringResource(Res.string.action_delete), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -349,13 +360,13 @@ private fun ImportDialog( ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "Import Collections", + text = stringResource(Res.string.collections_import_header), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Paste your collections JSON below.", + text = stringResource(Res.string.collections_import_paste_description), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -366,7 +377,12 @@ private fun ImportDialog( modifier = Modifier .fillMaxWidth() .height(160.dp), - placeholder = { Text("JSON", style = MaterialTheme.typography.bodyLarge) }, + placeholder = { + Text( + stringResource(Res.string.collections_import_json_placeholder), + style = MaterialTheme.typography.bodyLarge, + ) + }, isError = importError != null, supportingText = importError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } @@ -399,7 +415,7 @@ private fun ImportDialog( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } Spacer(modifier = Modifier.width(10.dp)) androidx.compose.material3.Button( @@ -407,7 +423,7 @@ private fun ImportDialog( enabled = importText.isNotBlank(), shape = RoundedCornerShape(16.dp), ) { - Text("Import") + Text(stringResource(Res.string.action_import)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 1c58ca32..fc1aacef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -39,7 +39,7 @@ data class CollectionFolder( val focusGifUrl: String? = null, val focusGifEnabled: Boolean = true, val coverEmoji: String? = null, - val tileShape: String = "Poster", + val tileShape: String = "poster", val hideTitle: Boolean = false, val catalogSources: List = emptyList(), ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index b2d19bbe..7d9f5abd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -6,9 +6,19 @@ import com.nuvio.app.features.addons.ManagedAddon import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id +import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title +import nuvio.composeapp.generated.resources.collections_import_error_empty_json +import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id +import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title +import nuvio.composeapp.generated.resources.collections_import_error_invalid_json +import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields +import org.jetbrains.compose.resources.getString import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -110,28 +120,68 @@ object CollectionRepository { fun validateJson(jsonString: String): ValidationResult { if (jsonString.isBlank()) { - return ValidationResult(valid = false, error = "JSON is empty.") + return ValidationResult( + valid = false, + error = runBlocking { getString(Res.string.collections_import_error_empty_json) }, + ) } return try { val collections = json.decodeFromString>(jsonString) var totalFolders = 0 collections.forEachIndexed { ci, c -> if (c.id.isBlank()) { - return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.") + return ValidationResult( + valid = false, + error = runBlocking { + getString(Res.string.collections_import_error_collection_blank_id, ci + 1) + }, + ) } if (c.title.isBlank()) { - return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.") + return ValidationResult( + valid = false, + error = runBlocking { + getString(Res.string.collections_import_error_collection_blank_title, c.id) + }, + ) } c.folders.forEachIndexed { fi, f -> if (f.id.isBlank()) { - return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.") + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_folder_blank_id, + fi + 1, + c.title, + ) + }, + ) } if (f.title.isBlank()) { - return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.") + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_folder_blank_title, + f.id, + c.title, + ) + }, + ) } f.catalogSources.forEachIndexed { si, s -> if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) { - return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.") + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_source_blank_fields, + si + 1, + f.title, + ) + }, + ) } } totalFolders++ @@ -143,7 +193,12 @@ object CollectionRepository { folderCount = totalFolders, ) } catch (e: Exception) { - ValidationResult(valid = false, error = "Invalid JSON: ${e.message}") + ValidationResult( + valid = false, + error = runBlocking { + getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty()) + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 2328eb75..b7a4b096 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -6,6 +6,7 @@ import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.supportsPagination +import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.stableKey @@ -17,6 +18,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.collections_folder_addon_not_found +import nuvio.composeapp.generated.resources.collections_tab_all +import org.jetbrains.compose.resources.getString data class FolderTab( val label: String, @@ -113,7 +119,13 @@ object FolderDetailRepository { val tabs = buildList { if (showAll) { - add(FolderTab(label = "All", isAllTab = true, isLoading = true)) + add( + FolderTab( + label = runBlocking { getString(Res.string.collections_tab_all) }, + isAllTab = true, + isLoading = true, + ), + ) } folder.catalogSources.forEach { source -> val addon = addons.find { it.manifest?.id == source.addonId } @@ -121,9 +133,7 @@ object FolderDetailRepository { it.id == source.catalogId && it.type == source.type } val label = catalog?.name ?: source.catalogId - val typeLabel = source.type.replaceFirstChar { - if (it.isLowerCase()) it.titlecase() else it.toString() - } + val typeLabel = localizedMediaTypeLabel(source.type) val genreSuffix = if (source.genre != null) " · ${source.genre}" else "" add( FolderTab( @@ -155,7 +165,14 @@ object FolderDetailRepository { val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val addon = addons.find { it.manifest?.id == source.addonId } if (addon == null) { - updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") } + updateTab(tabIndex) { + it.copy( + isLoading = false, + error = runBlocking { + getString(Res.string.collections_folder_addon_not_found, source.addonId) + }, + ) + } return@forEachIndexed } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index fd065aef..07c3cb73 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -63,6 +63,11 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.collections_folder_empty_items +import nuvio.composeapp.generated.resources.collections_folder_not_found +import nuvio.composeapp.generated.resources.collections_tab_all +import org.jetbrains.compose.resources.stringResource private val FolderCoverHeight = 176.dp @@ -143,7 +148,7 @@ fun FolderDetailScreen( contentAlignment = Alignment.Center, ) { Text( - text = "Folder not found", + text = stringResource(Res.string.collections_folder_not_found), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -229,7 +234,11 @@ private fun TabbedGridContent( onClick = { onTabSelected(index) }, text = { Text( - text = tab.label, + text = if (tab.isAllTab) { + stringResource(Res.string.collections_tab_all) + } else { + tab.label + }, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -395,7 +404,7 @@ private fun EmptyMessage() { contentAlignment = Alignment.Center, ) { Text( - text = "No items found", + text = stringResource(Res.string.collections_folder_empty_items), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 6dbf3cfa..adcf6811 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamProxyHeaders +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -13,6 +14,8 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.jsonPrimitive +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString internal object MetaDetailsParser { private val json = Json { ignoreUnknownKeys = true } @@ -248,10 +251,10 @@ internal object MetaDetailsParser { MetaTrailer( id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey, key = normalizedKey, - name = trailer.string("name")?.takeIf(String::isNotBlank) ?: "Trailer", + name = trailer.string("name")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) }, site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube", size = trailer.int("size"), - type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "Trailer", + type = trailer.string("type")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) }, official = trailer.boolean("official") == true, publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"), seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"), @@ -273,7 +276,9 @@ internal object MetaDetailsParser { ?.objectValue("proxyHeaders") ?.toProxyHeaders() val streamData = obj["streamData"] as? JsonObject - val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded" + val addonName = streamData?.string("addon") + ?: obj.string("name") + ?: runBlocking { getString(Res.string.source_embedded) } StreamItem( name = obj.string("name"), description = obj.string("description") ?: obj.string("title"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index a5d32843..61f4ba86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object MetaDetailsRepository { private data class CachedMetaEntry( @@ -112,7 +114,7 @@ object MetaDetailsRepository { if (manifests.isEmpty()) { log.w { "No addon provides meta for type=$type id=$id" } _uiState.value = MetaDetailsUiState( - errorMessage = "No addon provides meta for this content.", + errorMessage = getString(Res.string.details_no_addon_meta), ) activeRequestKey = null return@launch @@ -157,7 +159,7 @@ object MetaDetailsRepository { } _uiState.value = MetaDetailsUiState( - errorMessage = "Could not load details from any addon.", + errorMessage = getString(Res.string.details_load_failed_all_addons), ) activeRequestKey = null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 31a9614e..0161bba5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -100,6 +100,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource @Composable @OptIn(ExperimentalSharedTransitionApi::class) @@ -186,7 +189,7 @@ fun MetaDetailsScreen( commentsCurrentPage = result.currentPage commentsPageCount = result.pageCount } catch (e: Exception) { - commentsError = e.message ?: "Failed to load comments" + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) } isCommentsLoading = false } @@ -242,14 +245,14 @@ fun MetaDetailsScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Failed to load", + text = stringResource(Res.string.details_failed_to_load), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) Text( text = when (networkStatusUiState.condition) { - NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again." - NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers." + NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection) + NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable) else -> uiState.errorMessage.orEmpty() }, style = MaterialTheme.typography.bodyMedium, @@ -262,7 +265,7 @@ fun MetaDetailsScreen( MetaDetailsRepository.load(type, id) }, ) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } @@ -300,7 +303,7 @@ fun MetaDetailsScreen( tab.key to (snapshot[tab.key] == true) } }.onFailure { error -> - pickerError = error.message ?: "Failed to load Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } pickerPending = false } @@ -394,7 +397,7 @@ fun MetaDetailsScreen( } trailerPlaybackSource = resolvedSource trailerErrorMessage = if (resolvedSource == null) { - "No playable trailer stream found." + getString(Res.string.trailer_no_playable_stream) } else { null } @@ -403,13 +406,15 @@ fun MetaDetailsScreen( } } } - val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) { + val playText = stringResource(Res.string.action_play) + val resumeText = stringResource(Res.string.action_resume) + val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes, playText, resumeText) { when { (meta.type == "series" || hasEpisodes) && seriesAction != null -> seriesAction.label meta.type != "series" && !hasEpisodes && movieProgress != null -> - "Resume" - else -> "Play" + resumeText + else -> playText } } val onPrimaryPlayClick: () -> Unit = { @@ -660,7 +665,7 @@ fun MetaDetailsScreen( commentsCurrentPage = result.currentPage commentsPageCount = result.pageCount } catch (e: Exception) { - commentsError = e.message ?: "Failed to load comments" + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) } isCommentsLoading = false } @@ -780,7 +785,9 @@ fun MetaDetailsScreen( } EpisodeWatchedActionSheet( episode = selectedEpisode, - seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials", + seasonLabel = selectedEpisode.season?.let { + stringResource(Res.string.episodes_season, it) + } ?: stringResource(Res.string.episodes_specials), isEpisodeWatched = isSelectedEpisodeWatched, canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(), arePreviousEpisodesWatched = arePreviousEpisodesWatched, @@ -865,7 +872,7 @@ fun MetaDetailsScreen( }.onSuccess { showLibraryListPicker = false }.onFailure { error -> - pickerError = error.message ?: "Failed to update Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) } pickerPending = false } @@ -993,7 +1000,11 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.ACTIONS -> { DetailActionButtons( playLabel = playButtonLabel, - saveLabel = if (isSaved) "Saved" else "Save", + saveLabel = if (isSaved) { + stringResource(Res.string.action_saved) + } else { + stringResource(Res.string.action_save) + }, isSaved = isSaved, isTablet = isTablet, onPlayClick = onPrimaryPlayClick, @@ -1072,7 +1083,7 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.MORE_LIKE_THIS -> { if (hasMoreLikeThisSection) { DetailPosterRailSection( - title = "More Like This", + title = stringResource(Res.string.details_more_like_this), items = meta.moreLikeThis, watchedKeys = watchedKeys, showHeader = showHeader, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index 93660110..22f1d1eb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -3,11 +3,15 @@ package com.nuvio.app.features.details import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString enum class MetaScreenSectionKey { ACTIONS, @@ -81,8 +85,8 @@ private data class StoredMetaScreenSettingsPayload( private data class MetaScreenSectionDefinition( val key: MetaScreenSectionKey, - val title: String, - val description: String, + val titleRes: StringResource, + val descriptionRes: StringResource, ) object MetaScreenSettingsRepository { @@ -94,53 +98,53 @@ object MetaScreenSettingsRepository { private val definitions = listOf( MetaScreenSectionDefinition( key = MetaScreenSectionKey.ACTIONS, - title = "Actions", - description = "Play and save controls.", + titleRes = Res.string.meta_section_actions_title, + descriptionRes = Res.string.meta_section_actions_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.OVERVIEW, - title = "Overview", - description = "Synopsis, ratings, genres, and core credits.", + titleRes = Res.string.meta_section_overview_title, + descriptionRes = Res.string.meta_section_overview_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.PRODUCTION, - title = "Production", - description = "Studios and networks.", + titleRes = Res.string.meta_section_production_title, + descriptionRes = Res.string.meta_section_production_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.CAST, - title = "Cast", - description = "Principal cast list.", + titleRes = Res.string.settings_meta_cast, + descriptionRes = Res.string.meta_section_cast_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.COMMENTS, - title = "Comments", - description = "Trakt comments section.", + titleRes = Res.string.settings_meta_comments, + descriptionRes = Res.string.meta_section_comments_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.TRAILERS, - title = "Trailers", - description = "Trailer rail and playback shortcuts.", + titleRes = Res.string.settings_meta_trailers, + descriptionRes = Res.string.meta_section_trailers_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.EPISODES, - title = "Episodes", - description = "Seasons and episode list for series.", + titleRes = Res.string.settings_meta_episodes, + descriptionRes = Res.string.meta_section_episodes_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.DETAILS, - title = "Details", - description = "Runtime, status, release, language, and related info.", + titleRes = Res.string.meta_section_details_title, + descriptionRes = Res.string.meta_section_details_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.COLLECTION, - title = "Collection", - description = "Related collection or franchise rail.", + titleRes = Res.string.meta_section_collection_title, + descriptionRes = Res.string.meta_section_collection_description, ), MetaScreenSectionDefinition( key = MetaScreenSectionKey.MORE_LIKE_THIS, - title = "More Like This", - description = "Recommendation rail.", + titleRes = Res.string.meta_section_more_like_this_title, + descriptionRes = Res.string.meta_section_more_like_this_description, ), ) @@ -152,6 +156,7 @@ object MetaScreenSettingsRepository { private var cinematicBackground: Boolean = false private var tabLayout: Boolean = false private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } fun ensureLoaded() { if (hasLoaded) return @@ -322,8 +327,8 @@ object MetaScreenSettingsRepository { val preference = preferences[definition.key] MetaScreenSectionItem( key = definition.key, - title = definition.title, - description = definition.description, + title = localizedString(definition.titleRes), + description = localizedString(definition.descriptionRes), enabled = preference?.enabled ?: true, order = preference?.order ?: 0, tabGroup = preference?.tabGroup, @@ -347,4 +352,4 @@ object MetaScreenSettingsRepository { ), ) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 0a00b7a5..769456b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest +import com.nuvio.app.core.i18n.localizedShortMonthName import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState @@ -63,6 +64,9 @@ import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.watchprogress.CurrentDateProvider +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private sealed interface PersonDetailUiState { data object Loading : PersonDetailUiState @@ -96,7 +100,7 @@ fun PersonDetailScreen( uiState = if (detail != null) { PersonDetailUiState.Success(detail) } else { - PersonDetailUiState.Error("Could not load details for $personName") + PersonDetailUiState.Error(getString(Res.string.person_load_failed, personName)) } } @@ -141,7 +145,7 @@ fun PersonDetailScreen( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(Res.string.action_back), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -268,7 +272,7 @@ private fun PersonDetailContent( if (popularCredits.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) DetailPosterRailSection( - title = "Popular", + title = stringResource(Res.string.person_popular), items = popularCredits, watchedKeys = emptySet(), headerHorizontalPadding = 20.dp, @@ -279,7 +283,7 @@ private fun PersonDetailContent( if (latestCredits.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) DetailPosterRailSection( - title = "Latest", + title = stringResource(Res.string.person_latest), items = latestCredits, watchedKeys = emptySet(), headerHorizontalPadding = 20.dp, @@ -290,7 +294,7 @@ private fun PersonDetailContent( if (upcomingCredits.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) DetailPosterRailSection( - title = "Upcoming", + title = stringResource(Res.string.person_upcoming), items = upcomingCredits, watchedKeys = emptySet(), headerHorizontalPadding = 20.dp, @@ -405,18 +409,23 @@ private fun HeroSection( val infoItems = buildList { person.birthday?.let { bday -> val age = calculateAge(bday, person.deathday) - val ageStr = if (age != null) " (age $age)" else "" + val ageStr = if (age != null) stringResource(Res.string.person_age, age) else "" val bdayDisplay = formatDateForDisplay(bday) ?: bday val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it } val line = if (deathDisplay != null) { - "Born $bdayDisplay — Died $deathDisplay$ageStr" + buildString { + append(stringResource(Res.string.person_born, bdayDisplay, "")) + append(" — ") + append(stringResource(Res.string.person_died, deathDisplay)) + append(ageStr) + } } else { - "Born $bdayDisplay$ageStr" + stringResource(Res.string.person_born, bdayDisplay, ageStr) } add(line) } person.placeOfBirth?.let { add(it) } - person.knownFor?.let { add("Known for: $it") } + person.knownFor?.let { add(stringResource(Res.string.person_known_for, it)) } } if (infoItems.isNotEmpty()) { infoItems.forEach { info -> @@ -682,7 +691,7 @@ private fun PersonDetailError( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "Something went wrong", + text = stringResource(Res.string.person_something_wrong), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -700,7 +709,7 @@ private fun PersonDetailError( contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } @@ -741,15 +750,11 @@ private fun calculateAge(birthday: String, deathday: String?): Int? { private fun formatDateForDisplay(date: String): String? { val parts = date.split("-").mapNotNull { it.toIntOrNull() } if (parts.size < 3) return null - val months = arrayOf( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ) val month = parts[1] val day = parts[2] val year = parts[0] return if (month in 1..12) { - "${months[month - 1]} $day, $year" + "${localizedShortMonthName(month)} $day, $year" } else { null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 7b0793e1..2c03a8fe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState @@ -73,6 +75,7 @@ fun TmdbEntityBrowseScreen( var uiState by remember(entityKind, entityId) { mutableStateOf(EntityBrowseUiState.Loading) } + val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName) LaunchedEffect(entityKind, entityId) { uiState = EntityBrowseUiState.Loading @@ -85,7 +88,7 @@ fun TmdbEntityBrowseScreen( uiState = if (data != null) { EntityBrowseUiState.Success(data) } else { - EntityBrowseUiState.Error("Could not load $entityName") + EntityBrowseUiState.Error(loadFailedMessage) } } @@ -117,7 +120,7 @@ fun TmdbEntityBrowseScreen( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(Res.string.action_back), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -170,7 +173,7 @@ private fun EntityBrowseContent( contentAlignment = Alignment.Center, ) { Text( - text = "No titles found", + text = stringResource(Res.string.catalog_empty_title), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -191,18 +194,16 @@ private fun EntityBrowseContent( ) data.rails.forEach { rail -> - val railTitle = remember(rail.mediaType, rail.railType) { - val mediaLabel = when (rail.mediaType) { - TmdbEntityMediaType.MOVIE -> "Movies" - TmdbEntityMediaType.TV -> "Series" - } - val railLabel = when (rail.railType) { - TmdbEntityRailType.POPULAR -> "Popular" - TmdbEntityRailType.TOP_RATED -> "Top Rated" - TmdbEntityRailType.RECENT -> "Recent" - } - "$mediaLabel • $railLabel" + val mediaLabel = when (rail.mediaType) { + TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies) + TmdbEntityMediaType.TV -> stringResource(Res.string.media_series) } + val railLabel = when (rail.railType) { + TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular) + TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated) + TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent) + } + val railTitle = stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel) DetailPosterRailSection( title = railTitle, @@ -230,8 +231,8 @@ private fun EntityHeroSection( Column(modifier = modifier.padding(horizontal = 20.dp)) { Text( text = when (header.kind) { - TmdbEntityKind.COMPANY -> "Production Company" - TmdbEntityKind.NETWORK -> "Network" + TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company) + TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network) }, style = MaterialTheme.typography.labelLarge.copy( fontWeight = FontWeight.Medium, @@ -405,7 +406,7 @@ private fun EntityBrowseError( contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt index c157b529..c13ae124 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.features.trakt.TraktCommentReview +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -114,7 +116,7 @@ fun CommentDetailSheet( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, - contentDescription = "Previous", + contentDescription = stringResource(Res.string.action_previous), tint = if (canGoBack) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), modifier = Modifier.size(20.dp), @@ -140,7 +142,7 @@ fun CommentDetailSheet( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Next", + contentDescription = stringResource(Res.string.action_next), tint = if (canGoForward) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), modifier = Modifier.size(20.dp), @@ -153,13 +155,13 @@ fun CommentDetailSheet( Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (comment.review) { - CommentDetailChip(text = "Review") + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) } if (comment.hasSpoilerContent) { - CommentDetailChip(text = "Spoiler") + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) } comment.rating?.let { rating -> - CommentDetailChip(text = "Rating $rating/10") + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating)) } } @@ -173,7 +175,7 @@ fun CommentDetailSheet( ) { Text( text = if (comment.hasSpoilerContent) { - "This comment contains spoilers and has been hidden." + stringResource(Res.string.detail_comments_spoiler_hidden_sheet) } else { comment.comment }, @@ -189,7 +191,7 @@ fun CommentDetailSheet( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "${comment.likes} likes", + text = stringResource(Res.string.detail_comments_likes, comment.likes), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 32b3a03d..6eb1d515 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -28,13 +28,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.appIconPainter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_play +import nuvio.composeapp.generated.resources.action_save +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalFoundationApi::class) @Composable fun DetailActionButtons( modifier: Modifier = Modifier, - playLabel: String = "Play", - saveLabel: String = "Save", + playLabel: String = stringResource(Res.string.action_play), + saveLabel: String = stringResource(Res.string.action_save), isSaved: Boolean = false, isTablet: Boolean = false, onPlayClick: () -> Unit = {}, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt index 40ec96ed..e3b42fdb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.formatRuntimeForDisplay +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DetailAdditionalInfoSection( @@ -27,14 +29,24 @@ fun DetailAdditionalInfoSection( showHeader: Boolean = true, ) { val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null } - val title = if (isSeriesLike) "Show Details" else "Movie Details" + val title = if (isSeriesLike) { + stringResource(Res.string.details_show_details) + } else { + stringResource(Res.string.details_movie_details) + } val rows = buildList { - meta.status?.let { add("Status" to it) } - meta.releaseInfo?.let { add("Release Info" to formatReleaseDateForDisplay(it)) } - formatRuntimeForDisplay(meta.runtime)?.let { add("Runtime" to it) } - meta.ageRating?.let { add("Certification" to it) } - meta.country?.let { add("Origin Country" to it) } - meta.language?.let { add("Original Language" to it.uppercase()) } + meta.status?.let { add(stringResource(Res.string.details_status) to it) } + meta.releaseInfo?.let { + add(stringResource(Res.string.details_release_info) to formatReleaseDateForDisplay(it)) + } + formatRuntimeForDisplay(meta.runtime)?.let { + add(stringResource(Res.string.details_runtime) to it) + } + meta.ageRating?.let { add(stringResource(Res.string.details_certification) to it) } + meta.country?.let { add(stringResource(Res.string.details_origin_country) to it) } + meta.language?.let { + add(stringResource(Res.string.details_original_language) to it.uppercase()) + } } if (rows.isEmpty()) return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 370a77bb..306152a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -34,6 +34,8 @@ import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.castAvatarSharedTransitionKey +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable @OptIn(ExperimentalSharedTransitionApi::class) @@ -48,7 +50,7 @@ fun DetailCastSection( if (cast.isEmpty()) return DetailSection( - title = "Cast", + title = stringResource(Res.string.settings_meta_cast), modifier = modifier, showHeader = showHeader, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt index 3d533343..43d740d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.features.trakt.TraktCommentReview import kotlinx.coroutines.flow.distinctUntilChanged +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DetailCommentsSection( @@ -101,14 +103,14 @@ fun DetailCommentsSection( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } comments.isEmpty() -> { Text( - text = "No comments yet.", + text = stringResource(Res.string.detail_comments_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -144,7 +146,7 @@ private fun CommentsHeader() { val titleSize = if (isTablet) 22.sp else 20.sp Text( - text = "Trakt Comments", + text = stringResource(Res.string.detail_comments_title), style = MaterialTheme.typography.titleLarge.copy( fontSize = titleSize, fontWeight = FontWeight.SemiBold, @@ -163,7 +165,7 @@ private fun CommentCard( val colorScheme = MaterialTheme.colorScheme val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505) val bodyText = if (review.hasSpoilerContent) { - "This comment contains spoilers." + stringResource(Res.string.detail_comments_spoiler_card) } else { review.comment } @@ -199,13 +201,13 @@ private fun CommentCard( Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (review.review) { - CommentChip(text = "Review") + CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) } if (review.hasSpoilerContent) { - CommentChip(text = "Spoiler") + CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) } review.rating?.let { rating -> - CommentChip(text = "Rating $rating/10") + CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating)) } } @@ -219,7 +221,7 @@ private fun CommentCard( ) Text( - text = "${review.likes} likes", + text = stringResource(Res.string.detail_comments_likes, review.likes), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), maxLines = 1, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 6fe603e9..55563357 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -41,6 +41,8 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.isIos +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DetailFloatingHeader( @@ -112,7 +114,7 @@ fun DetailFloatingHeader( if (meta.logo != null && !logoLoadError) { AsyncImage( model = meta.logo, - contentDescription = "${meta.name} logo", + contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name), modifier = Modifier .width(logoWidth) .widthIn(max = 240.dp) @@ -166,7 +168,11 @@ private fun DetailFloatingHeaderAction( ) { Icon( imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, - contentDescription = if (isSaved) "Remove from Library" else "Add to Library", + contentDescription = if (isSaved) { + stringResource(Res.string.hero_remove_from_library) + } else { + stringResource(Res.string.hero_add_to_library) + }, tint = MaterialTheme.colorScheme.onBackground, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index a091925f..4c60ee24 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaDetails +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DetailHero( @@ -103,7 +105,7 @@ fun DetailHero( if (meta.logo != null) { AsyncImage( model = meta.logo, - contentDescription = "${meta.name} logo", + contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name), modifier = Modifier .fillMaxWidth(if (isTablet) 0.56f else 0.6f) .widthIn(max = contentMaxWidth) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index 52bcde1c..50add3ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -49,7 +49,7 @@ import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT -import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.rating_audience_score import nuvio.composeapp.generated.resources.rating_imdb import nuvio.composeapp.generated.resources.rating_letterboxd @@ -58,7 +58,10 @@ import nuvio.composeapp.generated.resources.rating_rotten_tomatoes import nuvio.composeapp.generated.resources.rating_tmdb import nuvio.composeapp.generated.resources.rating_trakt import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import kotlinx.coroutines.runBlocking import kotlin.math.absoluteValue import kotlin.math.roundToInt @@ -114,7 +117,7 @@ fun DetailMetaInfo( color = ImdbYellow, ) { Text( - text = "IMDb", + text = stringResource(Res.string.source_imdb), modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), style = MaterialTheme.typography.labelMedium.copy( fontSize = 10.sp, @@ -148,14 +151,14 @@ fun DetailMetaInfo( if (meta.director.isNotEmpty()) { MetaLabelValueRow( - label = "Director", + label = stringResource(Res.string.details_director), value = meta.director.joinToString(", "), ) } if (meta.writer.isNotEmpty()) { MetaLabelValueRow( - label = "Writer", + label = stringResource(Res.string.details_writer), value = meta.writer.joinToString(", "), ) } @@ -182,7 +185,11 @@ fun DetailMetaInfo( if (canExpand) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = if (expanded) "Show Less" else "Show More ▾", + text = if (expanded) { + stringResource(Res.string.details_show_less) + } else { + stringResource(Res.string.details_show_more) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.clickable { expanded = !expanded }, @@ -341,7 +348,7 @@ private val ratingVisuals = listOf( ), RatingVisuals( source = PROVIDER_AUDIENCE, - displayName = "Audience Score", + displayName = runBlocking { getString(Res.string.rating_audience_score) }, logo = Res.drawable.rating_audience_score, logoWidth = 16.dp, valueColor = Color(0xFFFA320A), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index 84072c94..b9308252 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaDetails +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalLayoutApi::class) @Composable @@ -54,7 +56,11 @@ fun DetailProductionSection( if (displayItems.isEmpty()) return DetailSection( - title = if (isSeriesLike) "Network" else "Production", + title = if (isSeriesLike) { + stringResource(Res.string.details_networks) + } else { + stringResource(Res.string.meta_section_production_title) + }, modifier = modifier, showHeader = showHeader, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index ce6f2a2f..a463e5a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import co.touchlab.kermit.Logger import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.features.details.MetaDetails @@ -71,6 +72,10 @@ import com.nuvio.app.features.details.seasonSortKey import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private val log = Logger.withTag("SeriesContent") @@ -92,16 +97,16 @@ fun DetailSeriesContent( if (meta.videos.isEmpty()) { DetailSection( - title = "Episodes", + title = stringResource(Res.string.settings_meta_episodes), modifier = modifier, showHeader = showHeader, ) { Text( text = when { meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos -> - "Episodes have not been published by this addon yet." + stringResource(Res.string.details_series_unpublished) else -> - "This addon did not provide episode metadata for this series." + stringResource(Res.string.details_series_no_metadata) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -132,12 +137,12 @@ fun DetailSeriesContent( if (groupedEpisodes.isEmpty()) { if (meta.type == "series") { DetailSection( - title = "Episodes", + title = stringResource(Res.string.settings_meta_episodes), modifier = modifier, showHeader = showHeader, ) { Text( - text = "This addon returned videos for the series, but none included season or episode numbers.", + text = stringResource(Res.string.details_series_missing_numbers), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -182,7 +187,7 @@ fun DetailSeriesContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Seasons", + text = stringResource(Res.string.details_seasons), style = MaterialTheme.typography.titleLarge.copy( fontSize = sizing.seasonHeaderSize, fontWeight = FontWeight.SemiBold, @@ -250,7 +255,7 @@ fun DetailSeriesContent( label = "season_episodes", ) { seasonForContent -> val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) { - "Videos" + stringResource(Res.string.details_videos) } else { seasonForContent.label() } @@ -336,7 +341,11 @@ private fun SeasonViewModeToggle( contentAlignment = Alignment.Center, ) { Text( - text = if (isPosters) "Posters" else "Text", + text = if (isPosters) { + stringResource(Res.string.details_season_view_posters) + } else { + stringResource(Res.string.details_season_view_text) + }, style = MaterialTheme.typography.labelLarge.copy( fontSize = sizing.seasonToggleTextSize, fontWeight = FontWeight.SemiBold, @@ -1187,14 +1196,14 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing = private fun Int.label(): String = if (this <= 0) { - "Specials" + runBlocking { getString(Res.string.episodes_specials) } } else { - "Season $this" + runBlocking { getString(Res.string.episodes_season, this@label) } } private fun MetaVideo.episodeBadge(): String = when { - episode != null -> "E${episode.toString().padStart(2, '0')}" - season != null -> "S${season.toString().padStart(2, '0')}" - else -> "FILE" + episode != null || season != null -> + localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty() + else -> runBlocking { getString(Res.string.details_episode_badge_file) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index e4e02355..0edc1a3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaTrailer +import nuvio.composeapp.generated.resources.* +import nuvio.composeapp.generated.resources.detail_tab_trailer +import nuvio.composeapp.generated.resources.detail_trailer_category_count +import nuvio.composeapp.generated.resources.detail_trailers_title +import org.jetbrains.compose.resources.stringResource @Composable fun DetailTrailersSection( @@ -48,10 +53,11 @@ fun DetailTrailersSection( ) { if (trailers.isEmpty()) return + val trailerLabel = stringResource(Res.string.detail_tab_trailer) val grouped = remember(trailers) { linkedMapOf>().apply { trailers.forEach { trailer -> - val category = trailer.type.ifBlank { "Trailer" } + val category = trailer.type.ifBlank { trailerLabel } getOrPut(category) { mutableListOf() }.add(trailer) } } @@ -60,7 +66,7 @@ fun DetailTrailersSection( if (grouped.isEmpty()) return val initialCategory = remember(grouped) { - grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) } + grouped.keys.firstOrNull { it.equals(trailerLabel, ignoreCase = true) } ?: grouped.keys.first() } var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) } @@ -82,7 +88,7 @@ fun DetailTrailersSection( ) { if (showHeader) { DetailSectionTitle( - title = "Trailers", + title = stringResource(Res.string.detail_trailers_title), fullWidth = false, ) } @@ -131,7 +137,7 @@ fun DetailTrailersSection( DropdownMenuItem( text = { Text( - text = "$category ($count)", + text = stringResource(Res.string.detail_trailer_category_count, category, count), style = MaterialTheme.typography.bodyMedium, ) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 5a66d7e5..44c02bba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -29,8 +29,11 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.features.details.MetaVideo import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,7 +74,11 @@ fun EpisodeWatchedActionSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Default.CheckCircle, - title = if (isEpisodeWatched) "Mark as unwatched" else "Mark as watched", + title = if (isEpisodeWatched) { + stringResource(Res.string.episode_mark_unwatched) + } else { + stringResource(Res.string.episode_mark_watched) + }, onClick = { onToggleWatched() coroutineScope.launch { @@ -84,9 +91,9 @@ fun EpisodeWatchedActionSheet( NuvioBottomSheetActionRow( icon = Icons.Default.DoneAll, title = if (arePreviousEpisodesWatched) { - "Mark previous as unwatched" + stringResource(Res.string.episode_mark_previous_unwatched) } else { - "Mark previous as watched" + stringResource(Res.string.episode_mark_previous_watched) }, onClick = { onTogglePreviousWatched() @@ -100,9 +107,9 @@ fun EpisodeWatchedActionSheet( NuvioBottomSheetActionRow( icon = Icons.Default.PlaylistAddCheckCircle, title = if (isSeasonWatched) { - "Mark $seasonLabel as unwatched" + stringResource(Res.string.episode_mark_season_unwatched, seasonLabel) } else { - "Mark $seasonLabel as watched" + stringResource(Res.string.episode_mark_season_watched, seasonLabel) }, onClick = { onToggleSeasonWatched() @@ -115,7 +122,7 @@ fun EpisodeWatchedActionSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Default.PlayArrow, - title = "Play manually", + title = stringResource(Res.string.play_manually), onClick = { onPlayManually() coroutineScope.launch { @@ -149,8 +156,11 @@ private fun EpisodeActionSheetHeader( ) Text( text = buildString { - if (episode.season != null && episode.episode != null) { - append("S${episode.season}E${episode.episode}") + localizedSeasonEpisodeCode( + seasonNumber = episode.season, + episodeNumber = episode.episode, + )?.let { + append(it) append(" • ") } append(seasonLabel) @@ -162,4 +172,3 @@ private fun EpisodeActionSheetHeader( ) } } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt index 33142d68..c12ead6b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -39,6 +39,8 @@ import com.nuvio.app.features.player.PlatformPlayerSurface import com.nuvio.app.features.player.PlayerResizeMode import com.nuvio.app.features.trailer.TrailerPlaybackSource import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -55,7 +57,7 @@ fun TrailerPlayerPopup( ) { if (!visible) return - val headerType = trailerType.trim().ifBlank { "Trailer" } + val headerType = trailerType.trim().ifBlank { stringResource(Res.string.detail_tab_trailer) } val headerSubtitle = buildList { if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) { add(trailerTitle) @@ -119,7 +121,7 @@ fun TrailerPlayerPopup( IconButton(onClick = dismissSheet) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Close trailer", + contentDescription = stringResource(Res.string.trailer_close), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -147,7 +149,7 @@ fun TrailerPlayerPopup( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Unable to play trailer", + text = stringResource(Res.string.trailer_unable_to_play), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -160,7 +162,7 @@ fun TrailerPlayerPopup( ) if (onRetry != null) { TextButton(onClick = onRetry) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt index 05b1d0bf..94769875 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt @@ -1,6 +1,13 @@ package com.nuvio.app.features.downloads import kotlinx.serialization.Serializable +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.downloads_enqueue_missing_url +import nuvio.composeapp.generated.resources.downloads_enqueue_replaced +import nuvio.composeapp.generated.resources.downloads_enqueue_started +import nuvio.composeapp.generated.resources.downloads_enqueue_unsupported_format +import org.jetbrains.compose.resources.getString @Serializable enum class DownloadStatus { @@ -48,22 +55,7 @@ data class DownloadItem( get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank() val displaySubtitle: String - get() = if (isEpisode) { - buildString { - append("S") - append(seasonNumber) - append("E") - append(episodeNumber) - episodeTitle - ?.takeIf { it.isNotBlank() } - ?.let { - append(" • ") - append(it) - } - } - } else { - "Movie" - } + get() = episodeTitle.orEmpty() val progressFraction: Float get() { @@ -91,11 +83,18 @@ data class DownloadsUiState( get() = items.filter { it.status == DownloadStatus.Completed } } -enum class DownloadEnqueueResult( - val toastMessage: String, -) { - Started("Download started"), - Replaced("Replaced previous download"), - MissingUrl("No direct stream link available"), - UnsupportedFormat("Unsupported stream format for downloads"), +enum class DownloadEnqueueResult { + Started, + Replaced, + MissingUrl, + UnsupportedFormat; + + fun toastMessage(): String = runBlocking { + when (this@DownloadEnqueueResult) { + Started -> getString(Res.string.downloads_enqueue_started) + Replaced -> getString(Res.string.downloads_enqueue_replaced) + MissingUrl -> getString(Res.string.downloads_enqueue_missing_url) + UnsupportedFormat -> getString(Res.string.downloads_enqueue_unsupported_format) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index d60b97a0..f6e715ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.downloads import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -9,6 +10,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object DownloadsRepository { private val _uiState = MutableStateFlow(DownloadsUiState()) @@ -294,7 +297,7 @@ object DownloadsRepository { } else { current.copy( status = DownloadStatus.Failed, - errorMessage = message.ifBlank { "Download failed" }, + errorMessage = message.ifBlank { runBlocking { getString(Res.string.download_failed) } }, updatedAtEpochMs = DownloadsClock.nowEpochMs(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index d1215d60..d8952adf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -35,8 +35,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DownloadsScreen( @@ -66,9 +69,9 @@ fun DownloadsScreen( stickyHeader { NuvioScreenHeader( title = if (selectedShowId == null) { - "Downloads" + stringResource(Res.string.compose_settings_root_downloads_title) } else { - selectedShowTitle ?: "Show Downloads" + selectedShowTitle ?: stringResource(Res.string.downloads_show_downloads) }, onBack = { if (selectedShowId != null) { @@ -115,7 +118,7 @@ private fun LazyListScope.downloadsRootContent( if (activeItems.isNotEmpty()) { item { - SectionTitle("ACTIVE") + SectionTitle(stringResource(Res.string.downloads_section_active)) } items( items = activeItems, @@ -134,7 +137,7 @@ private fun LazyListScope.downloadsRootContent( if (completedMovies.isNotEmpty()) { item { - SectionTitle("MOVIES") + SectionTitle(stringResource(Res.string.downloads_section_movies)) } items( items = completedMovies, @@ -153,7 +156,7 @@ private fun LazyListScope.downloadsRootContent( if (completedShows.isNotEmpty()) { item { - SectionTitle("SHOWS") + SectionTitle(stringResource(Res.string.downloads_section_shows)) } items( items = completedShows, @@ -186,7 +189,7 @@ private fun LazyListScope.downloadsRootContent( overflow = TextOverflow.Ellipsis, ) Text( - text = "${episodes.size} downloaded episode${if (episodes.size == 1) "" else "s"}", + text = stringResource(Res.string.downloads_episode_count, episodes.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -210,7 +213,7 @@ private fun LazyListScope.downloadsRootContent( contentAlignment = Alignment.Center, ) { Text( - text = "No downloads yet", + text = stringResource(Res.string.downloads_empty_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -245,7 +248,7 @@ private fun LazyListScope.downloadsShowContent( contentAlignment = Alignment.Center, ) { Text( - text = "No completed episodes", + text = stringResource(Res.string.downloads_empty_episodes), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -255,13 +258,14 @@ private fun LazyListScope.downloadsShowContent( } seasons.forEach { (seasonNumber, entries) -> - val seasonTitle = if (seasonNumber == 0) { - "Specials" - } else { - "Season $seasonNumber" - } item { - SectionTitle(seasonTitle) + SectionTitle( + if (seasonNumber == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, seasonNumber) + }, + ) } val sortedEpisodes = entries.sortedWith( @@ -345,7 +349,7 @@ private fun DownloadRow( IconButton(onClick = onPause) { Icon( imageVector = Icons.Rounded.Pause, - contentDescription = "Pause", + contentDescription = stringResource(Res.string.compose_action_pause), ) } } @@ -353,7 +357,7 @@ private fun DownloadRow( IconButton(onClick = onResume) { Icon( imageVector = Icons.Rounded.PlayArrow, - contentDescription = "Resume", + contentDescription = stringResource(Res.string.action_resume), ) } } @@ -361,7 +365,7 @@ private fun DownloadRow( IconButton(onClick = onRetry) { Icon( imageVector = Icons.Rounded.Refresh, - contentDescription = "Retry", + contentDescription = stringResource(Res.string.action_retry), ) } } @@ -369,7 +373,7 @@ private fun DownloadRow( IconButton(onClick = onOpen) { Icon( imageVector = Icons.Rounded.PlayArrow, - contentDescription = "Play", + contentDescription = stringResource(Res.string.action_play), ) } } @@ -377,7 +381,7 @@ private fun DownloadRow( IconButton(onClick = onDelete) { Icon( imageVector = Icons.Rounded.Delete, - contentDescription = "Delete", + contentDescription = stringResource(Res.string.action_delete), ) } } @@ -410,6 +414,7 @@ private fun SectionTitle(title: String) { ) } +@Composable private fun statusText(item: DownloadItem): String { val size = if (item.totalBytes != null && item.totalBytes > 0L) { "${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}" @@ -418,23 +423,26 @@ private fun statusText(item: DownloadItem): String { } return when (item.status) { - DownloadStatus.Downloading -> "Downloading • $size" - DownloadStatus.Paused -> "Paused • $size" - DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}" - DownloadStatus.Failed -> item.errorMessage ?: "Failed" + DownloadStatus.Downloading -> stringResource(Res.string.downloads_status_downloading, size) + DownloadStatus.Paused -> stringResource(Res.string.downloads_status_paused, size) + DownloadStatus.Completed -> stringResource( + Res.string.downloads_status_completed, + formatBytes(item.totalBytes ?: item.downloadedBytes), + ) + DownloadStatus.Failed -> item.errorMessage ?: stringResource(Res.string.downloads_status_failed) } } private fun formatBytes(bytes: Long): String { - if (bytes <= 0L) return "0 B" + if (bytes <= 0L) return "0 ${localizedByteUnit("B")}" val kib = 1024.0 val mib = kib * 1024.0 val gib = mib * 1024.0 val value = bytes.toDouble() return when { - value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB" - value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB" - value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB" - else -> "$bytes B" + value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}" + value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}" + value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}" + else -> "$bytes ${localizedByteUnit("B")}" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt index 52342795..74f54494 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt @@ -1,7 +1,12 @@ package com.nuvio.app.features.home +import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.catalog.supportsPagination +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.home_catalog_default_title +import org.jetbrains.compose.resources.getString data class HomeCatalogDefinition( val key: String, @@ -23,7 +28,13 @@ fun buildHomeCatalogDefinitions(addons: List): List HomeCatalogDefinition( key = "${manifest.id}:${catalog.type}:${catalog.id}", - defaultTitle = "${catalog.name} - ${catalog.type.displayLabel()}", + defaultTitle = runBlocking { + getString( + Res.string.home_catalog_default_title, + catalog.name, + localizedMediaTypeLabel(catalog.type), + ) + }, addonName = addon.displayTitle, manifestUrl = addon.manifestUrl, type = catalog.type, @@ -33,7 +44,4 @@ fun buildHomeCatalogDefinitions(addons: List): List - if (char.isLowerCase()) char.titlecase() else char.toString() - } +internal fun String.displayLabel(): String = localizedMediaTypeLabel(this) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index ee49f681..96b5ba6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionRepository +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -10,6 +11,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString data class HomeCatalogSettingsItem( val key: String, @@ -480,7 +483,7 @@ internal fun buildCollectionDefinitions(collections: List): List 0f } ?.let { (it / 100f).coerceIn(0f, 1f) } @@ -679,7 +654,7 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem parentMetaType = contentType, videoId = videoId, title = name, - subtitle = subtitle, + subtitle = episodeTitle.orEmpty(), imageUrl = episodeThumbnail ?: backdrop ?: poster, logo = logo, poster = poster, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index c4281808..1fd74b47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -37,12 +37,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlin.math.roundToInt +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource private fun continueWatchingProgressPercent(progressFraction: Float): Int = (progressFraction * 100f).roundToInt().coerceIn(1, 99) @@ -95,7 +98,7 @@ private fun HomeContinueWatchingSectionContent( onItemLongPress: ((ContinueWatchingItem) -> Unit)?, ) { NuvioShelfSection( - title = "Continue Watching", + title = stringResource(Res.string.compose_settings_page_continue_watching), entries = items, modifier = modifier, headerHorizontalPadding = sectionPadding, @@ -305,11 +308,7 @@ private fun ContinueWatchingWideCard( ) { val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank() - val wideMetaLine = when { - item.progressFraction <= 0f && isEpisodeCard -> "Up Next • S${item.seasonNumber}E${item.episodeNumber}" - isEpisodeCard -> "S${item.seasonNumber}E${item.episodeNumber}" - else -> item.subtitle - } + val wideMetaLine = localizedContinueWatchingSubtitle(item) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -364,7 +363,10 @@ private fun ContinueWatchingWideCard( trackColor = Color.White.copy(alpha = 0.10f), ) Text( - text = "${continueWatchingProgressPercent(item.progressFraction)}% watched", + text = stringResource( + Res.string.home_continue_watching_watched, + continueWatchingProgressPercent(item.progressFraction), + ), style = MaterialTheme.typography.labelSmall.copy( fontSize = layout.progressLabelSize, fontWeight = FontWeight.Medium, @@ -466,7 +468,11 @@ private fun ContinueWatchingPosterCard( } if (item.seasonNumber != null && item.episodeNumber != null) { Text( - text = "S${item.seasonNumber} E${item.episodeNumber}", + text = stringResource( + Res.string.streams_episode_badge, + item.seasonNumber, + item.episodeNumber, + ), modifier = Modifier.padding(start = 6.dp), style = MaterialTheme.typography.labelSmall.copy( fontSize = layout.posterMetaSize, @@ -519,7 +525,7 @@ private fun UpNextBadge( ), ) { Text( - text = "Up next", + text = stringResource(Res.string.home_continue_watching_up_next), style = MaterialTheme.typography.labelSmall.copy( fontSize = textSize, fontWeight = FontWeight.Bold, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 15f08ebb..32ab8059 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -52,6 +52,8 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import kotlin.math.abs private const val HERO_BACKGROUND_PARALLAX = 0.055f @@ -256,7 +258,7 @@ fun HomeHeroSection( shape = RoundedCornerShape(40.dp), ) { Text( - text = "View Details", + text = stringResource(Res.string.home_view_details), modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index a3983cbf..7aae75f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -11,6 +11,7 @@ import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -24,6 +25,9 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_other +import org.jetbrains.compose.resources.getString @Serializable private data class StoredLibraryPayload( @@ -366,7 +370,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem( internal fun String.toLibraryDisplayTitle(): String { val normalized = trim() - if (normalized.isBlank()) return "Other" + if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) } return normalized .split('-', '_', ' ') @@ -374,5 +378,5 @@ internal fun String.toLibraryDisplayTitle(): String { .joinToString(" ") { token -> token.lowercase().replaceFirstChar { char -> char.uppercase() } } - .ifBlank { "Other" } + .ifBlank { runBlocking { getString(Res.string.library_other) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 1f86203f..9b48e32d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -32,6 +32,8 @@ import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun LibraryScreen( @@ -84,7 +86,11 @@ fun LibraryScreen( .background(MaterialTheme.colorScheme.background), ) { NuvioScreenHeader( - title = if (isTraktSource) "Trakt Library" else "Library", + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_title) + } else { + stringResource(Res.string.library_title) + }, modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(6.dp)) @@ -116,7 +122,11 @@ fun LibraryScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) "Couldn't load Trakt library" else "Couldn't load library", + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_load_failed) + } else { + stringResource(Res.string.library_load_failed) + }, message = uiState.errorMessage.orEmpty(), ) } @@ -139,11 +149,15 @@ fun LibraryScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty", - message = if (isTraktSource) { - "Connect Trakt and save titles to your watchlist or personal lists." + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) } else { - "Saved titles will appear here after you tap Save on a details screen." + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) }, ) } @@ -166,11 +180,13 @@ fun LibraryScreen( } NuvioStatusModal( - title = "Remove from Library?", - message = pendingRemovalItem?.let { "Remove ${it.name} from your library?" }.orEmpty(), + title = stringResource(Res.string.library_remove_title), + message = pendingRemovalItem?.let { + stringResource(Res.string.library_remove_message, it.name) + }.orEmpty(), isVisible = pendingRemovalItem != null, - confirmText = "Remove", - dismissText = "Cancel", + confirmText = stringResource(Res.string.library_remove_confirm), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { pendingRemovalItem?.id?.let(LibraryRepository::remove) pendingRemovalItem = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt index fa4ec03c..2e71f651 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt @@ -1,6 +1,15 @@ package com.nuvio.app.features.notifications +import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.notifications_episode_release_body_code +import nuvio.composeapp.generated.resources.notifications_episode_release_body_code_title +import nuvio.composeapp.generated.resources.notifications_episode_release_body_generic +import nuvio.composeapp.generated.resources.notifications_episode_release_body_title +import org.jetbrains.compose.resources.getString import kotlin.math.abs data class EpisodeReleaseNotificationsUiState( @@ -76,16 +85,24 @@ internal fun buildEpisodeReleaseNotificationBody( seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, -): String { - val seasonLabel = seasonNumber?.let { season -> "S${season.toString().padStart(2, '0')}" } - val episodeLabel = episodeNumber?.let { episode -> "E${episode.toString().padStart(2, '0')}" } - val code = listOfNotNull(seasonLabel, episodeLabel).joinToString(separator = "") +): String = runBlocking { + val code = when { + seasonNumber != null && episodeNumber != null -> + getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + episodeNumber != null -> + getString(Res.string.compose_player_episode_code_episode_only, episodeNumber) + else -> "" + } val title = episodeTitle?.trim().takeUnless { it.isNullOrBlank() } - return when { - code.isNotBlank() && title != null -> "$code • $title is out now" - code.isNotBlank() -> "$code is out now" - title != null -> "$title is out now" - else -> "A new episode is out now" + when { + code.isNotBlank() && title != null -> + getString(Res.string.notifications_episode_release_body_code_title, code, title) + code.isNotBlank() -> + getString(Res.string.notifications_episode_release_body_code, code) + title != null -> + getString(Res.string.notifications_episode_release_body_title, title) + else -> + getString(Res.string.notifications_episode_release_body_generic) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt index a7d41fa5..c2ab3eac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -28,6 +28,8 @@ import kotlin.concurrent.Volatile import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.serialization.json.Json object EpisodeReleaseNotificationsRepository { @@ -149,7 +151,7 @@ object EpisodeReleaseNotificationsRepository { permissionGranted = false, scheduledCount = 0, statusMessage = null, - errorMessage = "Notifications permission is disabled for Nuvio.", + errorMessage = getString(Res.string.settings_notifications_permission_disabled), ) persist() return@launch @@ -175,7 +177,7 @@ object EpisodeReleaseNotificationsRepository { _uiState.value = _uiState.value.copy( isSendingTest = false, statusMessage = null, - errorMessage = "Save a show to your library first to test a deeplink notification.", + errorMessage = getString(Res.string.settings_notifications_test_requires_saved_show), ) return@launch } @@ -197,7 +199,7 @@ object EpisodeReleaseNotificationsRepository { isSendingTest = false, permissionGranted = false, statusMessage = null, - errorMessage = "Notifications permission is disabled for Nuvio.", + errorMessage = getString(Res.string.settings_notifications_permission_disabled), ) return@launch } @@ -205,7 +207,7 @@ object EpisodeReleaseNotificationsRepository { val request = EpisodeReleaseNotificationRequest( requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}", notificationTitle = target.name, - notificationBody = "Preview episode release alert.", + notificationBody = getString(Res.string.notifications_test_preview_body), releaseDateIso = CurrentDateProvider.todayIsoDate(), deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id), backdropUrl = target.banner ?: target.poster, @@ -219,7 +221,7 @@ object EpisodeReleaseNotificationsRepository { _uiState.value = _uiState.value.copy( isSendingTest = false, permissionGranted = true, - statusMessage = "Test notification sent for ${target.name}.", + statusMessage = getString(Res.string.notifications_test_sent_for, target.name), errorMessage = null, ) }.onFailure { @@ -227,7 +229,7 @@ object EpisodeReleaseNotificationsRepository { isSendingTest = false, permissionGranted = true, statusMessage = null, - errorMessage = "Failed to send a test notification.", + errorMessage = getString(Res.string.notifications_test_send_failed), ) } } @@ -467,4 +469,4 @@ object EpisodeReleaseNotificationsRepository { ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt index 719476ae..3ae0de1c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt @@ -38,6 +38,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_audio_tracks +import nuvio.composeapp.generated.resources.compose_player_no_audio_tracks_available +import org.jetbrains.compose.resources.stringResource @Composable fun AudioTrackModal( @@ -93,7 +97,7 @@ fun AudioTrackModal( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Audio Tracks", + text = stringResource(Res.string.compose_player_audio_tracks), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -148,7 +152,7 @@ private fun AudioTrackRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = getTrackDisplayName(track.label, track.language, track.index), + text = localizedTrackDisplayName(track.label, track.language, track.index), color = textColor, fontSize = 15.sp, fontWeight = weight, @@ -184,7 +188,7 @@ private fun AudioEmptyState() { .then(Modifier), ) Text( - text = "No audio tracks available", + text = stringResource(Res.string.compose_player_no_audio_tracks_available), color = colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index d19c5334..018c1d5e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -52,6 +52,8 @@ import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.appIconPainter import com.nuvio.app.core.ui.nuvioTypeScale +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable internal fun PlayerControlsShell( @@ -212,7 +214,12 @@ private fun PlayerHeader( ) if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { Text( - text = "S${seasonNumber}E${episodeNumber} • $episodeTitle", + text = stringResource( + Res.string.compose_player_episode_title_format, + seasonNumber, + episodeNumber, + episodeTitle, + ), style = typeScale.bodyMd.copy( fontSize = metrics.episodeInfoSize, lineHeight = metrics.episodeInfoSize * 1.3f, @@ -256,7 +263,11 @@ private fun PlayerHeader( ) { PlayerHeaderIconButton( icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, - contentDescription = if (isLocked) "Unlock player controls" else "Lock player controls", + contentDescription = if (isLocked) { + stringResource(Res.string.compose_player_unlock_controls) + } else { + stringResource(Res.string.compose_player_lock_controls) + }, buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, onClick = onLockToggle, @@ -267,7 +278,7 @@ private fun PlayerHeader( contentColor = Color.White, buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, - contentDescription = "Close player", + contentDescription = stringResource(Res.string.compose_player_close), ) } } @@ -315,7 +326,7 @@ private fun CenterControls( ) { SideControlButton( icon = Icons.Rounded.Replay10, - contentDescription = "Seek backward 10 seconds", + contentDescription = stringResource(Res.string.compose_player_seek_back_10), metrics = metrics, onClick = onSeekBack, ) @@ -327,7 +338,7 @@ private fun CenterControls( ) SideControlButton( icon = Icons.Rounded.Forward10, - contentDescription = "Seek forward 10 seconds", + contentDescription = stringResource(Res.string.compose_player_seek_forward_10), metrics = metrics, onClick = onSeekForward, ) @@ -384,7 +395,11 @@ private fun PlayPauseControlButton( } else { Icon( painter = playPausePainter, - contentDescription = if (isPlaying) "Pause" else "Play", + contentDescription = if (isPlaying) { + stringResource(Res.string.compose_action_pause) + } else { + stringResource(Res.string.detail_btn_play) + }, tint = Color.White, modifier = Modifier.size(metrics.playIconSize), ) @@ -454,7 +469,7 @@ private fun ProgressControls( verticalAlignment = Alignment.CenterVertically, ) { PlayerActionPillButton( - label = resizeMode.label, + label = stringResource(resizeMode.labelRes), painter = aspectRatioPainter, onClick = onResizeModeClick, ) @@ -464,25 +479,25 @@ private fun ProgressControls( onClick = onSpeedClick, ) PlayerActionPillButton( - label = "Subs", + label = stringResource(Res.string.compose_player_subs), painter = subtitlesPainter, onClick = onSubtitleClick, ) PlayerActionPillButton( - label = "Audio", + label = stringResource(Res.string.compose_player_audio), painter = audioPainter, onClick = onAudioClick, ) if (onSourcesClick != null) { PlayerActionPillButton( - label = "Sources", + label = stringResource(Res.string.compose_player_sources), icon = Icons.Rounded.SwapHoriz, onClick = onSourcesClick, ) } if (onEpisodesClick != null) { PlayerActionPillButton( - label = "Episodes", + label = stringResource(Res.string.compose_player_episodes), icon = Icons.Rounded.VideoLibrary, onClick = onEpisodesClick, ) @@ -545,14 +560,14 @@ internal fun LockedPlayerOverlay( ) { Icon( imageVector = Icons.Rounded.Lock, - contentDescription = "Unlock player controls", + contentDescription = stringResource(Res.string.compose_player_unlock_controls), tint = Color.White, modifier = Modifier.size(34.dp), ) } Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Tap to unlock", + text = stringResource(Res.string.compose_player_tap_to_unlock), style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), color = Color.White.copy(alpha = 0.92f), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 8661f690..fc675a39 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -60,6 +60,8 @@ import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource /** * Episode selection panel shown inside the player. @@ -232,12 +234,12 @@ private fun EpisodesListSubView( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Episodes", + text = stringResource(Res.string.compose_player_panel_episodes), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) - PanelChipButton(label = "Close", onClick = onDismiss) + PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) } // Season tabs @@ -251,7 +253,11 @@ private fun EpisodesListSubView( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(availableSeasons, key = { season -> season }) { season -> - val label = if (season == 0) "Specials" else "Season $season" + val label = if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + } AddonFilterChip( label = label, isSelected = selectedSeason == season, @@ -273,7 +279,7 @@ private fun EpisodesListSubView( contentAlignment = Alignment.Center, ) { Text( - text = "No episodes available", + text = stringResource(Res.string.compose_player_no_episodes_available), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, ) @@ -345,9 +351,15 @@ private fun EpisodeRow( ) { val episodeLabel = buildString { if (episode.season != null && episode.episode != null) { - append("S${episode.season}E${episode.episode}") + append( + stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ), + ) } else if (episode.episode != null) { - append("E${episode.episode}") + append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode)) } } if (episodeLabel.isNotBlank()) { @@ -366,7 +378,7 @@ private fun EpisodeRow( .padding(horizontal = 6.dp, vertical = 2.dp), ) { Text( - text = "Playing", + text = stringResource(Res.string.compose_player_playing), color = colorScheme.onPrimaryContainer, fontSize = 9.sp, fontWeight = FontWeight.SemiBold, @@ -421,12 +433,12 @@ private fun EpisodeStreamsSubView( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Streams", + text = stringResource(Res.string.compose_player_panel_streams), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) - PanelChipButton(label = "Close", onClick = onDismiss) + PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) } // Back + reload + episode info @@ -439,19 +451,25 @@ private fun EpisodeStreamsSubView( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { PanelChipButton( - label = "Back", + label = stringResource(Res.string.action_back), icon = Icons.AutoMirrored.Rounded.ArrowBack, onClick = onBack, ) PanelChipButton( - label = "Reload", + label = stringResource(Res.string.compose_action_reload), icon = Icons.Rounded.Refresh, onClick = onReload, ) Text( text = buildString { if (episode.season != null && episode.episode != null) { - append("S${episode.season} E${episode.episode}") + append( + stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ), + ) } if (episode.title.isNotBlank()) { if (isNotEmpty()) append(" • ") @@ -480,7 +498,7 @@ private fun EpisodeStreamsSubView( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AddonFilterChip( - label = "All", + label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) @@ -522,7 +540,7 @@ private fun EpisodeStreamsSubView( contentAlignment = Alignment.Center, ) { Text( - text = "No streams found", + text = stringResource(Res.string.compose_player_no_streams_found), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt index 47746d49..48088665 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt @@ -1,8 +1,97 @@ package com.nuvio.app.features.player +import androidx.compose.runtime.Composable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.lang_afrikaans +import nuvio.composeapp.generated.resources.lang_albanian +import nuvio.composeapp.generated.resources.lang_amharic +import nuvio.composeapp.generated.resources.lang_arabic +import nuvio.composeapp.generated.resources.lang_armenian +import nuvio.composeapp.generated.resources.lang_azerbaijani +import nuvio.composeapp.generated.resources.lang_basque +import nuvio.composeapp.generated.resources.lang_belarusian +import nuvio.composeapp.generated.resources.lang_bengali +import nuvio.composeapp.generated.resources.lang_bosnian +import nuvio.composeapp.generated.resources.lang_bulgarian +import nuvio.composeapp.generated.resources.lang_burmese +import nuvio.composeapp.generated.resources.lang_catalan +import nuvio.composeapp.generated.resources.lang_chinese +import nuvio.composeapp.generated.resources.lang_chinese_simplified +import nuvio.composeapp.generated.resources.lang_chinese_traditional +import nuvio.composeapp.generated.resources.lang_croatian +import nuvio.composeapp.generated.resources.lang_czech +import nuvio.composeapp.generated.resources.lang_danish +import nuvio.composeapp.generated.resources.lang_dutch +import nuvio.composeapp.generated.resources.lang_english +import nuvio.composeapp.generated.resources.lang_estonian +import nuvio.composeapp.generated.resources.lang_filipino +import nuvio.composeapp.generated.resources.lang_finnish +import nuvio.composeapp.generated.resources.lang_french +import nuvio.composeapp.generated.resources.lang_galician +import nuvio.composeapp.generated.resources.lang_georgian +import nuvio.composeapp.generated.resources.lang_german +import nuvio.composeapp.generated.resources.lang_greek +import nuvio.composeapp.generated.resources.lang_gujarati +import nuvio.composeapp.generated.resources.lang_hebrew +import nuvio.composeapp.generated.resources.lang_hindi +import nuvio.composeapp.generated.resources.lang_hungarian +import nuvio.composeapp.generated.resources.lang_icelandic +import nuvio.composeapp.generated.resources.lang_indonesian +import nuvio.composeapp.generated.resources.lang_irish +import nuvio.composeapp.generated.resources.lang_italian +import nuvio.composeapp.generated.resources.lang_japanese +import nuvio.composeapp.generated.resources.lang_kannada +import nuvio.composeapp.generated.resources.lang_kazakh +import nuvio.composeapp.generated.resources.lang_khmer +import nuvio.composeapp.generated.resources.lang_korean +import nuvio.composeapp.generated.resources.lang_lao +import nuvio.composeapp.generated.resources.lang_latvian +import nuvio.composeapp.generated.resources.lang_lithuanian +import nuvio.composeapp.generated.resources.lang_macedonian +import nuvio.composeapp.generated.resources.lang_malay +import nuvio.composeapp.generated.resources.lang_malayalam +import nuvio.composeapp.generated.resources.lang_maltese +import nuvio.composeapp.generated.resources.lang_marathi +import nuvio.composeapp.generated.resources.lang_mongolian +import nuvio.composeapp.generated.resources.lang_nepali +import nuvio.composeapp.generated.resources.lang_norwegian +import nuvio.composeapp.generated.resources.lang_persian +import nuvio.composeapp.generated.resources.lang_polish +import nuvio.composeapp.generated.resources.lang_portuguese_brazil +import nuvio.composeapp.generated.resources.lang_portuguese_portugal +import nuvio.composeapp.generated.resources.lang_punjabi +import nuvio.composeapp.generated.resources.lang_romanian +import nuvio.composeapp.generated.resources.lang_russian +import nuvio.composeapp.generated.resources.lang_serbian +import nuvio.composeapp.generated.resources.lang_sinhala +import nuvio.composeapp.generated.resources.lang_slovak +import nuvio.composeapp.generated.resources.lang_slovenian +import nuvio.composeapp.generated.resources.lang_spanish +import nuvio.composeapp.generated.resources.lang_spanish_latin_america +import nuvio.composeapp.generated.resources.lang_swahili +import nuvio.composeapp.generated.resources.lang_swedish +import nuvio.composeapp.generated.resources.lang_tamil +import nuvio.composeapp.generated.resources.lang_telugu +import nuvio.composeapp.generated.resources.lang_thai +import nuvio.composeapp.generated.resources.lang_turkish +import nuvio.composeapp.generated.resources.lang_ukrainian +import nuvio.composeapp.generated.resources.lang_urdu +import nuvio.composeapp.generated.resources.lang_uzbek +import nuvio.composeapp.generated.resources.lang_vietnamese +import nuvio.composeapp.generated.resources.lang_welsh +import nuvio.composeapp.generated.resources.lang_zulu +import nuvio.composeapp.generated.resources.settings_playback_option_default +import nuvio.composeapp.generated.resources.settings_playback_option_device_language +import nuvio.composeapp.generated.resources.settings_playback_option_forced +import nuvio.composeapp.generated.resources.settings_playback_option_none +import nuvio.composeapp.generated.resources.subtitle_language_unknown +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + data class LanguagePreferenceOption( val code: String, - val label: String, + val labelRes: StringResource, ) object AudioLanguageOption { @@ -17,84 +106,84 @@ object SubtitleLanguageOption { } val AvailableLanguageOptions: List = listOf( - LanguagePreferenceOption("af", "Afrikaans"), - LanguagePreferenceOption("sq", "Albanian"), - LanguagePreferenceOption("am", "Amharic"), - LanguagePreferenceOption("ar", "Arabic"), - LanguagePreferenceOption("hy", "Armenian"), - LanguagePreferenceOption("az", "Azerbaijani"), - LanguagePreferenceOption("eu", "Basque"), - LanguagePreferenceOption("be", "Belarusian"), - LanguagePreferenceOption("bn", "Bengali"), - LanguagePreferenceOption("bs", "Bosnian"), - LanguagePreferenceOption("bg", "Bulgarian"), - LanguagePreferenceOption("my", "Burmese"), - LanguagePreferenceOption("ca", "Catalan"), - LanguagePreferenceOption("zh", "Chinese"), - LanguagePreferenceOption("zh-CN", "Chinese (Simplified)"), - LanguagePreferenceOption("zh-TW", "Chinese (Traditional)"), - LanguagePreferenceOption("hr", "Croatian"), - LanguagePreferenceOption("cs", "Czech"), - LanguagePreferenceOption("da", "Danish"), - LanguagePreferenceOption("nl", "Dutch"), - LanguagePreferenceOption("en", "English"), - LanguagePreferenceOption("et", "Estonian"), - LanguagePreferenceOption("tl", "Filipino"), - LanguagePreferenceOption("fi", "Finnish"), - LanguagePreferenceOption("fr", "French"), - LanguagePreferenceOption("gl", "Galician"), - LanguagePreferenceOption("ka", "Georgian"), - LanguagePreferenceOption("de", "German"), - LanguagePreferenceOption("el", "Greek"), - LanguagePreferenceOption("gu", "Gujarati"), - LanguagePreferenceOption("he", "Hebrew"), - LanguagePreferenceOption("hi", "Hindi"), - LanguagePreferenceOption("hu", "Hungarian"), - LanguagePreferenceOption("is", "Icelandic"), - LanguagePreferenceOption("id", "Indonesian"), - LanguagePreferenceOption("ga", "Irish"), - LanguagePreferenceOption("it", "Italian"), - LanguagePreferenceOption("ja", "Japanese"), - LanguagePreferenceOption("kn", "Kannada"), - LanguagePreferenceOption("kk", "Kazakh"), - LanguagePreferenceOption("km", "Khmer"), - LanguagePreferenceOption("ko", "Korean"), - LanguagePreferenceOption("lo", "Lao"), - LanguagePreferenceOption("lv", "Latvian"), - LanguagePreferenceOption("lt", "Lithuanian"), - LanguagePreferenceOption("mk", "Macedonian"), - LanguagePreferenceOption("ms", "Malay"), - LanguagePreferenceOption("ml", "Malayalam"), - LanguagePreferenceOption("mt", "Maltese"), - LanguagePreferenceOption("mr", "Marathi"), - LanguagePreferenceOption("mn", "Mongolian"), - LanguagePreferenceOption("ne", "Nepali"), - LanguagePreferenceOption("no", "Norwegian"), - LanguagePreferenceOption("pa", "Punjabi"), - LanguagePreferenceOption("fa", "Persian"), - LanguagePreferenceOption("pl", "Polish"), - LanguagePreferenceOption("pt", "Portuguese (Portugal)"), - LanguagePreferenceOption("pt-BR", "Portuguese (Brazil)"), - LanguagePreferenceOption("ro", "Romanian"), - LanguagePreferenceOption("ru", "Russian"), - LanguagePreferenceOption("sr", "Serbian"), - LanguagePreferenceOption("si", "Sinhala"), - LanguagePreferenceOption("sk", "Slovak"), - LanguagePreferenceOption("sl", "Slovenian"), - LanguagePreferenceOption("es", "Spanish"), - LanguagePreferenceOption("es-419", "Spanish (Latin America)"), - LanguagePreferenceOption("sw", "Swahili"), - LanguagePreferenceOption("sv", "Swedish"), - LanguagePreferenceOption("ta", "Tamil"), - LanguagePreferenceOption("te", "Telugu"), - LanguagePreferenceOption("th", "Thai"), - LanguagePreferenceOption("tr", "Turkish"), - LanguagePreferenceOption("uk", "Ukrainian"), - LanguagePreferenceOption("ur", "Urdu"), - LanguagePreferenceOption("uz", "Uzbek"), - LanguagePreferenceOption("vi", "Vietnamese"), - LanguagePreferenceOption("cy", "Welsh"), - LanguagePreferenceOption("zu", "Zulu"), + LanguagePreferenceOption("af", Res.string.lang_afrikaans), + LanguagePreferenceOption("sq", Res.string.lang_albanian), + LanguagePreferenceOption("am", Res.string.lang_amharic), + LanguagePreferenceOption("ar", Res.string.lang_arabic), + LanguagePreferenceOption("hy", Res.string.lang_armenian), + LanguagePreferenceOption("az", Res.string.lang_azerbaijani), + LanguagePreferenceOption("eu", Res.string.lang_basque), + LanguagePreferenceOption("be", Res.string.lang_belarusian), + LanguagePreferenceOption("bn", Res.string.lang_bengali), + LanguagePreferenceOption("bs", Res.string.lang_bosnian), + LanguagePreferenceOption("bg", Res.string.lang_bulgarian), + LanguagePreferenceOption("my", Res.string.lang_burmese), + LanguagePreferenceOption("ca", Res.string.lang_catalan), + LanguagePreferenceOption("zh", Res.string.lang_chinese), + LanguagePreferenceOption("zh-CN", Res.string.lang_chinese_simplified), + LanguagePreferenceOption("zh-TW", Res.string.lang_chinese_traditional), + LanguagePreferenceOption("hr", Res.string.lang_croatian), + LanguagePreferenceOption("cs", Res.string.lang_czech), + LanguagePreferenceOption("da", Res.string.lang_danish), + LanguagePreferenceOption("nl", Res.string.lang_dutch), + LanguagePreferenceOption("en", Res.string.lang_english), + LanguagePreferenceOption("et", Res.string.lang_estonian), + LanguagePreferenceOption("tl", Res.string.lang_filipino), + LanguagePreferenceOption("fi", Res.string.lang_finnish), + LanguagePreferenceOption("fr", Res.string.lang_french), + LanguagePreferenceOption("gl", Res.string.lang_galician), + LanguagePreferenceOption("ka", Res.string.lang_georgian), + LanguagePreferenceOption("de", Res.string.lang_german), + LanguagePreferenceOption("el", Res.string.lang_greek), + LanguagePreferenceOption("gu", Res.string.lang_gujarati), + LanguagePreferenceOption("he", Res.string.lang_hebrew), + LanguagePreferenceOption("hi", Res.string.lang_hindi), + LanguagePreferenceOption("hu", Res.string.lang_hungarian), + LanguagePreferenceOption("is", Res.string.lang_icelandic), + LanguagePreferenceOption("id", Res.string.lang_indonesian), + LanguagePreferenceOption("ga", Res.string.lang_irish), + LanguagePreferenceOption("it", Res.string.lang_italian), + LanguagePreferenceOption("ja", Res.string.lang_japanese), + LanguagePreferenceOption("kn", Res.string.lang_kannada), + LanguagePreferenceOption("kk", Res.string.lang_kazakh), + LanguagePreferenceOption("km", Res.string.lang_khmer), + LanguagePreferenceOption("ko", Res.string.lang_korean), + LanguagePreferenceOption("lo", Res.string.lang_lao), + LanguagePreferenceOption("lv", Res.string.lang_latvian), + LanguagePreferenceOption("lt", Res.string.lang_lithuanian), + LanguagePreferenceOption("mk", Res.string.lang_macedonian), + LanguagePreferenceOption("ms", Res.string.lang_malay), + LanguagePreferenceOption("ml", Res.string.lang_malayalam), + LanguagePreferenceOption("mt", Res.string.lang_maltese), + LanguagePreferenceOption("mr", Res.string.lang_marathi), + LanguagePreferenceOption("mn", Res.string.lang_mongolian), + LanguagePreferenceOption("ne", Res.string.lang_nepali), + LanguagePreferenceOption("no", Res.string.lang_norwegian), + LanguagePreferenceOption("pa", Res.string.lang_punjabi), + LanguagePreferenceOption("fa", Res.string.lang_persian), + LanguagePreferenceOption("pl", Res.string.lang_polish), + LanguagePreferenceOption("pt", Res.string.lang_portuguese_portugal), + LanguagePreferenceOption("pt-BR", Res.string.lang_portuguese_brazil), + LanguagePreferenceOption("ro", Res.string.lang_romanian), + LanguagePreferenceOption("ru", Res.string.lang_russian), + LanguagePreferenceOption("sr", Res.string.lang_serbian), + LanguagePreferenceOption("si", Res.string.lang_sinhala), + LanguagePreferenceOption("sk", Res.string.lang_slovak), + LanguagePreferenceOption("sl", Res.string.lang_slovenian), + LanguagePreferenceOption("es", Res.string.lang_spanish), + LanguagePreferenceOption("es-419", Res.string.lang_spanish_latin_america), + LanguagePreferenceOption("sw", Res.string.lang_swahili), + LanguagePreferenceOption("sv", Res.string.lang_swedish), + LanguagePreferenceOption("ta", Res.string.lang_tamil), + LanguagePreferenceOption("te", Res.string.lang_telugu), + LanguagePreferenceOption("th", Res.string.lang_thai), + LanguagePreferenceOption("tr", Res.string.lang_turkish), + LanguagePreferenceOption("uk", Res.string.lang_ukrainian), + LanguagePreferenceOption("ur", Res.string.lang_urdu), + LanguagePreferenceOption("uz", Res.string.lang_uzbek), + LanguagePreferenceOption("vi", Res.string.lang_vietnamese), + LanguagePreferenceOption("cy", Res.string.lang_welsh), + LanguagePreferenceOption("zu", Res.string.lang_zulu), ) private val Iso639Aliases = mapOf( @@ -149,12 +238,40 @@ fun languageMatchesPreference(trackLanguage: String?, targetLanguage: String): B return trackPrimary == targetPrimary } -fun languageLabelForCode(code: String?): String { - if (code.isNullOrBlank()) return "None" - if (code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true)) return "Forced" +private fun languageLabelResForCode(code: String?): StringResource? { + val normalized = normalizeLanguageCode(code) ?: return null return AvailableLanguageOptions.firstOrNull { - it.code.equals(code, ignoreCase = true) - }?.label ?: formatLanguage(code) + normalizeLanguageCode(it.code) == normalized + }?.labelRes +} + +@Composable +fun languageLabelForCode(code: String?): String = when { + code.isNullOrBlank() || code.equals(SubtitleLanguageOption.NONE, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_none) + code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_forced) + code.equals(AudioLanguageOption.DEFAULT, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_default) + code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) || + code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_device_language) + else -> languageLabelResForCode(code)?.let { stringResource(it) } + ?: stringResource(Res.string.subtitle_language_unknown) +} + +suspend fun getLanguageLabelForCode(code: String?): String = when { + code.isNullOrBlank() || code.equals(SubtitleLanguageOption.NONE, ignoreCase = true) -> + getString(Res.string.settings_playback_option_none) + code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true) -> + getString(Res.string.settings_playback_option_forced) + code.equals(AudioLanguageOption.DEFAULT, ignoreCase = true) -> + getString(Res.string.settings_playback_option_default) + code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) || + code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) -> + getString(Res.string.settings_playback_option_device_language) + else -> languageLabelResForCode(code)?.let { getString(it) } + ?: getString(Res.string.subtitle_language_unknown) } fun resolvePreferredAudioLanguageTargets( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt index 88616160..86a0d318 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt @@ -9,6 +9,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_resize_fill +import nuvio.composeapp.generated.resources.compose_player_resize_fit +import nuvio.composeapp.generated.resources.compose_player_resize_zoom +import org.jetbrains.compose.resources.StringResource import kotlin.math.max internal data class PlayerLayoutMetrics( @@ -124,11 +129,11 @@ internal fun PlayerResizeMode.next(): PlayerResizeMode = PlayerResizeMode.Zoom -> PlayerResizeMode.Fit } -internal val PlayerResizeMode.label: String +internal val PlayerResizeMode.labelRes: StringResource get() = when (this) { - PlayerResizeMode.Fit -> "Fit" - PlayerResizeMode.Fill -> "Fill" - PlayerResizeMode.Zoom -> "Zoom" + PlayerResizeMode.Fit -> Res.string.compose_player_resize_fit + PlayerResizeMode.Fill -> Res.string.compose_player_resize_fill + PlayerResizeMode.Zoom -> Res.string.compose_player_resize_zoom } internal fun formatPlaybackTime(positionMs: Long): String { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index f32d2c63..a9644baa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -59,6 +59,14 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.nuvioTypeScale +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_close +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_go_back +import nuvio.composeapp.generated.resources.compose_player_playback_error +import nuvio.composeapp.generated.resources.compose_player_youre_watching +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import kotlin.math.max internal enum class GestureFeedbackIcon { @@ -71,10 +79,14 @@ internal enum class GestureFeedbackIcon { } internal data class GestureFeedbackState( - val message: String, + val message: String? = null, + val messageRes: StringResource? = null, + val messageArgs: List = emptyList(), val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed, val isDanger: Boolean = false, val secondaryMessage: String? = null, + val secondaryMessageRes: StringResource? = null, + val secondaryMessageArgs: List = emptyList(), val secondaryMessageColor: Color? = null, ) @@ -141,7 +153,7 @@ internal fun OpeningOverlay( contentColor = Color.White, buttonSize = 44.dp, iconSize = 24.dp, - contentDescription = "Close player", + contentDescription = stringResource(Res.string.compose_player_close), ) Column( @@ -218,6 +230,12 @@ internal fun GestureFeedbackPill( GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind } val iconTint = if (feedback.isDanger) Color(0xFFFFC1C1) else Color.White + val messageText = feedback.messageRes?.let { resource -> + stringResource(resource, *feedback.messageArgs.toTypedArray()) + } ?: feedback.message.orEmpty() + val secondaryMessageText = feedback.secondaryMessageRes?.let { resource -> + stringResource(resource, *feedback.secondaryMessageArgs.toTypedArray()) + } ?: feedback.secondaryMessage Row( modifier = modifier @@ -242,11 +260,11 @@ internal fun GestureFeedbackPill( ) } Text( - text = feedback.message, + text = messageText, style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold), color = Color.White, ) - feedback.secondaryMessage?.let { secondaryMessage -> + secondaryMessageText?.let { secondaryMessage -> Text( text = secondaryMessage, style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), @@ -290,7 +308,7 @@ internal fun PauseMetadataOverlay( verticalArrangement = Arrangement.Bottom, ) { Text( - text = "You're watching", + text = stringResource(Res.string.compose_player_youre_watching), style = MaterialTheme.nuvioTypeScale.bodyLg, color = Color(0xFFB8B8B8), ) @@ -318,7 +336,7 @@ internal fun PauseMetadataOverlay( } val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) { - "S${seasonNumber}E${episodeNumber}" + stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) } else { providerName } @@ -377,7 +395,7 @@ internal fun ErrorModal( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = "Playback error", + text = stringResource(Res.string.compose_player_playback_error), style = MaterialTheme.nuvioTypeScale.displaySm.copy(fontWeight = FontWeight.Bold), color = Color.White, textAlign = TextAlign.Center, @@ -399,7 +417,7 @@ internal fun ErrorModal( shape = RoundedCornerShape(12.dp), ) { Text( - text = "Go back", + text = stringResource(Res.string.compose_player_go_back), modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 196b81f6..6363109d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -62,6 +62,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import kotlin.math.abs import kotlin.math.roundToLong import kotlin.math.roundToInt @@ -153,6 +155,12 @@ fun PlayerScreen( val overlayBottomPadding = sliderOverlayBottomPadding(metrics) val scope = rememberCoroutineScope() val hapticFeedback = LocalHapticFeedback.current + val resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit) + val resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill) + val resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom) + val downloadedLabel = stringResource(Res.string.compose_player_downloaded) + val airsPrefix = stringResource(Res.string.compose_player_airs_prefix) + val tbaLabel = stringResource(Res.string.compose_player_tba) val gestureController = rememberPlayerGestureController() var controlsVisible by rememberSaveable { mutableStateOf(true) } var playerControlsLocked by rememberSaveable { mutableStateOf(false) } @@ -534,7 +542,12 @@ fun PlayerScreen( if (seconds <= 0L) return showGestureFeedback( GestureFeedbackState( - message = if (direction == PlayerSeekDirection.Forward) "+${seconds}s" else "-${seconds}s", + messageRes = if (direction == PlayerSeekDirection.Forward) { + Res.string.compose_player_seek_feedback_forward + } else { + Res.string.compose_player_seek_feedback_backward + }, + messageArgs = listOf(seconds), icon = if (direction == PlayerSeekDirection.Forward) { GestureFeedbackIcon.SeekForward } else { @@ -554,11 +567,12 @@ fun PlayerScreen( } else { GestureFeedbackIcon.SeekBackward }, - secondaryMessage = buildString { - if (deltaMs >= 0L) append("+") - append((abs(deltaMs) / 1000f).roundToInt()) - append("s") + secondaryMessageRes = if (deltaMs >= 0L) { + Res.string.compose_player_seek_delta_forward + } else { + Res.string.compose_player_seek_delta_backward }, + secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()), secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { Color(0xFF6EE7A8) } else { @@ -571,7 +585,8 @@ fun PlayerScreen( val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() showGestureFeedback( GestureFeedbackState( - message = "Brightness $percentage%", + messageRes = Res.string.compose_player_brightness_level, + messageArgs = listOf(percentage), icon = GestureFeedbackIcon.Brightness, ), ) @@ -581,7 +596,12 @@ fun PlayerScreen( val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() showGestureFeedback( GestureFeedbackState( - message = if (level.isMuted) "Muted" else "Volume $percentage%", + messageRes = if (level.isMuted) { + Res.string.compose_player_muted + } else { + Res.string.compose_player_volume_level + }, + messageArgs = if (level.isMuted) emptyList() else listOf(percentage), icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume, isDanger = level.isMuted, ), @@ -650,7 +670,13 @@ fun PlayerScreen( val nextMode = resizeMode.next() resizeMode = nextMode PlayerSettingsRepository.setResizeMode(nextMode) - showGestureMessage(nextMode.label) + showGestureMessage( + when (nextMode) { + PlayerResizeMode.Fit -> resizeModeFitLabel + PlayerResizeMode.Fill -> resizeModeFillLabel + PlayerResizeMode.Zoom -> resizeModeZoomLabel + }, + ) controlsVisible = true } @@ -872,7 +898,7 @@ fun PlayerScreen( episode.title.ifBlank { title } } activeStreamSubtitle = downloadItem.streamSubtitle - activeProviderName = downloadItem.providerName.ifBlank { "Downloaded" } + activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel } activeProviderAddonId = downloadItem.providerAddonId currentStreamBingeGroup = null activeSeasonNumber = episode.season @@ -1268,7 +1294,7 @@ fun PlayerScreen( released = nextVideo.released, hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { - "Airs ${nextVideo.released ?: "TBA"}" + "$airsPrefix ${nextVideo.released ?: tbaLabel}" } else null, ) } else null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index e13e2318..9d54dfd1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun PlayerSourcesPanel( @@ -108,19 +110,19 @@ fun PlayerSourcesPanel( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Sources", + text = stringResource(Res.string.compose_player_panel_sources), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { PanelChipButton( - label = "Reload", + label = stringResource(Res.string.compose_action_reload), icon = Icons.Rounded.Refresh, onClick = onReload, ) PanelChipButton( - label = "Close", + label = stringResource(Res.string.action_close), onClick = onDismiss, ) } @@ -140,7 +142,7 @@ fun PlayerSourcesPanel( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AddonFilterChip( - label = "All", + label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) @@ -182,7 +184,7 @@ fun PlayerSourcesPanel( contentAlignment = Alignment.Center, ) { Text( - text = "No streams found", + text = stringResource(Res.string.compose_player_no_streams_found), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, ) @@ -270,7 +272,7 @@ private fun SourceStreamRow( .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( - text = "Playing", + text = stringResource(Res.string.compose_player_playing), color = colorScheme.onPrimaryContainer, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, @@ -301,7 +303,7 @@ private fun SourceStreamRow( if (isCurrent) { Icon( imageVector = Icons.Rounded.Check, - contentDescription = "Currently playing", + contentDescription = stringResource(Res.string.compose_player_currently_playing), tint = colorScheme.primary, modifier = Modifier.size(20.dp), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index bf8dcafb..895e6fde 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -1,6 +1,10 @@ package com.nuvio.app.features.player +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_track_number +import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt data class AudioTrack( @@ -109,103 +113,9 @@ data class SubtitleAudioUiState( val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn, ) -fun getTrackDisplayName(label: String?, language: String?, index: Int): String { +@Composable +fun localizedTrackDisplayName(label: String?, language: String?, index: Int): String { if (!label.isNullOrBlank()) return label - if (!language.isNullOrBlank()) return formatLanguage(language) - return "Track ${index + 1}" + if (!language.isNullOrBlank()) return languageLabelForCode(language) + return stringResource(Res.string.compose_player_track_number, index + 1) } - -fun formatLanguage(code: String): String { - val lower = code.lowercase() - return LanguageNames[lower] ?: lower.replaceFirstChar { it.uppercase() } -} - -private val LanguageNames = mapOf( - "en" to "English", - "eng" to "English", - "es" to "Spanish", - "spa" to "Spanish", - "fr" to "French", - "fre" to "French", - "fra" to "French", - "de" to "German", - "ger" to "German", - "deu" to "German", - "it" to "Italian", - "ita" to "Italian", - "pt" to "Portuguese", - "por" to "Portuguese", - "ru" to "Russian", - "rus" to "Russian", - "ja" to "Japanese", - "jpn" to "Japanese", - "ko" to "Korean", - "kor" to "Korean", - "zh" to "Chinese", - "chi" to "Chinese", - "zho" to "Chinese", - "ar" to "Arabic", - "ara" to "Arabic", - "hi" to "Hindi", - "hin" to "Hindi", - "nl" to "Dutch", - "nld" to "Dutch", - "dut" to "Dutch", - "pl" to "Polish", - "pol" to "Polish", - "sv" to "Swedish", - "swe" to "Swedish", - "tr" to "Turkish", - "tur" to "Turkish", - "he" to "Hebrew", - "heb" to "Hebrew", - "th" to "Thai", - "tha" to "Thai", - "vi" to "Vietnamese", - "vie" to "Vietnamese", - "cs" to "Czech", - "ces" to "Czech", - "cze" to "Czech", - "ro" to "Romanian", - "ron" to "Romanian", - "rum" to "Romanian", - "hu" to "Hungarian", - "hun" to "Hungarian", - "el" to "Greek", - "ell" to "Greek", - "gre" to "Greek", - "da" to "Danish", - "dan" to "Danish", - "fi" to "Finnish", - "fin" to "Finnish", - "no" to "Norwegian", - "nor" to "Norwegian", - "uk" to "Ukrainian", - "ukr" to "Ukrainian", - "bg" to "Bulgarian", - "bul" to "Bulgarian", - "hr" to "Croatian", - "hrv" to "Croatian", - "sr" to "Serbian", - "srp" to "Serbian", - "sk" to "Slovak", - "slk" to "Slovak", - "slo" to "Slovak", - "sl" to "Slovenian", - "slv" to "Slovenian", - "id" to "Indonesian", - "ind" to "Indonesian", - "ms" to "Malay", - "msa" to "Malay", - "may" to "Malay", - "ta" to "Tamil", - "tam" to "Tamil", - "te" to "Telugu", - "tel" to "Telugu", - "ml" to "Malayalam", - "mal" to "Malayalam", - "bn" to "Bengali", - "ben" to "Bengali", - "ur" to "Urdu", - "urd" to "Urdu", -) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index 7aa3b9b2..e519a854 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -43,6 +43,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.addon_title +import nuvio.composeapp.generated.resources.compose_player_built_in +import nuvio.composeapp.generated.resources.compose_player_fetch_subtitles +import nuvio.composeapp.generated.resources.compose_player_none +import nuvio.composeapp.generated.resources.compose_player_style +import nuvio.composeapp.generated.resources.compose_player_subtitles +import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleModal( @@ -110,7 +118,7 @@ fun SubtitleModal( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Subtitles", + text = stringResource(Res.string.compose_player_subtitles), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -191,9 +199,9 @@ private fun SubtitleTabBar( ) { Text( text = when (tab) { - SubtitleTab.BuiltIn -> "Built-in" - SubtitleTab.Addons -> "Addons" - SubtitleTab.Style -> "Style" + SubtitleTab.BuiltIn -> stringResource(Res.string.compose_player_built_in) + SubtitleTab.Addons -> stringResource(Res.string.addon_title) + SubtitleTab.Style -> stringResource(Res.string.compose_player_style) }, color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant, fontSize = 13.sp, @@ -230,7 +238,7 @@ private fun BuiltInSubtitleList( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "None", + text = stringResource(Res.string.compose_player_none), color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, @@ -258,7 +266,7 @@ private fun BuiltInSubtitleList( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = getTrackDisplayName(track.label, track.language, track.index), + text = localizedTrackDisplayName(track.label, track.language, track.index), color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, fontSize = 15.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, @@ -324,7 +332,7 @@ private fun AddonSubtitleList( modifier = Modifier.size(32.dp), ) Text( - text = "Tap to fetch subtitles", + text = stringResource(Res.string.compose_player_fetch_subtitles), color = colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp), ) @@ -360,7 +368,7 @@ private fun AddonSubtitleList( fontWeight = FontWeight.SemiBold, ) Text( - text = formatLanguage(sub.language), + text = languageLabelForCode(sub.language), color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant, fontSize = 11.sp, modifier = Modifier.padding(bottom = 3.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index 82ed1dcb..a6991e74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -18,6 +18,9 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found +import org.jetbrains.compose.resources.getString object SubtitleRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -76,7 +79,7 @@ object SubtitleRepository { id = id, url = url, language = lang, - display = "${formatLanguage(lang)} (${addon.displayTitle})", + display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})", ) ) } @@ -86,7 +89,7 @@ object SubtitleRepository { _addonSubtitles.value = allSubs if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) { - _error.value = "No subtitles found" + _error.value = getString(Res.string.compose_player_no_subtitles_found) } _isLoading.value = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt index 31781ab1..0f5fc243 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleStylePanel( @@ -73,7 +75,7 @@ private fun StyleControlsCard( ) { SectionHeader( icon = Icons.Rounded.Tune, - label = "Style", + label = stringResource(Res.string.compose_player_style), ) Row( @@ -82,13 +84,13 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Font Size", + text = stringResource(Res.string.compose_player_font_size), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, ) StepperControl( - value = "${style.fontSizeSp}sp", + value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), onMinus = { onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) }, @@ -109,7 +111,7 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Outline", + text = stringResource(Res.string.compose_player_outline), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -126,7 +128,8 @@ private fun StyleControlsCard( .padding(horizontal = 10.dp, vertical = 8.dp), ) { Text( - text = if (style.outlineEnabled) "On" else "Off", + text = if (style.outlineEnabled) stringResource(Res.string.compose_action_on) + else stringResource(Res.string.compose_action_off), color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 13.sp, @@ -140,7 +143,7 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Bottom Offset", + text = stringResource(Res.string.compose_player_bottom_offset), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -163,7 +166,7 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Color", + text = stringResource(Res.string.compose_player_color), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -203,7 +206,7 @@ private fun StyleControlsCard( .padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp), ) { Text( - text = "Reset Defaults", + text = stringResource(Res.string.compose_player_reset_defaults), color = colorScheme.onSurface, fontWeight = FontWeight.SemiBold, fontSize = if (isCompact) 12.sp else 14.sp, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index 5cc44184..2de18276 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt @@ -38,6 +38,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_episode_title_format +import nuvio.composeapp.generated.resources.detail_btn_play +import nuvio.composeapp.generated.resources.player_next_episode +import nuvio.composeapp.generated.resources.player_next_episode_finding_source +import nuvio.composeapp.generated.resources.player_next_episode_playing_via_countdown +import nuvio.composeapp.generated.resources.player_next_episode_thumbnail +import nuvio.composeapp.generated.resources.player_next_episode_unaired +import org.jetbrains.compose.resources.stringResource @Composable fun NextEpisodeCard( @@ -81,7 +90,7 @@ fun NextEpisodeCard( ) { AsyncImage( model = nextEpisode.thumbnail, - contentDescription = "Next episode thumbnail", + contentDescription = stringResource(Res.string.player_next_episode_thumbnail), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) @@ -107,14 +116,19 @@ fun NextEpisodeCard( verticalArrangement = Arrangement.Center, ) { Text( - text = "Next Episode", + text = stringResource(Res.string.player_next_episode), color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp, fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "S${nextEpisode.season}E${nextEpisode.episode} • ${nextEpisode.title}", + text = stringResource( + Res.string.compose_player_episode_title_format, + nextEpisode.season, + nextEpisode.episode, + nextEpisode.title, + ), color = Color.White, fontSize = 12.sp, maxLines = 1, @@ -123,9 +137,13 @@ fun NextEpisodeCard( ) val autoPlayStatus = when { !isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage - isAutoPlaySearching -> "Finding source…" + isAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source) !autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null -> - "Playing via $autoPlaySourceName in $autoPlayCountdownSec…" + stringResource( + Res.string.player_next_episode_playing_via_countdown, + autoPlaySourceName, + autoPlayCountdownSec, + ) else -> null } if (autoPlayStatus != null) { @@ -156,7 +174,11 @@ fun NextEpisodeCard( modifier = Modifier.size(13.dp), ) Text( - text = if (isPlayable) "Play" else "Unaired", + text = if (isPlayable) { + stringResource(Res.string.detail_btn_play) + } else { + stringResource(Res.string.player_next_episode_unaired) + }, color = if (isPlayable) Color.White else Color.White.copy(alpha = 0.72f), fontSize = 11.sp, modifier = Modifier.padding(start = 3.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt index 94a187ad..755a67f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt @@ -37,6 +37,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_skip +import nuvio.composeapp.generated.resources.player_skip_intro +import nuvio.composeapp.generated.resources.player_skip_outro +import nuvio.composeapp.generated.resources.player_skip_recap +import org.jetbrains.compose.resources.stringResource @Composable fun SkipIntroButton( @@ -112,7 +118,7 @@ fun SkipIntroButton( modifier = Modifier.size(20.dp), ) Text( - text = getSkipLabel(lastType), + text = skipLabel(lastType), color = Color.White, fontSize = 14.sp, modifier = Modifier.padding(start = 8.dp), @@ -140,11 +146,11 @@ fun SkipIntroButton( } } -private fun getSkipLabel(type: String?): String { - return when (type?.lowercase()) { - "intro", "op", "mixed-op" -> "Skip Intro" - "outro", "ed", "mixed-ed", "credits" -> "Skip Outro" - "recap" -> "Skip Recap" - else -> "Skip" +@Composable +private fun skipLabel(type: String?): String = + when (type?.lowercase()) { + "intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro) + "outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro) + "recap" -> stringResource(Res.string.player_skip_recap) + else -> stringResource(Res.string.player_skip) } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt index 26707681..ce6ef590 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt @@ -48,6 +48,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -75,7 +78,7 @@ fun PinEntryDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Enter PIN", + text = stringResource(Res.string.pin_enter), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, @@ -128,9 +131,12 @@ fun PinEntryDialog( } else { haptic.performHapticFeedback(HapticFeedbackType.LongPress) error = result.message ?: if (result.retryAfterSeconds > 0) { - "Locked. Try again in ${result.retryAfterSeconds}s" + getString( + Res.string.pin_locked_try_again, + result.retryAfterSeconds, + ) } else { - "Incorrect PIN" + getString(Res.string.pin_incorrect) } pin = "" } @@ -151,7 +157,7 @@ fun PinEntryDialog( if (onForgotPin != null) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Forgot PIN?", + text = stringResource(Res.string.pin_forgot), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 4a0e7142..83d26dc6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalLayoutApi::class) @Composable @@ -104,7 +106,11 @@ fun ProfileEditScreen( NuvioScreen(modifier = modifier) { stickyHeader { NuvioScreenHeader( - title = if (isNew) "Add Profile" else "Edit Profile", + title = if (isNew) { + stringResource(Res.string.profile_edit_add_title) + } else { + stringResource(Res.string.profile_edit_edit_title) + }, onBack = onBack, ) } @@ -127,13 +133,17 @@ fun ProfileEditScreen( NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = "Choose an avatar", + text = stringResource(Res.string.profile_choose_avatar), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = selectedAvatarItem?.displayName - ?: if (avatars.isEmpty()) "Loading avatars..." else "Select an avatar for this profile.", + ?: if (avatars.isEmpty()) { + stringResource(Res.string.profile_loading_avatars) + } else { + stringResource(Res.string.profile_select_avatar) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -171,27 +181,27 @@ fun ProfileEditScreen( NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = "Security", + text = stringResource(Res.string.profile_security), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = if (currentProfile?.pinEnabled == true) { - "This profile is protected with a PIN." + stringResource(Res.string.profile_security_pin_enabled) } else { - "Add a PIN if you want this profile locked before switching into it." + stringResource(Res.string.profile_security_pin_disabled) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (currentProfile?.pinEnabled == true) { NuvioPrimaryButton( - text = "Remove PIN Lock", + text = stringResource(Res.string.profile_remove_pin_lock), onClick = { showPinClear = true }, ) } else { NuvioPrimaryButton( - text = "Set PIN Lock", + text = stringResource(Res.string.profile_set_pin_lock), onClick = { showPinSetup = true }, ) } @@ -203,7 +213,13 @@ fun ProfileEditScreen( item { Spacer(modifier = Modifier.height(8.dp)) NuvioPrimaryButton( - text = if (isSaving) "Saving..." else if (isNew) "Create Profile" else "Save Changes", + text = if (isSaving) { + stringResource(Res.string.profile_saving) + } else if (isNew) { + stringResource(Res.string.profile_create_profile) + } else { + stringResource(Res.string.collections_editor_save_changes) + }, enabled = name.isNotBlank() && !isSaving, onClick = { isSaving = true @@ -247,7 +263,7 @@ fun ProfileEditScreen( ), ) { Text( - text = "Delete Profile", + text = stringResource(Res.string.profile_delete_title), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) @@ -257,11 +273,14 @@ fun ProfileEditScreen( } NuvioStatusModal( - title = "Delete Profile?", - message = "All data for \"${currentProfile?.name}\" will be permanently deleted.", + title = stringResource(Res.string.profile_delete_title), + message = stringResource( + Res.string.profile_delete_confirm_message, + currentProfile?.name.orEmpty(), + ), isVisible = showDeleteConfirm, - confirmText = "Delete", - dismissText = "Cancel", + confirmText = stringResource(Res.string.action_delete), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { showDeleteConfirm = false scope.launch { @@ -290,7 +309,7 @@ fun ProfileEditScreen( if (showPinClear && currentProfile != null) { PinEntryDialog( - profileName = "Remove PIN for ${currentProfile.name}", + profileName = stringResource(Res.string.profile_remove_pin_for, currentProfile.name), onVerify = { pin -> ProfileRepository.clearPin(currentProfile.profileIndex, pin) }, onVerified = { showPinClear = false @@ -364,24 +383,39 @@ private fun ProfileIdentityCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = name.ifBlank { if (isNew) "New profile" else "Unnamed profile" }, + text = name.ifBlank { + if (isNew) stringResource(Res.string.profile_new) + else stringResource(Res.string.profile_unnamed) + }, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( text = listOf( - if (isNew) "New profile" else (profileIndex?.let { "Profile $it" } ?: "Profile"), - if (usesPrimaryAddons) "Primary addons on" else "Primary addons off", + if (isNew) { + stringResource(Res.string.profile_new) + } else { + profileIndex?.let { stringResource(Res.string.profile_label_number, it) } + ?: stringResource(Res.string.profile_unnamed) + }, + if (usesPrimaryAddons) { + stringResource(Res.string.profile_primary_addons_on) + } else { + stringResource(Res.string.profile_primary_addons_off) + }, ).joinToString(" | "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = when { - selectedAvatar != null -> "Avatar: ${selectedAvatar.displayName}" - hasAvatarChoices -> "Choose an avatar below." - else -> "Avatar options will appear here when the catalog loads." + selectedAvatar != null -> stringResource( + Res.string.profile_avatar_selected, + selectedAvatar.displayName, + ) + hasAvatarChoices -> stringResource(Res.string.profile_choose_avatar_below) + else -> stringResource(Res.string.profile_avatar_options_pending) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -392,12 +426,12 @@ private fun ProfileIdentityCard( NuvioInputField( value = name, onValueChange = onNameChange, - placeholder = "Profile name", + placeholder = stringResource(Res.string.profile_name_placeholder), ) ProfileOptionRow( - title = "Use Primary Addons", - description = "Share the main profile's addon setup instead of managing a separate list.", + title = stringResource(Res.string.profile_use_primary_addons), + description = stringResource(Res.string.profile_use_primary_addons_description), checked = usesPrimaryAddons, onCheckedChange = onUsesPrimaryAddonsChange, ) @@ -510,7 +544,7 @@ fun PinSetupDialog( when (step) { "current" -> PinEntryDialog( - profileName = "Enter current PIN", + profileName = stringResource(Res.string.profile_enter_current_pin), onVerify = { pin -> ProfileRepository.verifyPin(profileIndex, pin) }, onVerified = { pin -> currentPin = pin @@ -520,7 +554,7 @@ fun PinSetupDialog( ) "new" -> PinEntryDialog( - profileName = "Enter new PIN", + profileName = stringResource(Res.string.profile_enter_new_pin), onVerify = { pin -> ProfileRepository.setPin( profileIndex = profileIndex, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 65e48b5c..b6637490 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -40,6 +41,9 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString @Serializable private data class StoredProfilePayload( @@ -52,6 +56,7 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) val state: StateFlow = _state.asStateFlow() @@ -274,7 +279,7 @@ object ProfileRepository { suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult { if (AuthRepository.state.value !is AuthState.Authenticated) { - return PinVerifyResult(unlocked = false, message = "Connect to the internet to set a PIN.") + return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_requires_internet)) } return runCatching { @@ -290,13 +295,13 @@ object ProfileRepository { }.onFailure { e -> log.e(e) { "Failed to set pin" } }.getOrElse { - PinVerifyResult(unlocked = false, message = "Couldn't set PIN. Try again.") + PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_failed)) } } suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult { if (AuthRepository.state.value !is AuthState.Authenticated) { - return PinVerifyResult(unlocked = false, message = "Connect to the internet to remove the PIN lock.") + return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_requires_internet)) } return runCatching { @@ -311,7 +316,7 @@ object ProfileRepository { }.onFailure { e -> log.e(e) { "Failed to clear pin" } }.getOrElse { - PinVerifyResult(unlocked = false, message = "Couldn't remove PIN lock. Try again.") + PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_failed)) } } @@ -407,7 +412,7 @@ object ProfileRepository { if (payload.isEmpty()) { return PinVerifyResult( unlocked = false, - message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.", + message = localizedString(Res.string.profile_pin_offline_verification_requires_online), ) } @@ -415,7 +420,7 @@ object ProfileRepository { json.decodeFromString(payload) }.getOrNull() ?: return PinVerifyResult( unlocked = false, - message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.", + message = localizedString(Res.string.profile_pin_offline_verification_requires_online), ) if ( @@ -426,7 +431,7 @@ object ProfileRepository { ProfilePinCacheStorage.removePayload(profileIndex) return PinVerifyResult( unlocked = false, - message = "This profile PIN changed. Connect once to refresh the lock on this device.", + message = localizedString(Res.string.profile_pin_changed_requires_refresh), ) } @@ -434,7 +439,7 @@ object ProfileRepository { return if (digest == cached.digest) { PinVerifyResult(unlocked = true) } else { - PinVerifyResult(unlocked = false, message = "Incorrect PIN") + PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect)) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 4b4f165c..3d487c02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -61,6 +61,8 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun ProfileSelectionScreen( @@ -132,7 +134,7 @@ fun ProfileSelectionScreen( Spacer(modifier = Modifier.height(if (isTabletLayout) 0.dp else 60.dp)) Text( - text = "Who's watching?", + text = stringResource(Res.string.profile_who_is_watching), style = MaterialTheme.typography.headlineLarge.copy( fontSize = 30.sp, letterSpacing = (-0.5).sp, @@ -258,7 +260,11 @@ fun ProfileSelectionScreen( .padding(horizontal = 24.dp, vertical = 10.dp), ) { Text( - text = if (isEditMode) "Done" else "Manage Profiles", + text = if (isEditMode) { + stringResource(Res.string.action_done) + } else { + stringResource(Res.string.profile_manage_profiles) + }, style = MaterialTheme.typography.bodyLarge, color = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, @@ -429,7 +435,9 @@ private fun ProfileAvatarCard( Spacer(modifier = Modifier.height(12.dp)) Text( - text = profile.name.ifBlank { "Profile ${profile.profileIndex}" }, + text = profile.name.ifBlank { + stringResource(Res.string.profile_label_number, profile.profileIndex) + }, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -506,7 +514,7 @@ private fun AddProfileCard( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Add Profile", + text = stringResource(Res.string.compose_profile_add_profile), style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 198956be..d6a77b3f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -66,6 +66,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource @Composable fun ProfileSwitcherTab( @@ -305,7 +308,7 @@ private fun PopupAddProfileBubble( ) { Icon( imageVector = Icons.Rounded.Add, - contentDescription = "Add Profile", + contentDescription = stringResource(Res.string.compose_profile_add_profile), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp), ) @@ -314,7 +317,7 @@ private fun PopupAddProfileBubble( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Add", + text = stringResource(Res.string.compose_profile_add_profile), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, @@ -466,7 +469,9 @@ private fun PopupProfileBubble( Spacer(modifier = Modifier.height(4.dp)) Text( - text = profile.name.ifBlank { "Profile ${profile.profileIndex}" }, + text = profile.name.ifBlank { + stringResource(Res.string.profile_label_number, profile.profileIndex) + }, style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, @@ -501,7 +506,7 @@ private fun InlinePinEntry( modifier = Modifier.padding(top = 16.dp), ) { Text( - text = "Enter PIN for $profileName", + text = stringResource(Res.string.pin_enter_for, profileName), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -579,9 +584,9 @@ private fun InlinePinEntry( onVerified() } else { error = if (result.retryAfterSeconds > 0) { - "Locked. Try again in ${result.retryAfterSeconds}s" + getString(Res.string.pin_locked_try_again, result.retryAfterSeconds) } else { - "Wrong PIN" + getString(Res.string.pin_incorrect) } pin = "" } @@ -601,7 +606,7 @@ private fun InlinePinEntry( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Cancel", + text = stringResource(Res.string.pin_cancel), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold, @@ -645,7 +650,7 @@ private fun CompactPinKeypad( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Backspace, - contentDescription = "Backspace", + contentDescription = stringResource(Res.string.pin_backspace), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp), ) @@ -685,7 +690,7 @@ fun ActiveProfileMiniAvatar( if (profile == null) { Icon( imageVector = Icons.Rounded.Person, - contentDescription = "Profile", + contentDescription = stringResource(Res.string.compose_nav_profile), modifier = Modifier.size(size.dp), ) return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 25d57c04..5648e096 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -63,6 +63,8 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.discoverContent( state: DiscoverUiState, @@ -91,7 +93,11 @@ internal fun LazyListScope.discoverContent( state.selectedCatalog?.let { selectedCatalog -> item { Text( - text = "${selectedCatalog.addonName} • ${selectedCatalog.type.displayTypeLabel()}", + text = stringResource( + Res.string.discover_catalog_context, + selectedCatalog.addonName, + selectedCatalog.type.displayTypeLabel(), + ), modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.bodyMedium.copy( fontSize = 14.sp, @@ -149,7 +155,7 @@ internal fun LazyListScope.discoverContent( @Composable private fun DiscoverSectionHeader(modifier: Modifier = Modifier) { Text( - text = "Discover", + text = stringResource(Res.string.compose_search_discover_title), modifier = modifier, style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onBackground, @@ -169,16 +175,16 @@ private fun DiscoverFilterRow( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { DiscoverDropdownChip( - title = "Select Type", - label = state.selectedType?.displayTypeLabel() ?: "Type", + title = stringResource(Res.string.discover_select_type), + label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type), selectedKey = state.selectedType, options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) }, enabled = state.typeOptions.isNotEmpty(), onSelected = { onTypeSelected(it.key) }, ) DiscoverDropdownChip( - title = "Select Catalog", - label = state.selectedCatalog?.catalogName ?: "Catalog", + title = stringResource(Res.string.discover_select_catalog), + label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog), selectedKey = state.selectedCatalogKey, options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) }, enabled = state.catalogOptions.isNotEmpty(), @@ -188,13 +194,13 @@ private fun DiscoverFilterRow( val selectedCatalog = state.selectedCatalog val genreOptions = buildList { if (selectedCatalog?.genreRequired != true) { - add(DiscoverOptionItem(key = "", label = "All Genres")) + add(DiscoverOptionItem(key = "", label = stringResource(Res.string.discover_all_genres))) } addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) }) } DiscoverDropdownChip( - title = "Select Genre", - label = state.selectedGenre ?: "All Genres", + title = stringResource(Res.string.discover_select_genre), + label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres), selectedKey = state.selectedGenre ?: "", options = genreOptions, enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true, @@ -490,23 +496,23 @@ private fun DiscoverEmptyStateCard( when (reason) { DiscoverEmptyStateReason.NoActiveAddons -> { - title = "No active addons" - message = "Install and validate at least one addon before browsing discover catalogs." + title = stringResource(Res.string.compose_search_empty_no_active_addons_title) + message = stringResource(Res.string.discover_empty_no_active_addons_message) } DiscoverEmptyStateReason.NoDiscoverCatalogs -> { - title = "No discover catalogs" - message = "Installed addons do not expose board-compatible catalogs for discover." + title = stringResource(Res.string.discover_empty_no_catalogs_title) + message = stringResource(Res.string.discover_empty_no_catalogs_message) } DiscoverEmptyStateReason.RequestFailed -> { - title = "Could not load discover" - message = errorMessage ?: "The selected catalog failed to return discover items." + title = stringResource(Res.string.discover_empty_load_failed_title) + message = errorMessage ?: stringResource(Res.string.discover_empty_load_failed_message) } DiscoverEmptyStateReason.NoResults, null -> { - title = "No titles found" - message = "The selected catalog and filters did not return any items." + title = stringResource(Res.string.discover_empty_no_results_title) + message = stringResource(Res.string.discover_empty_no_results_message) } } @@ -522,13 +528,14 @@ private data class DiscoverOptionItem( val label: String, ) +@Composable private fun String.displayTypeLabel(): String = when (lowercase()) { - "movie" -> "Movies" - "series" -> "Series" - "anime" -> "Anime" - "channel" -> "Channels" - "tv" -> "TV" + "movie" -> stringResource(Res.string.media_movies) + "series" -> stringResource(Res.string.media_series) + "anime" -> stringResource(Res.string.media_anime) + "channel" -> stringResource(Res.string.media_channels) + "tv" -> stringResource(Res.string.media_tv) else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index 30d3c5f7..6579e0db 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.search import co.touchlab.kermit.Logger +import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon @@ -21,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object SearchRepository { private val log = Logger.withTag("SearchRepository") @@ -313,7 +316,7 @@ object SearchRepository { return HomeCatalogSection( key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}", - title = "$catalogName - ${type.displayLabel()}", + title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()), subtitle = addon.displayTitle, addonName = addon.displayTitle, type = type, @@ -410,7 +413,7 @@ object SearchRepository { isLoading = false, nextSkip = null, emptyStateReason = DiscoverEmptyStateReason.RequestFailed, - errorMessage = error.message ?: "Unable to load discover items.", + errorMessage = error.message ?: getString(Res.string.discover_empty_load_failed_message), ) }, ) @@ -486,9 +489,7 @@ private fun List.previewNames(limit: Int = 5): String { } private fun String.displayLabel(): String = - replaceFirstChar { char -> - if (char.isLowerCase()) char.titlecase() else char.toString() - } + localizedMediaTypeLabel(this) private fun String.typeSortKey(): String = when (lowercase()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 90a834b6..c127cf3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -55,6 +55,22 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_nav_search +import nuvio.composeapp.generated.resources.compose_search_clear +import nuvio.composeapp.generated.resources.compose_search_discover_title +import nuvio.composeapp.generated.resources.compose_search_empty_failed_message +import nuvio.composeapp.generated.resources.compose_search_empty_failed_title +import nuvio.composeapp.generated.resources.compose_search_empty_no_active_addons_message +import nuvio.composeapp.generated.resources.compose_search_empty_no_active_addons_title +import nuvio.composeapp.generated.resources.compose_search_empty_no_results_message +import nuvio.composeapp.generated.resources.compose_search_empty_no_results_title +import nuvio.composeapp.generated.resources.compose_search_empty_no_search_catalogs_message +import nuvio.composeapp.generated.resources.compose_search_empty_no_search_catalogs_title +import nuvio.composeapp.generated.resources.compose_search_placeholder +import nuvio.composeapp.generated.resources.compose_search_recent_searches +import nuvio.composeapp.generated.resources.compose_search_remove_recent_search +import org.jetbrains.compose.resources.stringResource @Composable fun SearchScreen( @@ -78,14 +94,9 @@ fun SearchScreen( var lastRequestedQuery by rememberSaveable { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val listState = rememberLazyListState() - val headerTitle by remember(query, listState) { + val discoverInFocus by remember(query, listState) { derivedStateOf { - if (query.isNotBlank()) { - "Search" - } else { - val discoverInFocus = listState.firstVisibleItemIndex > 0 - if (discoverInFocus) "Discover" else "Search" - } + query.isBlank() && listState.firstVisibleItemIndex > 0 } } @@ -191,6 +202,11 @@ fun SearchScreen( val homeSectionPadding = remember(maxWidth) { homeSectionHorizontalPaddingForWidth(maxWidth.value) } + val headerTitle = when { + query.isNotBlank() -> stringResource(Res.string.compose_nav_search) + discoverInFocus -> stringResource(Res.string.compose_search_discover_title) + else -> stringResource(Res.string.compose_nav_search) + } NuvioScreen( horizontalPadding = 0.dp, @@ -212,13 +228,13 @@ fun SearchScreen( NuvioInputField( value = query, onValueChange = { query = it }, - placeholder = "Search movies, shows...", + placeholder = stringResource(Res.string.compose_search_placeholder), trailingContent = if (query.isNotBlank()) { { IconButton(onClick = { query = "" }) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Clear search", + contentDescription = stringResource(Res.string.compose_search_clear), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -336,23 +352,23 @@ private fun SearchEmptyStateCard( when (reason) { SearchEmptyStateReason.NoActiveAddons -> { - title = "No active addons" - message = "Install and validate at least one addon before searching." + title = stringResource(Res.string.compose_search_empty_no_active_addons_title) + message = stringResource(Res.string.compose_search_empty_no_active_addons_message) } SearchEmptyStateReason.NoSearchCatalogs -> { - title = "No searchable catalogs" - message = "Your installed addons do not expose catalog search." + title = stringResource(Res.string.compose_search_empty_no_search_catalogs_title) + message = stringResource(Res.string.compose_search_empty_no_search_catalogs_message) } SearchEmptyStateReason.RequestFailed -> { - title = "Search failed" - message = errorMessage ?: "Installed addons failed to return valid search results." + title = stringResource(Res.string.compose_search_empty_failed_title) + message = errorMessage ?: stringResource(Res.string.compose_search_empty_failed_message) } SearchEmptyStateReason.NoResults, null -> { - title = "No results found" - message = "Installed searchable catalogs did not return any matches for this query." + title = stringResource(Res.string.compose_search_empty_no_results_title) + message = stringResource(Res.string.compose_search_empty_no_results_message) } } @@ -377,7 +393,7 @@ private fun SearchRecentSection( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "Recent Searches", + text = stringResource(Res.string.compose_search_recent_searches), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, ) @@ -439,7 +455,7 @@ private fun SearchRecentRow( IconButton(onClick = onRemovePress) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Remove recent search", + contentDescription = stringResource(Res.string.compose_search_remove_recent_search), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index acb2ff97..4e17b58a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -30,6 +30,23 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_delete +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.settings_account_delete_account +import nuvio.composeapp.generated.resources.settings_account_delete_account_description +import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message +import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title +import nuvio.composeapp.generated.resources.settings_account_email +import nuvio.composeapp.generated.resources.settings_account_not_signed_in +import nuvio.composeapp.generated.resources.settings_account_sign_out +import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_message +import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_title +import nuvio.composeapp.generated.resources.settings_account_status +import nuvio.composeapp.generated.resources.settings_account_status_anonymous +import nuvio.composeapp.generated.resources.settings_account_status_signed_in +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.accountSettingsContent( isTablet: Boolean, @@ -51,7 +68,7 @@ private fun AccountSettingsBody( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { NuvioSurfaceCard { Text( - text = "Account", + text = stringResource(Res.string.compose_settings_page_account), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -65,12 +82,16 @@ private fun AccountSettingsBody( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Status", + text = stringResource(Res.string.settings_account_status), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = if (state.isAnonymous) "Anonymous" else "Signed In", + text = if (state.isAnonymous) { + stringResource(Res.string.settings_account_status_anonymous) + } else { + stringResource(Res.string.settings_account_status_signed_in) + }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, @@ -83,7 +104,7 @@ private fun AccountSettingsBody( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Email", + text = stringResource(Res.string.settings_account_email), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -98,7 +119,7 @@ private fun AccountSettingsBody( } else -> { Text( - text = "Not signed in", + text = stringResource(Res.string.settings_account_not_signed_in), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -107,7 +128,7 @@ private fun AccountSettingsBody( } NuvioPrimaryButton( - text = "Sign Out", + text = stringResource(Res.string.settings_account_sign_out), onClick = { showSignOutConfirm = true }, ) @@ -126,13 +147,13 @@ private fun AccountSettingsBody( ), ) { Text( - text = "Delete Account", + text = stringResource(Res.string.settings_account_delete_account), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) } Text( - text = "This will permanently delete your account and all associated data.", + text = stringResource(Res.string.settings_account_delete_account_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), @@ -142,11 +163,11 @@ private fun AccountSettingsBody( } NuvioStatusModal( - title = "Sign Out?", - message = "You will be returned to the login screen.", + title = stringResource(Res.string.settings_account_sign_out_confirm_title), + message = stringResource(Res.string.settings_account_sign_out_confirm_message), isVisible = showSignOutConfirm, - confirmText = "Sign Out", - dismissText = "Cancel", + confirmText = stringResource(Res.string.settings_account_sign_out), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { showSignOutConfirm = false scope.launch { AuthRepository.signOut() } @@ -155,11 +176,11 @@ private fun AccountSettingsBody( ) NuvioStatusModal( - title = "Delete Account?", - message = "This action cannot be undone. All your data, profiles, and sync history will be permanently removed.", + title = stringResource(Res.string.settings_account_delete_confirm_title), + message = stringResource(Res.string.settings_account_delete_confirm_message), isVisible = showDeleteConfirm, - confirmText = "Delete", - dismissText = "Cancel", + confirmText = stringResource(Res.string.action_delete), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { showDeleteConfirm = false scope.launch { AuthRepository.deleteAccount() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt new file mode 100644 index 00000000..10bd8606 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.lang_english +import nuvio.composeapp.generated.resources.lang_spanish +import org.jetbrains.compose.resources.StringResource + +enum class AppLanguage( + val code: String, + val labelRes: StringResource, +) { + ENGLISH("en", Res.string.lang_english), + SPANISH("es", Res.string.lang_spanish), + ; + + companion object { + fun fromCode(code: String?): AppLanguage = + entries.firstOrNull { it.code.equals(code, ignoreCase = true) } ?: ENGLISH + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index 96239860..f697b48d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -18,12 +18,18 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Style import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,7 +38,30 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.NuvioBottomSheetActionRow +import com.nuvio.app.core.ui.NuvioBottomSheetDivider +import com.nuvio.app.core.ui.NuvioModalBottomSheet +import com.nuvio.app.core.ui.dismissNuvioBottomSheet +import com.nuvio.app.core.ui.labelRes import com.nuvio.app.core.ui.ThemeColors +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.cd_selected +import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization +import nuvio.composeapp.generated.resources.settings_appearance_app_language +import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title +import nuvio.composeapp.generated.resources.settings_appearance_amoled_black +import nuvio.composeapp.generated.resources.settings_appearance_amoled_description +import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description +import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description +import nuvio.composeapp.generated.resources.settings_appearance_section_display +import nuvio.composeapp.generated.resources.settings_appearance_section_home +import nuvio.composeapp.generated.resources.settings_appearance_section_theme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState @OptIn(ExperimentalLayoutApi::class) internal fun LazyListScope.appearanceSettingsContent( @@ -41,12 +70,14 @@ internal fun LazyListScope.appearanceSettingsContent( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + selectedAppLanguage: AppLanguage, + onAppLanguageSelected: (AppLanguage) -> Unit, onContinueWatchingClick: () -> Unit, onPosterCustomizationClick: () -> Unit, ) { item { SettingsSection( - title = "THEME", + title = stringResource(Res.string.settings_appearance_section_theme), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -74,39 +105,59 @@ internal fun LazyListScope.appearanceSettingsContent( } item { + var showLanguageSheet by remember { mutableStateOf(false) } SettingsSection( - title = "DISPLAY", + title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "AMOLED Black", - description = "Use pure black backgrounds for OLED screens.", + title = stringResource(Res.string.settings_appearance_amoled_black), + description = stringResource(Res.string.settings_appearance_amoled_description), checked = amoledEnabled, isTablet = isTablet, onCheckedChange = onAmoledToggle, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_app_language), + description = stringResource(selectedAppLanguage.labelRes), + icon = Icons.Rounded.Language, + isTablet = isTablet, + onClick = { showLanguageSheet = true }, + ) } } + + if (showLanguageSheet) { + AppearanceLanguageBottomSheet( + selectedLanguage = selectedAppLanguage, + onLanguageSelected = { + onAppLanguageSelected(it) + showLanguageSheet = false + }, + onDismiss = { showLanguageSheet = false }, + ) + } } item { SettingsSection( - title = "HOME", + title = stringResource(Res.string.settings_appearance_section_home), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Continue Watching", - description = "Show, hide, and style the Continue Watching shelf.", + title = stringResource(Res.string.compose_settings_page_continue_watching), + description = stringResource(Res.string.settings_appearance_continue_watching_description), icon = Icons.Rounded.Style, isTablet = isTablet, onClick = onContinueWatchingClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Poster Customization", - description = "Adjust shared poster card width and corner radius presets.", + title = stringResource(Res.string.compose_settings_page_poster_customization), + description = stringResource(Res.string.settings_appearance_poster_customization_description), icon = Icons.Rounded.Tune, isTablet = isTablet, onClick = onPosterCustomizationClick, @@ -116,6 +167,78 @@ internal fun LazyListScope.appearanceSettingsContent( } } +private data class AppLanguageSheetOption( + val language: AppLanguage, + val labelRes: StringResource, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppearanceLanguageBottomSheet( + selectedLanguage: AppLanguage, + onLanguageSelected: (AppLanguage) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + val options = remember { + AppLanguage.entries.map { language -> + AppLanguageSheetOption( + language = language, + labelRes = language.labelRes, + ) + } + } + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Text( + text = stringResource(Res.string.settings_appearance_app_language_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + + options.forEachIndexed { index, option -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(option.labelRes), + onClick = { + onLanguageSelected(option.language) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (option.language == selectedLanguage) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} + @Composable private fun ThemeChip( theme: AppTheme, @@ -152,7 +275,7 @@ private fun ThemeChip( if (isSelected) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Selected", + contentDescription = stringResource(Res.string.cd_selected), tint = palette.onSecondary, modifier = Modifier.size(22.dp), ) @@ -162,7 +285,7 @@ private fun ThemeChip( Spacer(modifier = Modifier.height(6.dp)) Text( - text = theme.displayName, + text = stringResource(theme.labelRes), style = MaterialTheme.typography.labelMedium, color = if (isSelected) { MaterialTheme.colorScheme.onSurface diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 4d1b4da8..438fd3c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -7,6 +7,20 @@ import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Hub import androidx.compose.material.icons.rounded.Tune +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen +import nuvio.composeapp.generated.resources.compose_settings_page_plugins +import nuvio.composeapp.generated.resources.collections_header +import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description +import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description +import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description +import nuvio.composeapp.generated.resources.settings_content_discovery_meta_screen_description +import nuvio.composeapp.generated.resources.settings_content_discovery_plugins_description +import nuvio.composeapp.generated.resources.settings_content_discovery_section_home +import nuvio.composeapp.generated.resources.settings_content_discovery_section_sources +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.contentDiscoveryContent( isTablet: Boolean, @@ -19,21 +33,21 @@ internal fun LazyListScope.contentDiscoveryContent( ) { item { SettingsSection( - title = "SOURCES", + title = stringResource(Res.string.settings_content_discovery_section_sources), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Addons", - description = "Install, remove, refresh, and sort your content sources.", + title = stringResource(Res.string.compose_settings_page_addons), + description = stringResource(Res.string.settings_content_discovery_addons_description), icon = Icons.Rounded.Extension, isTablet = isTablet, onClick = onAddonsClick, ) if (showPluginsEntry) { SettingsNavigationRow( - title = "Plugins", - description = "Install JavaScript scraper repositories and test providers internally.", + title = stringResource(Res.string.compose_settings_page_plugins), + description = stringResource(Res.string.settings_content_discovery_plugins_description), icon = Icons.Rounded.Hub, isTablet = isTablet, onClick = onPluginsClick, @@ -44,27 +58,27 @@ internal fun LazyListScope.contentDiscoveryContent( } item { SettingsSection( - title = "HOME", + title = stringResource(Res.string.settings_content_discovery_section_home), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Homescreen", - description = "Control which catalogs appear on Home and in what order.", + title = stringResource(Res.string.compose_settings_page_homescreen), + description = stringResource(Res.string.settings_content_discovery_homescreen_description), icon = Icons.Rounded.Home, isTablet = isTablet, onClick = onHomescreenClick, ) SettingsNavigationRow( - title = "Meta Screen", - description = "Disable detail sections and reorder everything below Hero.", + title = stringResource(Res.string.compose_settings_page_meta_screen), + description = stringResource(Res.string.settings_content_discovery_meta_screen_description), icon = Icons.Rounded.Tune, isTablet = isTablet, onClick = onMetaScreenClick, ) SettingsNavigationRow( - title = "Collections", - description = "Create custom catalog groupings with folders shown on Home.", + title = stringResource(Res.string.collections_header), + description = stringResource(Res.string.settings_content_discovery_collections_description), icon = Icons.Rounded.CollectionsBookmark, isTablet = isTablet, onClick = onCollectionsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 8668f0e8..34ab403c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -25,6 +25,23 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.features.home.components.ContinueWatchingStylePreview import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description +import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title +import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style +import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch +import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior +import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility +import nuvio.composeapp.generated.resources.settings_continue_watching_show_description +import nuvio.composeapp.generated.resources.settings_continue_watching_show_title +import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster +import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description +import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide +import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description +import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description +import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.continueWatchingSettingsContent( isTablet: Boolean, @@ -35,13 +52,13 @@ internal fun LazyListScope.continueWatchingSettingsContent( ) { item { SettingsSection( - title = "VISIBILITY", + title = stringResource(Res.string.settings_continue_watching_section_visibility), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Show Continue Watching", - description = "Display the Continue Watching shelf on the Home screen.", + title = stringResource(Res.string.settings_continue_watching_show_title), + description = stringResource(Res.string.settings_continue_watching_show_description), checked = isVisible, isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setVisible, @@ -51,7 +68,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( } item { SettingsSection( - title = "CARD STYLE", + title = stringResource(Res.string.settings_continue_watching_section_card_style), isTablet = isTablet, ) { ContinueWatchingStyleSelector( @@ -63,13 +80,13 @@ internal fun LazyListScope.continueWatchingSettingsContent( } item { SettingsSection( - title = "UP NEXT BEHAVIOR", + title = stringResource(Res.string.settings_continue_watching_section_up_next_behavior), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Up Next from furthest episode", - description = "When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode. useful if you rewatch earlier episodes.", + title = stringResource(Res.string.settings_continue_watching_up_next_title), + description = stringResource(Res.string.settings_continue_watching_up_next_description), checked = upNextFromFurthestEpisode, isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, @@ -79,13 +96,13 @@ internal fun LazyListScope.continueWatchingSettingsContent( } item { SettingsSection( - title = "ON LAUNCH", + title = stringResource(Res.string.settings_continue_watching_section_on_launch), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Resume prompt on launch", - description = "Show a popup to continue where you left off when opening the app after leaving from the player.", + title = stringResource(Res.string.settings_continue_watching_resume_prompt_title), + description = stringResource(Res.string.settings_continue_watching_resume_prompt_description), checked = showResumePromptOnLaunch, isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch, @@ -173,20 +190,28 @@ private fun ContinueWatchingStyleOption( ) } Text( - text = style.name.lowercase().replaceFirstChar(Char::uppercase), + text = stringResource(style.labelRes), style = MaterialTheme.typography.bodyMedium, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = if (style == ContinueWatchingSectionStyle.Wide) { - "Info-dense horizontal card" - } else { - "Artwork-first poster card" - }, + text = stringResource(style.descriptionRes), style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } + +private val ContinueWatchingSectionStyle.labelRes: StringResource + get() = when (this) { + ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide + ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster + } + +private val ContinueWatchingSectionStyle.descriptionRes: StringResource + get() = when (this) { + ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description + ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index 4ea4b783..adaea670 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -36,6 +36,25 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.settings_homescreen_empty_message +import nuvio.composeapp.generated.resources.settings_homescreen_empty_title +import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused +import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached +import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected +import nuvio.composeapp.generated.resources.settings_homescreen_pin_to_move_toast +import nuvio.composeapp.generated.resources.settings_homescreen_section_catalogs +import nuvio.composeapp.generated.resources.settings_homescreen_section_catalogs_collections +import nuvio.composeapp.generated.resources.settings_homescreen_section_collections +import nuvio.composeapp.generated.resources.settings_homescreen_section_hero +import nuvio.composeapp.generated.resources.settings_homescreen_section_hero_sources +import nuvio.composeapp.generated.resources.settings_homescreen_selected_count +import nuvio.composeapp.generated.resources.settings_homescreen_show_hero +import nuvio.composeapp.generated.resources.settings_homescreen_show_hero_description +import nuvio.composeapp.generated.resources.settings_homescreen_summary +import nuvio.composeapp.generated.resources.settings_homescreen_summary_hint +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -57,13 +76,13 @@ internal fun LazyListScope.homescreenSettingsContent( } item { SettingsSection( - title = "HERO", + title = stringResource(Res.string.settings_homescreen_section_hero), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Show Hero", - description = "Display a featured hero carousel at the top of Home. Choose up to 2 source catalogs below.", + title = stringResource(Res.string.settings_homescreen_show_hero), + description = stringResource(Res.string.settings_homescreen_show_hero_description), checked = heroEnabled, isTablet = isTablet, onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, @@ -76,7 +95,7 @@ internal fun LazyListScope.homescreenSettingsContent( if (heroEnabled && catalogOnlyItems.isNotEmpty()) { var heroSourcesExpanded by remember { mutableStateOf(false) } SettingsSection( - title = "HERO SOURCES", + title = stringResource(Res.string.settings_homescreen_section_hero_sources), isTablet = isTablet, ) { HeroSourcesDropdown( @@ -93,35 +112,36 @@ internal fun LazyListScope.homescreenSettingsContent( if (items.isEmpty()) { HomeEmptyStateCard( modifier = Modifier.fillMaxWidth(), - title = "No home catalogs", - message = "Install an addon with board-compatible catalogs to configure Homescreen rows.", + title = stringResource(Res.string.settings_homescreen_empty_title), + message = stringResource(Res.string.settings_homescreen_empty_message), ) } else { val catalogCount = items.count { !it.isCollection } val collectionCount = items.count { it.isCollection } val sectionTitle = when { - collectionCount > 0 && catalogCount > 0 -> "CATALOGS & COLLECTIONS" - collectionCount > 0 -> "COLLECTIONS" - else -> "CATALOGS" + collectionCount > 0 && catalogCount > 0 -> stringResource(Res.string.settings_homescreen_section_catalogs_collections) + collectionCount > 0 -> stringResource(Res.string.settings_homescreen_section_collections) + else -> stringResource(Res.string.settings_homescreen_section_catalogs) } SettingsSection( title = sectionTitle, isTablet = isTablet, actions = { NuvioActionLabel( - text = "Reset", + text = stringResource(Res.string.action_reset), onClick = HomeCatalogSettingsRepository::resetToDefaults, ) }, ) { val hapticFeedback = LocalHapticFeedback.current + val pinToMoveToast = stringResource(Res.string.settings_homescreen_pin_to_move_toast) HomescreenCatalogList( isTablet = isTablet, items = items, onPinnedDragAttempt = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - NuvioToastController.show("Remove pin to top from collection to move") + NuvioToastController.show(pinToMoveToast) }, ) } @@ -137,6 +157,7 @@ private fun HeroSourcesDropdown( expanded: Boolean, onExpandedChange: (Boolean) -> Unit, ) { + val noSourcesSelected = stringResource(Res.string.settings_homescreen_no_sources_selected) SettingsGroup(isTablet = isTablet) { Row( modifier = Modifier @@ -150,7 +171,11 @@ private fun HeroSourcesDropdown( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "$selectedHeroSourceCount of ${HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT} selected", + text = stringResource( + Res.string.settings_homescreen_selected_count, + selectedHeroSourceCount, + HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT, + ), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, @@ -158,7 +183,7 @@ private fun HeroSourcesDropdown( Text( text = items.filter { it.heroSourceEnabled } .joinToString(separator = ", ") { it.displayTitle } - .ifBlank { "No hero sources selected" }, + .ifBlank { noSourcesSelected }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -182,7 +207,11 @@ private fun HeroSourcesDropdown( description = if (!item.heroSourceEnabled && selectedHeroSourceCount >= HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT ) { - "${item.addonName} • Limit reached (max 2)" + stringResource( + Res.string.settings_homescreen_limit_reached, + item.addonName, + HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT, + ) } else { item.addonName }, @@ -211,18 +240,23 @@ private fun HomescreenSummaryCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = "Keep Home focused", + text = stringResource(Res.string.settings_homescreen_keep_home_focused), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "$enabledCatalogCount of $totalCatalogCount catalogs visible • $selectedHeroSourceCount hero sources selected", + text = stringResource( + Res.string.settings_homescreen_summary, + enabledCatalogCount, + totalCatalogCount, + selectedHeroSourceCount, + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "Open a catalog only when you need to rename or reorder it.", + text = stringResource(Res.string.settings_homescreen_summary_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index a069bff0..7602c3e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,6 +1,13 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.lazy.LazyListScope +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings +import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment +import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description +import nuvio.composeapp.generated.resources.settings_integrations_section_title +import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.integrationsContent( isTablet: Boolean, @@ -9,21 +16,21 @@ internal fun LazyListScope.integrationsContent( ) { item { SettingsSection( - title = "INTEGRATIONS", + title = stringResource(Res.string.settings_integrations_section_title), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "TMDB Enrichment", - description = "Enhance detail pages with TMDB artwork, credits, episode metadata, and more.", + title = stringResource(Res.string.compose_settings_page_tmdb_enrichment), + description = stringResource(Res.string.settings_integrations_tmdb_description), iconPainter = integrationLogoPainter(IntegrationLogo.Tmdb), isTablet = isTablet, onClick = onTmdbClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "MDBList Ratings", - description = "Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages.", + title = stringResource(Res.string.compose_settings_page_mdblist_ratings), + description = stringResource(Res.string.settings_integrations_mdblist_description), iconPainter = integrationLogoPainter(IntegrationLogo.MdbList), isTablet = isTablet, onClick = onMdbListClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt index 951aed40..9787b732 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt @@ -24,6 +24,26 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettings import com.nuvio.app.features.mdblist.MdbListSettingsRepository +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.settings_mdb_add_api_key_first +import nuvio.composeapp.generated.resources.settings_mdb_api_key_description +import nuvio.composeapp.generated.resources.settings_mdb_api_key_label +import nuvio.composeapp.generated.resources.settings_mdb_api_key_title +import nuvio.composeapp.generated.resources.settings_mdb_enable_ratings +import nuvio.composeapp.generated.resources.settings_mdb_enable_ratings_description +import nuvio.composeapp.generated.resources.settings_mdb_section_api_key +import nuvio.composeapp.generated.resources.settings_mdb_section_rating_providers +import nuvio.composeapp.generated.resources.settings_mdb_section_title +import nuvio.composeapp.generated.resources.source_audience_score +import nuvio.composeapp.generated.resources.source_imdb +import nuvio.composeapp.generated.resources.source_letterboxd +import nuvio.composeapp.generated.resources.source_metacritic +import nuvio.composeapp.generated.resources.source_rotten_tomatoes +import nuvio.composeapp.generated.resources.source_tmdb +import nuvio.composeapp.generated.resources.source_trakt +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.mdbListSettingsContent( isTablet: Boolean, @@ -33,13 +53,13 @@ internal fun LazyListScope.mdbListSettingsContent( item { SettingsSection( - title = "MDBLIST", + title = stringResource(Res.string.settings_mdb_section_title), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Enable MDBList ratings", - description = "Show external ratings from MDBList on metadata pages when an IMDb ID is available.", + title = stringResource(Res.string.settings_mdb_enable_ratings), + description = stringResource(Res.string.settings_mdb_enable_ratings_description), checked = settings.enabled, enabled = settings.hasApiKey, isTablet = isTablet, @@ -49,7 +69,7 @@ internal fun LazyListScope.mdbListSettingsContent( SettingsGroupDivider(isTablet = isTablet) MdbListInfoRow( isTablet = isTablet, - text = "Add your MDBList API key below before turning ratings on.", + text = stringResource(Res.string.settings_mdb_add_api_key_first), ) } } @@ -58,7 +78,7 @@ internal fun LazyListScope.mdbListSettingsContent( item { SettingsSection( - title = "API KEY", + title = stringResource(Res.string.settings_mdb_section_api_key), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -73,7 +93,7 @@ internal fun LazyListScope.mdbListSettingsContent( item { SettingsSection( - title = "RATING PROVIDERS", + title = stringResource(Res.string.settings_mdb_section_rating_providers), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -94,18 +114,18 @@ private fun ProviderRows( controlsEnabled: Boolean, ) { val providers = listOf( - MdbListMetadataService.PROVIDER_IMDB to "IMDb", - MdbListMetadataService.PROVIDER_TMDB to "TMDB", - MdbListMetadataService.PROVIDER_TOMATOES to "Rotten Tomatoes", - MdbListMetadataService.PROVIDER_METACRITIC to "Metacritic", - MdbListMetadataService.PROVIDER_TRAKT to "Trakt", - MdbListMetadataService.PROVIDER_LETTERBOXD to "Letterboxd", - MdbListMetadataService.PROVIDER_AUDIENCE to "Audience Score", + MdbListMetadataService.PROVIDER_IMDB to Res.string.source_imdb, + MdbListMetadataService.PROVIDER_TMDB to Res.string.source_tmdb, + MdbListMetadataService.PROVIDER_TOMATOES to Res.string.source_rotten_tomatoes, + MdbListMetadataService.PROVIDER_METACRITIC to Res.string.source_metacritic, + MdbListMetadataService.PROVIDER_TRAKT to Res.string.source_trakt, + MdbListMetadataService.PROVIDER_LETTERBOXD to Res.string.source_letterboxd, + MdbListMetadataService.PROVIDER_AUDIENCE to Res.string.source_audience_score, ) - providers.forEachIndexed { index, (providerId, providerLabel) -> + providers.forEachIndexed { index, (providerId, providerLabelRes) -> SettingsSwitchRow( - title = providerLabel, + title = stringResource(providerLabelRes), checked = settings.isProviderEnabled(providerId), enabled = controlsEnabled, isTablet = isTablet, @@ -138,13 +158,13 @@ private fun MdbListApiKeyRow( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "MDBList API key", + text = stringResource(Res.string.settings_mdb_api_key_title), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Get a key from https://mdblist.com/preferences and paste it here.", + text = stringResource(Res.string.settings_mdb_api_key_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -157,7 +177,7 @@ private fun MdbListApiKeyRow( }, modifier = Modifier.fillMaxWidth(), singleLine = true, - label = { Text("API key") }, + label = { Text(stringResource(Res.string.settings_mdb_api_key_label)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), @@ -176,7 +196,7 @@ private fun MdbListApiKeyRow( }, enabled = normalizedDraft != value, ) { - Text("Save Key") + Text(stringResource(Res.string.action_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index 8885fa89..adfd3e02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -50,8 +50,51 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaScreenSectionItem +import com.nuvio.app.features.details.MetaScreenSectionKey import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reorder +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.settings_homescreen_hidden +import nuvio.composeapp.generated.resources.settings_homescreen_visible +import nuvio.composeapp.generated.resources.settings_meta_actions +import nuvio.composeapp.generated.resources.settings_meta_actions_description +import nuvio.composeapp.generated.resources.settings_meta_cast +import nuvio.composeapp.generated.resources.settings_meta_cast_description +import nuvio.composeapp.generated.resources.settings_meta_cinematic_background +import nuvio.composeapp.generated.resources.settings_meta_cinematic_background_description +import nuvio.composeapp.generated.resources.settings_meta_collection +import nuvio.composeapp.generated.resources.settings_meta_collection_description +import nuvio.composeapp.generated.resources.settings_meta_comments +import nuvio.composeapp.generated.resources.settings_meta_comments_description +import nuvio.composeapp.generated.resources.settings_meta_details +import nuvio.composeapp.generated.resources.settings_meta_details_description +import nuvio.composeapp.generated.resources.settings_meta_episode_cards +import nuvio.composeapp.generated.resources.settings_meta_episode_cards_description +import nuvio.composeapp.generated.resources.settings_meta_episode_style_horizontal +import nuvio.composeapp.generated.resources.settings_meta_episode_style_horizontal_description +import nuvio.composeapp.generated.resources.settings_meta_episode_style_list +import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description +import nuvio.composeapp.generated.resources.settings_meta_episodes +import nuvio.composeapp.generated.resources.settings_meta_episodes_description +import nuvio.composeapp.generated.resources.settings_meta_group_label +import nuvio.composeapp.generated.resources.settings_meta_more_like_this +import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description +import nuvio.composeapp.generated.resources.settings_meta_none +import nuvio.composeapp.generated.resources.settings_meta_overview +import nuvio.composeapp.generated.resources.settings_meta_overview_description +import nuvio.composeapp.generated.resources.settings_meta_production +import nuvio.composeapp.generated.resources.settings_meta_production_description +import nuvio.composeapp.generated.resources.settings_meta_section_appearance +import nuvio.composeapp.generated.resources.settings_meta_section_sections +import nuvio.composeapp.generated.resources.settings_meta_tab_group_format +import nuvio.composeapp.generated.resources.settings_meta_tab_layout +import nuvio.composeapp.generated.resources.settings_meta_tab_layout_description +import nuvio.composeapp.generated.resources.settings_meta_trailers +import nuvio.composeapp.generated.resources.settings_meta_trailers_description +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -62,21 +105,21 @@ internal fun LazyListScope.metaScreenSettingsContent( ) { item { SettingsSection( - title = "APPEARANCE", + title = stringResource(Res.string.settings_meta_section_appearance), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Cinematic Background", - description = "Blurred backdrop behind content, similar to stream screen.", + title = stringResource(Res.string.settings_meta_cinematic_background), + description = stringResource(Res.string.settings_meta_cinematic_background_description), checked = uiState.cinematicBackground, isTablet = isTablet, onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) }, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Tab Layout", - description = "Group sections into tabs like the TV app. Assign up to 3 sections per tab group.", + title = stringResource(Res.string.settings_meta_tab_layout), + description = stringResource(Res.string.settings_meta_tab_layout_description), checked = uiState.tabLayout, isTablet = isTablet, onCheckedChange = { MetaScreenSettingsRepository.setTabLayout(it) }, @@ -92,11 +135,11 @@ internal fun LazyListScope.metaScreenSettingsContent( } item { SettingsSection( - title = "SECTIONS", + title = stringResource(Res.string.settings_meta_section_sections), isTablet = isTablet, actions = { NuvioActionLabel( - text = "Reset", + text = stringResource(Res.string.action_reset), onClick = MetaScreenSettingsRepository::resetToDefaults, ) }, @@ -197,7 +240,7 @@ private fun MetaSectionRow( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = item.title, + text = stringResource(item.key.titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -205,7 +248,7 @@ private fun MetaSectionRow( overflow = TextOverflow.Ellipsis, ) Text( - text = item.description, + text = stringResource(item.key.descriptionRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -214,7 +257,11 @@ private fun MetaSectionRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (item.enabled) "Visible" else "Hidden", + text = if (item.enabled) { + stringResource(Res.string.settings_homescreen_visible) + } else { + stringResource(Res.string.settings_homescreen_hidden) + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -226,7 +273,7 @@ private fun MetaSectionRow( .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), ) Text( - text = "Tab Group ${item.tabGroup}", + text = stringResource(Res.string.settings_meta_tab_group_format, item.tabGroup ?: 0), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, @@ -259,7 +306,7 @@ private fun MetaSectionRow( ) { Icon( Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.action_reorder), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -277,7 +324,7 @@ private fun MetaSectionRow( verticalArrangement = Arrangement.spacedBy(4.dp), ) { TabGroupChip( - label = "None", + label = stringResource(Res.string.settings_meta_none), selected = item.tabGroup == null, onClick = { onTabGroupChange(null) }, ) @@ -286,7 +333,7 @@ private fun MetaSectionRow( val isSelected = item.tabGroup == groupId val isFull = currentCount >= 3 && !isSelected TabGroupChip( - label = "Group $groupId", + label = stringResource(Res.string.settings_meta_group_label, groupId), selected = isSelected, enabled = !isFull, onClick = { onTabGroupChange(groupId) }, @@ -334,13 +381,13 @@ private fun MetaEpisodeCardStyleSelector( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Episode Cards", + text = stringResource(Res.string.settings_meta_episode_cards), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "Choose how episodes are rendered on the metadata screen.", + text = stringResource(Res.string.settings_meta_episode_cards_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -404,17 +451,13 @@ private fun MetaEpisodeCardStyleOption( ) } Text( - text = if (style == MetaEpisodeCardStyle.Horizontal) "Horizontal" else "List", + text = stringResource(style.labelRes), style = MaterialTheme.typography.bodyMedium, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = if (style == MetaEpisodeCardStyle.Horizontal) { - "Backdrop-style row cards" - } else { - "Detail-first stacked cards" - }, + text = stringResource(style.descriptionRes), style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -422,6 +465,46 @@ private fun MetaEpisodeCardStyleOption( } } +private val MetaEpisodeCardStyle.labelRes: StringResource + get() = when (this) { + MetaEpisodeCardStyle.Horizontal -> Res.string.settings_meta_episode_style_horizontal + MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list + } + +private val MetaEpisodeCardStyle.descriptionRes: StringResource + get() = when (this) { + MetaEpisodeCardStyle.Horizontal -> Res.string.settings_meta_episode_style_horizontal_description + MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list_description + } + +private val MetaScreenSectionKey.titleRes: StringResource + get() = when (this) { + MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions + MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview + MetaScreenSectionKey.PRODUCTION -> Res.string.settings_meta_production + MetaScreenSectionKey.CAST -> Res.string.settings_meta_cast + MetaScreenSectionKey.COMMENTS -> Res.string.settings_meta_comments + MetaScreenSectionKey.TRAILERS -> Res.string.settings_meta_trailers + MetaScreenSectionKey.EPISODES -> Res.string.settings_meta_episodes + MetaScreenSectionKey.DETAILS -> Res.string.settings_meta_details + MetaScreenSectionKey.COLLECTION -> Res.string.settings_meta_collection + MetaScreenSectionKey.MORE_LIKE_THIS -> Res.string.settings_meta_more_like_this + } + +private val MetaScreenSectionKey.descriptionRes: StringResource + get() = when (this) { + MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions_description + MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview_description + MetaScreenSectionKey.PRODUCTION -> Res.string.settings_meta_production_description + MetaScreenSectionKey.CAST -> Res.string.settings_meta_cast_description + MetaScreenSectionKey.COMMENTS -> Res.string.settings_meta_comments_description + MetaScreenSectionKey.TRAILERS -> Res.string.settings_meta_trailers_description + MetaScreenSectionKey.EPISODES -> Res.string.settings_meta_episodes_description + MetaScreenSectionKey.DETAILS -> Res.string.settings_meta_details_description + MetaScreenSectionKey.COLLECTION -> Res.string.settings_meta_collection_description + MetaScreenSectionKey.MORE_LIKE_THIS -> Res.string.settings_meta_more_like_this_description + } + @Composable private fun MetaEpisodeCardStylePreview( style: MetaEpisodeCardStyle, @@ -530,4 +613,4 @@ private fun MetaEpisodeCardStylePreview( } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt index f2ef00c5..a7eab2c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt @@ -16,6 +16,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_notifications_disabled_in_app +import nuvio.composeapp.generated.resources.settings_notifications_episode_release_alerts +import nuvio.composeapp.generated.resources.settings_notifications_episode_release_alerts_description +import nuvio.composeapp.generated.resources.settings_notifications_permission_disabled +import nuvio.composeapp.generated.resources.settings_notifications_scheduled_count +import nuvio.composeapp.generated.resources.settings_notifications_section_alerts +import nuvio.composeapp.generated.resources.settings_notifications_section_test +import nuvio.composeapp.generated.resources.settings_notifications_send_test +import nuvio.composeapp.generated.resources.settings_notifications_sending_test +import nuvio.composeapp.generated.resources.settings_notifications_test_for_title +import nuvio.composeapp.generated.resources.settings_notifications_test_requires_saved_show +import nuvio.composeapp.generated.resources.settings_notifications_test_title +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.notificationsSettingsContent( isTablet: Boolean, @@ -23,13 +37,13 @@ internal fun LazyListScope.notificationsSettingsContent( ) { item { SettingsSection( - title = "ALERTS", + title = stringResource(Res.string.settings_notifications_section_alerts), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Episode release alerts", - description = "Schedule local notifications when a new episode for a saved show becomes available.", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), checked = uiState.isEnabled, enabled = !uiState.isLoading, isTablet = isTablet, @@ -41,7 +55,7 @@ internal fun LazyListScope.notificationsSettingsContent( item { SettingsSection( - title = "TEST", + title = stringResource(Res.string.settings_notifications_section_test), isTablet = isTablet, ) { NotificationTestCard( @@ -74,23 +88,23 @@ private fun NotificationTestCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = "Test notification", + text = stringResource(Res.string.settings_notifications_test_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( text = uiState.testTargetTitle?.let { title -> - "Send a local test notification for $title." - } ?: "Save a show to your library first to test notifications.", + stringResource(Res.string.settings_notifications_test_for_title, title) + } ?: stringResource(Res.string.settings_notifications_test_requires_saved_show), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = if (uiState.isEnabled) { - "${uiState.scheduledCount} release alerts are currently scheduled on this device." + stringResource(Res.string.settings_notifications_scheduled_count, uiState.scheduledCount) } else { - "Notifications are currently disabled in Nuvio." + stringResource(Res.string.settings_notifications_disabled_in_app) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -108,7 +122,13 @@ private fun NotificationTestCard( contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { - Text(if (uiState.isSendingTest) "Sending Test Notification..." else "Send Test Notification") + Text( + if (uiState.isSendingTest) { + stringResource(Res.string.settings_notifications_sending_test) + } else { + stringResource(Res.string.settings_notifications_send_test) + }, + ) } uiState.statusMessage?.let { message -> @@ -129,11 +149,11 @@ private fun NotificationTestCard( if (!uiState.permissionGranted) { Text( - text = "System notifications are disabled for Nuvio. Enable them to receive alerts and test notifications.", + text = stringResource(Res.string.settings_notifications_permission_disabled), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 590338df..d3ba2d92 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -58,6 +58,9 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.streams.StreamAutoPlayMode import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.isIos +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.playbackSettingsContent( isTablet: Boolean, @@ -144,21 +147,21 @@ private fun PlaybackSettingsSection( verticalArrangement = Arrangement.spacedBy(sectionSpacing), ) { SettingsSection( - title = "PLAYER", + title = stringResource(Res.string.settings_playback_section_player), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Show Loading Overlay", - description = "Show the opening loading overlay while a stream starts playing.", + title = stringResource(Res.string.settings_playback_show_loading_overlay), + description = stringResource(Res.string.settings_playback_show_loading_overlay_description), checked = showLoadingOverlay, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Hold To Speed", - description = "Long-press anywhere on the player surface to temporarily boost playback speed.", + title = stringResource(Res.string.settings_playback_hold_to_speed), + description = stringResource(Res.string.settings_playback_hold_to_speed_description), checked = holdToSpeedEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setHoldToSpeedEnabled, @@ -166,7 +169,7 @@ private fun PlaybackSettingsSection( if (holdToSpeedEnabled) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Hold Speed", + title = stringResource(Res.string.settings_playback_hold_speed), description = formatPlaybackSpeedLabel(holdToSpeedValue), isTablet = isTablet, onClick = { showHoldToSpeedValueDialog = true }, @@ -176,15 +179,15 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "SUBTITLE AND AUDIO", + title = stringResource(Res.string.settings_playback_section_subtitle_audio), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Preferred Audio Language", + title = stringResource(Res.string.settings_playback_preferred_audio_language), description = when (preferredAudioLanguage) { - AudioLanguageOption.DEFAULT -> "Default" - AudioLanguageOption.DEVICE -> "Device Language" + AudioLanguageOption.DEFAULT -> stringResource(Res.string.settings_playback_option_default) + AudioLanguageOption.DEVICE -> stringResource(Res.string.settings_playback_option_device_language) else -> languageLabelForCode(preferredAudioLanguage) }, isTablet = isTablet, @@ -192,18 +195,18 @@ private fun PlaybackSettingsSection( ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Secondary Audio Language", + title = stringResource(Res.string.settings_playback_secondary_audio_language), description = languageLabelForCode(secondaryPreferredAudioLanguage), isTablet = isTablet, onClick = { showSecondaryAudioDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Preferred Subtitle Language", + title = stringResource(Res.string.settings_playback_preferred_subtitle_language), description = when (preferredSubtitleLanguage) { - SubtitleLanguageOption.NONE -> "None" - SubtitleLanguageOption.DEVICE -> "Device Language" - SubtitleLanguageOption.FORCED -> "Forced" + SubtitleLanguageOption.NONE -> stringResource(Res.string.settings_playback_option_none) + SubtitleLanguageOption.DEVICE -> stringResource(Res.string.settings_playback_option_device_language) + SubtitleLanguageOption.FORCED -> stringResource(Res.string.settings_playback_option_forced) else -> languageLabelForCode(preferredSubtitleLanguage) }, isTablet = isTablet, @@ -211,7 +214,7 @@ private fun PlaybackSettingsSection( ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Secondary Subtitle Language", + title = stringResource(Res.string.settings_playback_secondary_subtitle_language), description = languageLabelForCode(secondaryPreferredSubtitleLanguage), isTablet = isTablet, onClick = { showSecondarySubtitleDialog = true }, @@ -220,13 +223,13 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "STREAM SELECTION", + title = stringResource(Res.string.settings_playback_section_stream_selection), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Reuse Last Link", - description = "Auto-play your last working stream for this same movie/episode when cache is still valid.", + title = stringResource(Res.string.settings_playback_reuse_last_link), + description = stringResource(Res.string.settings_playback_reuse_last_link_description), checked = streamReuseLastLinkEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamReuseLastLinkEnabled, @@ -234,7 +237,7 @@ private fun PlaybackSettingsSection( if (streamReuseLastLinkEnabled) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Last Link Cache Duration", + title = stringResource(Res.string.settings_playback_last_link_cache_duration), description = formatReuseCacheDuration(streamReuseLastLinkCacheHours), isTablet = isTablet, onClick = { showReuseCacheDurationDialog = true }, @@ -244,26 +247,23 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "STREAM AUTO-PLAY", + title = stringResource(Res.string.settings_playback_section_stream_auto_play), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Stream Selection Mode", - description = when (autoPlayPlayerSettings.streamAutoPlayMode) { - StreamAutoPlayMode.MANUAL -> "Manual" - StreamAutoPlayMode.FIRST_STREAM -> "First Available Stream" - StreamAutoPlayMode.REGEX_MATCH -> "Regex Match" - }, + title = stringResource(Res.string.settings_playback_stream_selection_mode), + description = stringResource(autoPlayPlayerSettings.streamAutoPlayMode.labelRes), isTablet = isTablet, onClick = { showAutoPlayModeDialog = true }, ) if (autoPlayPlayerSettings.streamAutoPlayMode != StreamAutoPlayMode.MANUAL) { if (autoPlayPlayerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { SettingsGroupDivider(isTablet = isTablet) + val notSetLabel = stringResource(Res.string.settings_playback_not_set) SettingsNavigationRow( - title = "Regex Pattern", - description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { "Not set" }, + title = stringResource(Res.string.settings_playback_regex_pattern), + description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { notSetLabel }, isTablet = isTablet, onClick = { showAutoPlayRegexDialog = true }, ) @@ -271,9 +271,9 @@ private fun PlaybackSettingsSection( SettingsGroupDivider(isTablet = isTablet) val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds val timeoutLabel = when (timeoutSec) { - 0 -> "Instant" - 11 -> "Unlimited" - else -> "${timeoutSec}s" + 0 -> stringResource(Res.string.settings_playback_timeout_instant) + 11 -> stringResource(Res.string.settings_playback_timeout_unlimited) + else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) } Column( modifier = Modifier @@ -287,12 +287,12 @@ private fun PlaybackSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Stream Timeout", + text = stringResource(Res.string.settings_playback_stream_timeout), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "How long to wait for streams before auto-selecting.", + text = stringResource(Res.string.settings_playback_stream_timeout_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -330,24 +330,23 @@ private fun PlaybackSettingsSection( } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Source Scope", - description = when (autoPlayPlayerSettings.streamAutoPlaySource) { - StreamAutoPlaySource.ALL_SOURCES -> if (pluginsEnabled) "All Sources" else "All Addons" - StreamAutoPlaySource.INSTALLED_ADDONS_ONLY -> "Installed Addons Only" - StreamAutoPlaySource.ENABLED_PLUGINS_ONLY -> "Enabled Plugins Only" - }, + title = stringResource(Res.string.settings_playback_source_scope), + description = stringResource(autoPlayPlayerSettings.streamAutoPlaySource.labelRes(pluginsEnabled)), isTablet = isTablet, onClick = { showAutoPlaySourceDialog = true }, ) if (autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.ENABLED_PLUGINS_ONLY) { SettingsGroupDivider(isTablet = isTablet) val addonSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedAddons.isEmpty()) { - "All Addons" + stringResource(Res.string.settings_playback_all_addons) } else { - "${autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size} selected" + stringResource( + Res.string.settings_playback_selected_count, + autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size, + ) } SettingsNavigationRow( - title = "Allowed Addons", + title = stringResource(Res.string.settings_playback_allowed_addons), description = addonSubtitle, isTablet = isTablet, onClick = { showAutoPlayAddonSelectionDialog = true }, @@ -356,12 +355,15 @@ private fun PlaybackSettingsSection( if (pluginsEnabled && autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.INSTALLED_ADDONS_ONLY) { SettingsGroupDivider(isTablet = isTablet) val pluginSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.isEmpty()) { - "All Plugins" + stringResource(Res.string.settings_playback_all_plugins) } else { - "${autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size} selected" + stringResource( + Res.string.settings_playback_selected_count, + autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size, + ) } SettingsNavigationRow( - title = "Allowed Plugins", + title = stringResource(Res.string.settings_playback_allowed_plugins), description = pluginSubtitle, isTablet = isTablet, onClick = { showAutoPlayPluginSelectionDialog = true }, @@ -373,33 +375,28 @@ private fun PlaybackSettingsSection( if (!isIos) { SettingsSection( - title = "DECODER", + title = stringResource(Res.string.settings_playback_section_decoder), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Decoder Priority", - description = when (decoderPriority) { - 0 -> "Device Only" - 1 -> "Prefer Device" - 2 -> "Prefer App (FFmpeg)" - else -> "Prefer Device" - }, + title = stringResource(Res.string.settings_playback_decoder_priority), + description = decoderPriorityLabel(decoderPriority), isTablet = isTablet, onClick = { showDecoderPriorityDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Map DV7 to HEVC", - description = "Dolby Vision Profile 7 to HEVC fallback for unsupported devices.", + title = stringResource(Res.string.settings_playback_map_dv7_to_hevc), + description = stringResource(Res.string.settings_playback_map_dv7_to_hevc_description), checked = mapDV7ToHevc, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setMapDV7ToHevc, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Tunneled Playback", - description = "Enable tunneled playback for lower latency audio/video sync.", + title = stringResource(Res.string.settings_playback_tunneled_playback), + description = stringResource(Res.string.settings_playback_tunneled_playback_description), checked = tunnelingEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setTunnelingEnabled, @@ -410,13 +407,13 @@ private fun PlaybackSettingsSection( if (!isIos) { SettingsSection( - title = "SUBTITLE RENDERING", + title = stringResource(Res.string.settings_playback_section_subtitle_rendering), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Enable libass", - description = "Use libass for ASS/SSA subtitle rendering instead of the default renderer.", + title = stringResource(Res.string.settings_playback_enable_libass), + description = stringResource(Res.string.settings_playback_enable_libass_description), checked = useLibass, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setUseLibass, @@ -424,15 +421,8 @@ private fun PlaybackSettingsSection( if (useLibass) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Render Type", - description = when (libassRenderType) { - "OVERLAY_OPEN_GL" -> "Overlay OpenGL" - "OVERLAY_CANVAS" -> "Overlay Canvas" - "EFFECTS_OPEN_GL" -> "Effects OpenGL" - "EFFECTS_CANVAS" -> "Effects Canvas" - "CUES" -> "Standard (Cues)" - else -> "Standard (Cues)" - }, + title = stringResource(Res.string.settings_playback_render_type), + description = libassRenderTypeLabel(libassRenderType), isTablet = isTablet, onClick = { showLibassRenderTypeDialog = true }, ) @@ -442,21 +432,21 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "SKIP SEGMENTS", + title = stringResource(Res.string.settings_playback_section_skip_segments), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Skip Intro/Outro/Recap", - description = "Show skip button during detected intro, outro, and recap segments.", + title = stringResource(Res.string.settings_playback_skip_intro_outro_recap), + description = stringResource(Res.string.settings_playback_skip_intro_outro_recap_description), checked = autoPlayPlayerSettings.skipIntroEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setSkipIntroEnabled, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Anime Skip", - description = "Also search AnimeSkip for skip timestamps (requires client ID).", + title = stringResource(Res.string.settings_playback_anime_skip), + description = stringResource(Res.string.settings_playback_anime_skip_description), checked = autoPlayPlayerSettings.animeSkipEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setAnimeSkipEnabled, @@ -464,9 +454,10 @@ private fun PlaybackSettingsSection( if (autoPlayPlayerSettings.animeSkipEnabled) { SettingsGroupDivider(isTablet = isTablet) var showAnimeSkipClientIdDialog by remember { mutableStateOf(false) } + val notSetLabel = stringResource(Res.string.settings_playback_not_set) SettingsNavigationRow( - title = "AnimeSkip Client ID", - description = autoPlayPlayerSettings.animeSkipClientId.ifBlank { "Not set" }, + title = stringResource(Res.string.settings_playback_anime_skip_client_id), + description = autoPlayPlayerSettings.animeSkipClientId.ifBlank { notSetLabel }, isTablet = isTablet, onClick = { showAnimeSkipClientIdDialog = true }, ) @@ -485,21 +476,21 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "NEXT EPISODE", + title = stringResource(Res.string.settings_playback_section_next_episode), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Auto-Play Next Episode", - description = "Automatically find and play the next episode when the threshold is reached.", + title = stringResource(Res.string.settings_playback_auto_play_next_episode), + description = stringResource(Res.string.settings_playback_auto_play_next_episode_description), checked = autoPlayPlayerSettings.streamAutoPlayNextEpisodeEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayNextEpisodeEnabled, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Prefer Binge Group", - description = "When auto-playing, prefer a stream from the same binge group as the current one.", + title = stringResource(Res.string.settings_playback_prefer_binge_group), + description = stringResource(Res.string.settings_playback_prefer_binge_group_description), checked = autoPlayPlayerSettings.streamAutoPlayPreferBingeGroup, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayPreferBingeGroup, @@ -507,11 +498,8 @@ private fun PlaybackSettingsSection( SettingsGroupDivider(isTablet = isTablet) var showThresholdModeDialog by remember { mutableStateOf(false) } SettingsNavigationRow( - title = "Threshold Mode", - description = when (autoPlayPlayerSettings.nextEpisodeThresholdMode) { - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> "Percentage" - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> "Minutes Before End" - }, + title = stringResource(Res.string.settings_playback_threshold_mode), + description = stringResource(autoPlayPlayerSettings.nextEpisodeThresholdMode.labelRes), isTablet = isTablet, onClick = { showThresholdModeDialog = true }, ) @@ -541,18 +529,21 @@ private fun PlaybackSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Threshold Percentage", + text = stringResource(Res.string.settings_playback_threshold_percentage), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Show next episode card when playback reaches this percentage.", + text = stringResource(Res.string.settings_playback_threshold_percentage_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( - text = "${thresholdPercent.toInt()}%", + text = stringResource( + Res.string.settings_playback_threshold_percentage_value, + thresholdPercent.toInt(), + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold, @@ -597,18 +588,21 @@ private fun PlaybackSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Minutes Before End", + text = stringResource(Res.string.settings_playback_minutes_before_end), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Show next episode card this many minutes before the end.", + text = stringResource(Res.string.settings_playback_minutes_before_end_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( - text = "${thresholdMinutes.toInt()} min", + text = stringResource( + Res.string.settings_playback_minutes_value, + thresholdMinutes.toInt(), + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold, @@ -646,12 +640,12 @@ private fun PlaybackSettingsSection( if (showPreferredAudioDialog) { LanguageSelectionDialog( - title = "Preferred Audio Language", + title = stringResource(Res.string.settings_playback_preferred_audio_language), options = listOf( - LanguageSelectionOption(AudioLanguageOption.DEFAULT, "Default"), - LanguageSelectionOption(AudioLanguageOption.DEVICE, "Device Language"), + LanguageSelectionOption(AudioLanguageOption.DEFAULT, stringResource(Res.string.settings_playback_option_default)), + LanguageSelectionOption(AudioLanguageOption.DEVICE, stringResource(Res.string.settings_playback_option_device_language)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = preferredAudioLanguage, onSelect = { value -> @@ -664,11 +658,11 @@ private fun PlaybackSettingsSection( if (showSecondaryAudioDialog) { LanguageSelectionDialog( - title = "Secondary Audio Language", + title = stringResource(Res.string.settings_playback_secondary_audio_language), options = listOf( - LanguageSelectionOption(null, "None"), + LanguageSelectionOption(null, stringResource(Res.string.settings_playback_option_none)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = secondaryPreferredAudioLanguage, onSelect = { value -> @@ -681,13 +675,13 @@ private fun PlaybackSettingsSection( if (showPreferredSubtitleDialog) { LanguageSelectionDialog( - title = "Preferred Subtitle Language", + title = stringResource(Res.string.settings_playback_preferred_subtitle_language), options = listOf( - LanguageSelectionOption(SubtitleLanguageOption.NONE, "None"), - LanguageSelectionOption(SubtitleLanguageOption.DEVICE, "Device Language"), - LanguageSelectionOption(SubtitleLanguageOption.FORCED, "Forced"), + LanguageSelectionOption(SubtitleLanguageOption.NONE, stringResource(Res.string.settings_playback_option_none)), + LanguageSelectionOption(SubtitleLanguageOption.DEVICE, stringResource(Res.string.settings_playback_option_device_language)), + LanguageSelectionOption(SubtitleLanguageOption.FORCED, stringResource(Res.string.settings_playback_option_forced)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = preferredSubtitleLanguage, onSelect = { value -> @@ -700,12 +694,12 @@ private fun PlaybackSettingsSection( if (showSecondarySubtitleDialog) { LanguageSelectionDialog( - title = "Secondary Subtitle Language", + title = stringResource(Res.string.settings_playback_secondary_subtitle_language), options = listOf( - LanguageSelectionOption(null, "None"), - LanguageSelectionOption(SubtitleLanguageOption.FORCED, "Forced"), + LanguageSelectionOption(null, stringResource(Res.string.settings_playback_option_none)), + LanguageSelectionOption(SubtitleLanguageOption.FORCED, stringResource(Res.string.settings_playback_option_forced)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = secondaryPreferredSubtitleLanguage, onSelect = { value -> @@ -791,8 +785,8 @@ private fun PlaybackSettingsSection( .distinct() .sorted() StreamAutoPlayProviderSelectionDialog( - title = "Allowed Addons", - allLabel = "All Addons", + title = stringResource(Res.string.settings_playback_allowed_addons), + allLabel = stringResource(Res.string.settings_playback_all_addons), items = addonNames, selectedItems = autoPlayPlayerSettings.streamAutoPlaySelectedAddons, onSelectionSaved = { @@ -810,8 +804,8 @@ private fun PlaybackSettingsSection( .distinct() .sorted() StreamAutoPlayProviderSelectionDialog( - title = "Allowed Plugins", - allLabel = "All Plugins", + title = stringResource(Res.string.settings_playback_allowed_plugins), + allLabel = stringResource(Res.string.settings_playback_all_plugins), items = pluginNames, selectedItems = autoPlayPlayerSettings.streamAutoPlaySelectedPlugins, onSelectionSaved = { @@ -834,13 +828,16 @@ private fun PlaybackSettingsSection( } } +@Composable private fun formatReuseCacheDuration(hours: Int): String = when { - hours < 24 -> "$hours hour${if (hours != 1) "s" else ""}" + hours < 24 && hours == 1 -> stringResource(Res.string.settings_playback_duration_hour_one, hours) + hours < 24 -> stringResource(Res.string.settings_playback_duration_hours, hours) hours % 24 == 0 -> { val days = hours / 24 - "$days day${if (days != 1) "s" else ""}" + if (days == 1) stringResource(Res.string.settings_playback_duration_day_one, days) + else stringResource(Res.string.settings_playback_duration_days, days) } - else -> "$hours hours" + else -> stringResource(Res.string.settings_playback_duration_hours, hours) } private data class LanguageSelectionOption( @@ -929,7 +926,7 @@ private fun LanguageSelectionDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -960,7 +957,7 @@ private fun ReuseCacheDurationDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Last Link Cache Duration", + text = stringResource(Res.string.settings_playback_last_link_cache_duration), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1016,7 +1013,7 @@ private fun ReuseCacheDurationDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1033,9 +1030,9 @@ private fun DecoderPriorityDialog( onDismiss: () -> Unit, ) { val options = listOf( - 0 to "Device Only", - 1 to "Prefer Device", - 2 to "Prefer App (FFmpeg)", + 0 to Res.string.settings_playback_decoder_device_only, + 1 to Res.string.settings_playback_decoder_prefer_device, + 2 to Res.string.settings_playback_decoder_prefer_app, ) BasicAlertDialog( @@ -1051,7 +1048,7 @@ private fun DecoderPriorityDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Decoder Priority", + text = stringResource(Res.string.settings_playback_decoder_priority), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1061,7 +1058,7 @@ private fun DecoderPriorityDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (priority, label) -> + options.forEach { (priority, labelRes) -> val isSelected = priority == selectedPriority val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1083,7 +1080,7 @@ private fun DecoderPriorityDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = label, + text = stringResource(labelRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -1107,7 +1104,7 @@ private fun DecoderPriorityDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1138,7 +1135,7 @@ private fun HoldToSpeedValueDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Hold Speed", + text = stringResource(Res.string.settings_playback_hold_speed), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1194,7 +1191,7 @@ private fun HoldToSpeedValueDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1211,11 +1208,11 @@ private fun LibassRenderTypeDialog( onDismiss: () -> Unit, ) { val options = listOf( - "OVERLAY_OPEN_GL" to "Overlay OpenGL", - "OVERLAY_CANVAS" to "Overlay Canvas", - "EFFECTS_OPEN_GL" to "Effects OpenGL", - "EFFECTS_CANVAS" to "Effects Canvas", - "CUES" to "Standard (Cues)", + "OVERLAY_OPEN_GL" to Res.string.settings_playback_render_type_overlay_opengl, + "OVERLAY_CANVAS" to Res.string.settings_playback_render_type_overlay_canvas, + "EFFECTS_OPEN_GL" to Res.string.settings_playback_render_type_effects_opengl, + "EFFECTS_CANVAS" to Res.string.settings_playback_render_type_effects_canvas, + "CUES" to Res.string.settings_playback_render_type_cues, ) BasicAlertDialog( @@ -1231,7 +1228,7 @@ private fun LibassRenderTypeDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Render Type", + text = stringResource(Res.string.settings_playback_render_type), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1241,7 +1238,7 @@ private fun LibassRenderTypeDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (value, label) -> + options.forEach { (value, labelRes) -> val isSelected = value == selectedRenderType val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1263,7 +1260,7 @@ private fun LibassRenderTypeDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = label, + text = stringResource(labelRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -1287,7 +1284,7 @@ private fun LibassRenderTypeDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1304,9 +1301,21 @@ private fun StreamAutoPlayModeDialog( onDismiss: () -> Unit, ) { val options = listOf( - Triple(StreamAutoPlayMode.MANUAL, "Manual", "Select streams manually each time."), - Triple(StreamAutoPlayMode.FIRST_STREAM, "First Available Stream", "Automatically play the first stream found."), - Triple(StreamAutoPlayMode.REGEX_MATCH, "Regex Match", "Auto-select a stream matching a regex pattern."), + Triple( + StreamAutoPlayMode.MANUAL, + Res.string.settings_playback_stream_selection_mode_manual, + Res.string.settings_playback_stream_selection_mode_manual_description, + ), + Triple( + StreamAutoPlayMode.FIRST_STREAM, + Res.string.settings_playback_stream_selection_mode_first_stream, + Res.string.settings_playback_stream_selection_mode_first_stream_description, + ), + Triple( + StreamAutoPlayMode.REGEX_MATCH, + Res.string.settings_playback_stream_selection_mode_regex, + Res.string.settings_playback_stream_selection_mode_regex_description, + ), ) BasicAlertDialog( @@ -1322,7 +1331,7 @@ private fun StreamAutoPlayModeDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Stream Selection Mode", + text = stringResource(Res.string.settings_playback_stream_selection_mode), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1332,7 +1341,7 @@ private fun StreamAutoPlayModeDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (mode, title, description) -> + options.forEach { (mode, titleRes, descriptionRes) -> val isSelected = mode == selectedMode val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1355,13 +1364,13 @@ private fun StreamAutoPlayModeDialog( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = title, + text = stringResource(titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = description, + text = stringResource(descriptionRes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1385,7 +1394,7 @@ private fun StreamAutoPlayModeDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1406,27 +1415,31 @@ private fun StreamAutoPlaySourceDialog( add( Triple( StreamAutoPlaySource.ALL_SOURCES, - if (pluginsEnabled) "All Sources" else "All Addons", if (pluginsEnabled) { - "Consider streams from both addons and plugins." + Res.string.settings_playback_source_scope_all_sources } else { - "Consider streams from all installed addons." + Res.string.settings_playback_source_scope_all_addons + }, + if (pluginsEnabled) { + Res.string.settings_playback_source_scope_all_sources_description + } else { + Res.string.settings_playback_source_scope_all_addons_description }, ), ) add( Triple( StreamAutoPlaySource.INSTALLED_ADDONS_ONLY, - "Installed Addons Only", - "Only consider streams from installed addons.", + Res.string.settings_playback_source_scope_installed_addons_only, + Res.string.settings_playback_source_scope_installed_addons_only_description, ), ) if (pluginsEnabled) { add( Triple( StreamAutoPlaySource.ENABLED_PLUGINS_ONLY, - "Enabled Plugins Only", - "Only consider streams from enabled plugins.", + Res.string.settings_playback_source_scope_enabled_plugins_only, + Res.string.settings_playback_source_scope_enabled_plugins_only_description, ), ) } @@ -1445,7 +1458,7 @@ private fun StreamAutoPlaySourceDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Source Scope", + text = stringResource(Res.string.settings_playback_source_scope), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1455,7 +1468,7 @@ private fun StreamAutoPlaySourceDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (source, title, description) -> + options.forEach { (source, titleRes, descriptionRes) -> val isSelected = source == selectedSource val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1478,13 +1491,13 @@ private fun StreamAutoPlaySourceDialog( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = title, + text = stringResource(titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = description, + text = stringResource(descriptionRes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1508,7 +1521,7 @@ private fun StreamAutoPlaySourceDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1589,7 +1602,7 @@ private fun StreamAutoPlayProviderSelectionDialog( if (items.isEmpty()) { Text( - text = "No items available", + text = stringResource(Res.string.settings_playback_no_items_available), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1648,7 +1661,7 @@ private fun StreamAutoPlayProviderSelectionDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to save & close", + text = stringResource(Res.string.settings_playback_dialog_save_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1667,23 +1680,22 @@ private fun StreamAutoPlayRegexDialog( var regex by remember(initialRegex) { mutableStateOf(initialRegex) } var regexError by remember { mutableStateOf(null) } - val presets = remember { - listOf( - "Any 1080p+" to "(2160p|4k|1080p)", - "4K / Remux" to "(2160p|4k|remux)", - "1080p Standard" to "(1080p|full\\s*hd)", - "720p / Smaller" to "(720p|webrip|web-dl)", - "WEB Sources" to "(web[-\\s]?dl|webrip)", - "BluRay Quality" to "(bluray|b[dr]rip|remux)", - "HEVC / x265" to "(hevc|x265|h\\.265)", - "AVC / x264" to "(x264|h\\.264|avc)", - "HDR / Dolby Vision" to "(hdr|hdr10\\+?|dv|dolby\\s*vision)", - "Dolby Atmos / DTS" to "(atmos|truehd|dts[-\\s]?hd|dtsx?)", - "English" to "(\\beng\\b|english)", - "No CAM/TS" to "^(?!.*\\b(cam|hdcam|ts|telesync)\\b).*$", - "No REMUX/HDR" to "(?is)^(?!.*\\b(hdr|hdr10|dv|dolby|vision|hevc|remux|2160p)\\b).+$", - ) - } + val invalidRegexPattern = stringResource(Res.string.settings_playback_invalid_regex_pattern) + val presets = listOf( + stringResource(Res.string.settings_playback_regex_preset_any_1080p) to "(2160p|4k|1080p)", + stringResource(Res.string.settings_playback_regex_preset_quality_4k_remux) to "(2160p|4k|remux)", + stringResource(Res.string.settings_playback_regex_preset_quality_1080p_standard) to "(1080p|full\\s*hd)", + stringResource(Res.string.settings_playback_regex_preset_quality_720p_smaller) to "(720p|webrip|web-dl)", + stringResource(Res.string.settings_playback_regex_preset_web_sources) to "(web[-\\s]?dl|webrip)", + stringResource(Res.string.settings_playback_regex_preset_bluray_quality) to "(bluray|b[dr]rip|remux)", + stringResource(Res.string.settings_playback_regex_preset_hevc_x265) to "(hevc|x265|h\\.265)", + stringResource(Res.string.settings_playback_regex_preset_avc_x264) to "(x264|h\\.264|avc)", + stringResource(Res.string.settings_playback_regex_preset_hdr_dolby_vision) to "(hdr|hdr10\\+?|dv|dolby\\s*vision)", + stringResource(Res.string.settings_playback_regex_preset_dolby_atmos_dts) to "(atmos|truehd|dts[-\\s]?hd|dtsx?)", + stringResource(Res.string.settings_playback_regex_preset_english) to "(\\beng\\b|english)", + stringResource(Res.string.settings_playback_regex_preset_no_cam_ts) to "^(?!.*\\b(cam|hdcam|ts|telesync)\\b).*$", + stringResource(Res.string.settings_playback_regex_preset_no_remux_hdr) to "(?is)^(?!.*\\b(hdr|hdr10|dv|dolby|vision|hevc|remux|2160p)\\b).+$", + ) BasicAlertDialog( onDismissRequest = onDismiss, @@ -1700,20 +1712,20 @@ private fun StreamAutoPlayRegexDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Regex Pattern", + text = stringResource(Res.string.settings_playback_regex_pattern), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "Matches against stream name, label, description, addon, and URL.", + text = stringResource(Res.string.settings_playback_regex_matches_against), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "Presets", + text = stringResource(Res.string.settings_playback_presets), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1772,7 +1784,7 @@ private fun StreamAutoPlayRegexDialog( decorationBox = { innerTextField -> if (regex.isBlank()) { Text( - text = "4K|2160p|Remux", + text = stringResource(Res.string.settings_playback_regex_placeholder), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) @@ -1796,26 +1808,26 @@ private fun StreamAutoPlayRegexDialog( verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } TextButton(onClick = { regex = "" regexError = null }) { - Text("Clear") + Text(stringResource(Res.string.action_clear)) } TextButton(onClick = { val value = regex.trim() if (value.isNotEmpty()) { val valid = runCatching { Regex(value, RegexOption.IGNORE_CASE) }.isSuccess if (!valid) { - regexError = "Invalid regex pattern" + regexError = invalidRegexPattern return@TextButton } } onSave(value) }) { - Text("Save") + Text(stringResource(Res.string.action_save)) } } } @@ -1843,13 +1855,13 @@ private fun AnimeSkipClientIdDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "AnimeSkip Client ID", + text = stringResource(Res.string.settings_playback_anime_skip_client_id), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "Enter your AnimeSkip API client ID. Get one at anime-skip.com.", + text = stringResource(Res.string.settings_playback_anime_skip_client_id_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1875,8 +1887,8 @@ private fun AnimeSkipClientIdDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - TextButton(onClick = onDismiss) { Text("Cancel") } - TextButton(onClick = { onSave(value.trim()) }) { Text("Save") } + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_cancel)) } + TextButton(onClick = { onSave(value.trim()) }) { Text(stringResource(Res.string.action_save)) } } } } @@ -1903,7 +1915,7 @@ private fun NextEpisodeThresholdModeDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Threshold Mode", + text = stringResource(Res.string.settings_playback_threshold_mode), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1916,11 +1928,6 @@ private fun NextEpisodeThresholdModeDialog( } else { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) } - val label = when (mode) { - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> "Percentage" - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> "Minutes Before End" - } - Surface( modifier = Modifier .fillMaxWidth() @@ -1935,7 +1942,7 @@ private fun NextEpisodeThresholdModeDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = label, + text = stringResource(mode.labelRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -1958,7 +1965,7 @@ private fun NextEpisodeThresholdModeDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1966,3 +1973,48 @@ private fun NextEpisodeThresholdModeDialog( } } } + +private fun decoderPriorityRes(priority: Int): StringResource = when (priority) { + 0 -> Res.string.settings_playback_decoder_device_only + 1 -> Res.string.settings_playback_decoder_prefer_device + 2 -> Res.string.settings_playback_decoder_prefer_app + else -> Res.string.settings_playback_decoder_prefer_device +} + +@Composable +private fun decoderPriorityLabel(priority: Int): String = stringResource(decoderPriorityRes(priority)) + +private fun StreamAutoPlaySource.labelRes(pluginsEnabled: Boolean): StringResource = when (this) { + StreamAutoPlaySource.ALL_SOURCES -> + if (pluginsEnabled) Res.string.settings_playback_source_scope_all_sources + else Res.string.settings_playback_source_scope_all_addons + StreamAutoPlaySource.INSTALLED_ADDONS_ONLY -> Res.string.settings_playback_source_scope_installed_addons_only + StreamAutoPlaySource.ENABLED_PLUGINS_ONLY -> Res.string.settings_playback_source_scope_enabled_plugins_only +} + +private val StreamAutoPlayMode.labelRes: StringResource + get() = when (this) { + StreamAutoPlayMode.MANUAL -> Res.string.settings_playback_stream_selection_mode_manual + StreamAutoPlayMode.FIRST_STREAM -> Res.string.settings_playback_stream_selection_mode_first_stream + StreamAutoPlayMode.REGEX_MATCH -> Res.string.settings_playback_stream_selection_mode_regex + } + +private val com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.labelRes: StringResource + get() = when (this) { + com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> + Res.string.settings_playback_threshold_mode_percentage + com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> + Res.string.settings_playback_threshold_mode_minutes_before_end + } + +private fun libassRenderTypeRes(renderType: String): StringResource = when (renderType) { + "OVERLAY_OPEN_GL" -> Res.string.settings_playback_render_type_overlay_opengl + "OVERLAY_CANVAS" -> Res.string.settings_playback_render_type_overlay_canvas + "EFFECTS_OPEN_GL" -> Res.string.settings_playback_render_type_effects_opengl + "EFFECTS_CANVAS" -> Res.string.settings_playback_render_type_effects_canvas + "CUES" -> Res.string.settings_playback_render_type_cues + else -> Res.string.settings_playback_render_type_cues +} + +@Composable +private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt index 17fc67d5..f4f494d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -33,6 +33,32 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.settings_poster_card_radius +import nuvio.composeapp.generated.resources.settings_poster_card_style +import nuvio.composeapp.generated.resources.settings_poster_card_width +import nuvio.composeapp.generated.resources.settings_poster_custom +import nuvio.composeapp.generated.resources.settings_poster_description +import nuvio.composeapp.generated.resources.settings_poster_hide_labels +import nuvio.composeapp.generated.resources.settings_poster_landscape_mode +import nuvio.composeapp.generated.resources.settings_poster_live_preview +import nuvio.composeapp.generated.resources.settings_poster_option_with_value +import nuvio.composeapp.generated.resources.settings_poster_preview_corner_radius +import nuvio.composeapp.generated.resources.settings_poster_preview_height +import nuvio.composeapp.generated.resources.settings_poster_preview_width +import nuvio.composeapp.generated.resources.settings_poster_radius_classic +import nuvio.composeapp.generated.resources.settings_poster_radius_pill +import nuvio.composeapp.generated.resources.settings_poster_radius_rounded +import nuvio.composeapp.generated.resources.settings_poster_radius_sharp +import nuvio.composeapp.generated.resources.settings_poster_radius_subtle +import nuvio.composeapp.generated.resources.settings_poster_width_balanced +import nuvio.composeapp.generated.resources.settings_poster_width_comfort +import nuvio.composeapp.generated.resources.settings_poster_width_compact +import nuvio.composeapp.generated.resources.settings_poster_width_dense +import nuvio.composeapp.generated.resources.settings_poster_width_large +import nuvio.composeapp.generated.resources.settings_poster_width_standard +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.posterCustomizationSettingsContent( isTablet: Boolean, @@ -40,11 +66,11 @@ internal fun LazyListScope.posterCustomizationSettingsContent( ) { item { SettingsSection( - title = "POSTER CARD STYLE", + title = stringResource(Res.string.settings_poster_card_style), isTablet = isTablet, actions = { NuvioActionLabel( - text = "Reset", + text = stringResource(Res.string.action_reset), onClick = PosterCardStyleRepository::resetToDefaults, ) }, @@ -80,19 +106,19 @@ private fun PosterCardStyleControls( onHideLabelsChange: (Boolean) -> Unit, ) { val widthOptions = listOf( - PresetOption("Compact", 104), - PresetOption("Dense", 112), - PresetOption("Standard", 120), - PresetOption("Balanced", 126), - PresetOption("Comfort", 134), - PresetOption("Large", 140), + PresetOption(stringResource(Res.string.settings_poster_width_compact), 104), + PresetOption(stringResource(Res.string.settings_poster_width_dense), 112), + PresetOption(stringResource(Res.string.settings_poster_width_standard), 120), + PresetOption(stringResource(Res.string.settings_poster_width_balanced), 126), + PresetOption(stringResource(Res.string.settings_poster_width_comfort), 134), + PresetOption(stringResource(Res.string.settings_poster_width_large), 140), ) val radiusOptions = listOf( - PresetOption("Sharp", 0), - PresetOption("Subtle", 4), - PresetOption("Classic", 8), - PresetOption("Rounded", 12), - PresetOption("Pill", 16), + PresetOption(stringResource(Res.string.settings_poster_radius_sharp), 0), + PresetOption(stringResource(Res.string.settings_poster_radius_subtle), 4), + PresetOption(stringResource(Res.string.settings_poster_radius_classic), 8), + PresetOption(stringResource(Res.string.settings_poster_radius_rounded), 12), + PresetOption(stringResource(Res.string.settings_poster_radius_pill), 16), ) Column( @@ -102,7 +128,7 @@ private fun PosterCardStyleControls( verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( - text = "Customize card width and corner radius for shared poster cards across the app.", + text = stringResource(Res.string.settings_poster_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -111,13 +137,13 @@ private fun PosterCardStyleControls( cornerRadiusDp = cornerRadiusDp, ) PosterStyleOptionRow( - title = "Card Width", + title = stringResource(Res.string.settings_poster_card_width), selectedValue = widthDp, options = widthOptions, onSelected = onWidthSelected, ) PosterStyleOptionRow( - title = "Card Radius", + title = stringResource(Res.string.settings_poster_card_radius), selectedValue = cornerRadiusDp, options = radiusOptions, onSelected = onCornerRadiusSelected, @@ -127,7 +153,7 @@ private fun PosterCardStyleControls( onCheckedChange = onCatalogLandscapeModeChange, ) PosterToggleRow( - title = "Hide labels", + title = stringResource(Res.string.settings_poster_hide_labels), checked = hideLabelsEnabled, onCheckedChange = onHideLabelsChange, ) @@ -140,7 +166,7 @@ private fun PosterLandscapeModeToggleRow( onCheckedChange: (Boolean) -> Unit, ) { PosterToggleRow( - title = "Landscape mode for shelf posters", + title = stringResource(Res.string.settings_poster_landscape_mode), checked = checked, onCheckedChange = onCheckedChange, ) @@ -205,7 +231,7 @@ private fun PosterCardLivePreview( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Live Preview", + text = stringResource(Res.string.settings_poster_live_preview), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -239,17 +265,17 @@ private fun PosterCardLivePreview( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "Width: ${widthDp}dp", + text = stringResource(Res.string.settings_poster_preview_width, widthDp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Corner radius: ${cornerRadiusDp}dp", + text = stringResource(Res.string.settings_poster_preview_corner_radius, cornerRadiusDp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Height: ${targetHeightDp}dp", + text = stringResource(Res.string.settings_poster_preview_height, targetHeightDp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -273,13 +299,14 @@ private fun PosterStyleOptionRow( options: List, onSelected: (Int) -> Unit, ) { - val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label ?: "Custom" + val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label + ?: stringResource(Res.string.settings_poster_custom) Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "$title ($selectedLabel)", + text = stringResource(Res.string.settings_poster_option_with_value, title, selectedLabel), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -307,4 +334,4 @@ private fun PosterStyleOptionRow( private data class PresetOption( val label: String, val value: Int, -) \ No newline at end of file +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index e835a4da..c8e2e418 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -49,6 +49,17 @@ import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.features.home.HomeCatalogSettingsItem +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon +import nuvio.composeapp.generated.resources.settings_homescreen_display_name +import nuvio.composeapp.generated.resources.settings_homescreen_hero_source +import nuvio.composeapp.generated.resources.settings_homescreen_hidden +import nuvio.composeapp.generated.resources.settings_homescreen_not_in_hero +import nuvio.composeapp.generated.resources.settings_homescreen_pinned +import nuvio.composeapp.generated.resources.settings_homescreen_pinned_to_top +import nuvio.composeapp.generated.resources.settings_homescreen_reorder +import nuvio.composeapp.generated.resources.settings_homescreen_visible +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope @Composable @@ -377,18 +388,37 @@ internal fun HomescreenCatalogRow( overflow = TextOverflow.Ellipsis, ) Text( - text = if (item.isCollection) "Collection • ${item.addonName}" else item.addonName, + text = if (item.isCollection) { + stringResource(Res.string.settings_homescreen_collection_with_addon, item.addonName) + } else { + item.addonName + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = buildString { - append(if (item.enabled) "Visible" else "Hidden") + append( + if (item.enabled) { + stringResource(Res.string.settings_homescreen_visible) + } else { + stringResource(Res.string.settings_homescreen_hidden) + }, + ) if (item.isCollection) { - if (item.isPinnedToTop) append(" • Pinned to top") + if (item.isPinnedToTop) { + append(" • ") + append(stringResource(Res.string.settings_homescreen_pinned_to_top)) + } } else { append(" • ") - append(if (item.heroSourceEnabled) "Hero source" else "Not in hero") + append( + if (item.heroSourceEnabled) { + stringResource(Res.string.settings_homescreen_hero_source) + } else { + stringResource(Res.string.settings_homescreen_not_in_hero) + }, + ) } }, style = MaterialTheme.typography.bodySmall, @@ -415,7 +445,7 @@ internal fun HomescreenCatalogRow( ) { Icon( imageVector = Icons.Rounded.Lock, - contentDescription = "Pinned", + contentDescription = stringResource(Res.string.settings_homescreen_pinned), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) } @@ -435,7 +465,7 @@ internal fun HomescreenCatalogRow( ) { Icon( imageVector = Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.settings_homescreen_reorder), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -452,7 +482,7 @@ internal fun HomescreenCatalogRow( onValueChange = onTitleChange, modifier = Modifier.fillMaxWidth(), singleLine = true, - label = { Text("Display Name") }, + label = { Text(stringResource(Res.string.settings_homescreen_display_name)) }, placeholder = { Text(item.defaultTitle) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index f23a4fc0..4dbd27d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -16,6 +16,14 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen +import nuvio.composeapp.generated.resources.compose_settings_page_plugins +import org.jetbrains.compose.resources.stringResource @Composable fun HomescreenSettingsScreen( @@ -59,7 +67,7 @@ fun HomescreenSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Homescreen", + title = stringResource(Res.string.compose_settings_page_homescreen), onBack = onBack, ) } @@ -85,7 +93,7 @@ fun MetaScreenSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Meta Screen", + title = stringResource(Res.string.compose_settings_page_meta_screen), onBack = onBack, ) } @@ -110,7 +118,7 @@ fun ContinueWatchingSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Continue Watching", + title = stringResource(Res.string.compose_settings_page_continue_watching), onBack = onBack, ) } @@ -137,7 +145,7 @@ fun AddonsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Addons", + title = stringResource(Res.string.compose_settings_page_addons), onBack = onBack, ) } @@ -163,7 +171,7 @@ fun PluginsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Plugins", + title = stringResource(Res.string.compose_settings_page_plugins), onBack = onBack, ) } @@ -180,7 +188,7 @@ fun AccountSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Account", + title = stringResource(Res.string.compose_settings_page_account), onBack = onBack, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index ba432304..e66779fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -6,103 +6,125 @@ import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Settings import androidx.compose.ui.graphics.vector.ImageVector +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_category_about +import nuvio.composeapp.generated.resources.compose_settings_category_general +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_appearance +import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_integrations +import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen +import nuvio.composeapp.generated.resources.compose_settings_page_notifications +import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_plugins +import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization +import nuvio.composeapp.generated.resources.compose_settings_page_root +import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors +import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment +import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.settings_account +import org.jetbrains.compose.resources.StringResource internal enum class SettingsCategory( - val label: String, + val labelRes: StringResource, val icon: ImageVector, ) { - Account("Account", Icons.Rounded.AccountCircle), - General("General", Icons.Rounded.Settings), - About("About", Icons.Rounded.Info), + Account(Res.string.settings_account, Icons.Rounded.AccountCircle), + General(Res.string.compose_settings_category_general, Icons.Rounded.Settings), + About(Res.string.compose_settings_category_about, Icons.Rounded.Info), } internal enum class SettingsPage( - val title: String, + val titleRes: StringResource, val category: SettingsCategory, val parentPage: SettingsPage?, ) { Root( - title = "Settings", + titleRes = Res.string.compose_settings_page_root, category = SettingsCategory.General, parentPage = null, ), Account( - title = "Account", + titleRes = Res.string.compose_settings_page_account, category = SettingsCategory.Account, parentPage = Root, ), SupportersContributors( - title = "Supporters & Contributors", + titleRes = Res.string.compose_settings_page_supporters_contributors, category = SettingsCategory.About, parentPage = Root, ), Playback( - title = "Playback", + titleRes = Res.string.compose_settings_page_playback, category = SettingsCategory.General, parentPage = Root, ), Appearance( - title = "Appearance", + titleRes = Res.string.compose_settings_page_appearance, category = SettingsCategory.General, parentPage = Root, ), Notifications( - title = "Notifications", + titleRes = Res.string.compose_settings_page_notifications, category = SettingsCategory.General, parentPage = Root, ), ContinueWatching( - title = "Continue Watching", + titleRes = Res.string.compose_settings_page_continue_watching, category = SettingsCategory.General, parentPage = Appearance, ), PosterCustomization( - title = "Poster Customization", + titleRes = Res.string.compose_settings_page_poster_customization, category = SettingsCategory.General, parentPage = Appearance, ), ContentDiscovery( - title = "Content & Discovery", + titleRes = Res.string.compose_settings_page_content_discovery, category = SettingsCategory.General, parentPage = Root, ), Addons( - title = "Addons", + titleRes = Res.string.compose_settings_page_addons, category = SettingsCategory.General, parentPage = ContentDiscovery, ), Plugins( - title = "Plugins", + titleRes = Res.string.compose_settings_page_plugins, category = SettingsCategory.General, parentPage = ContentDiscovery, ), Homescreen( - title = "Homescreen", + titleRes = Res.string.compose_settings_page_homescreen, category = SettingsCategory.General, parentPage = ContentDiscovery, ), MetaScreen( - title = "Meta Screen", + titleRes = Res.string.compose_settings_page_meta_screen, category = SettingsCategory.General, parentPage = ContentDiscovery, ), Integrations( - title = "Integrations", + titleRes = Res.string.compose_settings_page_integrations, category = SettingsCategory.General, parentPage = Root, ), TmdbEnrichment( - title = "TMDB Enrichment", + titleRes = Res.string.compose_settings_page_tmdb_enrichment, category = SettingsCategory.General, parentPage = Integrations, ), MdbListRatings( - title = "MDBList Ratings", + titleRes = Res.string.compose_settings_page_mdblist_ratings, category = SettingsCategory.General, parentPage = Integrations, ), TraktAuthentication( - title = "Trakt", + titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, parentPage = Root, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 47b341bc..e97576f6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -20,6 +20,35 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nuvio.app.core.build.AppVersionConfig +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_about_made_with +import nuvio.composeapp.generated.resources.compose_about_version_format +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_appearance +import nuvio.composeapp.generated.resources.compose_settings_page_integrations +import nuvio.composeapp.generated.resources.compose_settings_page_notifications +import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors +import nuvio.composeapp.generated.resources.compose_settings_root_account_description +import nuvio.composeapp.generated.resources.compose_settings_root_appearance_description +import nuvio.composeapp.generated.resources.compose_settings_root_check_updates_description +import nuvio.composeapp.generated.resources.compose_settings_root_check_updates_title +import nuvio.composeapp.generated.resources.compose_settings_root_content_discovery_description +import nuvio.composeapp.generated.resources.compose_settings_root_downloads_description +import nuvio.composeapp.generated.resources.compose_settings_root_downloads_title +import nuvio.composeapp.generated.resources.compose_settings_root_general_section +import nuvio.composeapp.generated.resources.compose_settings_root_integrations_description +import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description +import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description +import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title +import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description +import nuvio.composeapp.generated.resources.compose_settings_root_about_section +import nuvio.composeapp.generated.resources.compose_settings_root_account_section +import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.settings_playback_subtitle +import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.settingsRootContent( isTablet: Boolean, @@ -41,14 +70,14 @@ internal fun LazyListScope.settingsRootContent( if (showAccountSection) { item { SettingsSection( - title = "ACCOUNT", + title = stringResource(Res.string.compose_settings_root_account_section), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { if (onSwitchProfileClick != null) { SettingsNavigationRow( - title = "Switch Profile", - description = "Change to a different profile.", + title = stringResource(Res.string.compose_settings_root_switch_profile_title), + description = stringResource(Res.string.compose_settings_root_switch_profile_description), icon = Icons.Rounded.People, isTablet = isTablet, onClick = onSwitchProfileClick, @@ -56,16 +85,16 @@ internal fun LazyListScope.settingsRootContent( SettingsGroupDivider(isTablet = isTablet) } SettingsNavigationRow( - title = "Account", - description = "Manage your account, sign out, or delete.", + title = stringResource(Res.string.compose_settings_page_account), + description = stringResource(Res.string.compose_settings_root_account_description), icon = Icons.Rounded.AccountCircle, isTablet = isTablet, onClick = onAccountClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Trakt", - description = "Connect Trakt, sync watchlist lists, and save titles directly to Trakt.", + title = stringResource(Res.string.compose_settings_page_trakt), + description = stringResource(Res.string.compose_settings_root_trakt_description), iconPainter = integrationLogoPainter(IntegrationLogo.Trakt), isTablet = isTablet, onClick = onTraktClick, @@ -77,53 +106,53 @@ internal fun LazyListScope.settingsRootContent( if (showGeneralSection) { item { SettingsSection( - title = "GENERAL", + title = stringResource(Res.string.compose_settings_root_general_section), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Appearance", - description = "Tune home presentation and visual preferences.", + title = stringResource(Res.string.compose_settings_page_appearance), + description = stringResource(Res.string.compose_settings_root_appearance_description), icon = Icons.Rounded.Palette, isTablet = isTablet, onClick = onAppearanceClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Content & Discovery", - description = "Manage addons and discovery sources.", + title = stringResource(Res.string.compose_settings_page_content_discovery), + description = stringResource(Res.string.compose_settings_root_content_discovery_description), icon = Icons.Rounded.Extension, isTablet = isTablet, onClick = onContentDiscoveryClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Downloads", - description = "Manage your downloaded movies and episodes.", + title = stringResource(Res.string.compose_settings_root_downloads_title), + description = stringResource(Res.string.compose_settings_root_downloads_description), icon = Icons.Rounded.CloudDownload, isTablet = isTablet, onClick = onDownloadsClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Playback", - description = "Control player behavior and viewing defaults.", + title = stringResource(Res.string.compose_settings_page_playback), + description = stringResource(Res.string.settings_playback_subtitle), icon = Icons.Rounded.PlayArrow, isTablet = isTablet, onClick = onPlaybackClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Integrations", - description = "Connect TMDB and MDBList services.", + title = stringResource(Res.string.compose_settings_page_integrations), + description = stringResource(Res.string.compose_settings_root_integrations_description), icon = Icons.Rounded.Link, isTablet = isTablet, onClick = onIntegrationsClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Notifications", - description = "Manage episode release alerts and send a test notification.", + title = stringResource(Res.string.compose_settings_page_notifications), + description = stringResource(Res.string.compose_settings_root_notifications_description), icon = Icons.Rounded.Notifications, isTablet = isTablet, onClick = onNotificationsClick, @@ -135,13 +164,13 @@ internal fun LazyListScope.settingsRootContent( if (showAboutSection) { item { SettingsSection( - title = "ABOUT", + title = stringResource(Res.string.compose_settings_root_about_section), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Supporters & Contributors", - description = "See cross-app contributors and the supporters backing Nuvio.", + title = stringResource(Res.string.compose_settings_page_supporters_contributors), + description = stringResource(Res.string.about_supporters_contributors_subtitle), icon = Icons.Rounded.Favorite, isTablet = isTablet, onClick = onSupportersContributorsClick, @@ -149,8 +178,8 @@ internal fun LazyListScope.settingsRootContent( if (onCheckForUpdatesClick != null) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Check for updates", - description = "Check for new versions of the app.", + title = stringResource(Res.string.compose_settings_root_check_updates_title), + description = stringResource(Res.string.compose_settings_root_check_updates_description), icon = Icons.Rounded.CloudDownload, isTablet = isTablet, onClick = onCheckForUpdatesClick, @@ -167,14 +196,18 @@ internal fun LazyListScope.settingsRootContent( .padding(horizontal = 20.dp, vertical = if (isTablet) 20.dp else 16.dp), ) { Text( - text = "Made with ❤️ by Tapframe and friends", + text = stringResource(Res.string.compose_about_made_with), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Text( - text = "Version ${AppVersionConfig.VERSION_NAME} (${AppVersionConfig.VERSION_CODE})", + text = stringResource( + Res.string.compose_about_version_format, + AppVersionConfig.VERSION_NAME, + AppVersionConfig.VERSION_CODE, + ), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 3b31e45f..94ec9cd3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -58,6 +58,9 @@ import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_root +import org.jetbrains.compose.resources.stringResource @Composable fun SettingsScreen( @@ -87,6 +90,7 @@ fun SettingsScreen( ThemeSettingsRepository.selectedTheme }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() + val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState @@ -178,6 +182,8 @@ fun SettingsScreen( onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -216,6 +222,8 @@ fun SettingsScreen( onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -264,6 +272,8 @@ private fun MobileSettingsScreen( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + selectedAppLanguage: AppLanguage, + onAppLanguageSelected: (AppLanguage) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -290,7 +300,7 @@ private fun MobileSettingsScreen( stickyHeader { val previousPage = page.previousPage() NuvioScreenHeader( - title = page.title, + title = stringResource(page.titleRes), onBack = previousPage?.let { { onPageChange(it) } }, ) } @@ -339,6 +349,8 @@ private fun MobileSettingsScreen( onThemeSelected = onThemeSelected, amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = onAppLanguageSelected, onContinueWatchingClick = onContinueWatchingClick, onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, ) @@ -422,6 +434,8 @@ private fun TabletSettingsScreen( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + selectedAppLanguage: AppLanguage, + onAppLanguageSelected: (AppLanguage) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -468,7 +482,7 @@ private fun TabletSettingsScreen( .padding(top = topOffset), ) { Text( - text = "Settings", + text = stringResource(Res.string.compose_settings_page_root), modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) @@ -482,7 +496,7 @@ private fun TabletSettingsScreen( Spacer(modifier = Modifier.height(10.dp)) SettingsCategory.entries.forEach { category -> SettingsSidebarItem( - label = category.label, + label = stringResource(category.labelRes), icon = category.icon, selected = category == activeCategory, onClick = { @@ -509,7 +523,11 @@ private fun TabletSettingsScreen( item { val previousPage = page.previousPage() TabletPageHeader( - title = if (page == SettingsPage.Root) activeCategory.label else page.title, + title = if (page == SettingsPage.Root) { + stringResource(activeCategory.labelRes) + } else { + stringResource(page.titleRes) + }, showBack = previousPage != null, onBack = { previousPage?.let(onPageChange) }, ) @@ -561,6 +579,8 @@ private fun TabletSettingsScreen( onThemeSelected = onThemeSelected, amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = onAppLanguageSelected, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index b6fafa2d..ce25497f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -61,6 +61,9 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private enum class CommunityTab { Contributors, @@ -142,7 +145,7 @@ private object SupportersContributorsRepository { mobileResult.exceptionOrNull() ?: tvResult.exceptionOrNull() ?: webResult.exceptionOrNull() - ?: IllegalStateException("Unable to load contributors") + ?: IllegalStateException(getString(Res.string.community_error_unable_load_contributors)) ) } @@ -157,7 +160,7 @@ private object SupportersContributorsRepository { suspend fun getSupporters(limit: Int = 200): Result> = runCatching { val baseUrl = CommunityConfig.DONATIONS_BASE_URL.trim().removeSuffix("/") check(baseUrl.isNotBlank()) { - "Supporters endpoint is not configured. Add DONATIONS_BASE_URL to local.properties." + getString(Res.string.community_supporters_not_configured) } val response = httpRequestRaw( @@ -167,7 +170,7 @@ private object SupportersContributorsRepository { body = "", ) if (response.status !in 200..299) { - error("Donations API error: ${response.status}") + error(getString(Res.string.community_error_supporters_request_failed)) } json.decodeFromString(response.body) @@ -206,7 +209,7 @@ private object SupportersContributorsRepository { body = "", ) if (response.status !in 200..299) { - error("GitHub contributors API error for $repo: ${response.status}") + error(getString(Res.string.community_error_contributors_request_failed)) } contributors += json.decodeFromString>(response.body) @@ -348,7 +351,7 @@ fun SupportersContributorsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Supporters & Contributors", + title = stringResource(Res.string.compose_settings_page_supporters_contributors), onBack = onBack, ) } @@ -373,6 +376,8 @@ private fun SupportersContributorsBody( val donateUrl = remember { CommunityConfig.DONATIONS_DONATE_URL.trim().removeSuffix("/") } val donationsConfigured = remember { CommunityConfig.DONATIONS_BASE_URL.trim().isNotBlank() } val donateConfigured = donateUrl.isNotBlank() + val contributorsErrorFallback = stringResource(Res.string.community_error_unable_load_contributors) + val supportersErrorFallback = stringResource(Res.string.community_error_unable_load_supporters) var uiState by remember { mutableStateOf(CommunityUiState()) } var selectedContributor by remember { mutableStateOf(null) } @@ -400,7 +405,7 @@ private fun SupportersContributorsBody( isContributorsLoading = false, hasLoadedContributors = false, contributors = emptyList(), - contributorsErrorMessage = error.message ?: "Unable to load contributors.", + contributorsErrorMessage = error.message ?: contributorsErrorFallback, ) } } @@ -428,7 +433,7 @@ private fun SupportersContributorsBody( isSupportersLoading = false, hasLoadedSupporters = false, supporters = emptyList(), - supportersErrorMessage = error.message ?: "Unable to load supporters.", + supportersErrorMessage = error.message ?: supportersErrorFallback, ) } } @@ -449,14 +454,14 @@ private fun SupportersContributorsBody( ) { NuvioSurfaceCard { Text( - text = "Community", + text = stringResource(Res.string.community_section_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Spacer(modifier = Modifier.height(10.dp)) Text( - text = "See the people building and supporting Nuvio across Mobile, TV, and Web.", + text = stringResource(Res.string.community_section_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -472,12 +477,12 @@ private fun SupportersContributorsBody( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.size(8.dp)) - Text("Donate") + Text(stringResource(Res.string.action_donate)) } if (!donationsConfigured) { Spacer(modifier = Modifier.height(10.dp)) Text( - text = "Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties.", + text = stringResource(Res.string.community_supporters_not_configured), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -512,13 +517,18 @@ private fun SupportersContributorsBody( selectedContributor?.let { contributor -> val supportUrl = contributorSupportLink(contributor.login) + val contributionSummary = contributorContributionSummary(contributor) CommunityDetailsDialog( title = contributor.login, - subtitle = contributorContributionSummary(contributor), + subtitle = contributionSummary, onDismiss = { selectedContributor = null }, - primaryActionLabel = if (contributor.profileUrl != null) "Open GitHub" else null, + primaryActionLabel = if (contributor.profileUrl != null) { + stringResource(Res.string.community_open_github) + } else { + null + }, onPrimaryAction = contributor.profileUrl?.let { url -> { uriHandler.openUri(url) } }, - secondaryActionLabel = if (supportUrl != null) "Donate" else null, + secondaryActionLabel = if (supportUrl != null) stringResource(Res.string.action_donate) else null, onSecondaryAction = supportUrl?.let { url -> { uriHandler.openUri(url) } }, ) { Row( @@ -535,12 +545,12 @@ private fun SupportersContributorsBody( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = contributorContributionSummary(contributor), + text = contributionSummary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = contributor.profileUrl ?: "GitHub profile unavailable", + text = contributor.profileUrl ?: stringResource(Res.string.community_github_profile_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -568,7 +578,7 @@ private fun SupportersContributorsBody( modifier = Modifier.size(72.dp), ) Text( - text = supporter.message ?: "No message attached.", + text = supporter.message ?: stringResource(Res.string.community_no_message_attached), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -605,7 +615,11 @@ private fun CommunityTabRow( contentAlignment = Alignment.Center, ) { Text( - text = if (tab == CommunityTab.Contributors) "Contributors" else "Supporters", + text = if (tab == CommunityTab.Contributors) { + stringResource(Res.string.community_tab_contributors) + } else { + stringResource(Res.string.community_tab_supporters) + }, style = MaterialTheme.typography.bodyLarge, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, @@ -626,13 +640,13 @@ private fun ContributorsCard( ) { NuvioSurfaceCard { when { - isLoading -> LoadingState(label = "Loading contributors...") + isLoading -> LoadingState(label = stringResource(Res.string.community_loading_contributors)) errorMessage != null -> ErrorState( - title = "Couldn't load contributors", + title = stringResource(Res.string.community_load_contributors_failed), message = errorMessage, onRetry = onRetry, ) - contributors.isEmpty() -> EmptyState(label = "No contributors found.") + contributors.isEmpty() -> EmptyState(label = stringResource(Res.string.community_empty_contributors)) else -> LazyColumn( modifier = Modifier .fillMaxWidth() @@ -664,13 +678,13 @@ private fun SupportersCard( ) { NuvioSurfaceCard { when { - isLoading -> LoadingState(label = "Loading supporters...") + isLoading -> LoadingState(label = stringResource(Res.string.community_loading_supporters)) errorMessage != null -> ErrorState( - title = "Couldn't load supporters", + title = stringResource(Res.string.community_load_supporters_failed), message = errorMessage, onRetry = onRetry, ) - supporters.isEmpty() -> EmptyState(label = "No supporters found.") + supporters.isEmpty() -> EmptyState(label = stringResource(Res.string.community_empty_supporters)) else -> LazyColumn( modifier = Modifier .fillMaxWidth() @@ -905,7 +919,7 @@ private fun ErrorState( textAlign = TextAlign.Center, ) Button(onClick = onRetry) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } @@ -969,8 +983,9 @@ private fun CommunityDetailsDialog( } } +@Composable private fun contributorContributionSummary(contributor: CommunityContributor): String = - "${contributor.totalContributions} total commits" + stringResource(Res.string.community_total_commits, contributor.totalContributions) private fun contributorSupportLink(login: String): String? = when (login.lowercase()) { "skoruppa" -> "https://ko-fi.com/skoruppa" @@ -978,6 +993,7 @@ private fun contributorSupportLink(login: String): String? = when (login.lowerca else -> null } +@Composable private fun formatDonationDate(rawDate: String): String { val datePart = rawDate.substringBefore('T') val parts = datePart.split('-') @@ -985,10 +1001,20 @@ private fun formatDonationDate(rawDate: String): String { val year = parts[0] val month = parts[1].toIntOrNull()?.let { monthIndex -> listOf( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + stringResource(Res.string.community_month_jan), + stringResource(Res.string.community_month_feb), + stringResource(Res.string.community_month_mar), + stringResource(Res.string.community_month_apr), + stringResource(Res.string.community_month_may), + stringResource(Res.string.community_month_jun), + stringResource(Res.string.community_month_jul), + stringResource(Res.string.community_month_aug), + stringResource(Res.string.community_month_sep), + stringResource(Res.string.community_month_oct), + stringResource(Res.string.community_month_nov), + stringResource(Res.string.community_month_dec), ).getOrNull(monthIndex - 1) } ?: return rawDate val day = parts[2].toIntOrNull()?.toString() ?: return rawDate - return "$month $day, $year" -} \ No newline at end of file + return stringResource(Res.string.community_date_format, month, day, year) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index af35280e..8a2b5241 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -12,6 +12,9 @@ object ThemeSettingsRepository { private val _amoledEnabled = MutableStateFlow(false) val amoledEnabled: StateFlow = _amoledEnabled.asStateFlow() + private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) + val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() + private var hasLoaded = false fun ensureLoaded() { @@ -27,6 +30,7 @@ object ThemeSettingsRepository { hasLoaded = false _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false + _selectedAppLanguage.value = AppLanguage.ENGLISH } private fun loadFromDisk() { @@ -43,6 +47,9 @@ object ThemeSettingsRepository { } _selectedTheme.value = theme _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false + val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) + _selectedAppLanguage.value = appLanguage + ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) } fun setTheme(theme: AppTheme) { @@ -58,4 +65,12 @@ object ThemeSettingsRepository { _amoledEnabled.value = enabled ThemeSettingsStorage.saveAmoledEnabled(enabled) } + + fun setAppLanguage(language: AppLanguage) { + ensureLoaded() + if (_selectedAppLanguage.value == language) return + _selectedAppLanguage.value = language + ThemeSettingsStorage.saveSelectedAppLanguage(language.code) + ThemeSettingsStorage.applySelectedAppLanguage(language.code) + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 9de3eb78..dc39dee5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -7,6 +7,9 @@ internal expect object ThemeSettingsStorage { fun saveSelectedTheme(themeName: String) fun loadAmoledEnabled(): Boolean? fun saveAmoledEnabled(enabled: Boolean) + fun loadSelectedAppLanguage(): String? + fun saveSelectedAppLanguage(languageCode: String) + fun applySelectedAppLanguage(languageCode: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index 9a586d3f..8e1c330f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -22,6 +22,44 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.normalizeLanguage +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.settings_tmdb_add_api_key_first +import nuvio.composeapp.generated.resources.settings_tmdb_api_key_label +import nuvio.composeapp.generated.resources.settings_tmdb_enable_enrichment +import nuvio.composeapp.generated.resources.settings_tmdb_enable_enrichment_description +import nuvio.composeapp.generated.resources.settings_tmdb_enter_api_key +import nuvio.composeapp.generated.resources.settings_tmdb_language_code_label +import nuvio.composeapp.generated.resources.settings_tmdb_module_artwork +import nuvio.composeapp.generated.resources.settings_tmdb_module_artwork_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_basic_info +import nuvio.composeapp.generated.resources.settings_tmdb_module_basic_info_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_collections +import nuvio.composeapp.generated.resources.settings_tmdb_module_collections_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_credits +import nuvio.composeapp.generated.resources.settings_tmdb_module_credits_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_details +import nuvio.composeapp.generated.resources.settings_tmdb_module_details_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes +import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this +import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_networks +import nuvio.composeapp.generated.resources.settings_tmdb_module_networks_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_production_companies +import nuvio.composeapp.generated.resources.settings_tmdb_module_production_companies_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_season_posters +import nuvio.composeapp.generated.resources.settings_tmdb_module_season_posters_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_trailers +import nuvio.composeapp.generated.resources.settings_tmdb_module_trailers_description +import nuvio.composeapp.generated.resources.settings_tmdb_personal_api_key +import nuvio.composeapp.generated.resources.settings_tmdb_preferred_language +import nuvio.composeapp.generated.resources.settings_tmdb_preferred_language_description +import nuvio.composeapp.generated.resources.settings_tmdb_section_credentials +import nuvio.composeapp.generated.resources.settings_tmdb_section_localization +import nuvio.composeapp.generated.resources.settings_tmdb_section_modules +import nuvio.composeapp.generated.resources.settings_tmdb_section_title +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.tmdbSettingsContent( isTablet: Boolean, @@ -32,13 +70,13 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "TMDB", + title = stringResource(Res.string.settings_tmdb_section_title), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Enable TMDB enrichment", - description = "Use your TMDB API key to enrich addon metadata on the details screen when a TMDB or IMDb ID is available.", + title = stringResource(Res.string.settings_tmdb_enable_enrichment), + description = stringResource(Res.string.settings_tmdb_enable_enrichment_description), checked = settings.enabled, enabled = settings.hasApiKey, isTablet = isTablet, @@ -48,7 +86,7 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbInfoRow( isTablet = isTablet, - text = "Add your own TMDB API key below before turning enrichment on.", + text = stringResource(Res.string.settings_tmdb_add_api_key_first), ) } } @@ -57,7 +95,7 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "CREDENTIALS", + title = stringResource(Res.string.settings_tmdb_section_credentials), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -72,7 +110,7 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "LOCALIZATION", + title = stringResource(Res.string.settings_tmdb_section_localization), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -88,14 +126,14 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "MODULES", + title = stringResource(Res.string.settings_tmdb_section_modules), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { TmdbToggleRow( isTablet = isTablet, - title = "Trailers", - description = "Fetch and show TMDB trailer videos section on detail pages.", + title = stringResource(Res.string.settings_tmdb_module_trailers), + description = stringResource(Res.string.settings_tmdb_module_trailers_description), checked = settings.useTrailers, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseTrailers, @@ -103,8 +141,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Artwork", - description = "Replace backdrop, poster, and logo with TMDB artwork.", + title = stringResource(Res.string.settings_tmdb_module_artwork), + description = stringResource(Res.string.settings_tmdb_module_artwork_description), checked = settings.useArtwork, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseArtwork, @@ -112,8 +150,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Basic info", - description = "Use TMDB title, synopsis, genres, and rating.", + title = stringResource(Res.string.settings_tmdb_module_basic_info), + description = stringResource(Res.string.settings_tmdb_module_basic_info_description), checked = settings.useBasicInfo, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseBasicInfo, @@ -121,8 +159,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Details", - description = "Use TMDB release info, runtime, age rating, status, country, and language.", + title = stringResource(Res.string.settings_tmdb_module_details), + description = stringResource(Res.string.settings_tmdb_module_details_description), checked = settings.useDetails, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseDetails, @@ -130,8 +168,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Credits", - description = "Use TMDB creators, directors, writers, and cast photos.", + title = stringResource(Res.string.settings_tmdb_module_credits), + description = stringResource(Res.string.settings_tmdb_module_credits_description), checked = settings.useCredits, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseCredits, @@ -139,8 +177,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Production companies", - description = "Use TMDB production company metadata on the details screen.", + title = stringResource(Res.string.settings_tmdb_module_production_companies), + description = stringResource(Res.string.settings_tmdb_module_production_companies_description), checked = settings.useProductions, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseProductions, @@ -148,8 +186,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Networks", - description = "Use TMDB network metadata for TV titles.", + title = stringResource(Res.string.settings_tmdb_module_networks), + description = stringResource(Res.string.settings_tmdb_module_networks_description), checked = settings.useNetworks, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseNetworks, @@ -157,8 +195,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Episodes", - description = "Use TMDB episode titles, thumbnails, descriptions, and runtimes for series.", + title = stringResource(Res.string.settings_tmdb_module_episodes), + description = stringResource(Res.string.settings_tmdb_module_episodes_description), checked = settings.useEpisodes, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseEpisodes, @@ -166,8 +204,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Season posters", - description = "Use TMDB season posters in the metadata screen season selector for series.", + title = stringResource(Res.string.settings_tmdb_module_season_posters), + description = stringResource(Res.string.settings_tmdb_module_season_posters_description), checked = settings.useSeasonPosters, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseSeasonPosters, @@ -175,8 +213,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "More like this", - description = "Show TMDB recommendations at the bottom of detail pages.", + title = stringResource(Res.string.settings_tmdb_module_more_like_this), + description = stringResource(Res.string.settings_tmdb_module_more_like_this_description), checked = settings.useMoreLikeThis, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseMoreLikeThis, @@ -184,8 +222,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Collections", - description = "Show franchise and collection rails for movies when TMDB provides them.", + title = stringResource(Res.string.settings_tmdb_module_collections), + description = stringResource(Res.string.settings_tmdb_module_collections_description), checked = settings.useCollections, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseCollections, @@ -213,13 +251,13 @@ private fun TmdbApiKeyRow( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "Personal API key", + text = stringResource(Res.string.settings_tmdb_personal_api_key), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Enter your TMDB v3 API key.", + text = stringResource(Res.string.settings_tmdb_enter_api_key), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -234,7 +272,7 @@ private fun TmdbApiKeyRow( }, modifier = Modifier.fillMaxWidth(), singleLine = true, - label = { Text("TMDB API key") }, + label = { Text(stringResource(Res.string.settings_tmdb_api_key_label)) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), @@ -252,7 +290,7 @@ private fun TmdbApiKeyRow( }, enabled = normalizedDraft != value, ) { - Text("Save Key") + Text(stringResource(Res.string.action_save)) } } } @@ -278,13 +316,13 @@ private fun TmdbLanguageRow( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "Preferred language", + text = stringResource(Res.string.settings_tmdb_preferred_language), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Set the TMDB language code used for localized metadata, for example `en`, `en-US`, or `pt-BR`.", + text = stringResource(Res.string.settings_tmdb_preferred_language_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -298,7 +336,7 @@ private fun TmdbLanguageRow( enabled = enabled, modifier = Modifier.fillMaxWidth(), singleLine = true, - label = { Text("Language code") }, + label = { Text(stringResource(Res.string.settings_tmdb_language_code_label)) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), @@ -316,7 +354,7 @@ private fun TmdbLanguageRow( }, enabled = enabled && normalizedDraft != value, ) { - Text("Save Language") + Text(stringResource(Res.string.action_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index b57ce371..82130875 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -24,6 +24,25 @@ import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.traktBrandPainter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect +import nuvio.composeapp.generated.resources.settings_trakt_authentication +import nuvio.composeapp.generated.resources.settings_trakt_comments +import nuvio.composeapp.generated.resources.settings_trakt_comments_description +import nuvio.composeapp.generated.resources.settings_trakt_connect +import nuvio.composeapp.generated.resources.settings_trakt_connected_as +import nuvio.composeapp.generated.resources.settings_trakt_default_user +import nuvio.composeapp.generated.resources.settings_trakt_disconnect +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import nuvio.composeapp.generated.resources.settings_trakt_features +import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in +import nuvio.composeapp.generated.resources.settings_trakt_intro_description +import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials +import nuvio.composeapp.generated.resources.settings_trakt_open_login +import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description +import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.traktSettingsContent( isTablet: Boolean, @@ -39,7 +58,7 @@ internal fun LazyListScope.traktSettingsContent( item { SettingsSection( - title = "AUTHENTICATION", + title = stringResource(Res.string.settings_trakt_authentication), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -54,13 +73,13 @@ internal fun LazyListScope.traktSettingsContent( if (uiState.mode == TraktConnectionMode.CONNECTED) { item { SettingsSection( - title = "FEATURES", + title = stringResource(Res.string.settings_trakt_features), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Comments", - description = "Show Trakt comments on movie and show details", + title = stringResource(Res.string.settings_trakt_comments), + description = stringResource(Res.string.settings_trakt_comments_description), checked = commentsEnabled, isTablet = isTablet, onCheckedChange = onCommentsEnabledChange, @@ -92,12 +111,12 @@ private fun TraktBrandIntro( ) { androidx.compose.foundation.Image( painter = traktBrandPainter(TraktBrandAsset.Glyph), - contentDescription = "Trakt", + contentDescription = null, modifier = Modifier.size(if (isTablet) 84.dp else 72.dp), contentScale = ContentScale.Fit, ) Text( - text = "Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt.", + text = stringResource(Res.string.settings_trakt_intro_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -113,6 +132,7 @@ private fun TraktConnectionCard( val uriHandler = LocalUriHandler.current val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp + val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) Column( modifier = Modifier @@ -123,13 +143,16 @@ private fun TraktConnectionCard( when (uiState.mode) { TraktConnectionMode.CONNECTED -> { Text( - text = "Connected as ${uiState.username ?: "Trakt user"}", + text = stringResource( + Res.string.settings_trakt_connected_as, + uiState.username ?: stringResource(Res.string.settings_trakt_default_user), + ), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Your Save actions can now target Trakt watchlist and personal lists.", + text = stringResource(Res.string.settings_trakt_save_actions_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -148,20 +171,20 @@ private fun TraktConnectionCard( modifier = Modifier.size(18.dp), ) } else { - Text("Disconnect") + Text(stringResource(Res.string.settings_trakt_disconnect)) } } } TraktConnectionMode.AWAITING_APPROVAL -> { Text( - text = "Finish Trakt sign in in your browser", + text = stringResource(Res.string.settings_trakt_finish_sign_in), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "After approval, you will be redirected back automatically.", + text = stringResource(Res.string.settings_trakt_approval_redirect), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -173,13 +196,13 @@ private fun TraktConnectionCard( runCatching { uriHandler.openUri(authUrl) } .onFailure { TraktAuthRepository.onAuthLaunchFailed( - it.message ?: "Failed to open browser", + it.message ?: failedOpenBrowserMessage, ) } }, enabled = !uiState.isLoading, ) { - Text("Open Trakt Login") + Text(stringResource(Res.string.settings_trakt_open_login)) } Button( onClick = TraktAuthRepository::onCancelAuthorization, @@ -189,13 +212,13 @@ private fun TraktConnectionCard( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } } TraktConnectionMode.DISCONNECTED -> { Text( - text = "Sign in with Trakt to enable list-based saving and Trakt library mode.", + text = stringResource(Res.string.settings_trakt_sign_in_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -205,7 +228,7 @@ private fun TraktConnectionCard( runCatching { uriHandler.openUri(authUrl) } .onFailure { TraktAuthRepository.onAuthLaunchFailed( - it.message ?: "Failed to open browser", + it.message ?: failedOpenBrowserMessage, ) } }, @@ -218,12 +241,12 @@ private fun TraktConnectionCard( modifier = Modifier.size(18.dp), ) } else { - Text("Connect Trakt") + Text(stringResource(Res.string.settings_trakt_connect)) } } if (!uiState.credentialsConfigured) { Text( - text = "Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).", + text = stringResource(Res.string.settings_trakt_missing_credentials), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 8933ae87..545f71fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -1,5 +1,9 @@ package com.nuvio.app.features.streams +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + data class StreamItem( val name: String? = null, val description: String? = null, @@ -13,7 +17,7 @@ data class StreamItem( val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), ) { val streamLabel: String - get() = name ?: "Stream" + get() = name ?: runBlocking { getString(Res.string.stream_default_name) } val streamSubtitle: String? get() = description diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index d9516513..98d6e7e3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.coroutines.launch object StreamsRepository { @@ -313,7 +315,7 @@ object StreamsRepository { StreamLoadCompletion.PluginScraper( addonId = providerGroup.addonId, streams = emptyList(), - error = error.message ?: "Failed to load ${scraper.name}", + error = error.message ?: getString(Res.string.streams_failed_to_load_scraper, scraper.name), ) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 3dcffda4..fdf38d55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider @@ -87,6 +88,8 @@ import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch import kotlin.math.round import kotlin.math.roundToInt +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource // --------------------------------------------------------------------------- // Streams Screen @@ -124,6 +127,8 @@ fun StreamsScreen( } val isEpisode = seasonNumber != null && episodeNumber != null val clipboardManager = LocalClipboardManager.current + val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) + val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { @@ -257,7 +262,7 @@ fun StreamsScreen( ) { Icon( imageVector = Icons.Rounded.Refresh, - contentDescription = "Refresh streams", + contentDescription = stringResource(Res.string.streams_refresh), tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp), ) @@ -295,7 +300,7 @@ fun StreamsScreen( strokeWidth = 2.5.dp, ) Text( - text = "Finding source...", + text = stringResource(Res.string.streams_finding_source), style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.8f), ) @@ -310,9 +315,9 @@ fun StreamsScreen( val directUrl = stream.directPlaybackUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) - NuvioToastController.show("Stream link copied") + NuvioToastController.show(streamLinkCopiedText) } else { - NuvioToastController.show("No direct stream link available") + NuvioToastController.show(noDirectStreamLinkText) } }, onDownload = { stream -> @@ -331,7 +336,7 @@ fun StreamsScreen( episodeThumbnail = episodeThumbnail, stream = stream, ) - NuvioToastController.show(result.toastMessage) + NuvioToastController.show(result.toastMessage()) }, ) } @@ -446,8 +451,14 @@ internal fun ResumeBanner( modifier: Modifier = Modifier, ) { val resumeText = when { - progressFraction != null && progressFraction > 0f -> "Resume from ${(progressFraction * 100f).roundToInt()}%" - positionMs != null && positionMs > 0L -> "Resume from ${positionMs.toPlaybackClock()}" + progressFraction != null && progressFraction > 0f -> stringResource( + Res.string.streams_resume_from_percent, + (progressFraction * 100f).roundToInt(), + ) + positionMs != null && positionMs > 0L -> stringResource( + Res.string.streams_resume_from_time, + positionMs.toPlaybackClock(), + ) else -> null } ?: return @@ -576,7 +587,7 @@ private fun EpisodeHeroBlock( ) { // Episode label Text( - text = "S${seasonNumber} E${episodeNumber}", + text = stringResource(Res.string.streams_episode_badge, seasonNumber, episodeNumber), style = MaterialTheme.typography.labelMedium.copy( fontSize = 14.sp, fontWeight = FontWeight.Bold, @@ -634,7 +645,7 @@ internal fun ProviderFilterRow( ) { // "All" chip FilterChip( - label = "All", + label = stringResource(Res.string.collections_tab_all), isSelected = selectedFilter == null, onClick = { onFilterSelected(null) }, ) @@ -888,7 +899,7 @@ private fun StreamSectionHeader( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "Fetching…", + text = stringResource(Res.string.streams_fetching), style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), color = MaterialTheme.colorScheme.primary, ) @@ -1036,7 +1047,7 @@ private fun StreamActionsSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Rounded.ContentCopy, - title = "Copy stream link", + title = stringResource(Res.string.streams_copy_link), onClick = { onCopyLink(stream) coroutineScope.launch { @@ -1047,7 +1058,7 @@ private fun StreamActionsSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Rounded.Download, - title = "Download file", + title = stringResource(Res.string.streams_download_file), onClick = { onDownload(stream) coroutineScope.launch { @@ -1065,10 +1076,10 @@ private fun StreamFileSizeBadge(stream: StreamItem) { val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) val sizeLabel = if (gib >= 1.0) { val roundedGiB = round(gib * 10.0) / 10.0 - "$roundedGiB GB" + "$roundedGiB ${localizedByteUnit("GB")}" } else { val mib = bytes.toDouble() / (1024.0 * 1024.0) - "${round(mib).toInt()} MB" + "${round(mib).toInt()} ${localizedByteUnit("MB")}" } Box( @@ -1078,7 +1089,7 @@ private fun StreamFileSizeBadge(stream: StreamItem) { .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( - text = "SIZE $sizeLabel", + text = stringResource(Res.string.streams_size, sizeLabel), style = MaterialTheme.typography.labelSmall.copy( fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -1129,7 +1140,7 @@ private fun LoadingStateBlock(modifier: Modifier = Modifier) { modifier = Modifier.size(32.dp), ) Text( - text = "Finding streams…", + text = stringResource(Res.string.streams_finding_streams), style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -1149,23 +1160,23 @@ private fun EmptyStateBlock( when (reason) { StreamsEmptyStateReason.NoAddonsInstalled -> { - title = "No addons installed" - message = "Install an addon first to load streams for this title." + title = stringResource(Res.string.compose_search_empty_no_active_addons_title) + message = stringResource(Res.string.streams_empty_no_addons_message) } StreamsEmptyStateReason.NoCompatibleAddons -> { - title = "No stream addon available" - message = "Your installed addons do not provide streams for this type of title." + title = stringResource(Res.string.streams_empty_no_stream_addon_title) + message = stringResource(Res.string.streams_empty_no_stream_addon_message) } StreamsEmptyStateReason.StreamFetchFailed -> { - title = "Could not load streams" - message = "The installed stream addons failed to return a valid stream response." + title = stringResource(Res.string.streams_empty_load_failed_title) + message = stringResource(Res.string.streams_empty_load_failed_message) } StreamsEmptyStateReason.NoStreamsFound, null -> { - title = "No streams found" - message = "None of your installed addons returned streams for this title." + title = stringResource(Res.string.compose_player_no_streams_found) + message = stringResource(Res.string.streams_empty_no_streams_message) } } @@ -1216,7 +1227,7 @@ private fun FooterLoadingBlock(modifier: Modifier = Modifier) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Checking more addons…", + text = stringResource(Res.string.streams_checking_more_addons), style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index a7410f7f..3f33ce98 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -45,6 +45,8 @@ import com.nuvio.app.isIos import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable internal fun TabletStreamsLayout( @@ -250,7 +252,7 @@ private fun TabletMovieInfoPanel( ) } else { Text( - text = "No metadata available", + text = stringResource(Res.string.streams_no_metadata), style = MaterialTheme.typography.bodyLarge.copy( fontSize = 16.sp, fontStyle = FontStyle.Italic, @@ -309,7 +311,12 @@ private fun TabletEpisodeInfoPanel( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "S${seasonNumber}E${episodeNumber} - ${episodeTitle?.takeIf { it.isNotBlank() } ?: "Episode"}", + text = stringResource( + Res.string.streams_episode_title_with_name, + seasonNumber, + episodeNumber, + episodeTitle?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.streams_episode_fallback_title), + ), style = MaterialTheme.typography.bodyLarge.copy( fontSize = 16.sp, lineHeight = 24.sp, @@ -340,7 +347,7 @@ private fun ActiveScrapersStatusBlock( .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Active scrapers", + text = stringResource(Res.string.streams_active_scrapers), style = MaterialTheme.typography.labelSmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -375,4 +382,3 @@ private fun ActiveScrapersStatusBlock( } } } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index b59f9ad1..f398257f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -14,10 +14,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object TmdbMetadataService { private val log = Logger.withTag("TmdbMetadata") @@ -94,7 +97,7 @@ object TmdbMetadataService { val detail = PersonDetail( tmdbId = person.id ?: personId, - name = person.name ?: "Unknown", + name = person.name ?: runBlocking { getString(Res.string.generic_unknown) }, biography = biography, birthday = person.birthday?.takeIf { it.isNotBlank() }, deathday = person.deathday?.takeIf { it.isNotBlank() }, @@ -324,7 +327,7 @@ object TmdbMetadataService { header = header ?: TmdbEntityHeader( id = entityId, kind = entityKind, - name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown", + name = fallbackName?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.generic_unknown) }, logo = null, originCountry = null, secondaryLabel = null, @@ -439,7 +442,7 @@ object TmdbMetadataService { kind = entityKind, name = it.name?.takeIf { n -> n.isNotBlank() } ?: fallbackName?.takeIf { n -> n.isNotBlank() } - ?: "Unknown", + ?: runBlocking { getString(Res.string.generic_unknown) }, logo = buildImageUrl(it.logoPath, "w500"), originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, @@ -455,7 +458,7 @@ object TmdbMetadataService { kind = entityKind, name = it.name?.takeIf { n -> n.isNotBlank() } ?: fallbackName?.takeIf { n -> n.isNotBlank() } - ?: "Unknown", + ?: runBlocking { getString(Res.string.generic_unknown) }, logo = buildImageUrl(it.logoPath, "w500"), originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, @@ -1073,7 +1076,13 @@ object TmdbMetadataService { allVideos += videos.map { video -> video.toMetaTrailer( seasonNumber = seasonNumber, - displayName = "Season $seasonNumber - ${video.name}", + displayName = runBlocking { + getString( + Res.string.trailer_season_label, + seasonNumber, + video.name.orEmpty(), + ) + }, ) } } @@ -1087,7 +1096,9 @@ object TmdbMetadataService { trailer.site.equals("YouTube", ignoreCase = true) && trailer.key.isNotBlank() } .forEach { trailer -> - byCategory.getOrPut(trailer.type.ifBlank { "Trailer" }) { mutableListOf() } + byCategory.getOrPut( + trailer.type.ifBlank { runBlocking { getString(Res.string.generic_trailer) } }, + ) { mutableListOf() } .add(trailer) } @@ -1108,7 +1119,10 @@ object TmdbMetadataService { val sortedCategories = byCategory.keys.sortedWith( compareBy { category -> when { - category.equals("Trailer", ignoreCase = true) -> 0 + category.equals( + runBlocking { getString(Res.string.generic_trailer) }, + ignoreCase = true, + ) -> 0 byCategory[category].orEmpty().any { it.official } -> 1 else -> 2 } @@ -1231,7 +1245,7 @@ private fun buildPeople( val name = creator.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = "Creator", + role = runBlocking { getString(Res.string.person_role_creator) }, photo = buildImageUrl(creator.profilePath, "w500"), tmdbId = creator.id, ) @@ -1246,7 +1260,7 @@ private fun buildPeople( val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = "Director", + role = runBlocking { getString(Res.string.person_role_director) }, photo = buildImageUrl(crew.profilePath, "w500"), tmdbId = crew.id, ) @@ -1261,7 +1275,7 @@ private fun buildPeople( val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = "Writer", + role = runBlocking { getString(Res.string.person_role_writer) }, photo = buildImageUrl(crew.profilePath, "w500"), tmdbId = crew.id, ) @@ -1469,7 +1483,7 @@ private fun TmdbVideoResult.toMetaTrailer( displayName: String?, ): MetaTrailer { val videoKey = key?.trim().orEmpty() - val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: "Trailer" + val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) } val trailerId = id?.trim().takeUnless { it.isNullOrBlank() } ?: videoKey return MetaTrailer( id = trailerId, @@ -1477,7 +1491,7 @@ private fun TmdbVideoResult.toMetaTrailer( name = videoName, site = site?.trim().takeUnless { it.isNullOrBlank() } ?: "YouTube", size = size, - type = type?.trim().takeUnless { it.isNullOrBlank() } ?: "Trailer", + type = type?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) }, official = official == true, publishedAt = publishedAt, seasonNumber = seasonNumber, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index db61d844..3fed8022 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -19,6 +19,10 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.random.Random +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.StringResource +import kotlinx.coroutines.runBlocking object TraktAuthRepository { private const val BASE_URL = "https://api.trakt.tv" @@ -67,7 +71,7 @@ object TraktAuthRepository { fun onConnectRequested(): String? { ensureLoaded() if (!hasRequiredCredentials()) { - publish(errorMessage = "Missing Trakt credentials") + publish(errorMessage = localizedString(Res.string.trakt_missing_credentials)) return null } @@ -78,7 +82,7 @@ object TraktAuthRepository { ) persist() publish( - statusMessage = "Complete Trakt sign in in your browser", + statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser), errorMessage = null, ) @@ -183,7 +187,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = "Invalid Trakt callback", + errorMessage = localizedString(Res.string.trakt_invalid_callback), ) return } @@ -191,7 +195,7 @@ object TraktAuthRepository { val errorCode = parsedUrl.parameters["error"] if (!errorCode.isNullOrBlank()) { val errorDescription = parsedUrl.parameters["error_description"] - ?: "Authorization denied" + ?: localizedString(Res.string.trakt_authorization_denied) clearPendingAuthorization() persist() publish( @@ -207,7 +211,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = "Trakt did not return an authorization code", + errorMessage = localizedString(Res.string.trakt_missing_auth_code), ) return } @@ -219,7 +223,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = "Invalid Trakt callback state", + errorMessage = localizedString(Res.string.trakt_invalid_callback_state), ) return } @@ -251,7 +255,7 @@ object TraktAuthRepository { if (response == null) { clearPendingAuthorization() persist() - publish(isLoading = false, errorMessage = "Failed to complete Trakt sign in") + publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_sign_in_complete_failed)) return } @@ -262,7 +266,7 @@ object TraktAuthRepository { if (parsed == null) { clearPendingAuthorization() persist() - publish(isLoading = false, errorMessage = "Invalid Trakt token response") + publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response)) return } @@ -490,3 +494,4 @@ private data class TraktUserDto( private data class TraktUserIdsDto( val slug: String? = null, ) + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt index 33536361..378b8ef3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -4,9 +4,12 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetails +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString private const val COMMENTS_SORT = "likes" private const val COMMENTS_LIMIT = 100 @@ -224,7 +227,7 @@ private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview { val authorDisplayName = dto.user?.name ?.takeIf { it.isNotBlank() } ?: dto.user?.username?.takeIf { it.isNotBlank() } - ?: "Trakt user" + ?: runBlocking { getString(Res.string.trakt_user_fallback) } return TraktCommentReview( id = dto.id, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index c2100bc8..4e2468e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext @@ -182,7 +184,7 @@ object TraktLibraryRepository { _uiState.value = current.copy( isLoading = false, hasLoaded = true, - errorMessage = "Failed to load Trakt library", + errorMessage = getString(Res.string.trakt_library_load_failed), ) return } @@ -487,7 +489,7 @@ object TraktLibraryRepository { val watchlistTabs = listOf( TraktListTab( key = WATCHLIST_KEY, - title = "Watchlist", + title = getString(Res.string.trakt_watchlist), type = TraktListType.WATCHLIST, ), ) @@ -629,7 +631,7 @@ object TraktLibraryRepository { val traktId = list.ids?.trakt ?: return@mapNotNull null TraktListTab( key = "$PERSONAL_LIST_PREFIX$traktId", - title = list.name?.ifBlank { null } ?: "List $traktId", + title = list.name?.ifBlank { null } ?: getString(Res.string.trakt_list_fallback_title, traktId), type = TraktListType.PERSONAL, traktListId = traktId, slug = list.ids.slug, @@ -934,4 +936,3 @@ private data class TraktListShowRequestItemDto( val year: Int? = null, val ids: TraktIdsDto? = null, ) - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index b75022a6..6d10a78c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -93,7 +95,10 @@ object TraktProgressRepository { }.getOrNull() if (playbackEntries == null) { - _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Failed to load Trakt progress") + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = getString(Res.string.trakt_progress_load_failed), + ) return } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 302e860e..44ce4441 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.coroutines.CoroutineScope @@ -48,6 +49,9 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private const val gitHubOwner = "NuvioMedia" private const val gitHubRepo = "NuvioMobile" @@ -232,7 +236,9 @@ class AppUpdaterController internal constructor( fun checkForUpdates(force: Boolean, showNoUpdateFeedback: Boolean) { if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { if (showNoUpdateFeedback) { - NuvioToastController.show("In-app updates are not available on this build.") + scope.launch { + NuvioToastController.show(getString(Res.string.updates_not_available)) + } } return } @@ -269,7 +275,7 @@ class AppUpdaterController internal constructor( } if (showNoUpdateFeedback && !remoteNewer) { - NuvioToastController.show("You're using the latest version.") + NuvioToastController.show(getString(Res.string.updates_latest_version)) } }.onFailure { error -> _uiState.update { state -> @@ -283,7 +289,7 @@ class AppUpdaterController internal constructor( showDialog = force && error !is NoChannelReleaseException, showUnknownSourcesDialog = false, errorMessage = if (force && error !is NoChannelReleaseException) { - error.message ?: "Update check failed" + error.message ?: getString(Res.string.updates_check_failed) } else { null }, @@ -291,7 +297,7 @@ class AppUpdaterController internal constructor( } if (showNoUpdateFeedback || error is NoChannelReleaseException) { - NuvioToastController.show(error.message ?: "Update check failed") + NuvioToastController.show(error.message ?: getString(Res.string.updates_check_failed)) } } } @@ -351,7 +357,7 @@ class AppUpdaterController internal constructor( isDownloading = false, downloadProgress = null, downloadedApkPath = null, - errorMessage = error.message ?: "Download failed", + errorMessage = error.message ?: getString(Res.string.updates_download_failed), showDialog = true, ) } @@ -369,11 +375,14 @@ class AppUpdaterController internal constructor( AppUpdaterPlatform.installDownloadedApk(apkPath).onSuccess { _uiState.update { state -> state.copy(showUnknownSourcesDialog = false) } }.onFailure { error -> - _uiState.update { state -> - state.copy( - errorMessage = error.message ?: "Unable to start installation", - showDialog = true, - ) + scope.launch { + val fallbackMessage = error.message ?: getString(Res.string.updates_install_failed) + _uiState.update { state -> + state.copy( + errorMessage = fallbackMessage, + showDialog = true, + ) + } } } } @@ -437,9 +446,9 @@ fun AppUpdaterHost( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = when { - state.showUnknownSourcesDialog -> "Allow installs to continue" - state.isUpdateAvailable -> state.update?.title ?: "Update available" - else -> "Update status" + state.showUnknownSourcesDialog -> stringResource(Res.string.updates_title_allow_installs) + state.isUpdateAvailable -> state.update?.title ?: stringResource(Res.string.updates_title_available) + else -> stringResource(Res.string.updates_title_status) }, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, @@ -449,10 +458,10 @@ fun AppUpdaterHost( ) Text( text = when { - state.showUnknownSourcesDialog -> "Enable app installs for Nuvio, then come back and continue." - state.isDownloading -> "Downloading update..." - state.isUpdateAvailable -> "A new version is ready to install." - else -> "No updates found." + state.showUnknownSourcesDialog -> stringResource(Res.string.updates_message_allow_installs) + state.isDownloading -> stringResource(Res.string.updates_message_downloading) + state.isUpdateAvailable -> stringResource(Res.string.updates_message_ready) + else -> stringResource(Res.string.updates_message_no_updates) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -492,7 +501,7 @@ fun AppUpdaterHost( fontWeight = FontWeight.SemiBold, ) val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size -> - "$size • ${update.assetName}" + stringResource(Res.string.updates_asset_line, size, update.assetName) } ?: update.assetName Text( text = assetLine, @@ -510,9 +519,12 @@ fun AppUpdaterHost( ) Text( text = if (state.downloadProgress != null) { - "Downloading ${((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100)}%" + stringResource( + Res.string.updates_downloading_progress, + ((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100), + ) } else { - "Preparing download" + stringResource(Res.string.updates_preparing_download) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -523,7 +535,7 @@ fun AppUpdaterHost( if (update.notes.isNotBlank()) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Release notes", + text = stringResource(Res.string.updates_release_notes), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, @@ -567,10 +579,10 @@ fun AppUpdaterHost( ) { Text( when { - state.showUnknownSourcesDialog -> "Continue" - state.downloadedApkPath != null -> "Install" - state.isDownloading -> "Downloading" - else -> "Update" + state.showUnknownSourcesDialog -> stringResource(Res.string.action_continue) + state.downloadedApkPath != null -> stringResource(Res.string.action_install) + state.isDownloading -> stringResource(Res.string.updates_message_downloading) + else -> stringResource(Res.string.action_update) }, ) } @@ -586,7 +598,7 @@ fun AppUpdaterHost( modifier = Modifier.weight(1f), onClick = controller::ignoreThisVersion, ) { - Text("Ignore") + Text(stringResource(Res.string.action_ignore)) } OutlinedButton( @@ -594,7 +606,13 @@ fun AppUpdaterHost( onClick = controller::dismissDialog, enabled = !state.isDownloading, ) { - Text(if (state.isDownloading) "Downloading" else "Later") + Text( + if (state.isDownloading) { + stringResource(Res.string.updates_message_downloading) + } else { + stringResource(Res.string.action_later) + }, + ) } } } else { @@ -603,7 +621,13 @@ fun AppUpdaterHost( onClick = controller::dismissDialog, enabled = !state.isDownloading, ) { - Text(if (state.isDownloading) "Downloading" else "Later") + Text( + if (state.isDownloading) { + stringResource(Res.string.updates_message_downloading) + } else { + stringResource(Res.string.action_later) + }, + ) } } } @@ -613,7 +637,7 @@ fun AppUpdaterHost( } private fun formatFileSize(sizeBytes: Long): String { - if (sizeBytes <= 0L) return "0 B" + if (sizeBytes <= 0L) return "0 ${localizedByteUnit("B")}" val units = listOf("B", "KB", "MB", "GB") var value = sizeBytes.toDouble() var unitIndex = 0 @@ -626,5 +650,5 @@ private fun formatFileSize(sizeBytes: Long): String { } else { ((value * 10).toInt() / 10.0).toString() } - return "$roundedValue ${units[unitIndex]}" -} \ No newline at end of file + return "$roundedValue ${localizedByteUnit(units[unitIndex])}" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 8d117ba6..359eec29 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -1,5 +1,9 @@ package com.nuvio.app.features.watching.domain +import com.nuvio.app.core.i18n.localizedPlayLabel +import com.nuvio.app.core.i18n.localizedResumeLabel +import com.nuvio.app.core.i18n.localizedUpNextLabel + const val DefaultContinueWatchingLimit = 20 fun resumeProgressForSeries( @@ -130,25 +134,13 @@ fun buildPlaybackVideoId( } fun playLabel(seasonNumber: Int?, episodeNumber: Int?): String = - if (seasonNumber != null && episodeNumber != null) { - "Play S${seasonNumber}E${episodeNumber}" - } else { - "Play" - } + localizedPlayLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) fun upNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = - if (seasonNumber != null && episodeNumber != null) { - "Up Next S${seasonNumber}E${episodeNumber}" - } else { - "Up Next" - } + localizedUpNextLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) fun resumeLabel(seasonNumber: Int?, episodeNumber: Int?): String = - if (seasonNumber != null && episodeNumber != null) { - "Resume S${seasonNumber}E${episodeNumber}" - } else { - "Resume" - } + localizedResumeLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) private fun WatchingProgressRecord.toResumeAction(): WatchingSeriesPrimaryAction = WatchingSeriesPrimaryAction( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 5a678bdc..5f4157b0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -185,27 +185,12 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } - val subtitle = if (normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) { - buildString { - append("S") - append(normalizedEntry.seasonNumber) - append("E") - append(normalizedEntry.episodeNumber) - normalizedEntry.episodeTitle?.takeIf { it.isNotBlank() }?.let { - append(" • ") - append(it) - } - } - } else { - "Movie" - } - return ContinueWatchingItem( parentMetaId = normalizedEntry.parentMetaId, parentMetaType = normalizedEntry.parentMetaType, videoId = normalizedEntry.videoId, title = normalizedEntry.title, - subtitle = subtitle, + subtitle = normalizedEntry.episodeTitle.orEmpty(), imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, logo = normalizedEntry.logo, poster = normalizedEntry.poster, @@ -228,20 +213,6 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextEpisode: MetaVideo, ): ContinueWatchingItem { - val subtitle = buildString { - append("Up Next") - if (nextEpisode.season != null && nextEpisode.episode != null) { - append(" • S") - append(nextEpisode.season) - append("E") - append(nextEpisode.episode) - } - nextEpisode.title.takeIf { it.isNotBlank() }?.let { - append(" • ") - append(it) - } - } - return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -252,7 +223,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( fallbackVideoId = nextEpisode.id, ), title = title, - subtitle = subtitle, + subtitle = nextEpisode.title, imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, logo = logo, poster = poster, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index 4b2c78dc..cf91655b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -13,7 +13,8 @@ import platform.Foundation.NSUserDefaults actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" - private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey) + private const val selectedAppLanguageKey = "selected_app_language" + private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey, selectedAppLanguageKey) actual fun loadSelectedTheme(): String? = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedThemeKey)) @@ -36,9 +37,19 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey)) } + actual fun loadSelectedAppLanguage(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedAppLanguageKey)) + + actual fun saveSelectedAppLanguage(languageCode: String) { + NSUserDefaults.standardUserDefaults.setObject(languageCode, forKey = ProfileScopedKey.of(selectedAppLanguageKey)) + } + + actual fun applySelectedAppLanguage(languageCode: String) = Unit + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -48,5 +59,6 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) } } From 84a4771f67f131f0d5cebedfaccd1b452adacb33 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:01:31 +0530 Subject: [PATCH 2/2] feat: collection preserving to prevent overwriting unsupported fields in blobs --- .../collection/CollectionJsonPreserver.kt | 114 +++++++++++++++++ .../collection/CollectionRepository.kt | 21 +++- .../collection/CollectionSyncService.kt | 2 +- .../app/features/player/PlayerSourcesPanel.kt | 119 ++++++++++++------ 4 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt new file mode 100644 index 00000000..f7da122a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -0,0 +1,114 @@ +package com.nuvio.app.features.collection + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal object CollectionJsonPreserver { + fun merge( + json: Json, + rawCollectionsJson: JsonElement, + collections: List, + ): JsonArray { + val rawById = rawCollectionsJson.asObjectArrayById() + return buildJsonArray { + collections.forEach { collection -> + add( + mergeCollection( + json = json, + raw = rawById[collection.id], + collection = collection, + ), + ) + } + } + } + + private fun mergeCollection( + json: Json, + raw: JsonObject?, + collection: Collection, + ): JsonObject { + val encoded = json.encodeToJsonElement(Collection.serializer(), collection).jsonObject + val rawFoldersById = raw?.get("folders").asObjectArrayById() + val mergedFolders = buildJsonArray { + collection.folders.forEach { folder -> + add( + mergeFolder( + json = json, + raw = rawFoldersById[folder.id], + folder = folder, + ), + ) + } + } + return mergeObjects(raw, encoded, mapOf("folders" to mergedFolders)) + } + + private fun mergeFolder( + json: Json, + raw: JsonObject?, + folder: CollectionFolder, + ): JsonObject { + val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject + val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey) + val mergedSources = buildJsonArray { + folder.catalogSources.forEach { source -> + val sourceElement = + json.encodeToJsonElement(CollectionCatalogSource.serializer(), source) + add( + mergeSource( + json = json, + raw = rawSourcesByKey[sourceKey(sourceElement)], + source = source, + ), + ) + } + } + return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources)) + } + + private fun mergeSource( + json: Json, + raw: JsonObject?, + source: CollectionCatalogSource, + ): JsonObject { + val encoded = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source).jsonObject + return mergeObjects(raw, encoded) + } + + private fun mergeObjects( + raw: JsonObject?, + encoded: JsonObject, + overrides: Map = emptyMap(), + ): JsonObject = buildJsonObject { + raw?.forEach { (key, value) -> put(key, value) } + encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) } + } + + private fun JsonElement?.asObjectArrayById(): Map = + asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull } + + private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map = + (this as? JsonArray) + ?.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + keySelector(obj)?.let { key -> key to obj } + } + ?.toMap() + .orEmpty() + + private fun sourceKey(element: JsonElement): String? { + val obj = element as? JsonObject ?: return null + val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null + val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null + val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null + return "$addonId|$type|$catalogId" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 7d9f5abd..860f97d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title @@ -31,6 +33,7 @@ object CollectionRepository { private val _collections = MutableStateFlow>(emptyList()) val collections: StateFlow> = _collections.asStateFlow() + private var rawCollectionsJson: JsonElement = JsonArray(emptyList()) private var hasLoaded = false @@ -41,6 +44,8 @@ object CollectionRepository { if (payload.isNullOrBlank()) return runCatching { + val parsed = json.parseToJsonElement(payload) + rawCollectionsJson = parsed _collections.value = json.decodeFromString>(payload) }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } @@ -50,11 +55,13 @@ object CollectionRepository { fun onProfileChanged() { hasLoaded = false _collections.value = emptyList() + rawCollectionsJson = JsonArray(emptyList()) } fun clearLocalState() { hasLoaded = false _collections.value = emptyList() + rawCollectionsJson = JsonArray(emptyList()) } fun getCollection(id: String): Collection? = @@ -81,6 +88,7 @@ object CollectionRepository { } fun setCollections(collections: List) { + ensureLoaded() _collections.value = collections persist() } @@ -106,11 +114,12 @@ object CollectionRepository { fun exportToJson(): String { ensureLoaded() - return json.encodeToString(_collections.value) + return mergedCollectionsJson().toString() } fun importFromJson(jsonString: String): Result> { return runCatching { + rawCollectionsJson = json.parseToJsonElement(jsonString) val imported = json.decodeFromString>(jsonString) _collections.value = imported persist() @@ -228,7 +237,8 @@ object CollectionRepository { } } - internal fun applyFromRemote(collections: List) { + internal fun applyFromRemote(collections: List, rawJson: JsonElement) { + rawCollectionsJson = rawJson _collections.value = collections persist() } @@ -239,9 +249,14 @@ object CollectionRepository { private fun persist() { runCatching { - CollectionStorage.savePayload(json.encodeToString(_collections.value)) + CollectionStorage.savePayload(mergedCollectionsJson().toString()) }.onFailure { e -> log.e(e) { "Failed to persist collections" } } } + + private fun mergedCollectionsJson(): JsonArray = + CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also { + rawCollectionsJson = it + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index aced9be6..1ec14547 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -80,7 +80,7 @@ object CollectionSyncService { if (remoteCollections != null) { isSyncingFromRemote = true - CollectionRepository.applyFromRemote(remoteCollections) + CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson) isSyncingFromRemote = false log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index 9d54dfd1..9e64a911 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -22,14 +22,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -40,14 +40,17 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import kotlin.math.round import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -230,24 +233,32 @@ private fun SourceStreamRow( onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val cardShape = RoundedCornerShape(12.dp) Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .heightIn(min = 68.dp) + .shadow( + elevation = 2.dp, + shape = cardShape, + ambientColor = Color.Black.copy(alpha = 0.04f), + spotColor = Color.Black.copy(alpha = 0.04f), + ) + .clip(cardShape) .background( - if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent, + if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f), ) .then( if (isCurrent) { - Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape) } else { Modifier }, ) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(14.dp), + verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column(modifier = Modifier.weight(1f)) { @@ -258,11 +269,13 @@ private fun SourceStreamRow( Text( text = stream.streamLabel, color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + modifier = Modifier.weight(1f), ) if (isCurrent) { Box( @@ -280,34 +293,66 @@ private fun SourceStreamRow( } } } - stream.streamSubtitle?.let { subtitle -> - if (subtitle != stream.streamLabel) { - Text( - text = subtitle, - color = colorScheme.onSurfaceVariant, + + val subtitle = stream.streamSubtitle + if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + lineHeight = 18.sp, + ), + color = colorScheme.onSurfaceVariant, + ) } - Text( - text = stream.addonName, - color = colorScheme.onSurfaceVariant, + + Spacer(modifier = Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PlayerStreamFileSizeBadge(stream = stream) + Text( + text = stream.addonName, + color = colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontStyle = FontStyle.Italic, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun PlayerStreamFileSizeBadge(stream: StreamItem) { + val bytes = stream.behaviorHints.videoSize ?: return + val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) + val sizeLabel = if (gib >= 1.0) { + val roundedGiB = round(gib * 10.0) / 10.0 + "$roundedGiB ${localizedByteUnit("GB")}" + } else { + val mib = bytes.toDouble() / (1024.0 * 1024.0) + "${round(mib).toInt()} ${localizedByteUnit("MB")}" + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF0A0C0C)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = stringResource(Res.string.streams_size, sizeLabel), + style = MaterialTheme.typography.labelSmall.copy( fontSize = 11.sp, - fontStyle = FontStyle.Italic, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (isCurrent) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = stringResource(Res.string.compose_player_currently_playing), - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + ), + color = Color.White, + ) } }