feat: localization

This commit is contained in:
tapframe 2026-04-25 07:25:24 +05:30
parent 5fb414ea2f
commit 23080c4344
128 changed files with 5205 additions and 1556 deletions

View file

@ -219,6 +219,7 @@ kotlin {
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)

View file

@ -10,6 +10,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:localeConfig="@xml/locale_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Nuvio"> android:theme="@style/Theme.Nuvio">
<activity <activity

View file

@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.deeplink.handleAppUrl
@ -44,7 +45,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ResumePromptStorage import com.nuvio.app.features.watchprogress.ResumePromptStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
enableEdgeToEdge( enableEdgeToEdge(
@ -52,6 +53,7 @@ class MainActivity : ComponentActivity() {
scrim = 0xFF020404.toInt(), scrim = 0xFF020404.toInt(),
), ),
) )
ThemeSettingsStorage.initialize(applicationContext)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background) window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext)
@ -66,7 +68,6 @@ class MainActivity : ComponentActivity() {
ProfilePinCacheStorage.initialize(applicationContext) ProfilePinCacheStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext)

View file

@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import kotlin.math.abs import kotlin.math.abs
internal actual object DownloadsLiveStatusPlatform { internal actual object DownloadsLiveStatusPlatform {
private const val channelId = "downloads_live_status" private const val channelId = "downloads_live_status"
private const val channelName = "Downloads"
private const val channelDescription = "Shows live download progress and controls."
private const val notificationsPrefName = "nuvio_download_live_notifications" private const val notificationsPrefName = "nuvio_download_live_notifications"
private const val trackedDownloadIdsKey = "tracked_download_ids" private const val trackedDownloadIdsKey = "tracked_download_ids"
@ -143,7 +144,7 @@ internal actual object DownloadsLiveStatusPlatform {
.setProgress(0, 0, false) .setProgress(0, 0, false)
.addAction( .addAction(
0, 0,
"Resume", runBlocking { getString(Res.string.action_resume) },
buildActionPendingIntent( buildActionPendingIntent(
context = context, context = context,
action = DownloadsNotificationActionReceiver.actionResume, action = DownloadsNotificationActionReceiver.actionResume,
@ -163,15 +164,15 @@ internal actual object DownloadsLiveStatusPlatform {
val downloaded = formatBytes(item.downloadedBytes) val downloaded = formatBytes(item.downloadedBytes)
val total = item.totalBytes?.let(::formatBytes) val total = item.totalBytes?.let(::formatBytes)
if (total != null) { if (total != null) {
"Downloading $detail$downloaded / $total" runBlocking { getString(Res.string.downloads_live_downloading_with_total, detail, downloaded, total) }
} else { } else {
"Downloading $detail$downloaded" runBlocking { getString(Res.string.downloads_live_downloading, detail, downloaded) }
} }
} }
DownloadStatus.Paused -> "Paused $detail" DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) }
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed" DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) }
DownloadStatus.Completed -> "Download completed" DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) }
} }
} }
@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform {
if (manager.getNotificationChannel(channelId) != null) return if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel( manager.createNotificationChannel(
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply { NotificationChannel(
description = channelDescription channelId,
runBlocking { getString(Res.string.downloads_channel_name) },
NotificationManager.IMPORTANCE_LOW,
).apply {
description = runBlocking { getString(Res.string.downloads_channel_description) }
}, },
) )
} }

View file

@ -8,9 +8,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URI import java.net.URI
@ -44,7 +47,7 @@ internal actual object DownloadsPlatformDownloader {
scope.launch { scope.launch {
val context = appContext val context = appContext
if (context == null) { if (context == null) {
onFailure("Download system is not initialized") onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
return@launch return@launch
} }
@ -69,7 +72,9 @@ internal actual object DownloadsPlatformDownloader {
var attemptedRangeRequest = resumeFromBytes > 0L var attemptedRangeRequest = resumeFromBytes > 0L
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null) var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
call = downloadHttpClient.newCall(httpRequest) 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) { if (attemptedRangeRequest && response.code == 416) {
response.close() response.close()
@ -78,12 +83,18 @@ internal actual object DownloadsPlatformDownloader {
attemptedRangeRequest = false attemptedRangeRequest = false
httpRequest = buildRequest(null) httpRequest = buildRequest(null)
call = downloadHttpClient.newCall(httpRequest) 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 -> response.use { response ->
if (!response.isSuccessful) { 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 val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
@ -94,7 +105,9 @@ internal actual object DownloadsPlatformDownloader {
tempFile.delete() 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( val totalBytes = resolveTotalBytes(
startingBytes = startingBytes, startingBytes = startingBytes,
isPartialResume = isPartialResume, isPartialResume = isPartialResume,
@ -131,7 +144,7 @@ internal actual object DownloadsPlatformDownloader {
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
} }
} catch (error: Throwable) { } catch (error: Throwable) {
onFailure(error.message ?: "Download failed") onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
} }
} }

View file

@ -23,6 +23,9 @@ import androidx.work.WorkManager
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.android.Android 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.plugins.HttpTimeout
import io.ktor.client.request.get import io.ktor.client.request.get
import java.time.LocalDate import java.time.LocalDate
@ -285,13 +288,13 @@ internal actual object EpisodeReleaseNotificationPlatform {
val channel = NotificationChannel( val channel = NotificationChannel(
channelId, channelId,
"Episode Releases", runBlocking { getString(Res.string.notifications_channel_episode_releases_name) },
NotificationManager.IMPORTANCE_DEFAULT, NotificationManager.IMPORTANCE_DEFAULT,
).apply { ).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) notificationManager.createNotificationChannel(channel)
} }
private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId" private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId"
} }

View file

@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle 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.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C import androidx.media3.common.C
@ -184,7 +187,7 @@ actual fun PlatformPlayerSurface(
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) { 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) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -585,7 +588,10 @@ private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
else -> null else -> null
} }
val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } } 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) val suffix = listOfNotNull(channelLabel, codecLabel)
.joinToString(" ") .joinToString(" ")
.let { if (it.isNotBlank()) " ($it)" else "" } .let { if (it.isNotBlank()) " ($it)" else "" }

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.settings
import android.content.Context import android.content.Context
import android.content.SharedPreferences 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.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean import com.nuvio.app.core.sync.encodeSyncBoolean
@ -15,12 +17,14 @@ actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings" private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme" private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled" 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 private var preferences: SharedPreferences? = null
fun initialize(context: Context) { fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
} }
actual fun loadSelectedTheme(): String? = actual fun loadSelectedTheme(): String? =
@ -46,9 +50,26 @@ actual object ThemeSettingsStorage {
?.apply() ?.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 { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
} }
actual fun replaceFromSyncPayload(payload: JsonObject) { actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -58,5 +79,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
} }
} }

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Nuvio</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar"> <style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/nuvio_background</item> <item name="android:windowBackground">@color/nuvio_background</item>
</style> </style>
@ -9,4 +9,4 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item> <item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.Nuvio</item> <item name="postSplashScreenTheme">@style/Theme.Nuvio</item>
</style> </style>
</resources> </resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar"> <style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/nuvio_background</item> <item name="android:windowBackground">@color/nuvio_background</item>
</style> </style>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en"/>
<locale android:name="es"/>
</locale-config>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,7 @@ import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme 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.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository 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.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable 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.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_library
import nuvio.composeapp.generated.resources.sidebar_search import nuvio.composeapp.generated.resources.sidebar_search
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Serializable @Serializable
object TabsRoute object TabsRoute
@ -278,7 +287,6 @@ fun App() {
ThemeSettingsRepository.selectedTheme ThemeSettingsRepository.selectedTheme
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AuthRepository.initialize() AuthRepository.initialize()
@ -499,6 +507,7 @@ private fun MainAppContent(
val networkStatusUiState by remember { val networkStatusUiState by remember {
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
var initialHomeReady by rememberSaveable { mutableStateOf(false) } var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@ -542,11 +551,11 @@ private fun MainAppContent(
when (condition) { when (condition) {
NetworkCondition.NoInternet -> { NetworkCondition.NoInternet -> {
NuvioToastController.show("No internet connection") NuvioToastController.show(getString(Res.string.network_no_internet_connection))
} }
NetworkCondition.ServersUnreachable -> { NetworkCondition.ServersUnreachable -> {
NuvioToastController.show("Cannot reach servers") NuvioToastController.show(getString(Res.string.network_cannot_reach_servers))
} }
NetworkCondition.Online -> { NetworkCondition.Online -> {
@ -554,7 +563,7 @@ private fun MainAppContent(
previousConditionName == NetworkCondition.NoInternet.name || previousConditionName == NetworkCondition.NoInternet.name ||
previousConditionName == NetworkCondition.ServersUnreachable.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 }, streamTitle = downloadedItem.streamTitle.ifBlank { title },
streamSubtitle = downloadedItem.streamSubtitle, streamSubtitle = downloadedItem.streamSubtitle,
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
providerName = downloadedItem.providerName.ifBlank { "Downloaded" }, providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel },
providerAddonId = downloadedItem.providerAddonId, providerAddonId = downloadedItem.providerAddonId,
contentType = type, contentType = type,
videoId = videoId, 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 -> val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
navController.navigate( navController.navigate(
CatalogRoute( CatalogRoute(
title = section.displayTitle, title = section.displayTitle,
subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { subtitle = librarySectionSubtitle,
"Trakt Library"
} else {
"Library"
},
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
type = section.items.firstOrNull()?.type ?: "movie", type = section.items.firstOrNull()?.type ?: "movie",
catalogId = section.type, catalogId = section.type,
@ -899,19 +910,19 @@ private fun MainAppContent(
selected = selectedTab == AppScreenTab.Home, selected = selectedTab == AppScreenTab.Home,
onClick = { selectedTab = AppScreenTab.Home }, onClick = { selectedTab = AppScreenTab.Home },
icon = Icons.Filled.Home, icon = Icons.Filled.Home,
contentDescription = "Home", contentDescription = stringResource(Res.string.compose_nav_home),
) )
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Search, selected = selectedTab == AppScreenTab.Search,
onClick = { selectedTab = AppScreenTab.Search }, onClick = { selectedTab = AppScreenTab.Search },
icon = Res.drawable.sidebar_search, icon = Res.drawable.sidebar_search,
contentDescription = "Search", contentDescription = stringResource(Res.string.compose_nav_search),
) )
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Library, selected = selectedTab == AppScreenTab.Library,
onClick = { selectedTab = AppScreenTab.Library }, onClick = { selectedTab = AppScreenTab.Library },
icon = Res.drawable.sidebar_library, icon = Res.drawable.sidebar_library,
contentDescription = "Library", contentDescription = stringResource(Res.string.compose_nav_library),
) )
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Settings, selected = selectedTab == AppScreenTab.Settings,
@ -994,6 +1005,9 @@ private fun MainAppContent(
} }
composable<DetailRoute> { backStackEntry -> composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>() val route = backStackEntry.toRoute<DetailRoute>()
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( MetaDetailsScreen(
type = route.type, type = route.type,
id = route.id, id = route.id,
@ -1034,8 +1048,11 @@ private fun MainAppContent(
castAvatarTransitionKey = avatarTransitionKey, castAvatarTransitionKey = avatarTransitionKey,
preferCrew = person.role?.let { preferCrew = person.role?.let {
it.equals("Director", ignoreCase = true) || it.equals("Director", ignoreCase = true) ||
it.equals(directorRole, ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) || it.equals("Writer", ignoreCase = true) ||
it.equals(writerRole, ignoreCase = true) ||
it.equals("Creator", ignoreCase = true) it.equals("Creator", ignoreCase = true)
|| it.equals(creatorRole, ignoreCase = true)
} ?: false, } ?: false,
), ),
) )
@ -1662,7 +1679,7 @@ private fun MainAppContent(
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)
} }
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
} }
pickerPending = false pickerPending = false
} }
@ -1748,7 +1765,7 @@ private fun MainAppContent(
pickerItem = null pickerItem = null
pickerError = null pickerError = null
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
} }
pickerPending = false pickerPending = false
} }
@ -1756,11 +1773,11 @@ private fun MainAppContent(
) )
NuvioStatusModal( NuvioStatusModal(
title = "Exit app", title = stringResource(Res.string.app_exit_title),
message = "Do you want to exit the app?", message = stringResource(Res.string.app_exit_message),
isVisible = showExitConfirmation, isVisible = showExitConfirmation,
confirmText = "Yes", confirmText = stringResource(Res.string.action_yes),
dismissText = "No", dismissText = stringResource(Res.string.action_no),
onConfirm = { onConfirm = {
showExitConfirmation = false showExitConfirmation = false
platformExitApp() platformExitApp()
@ -1791,9 +1808,9 @@ private fun MainAppContent(
visible = resumePromptItem != null, visible = resumePromptItem != null,
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
title = resumePromptItem?.title.orEmpty(), title = resumePromptItem?.title.orEmpty(),
subtitle = resumePromptItem?.subtitle.orEmpty(), subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
progressFraction = resumePromptItem?.progressFraction ?: 0f, progressFraction = resumePromptItem?.progressFraction ?: 0f,
actionLabel = "Resume", actionLabel = stringResource(Res.string.resume_prompt_action),
onAction = { onAction = {
val item = resumePromptItem ?: return@NuvioFloatingPrompt val item = resumePromptItem ?: return@NuvioFloatingPrompt
resumePromptItem = null resumePromptItem = null
@ -1948,13 +1965,13 @@ private fun TabletFloatingTopBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TabletTopPillItem( TabletTopPillItem(
label = "Home", label = stringResource(Res.string.compose_nav_home),
selected = selectedTab == AppScreenTab.Home, selected = selectedTab == AppScreenTab.Home,
onClick = { onTabSelected(AppScreenTab.Home) }, onClick = { onTabSelected(AppScreenTab.Home) },
icon = { icon = {
Icon( Icon(
imageVector = Icons.Filled.Home, imageVector = Icons.Filled.Home,
contentDescription = "Home", contentDescription = stringResource(Res.string.compose_nav_home),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Home) { tint = if (selectedTab == AppScreenTab.Home) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@ -1965,13 +1982,13 @@ private fun TabletFloatingTopBar(
}, },
) )
TabletTopPillItem( TabletTopPillItem(
label = "Search", label = stringResource(Res.string.compose_nav_search),
selected = selectedTab == AppScreenTab.Search, selected = selectedTab == AppScreenTab.Search,
onClick = { onTabSelected(AppScreenTab.Search) }, onClick = { onTabSelected(AppScreenTab.Search) },
icon = { icon = {
Icon( Icon(
painter = painterResource(Res.drawable.sidebar_search), painter = painterResource(Res.drawable.sidebar_search),
contentDescription = "Search", contentDescription = stringResource(Res.string.compose_nav_search),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Search) { tint = if (selectedTab == AppScreenTab.Search) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@ -1982,13 +1999,13 @@ private fun TabletFloatingTopBar(
}, },
) )
TabletTopPillItem( TabletTopPillItem(
label = "Library", label = stringResource(Res.string.compose_nav_library),
selected = selectedTab == AppScreenTab.Library, selected = selectedTab == AppScreenTab.Library,
onClick = { onTabSelected(AppScreenTab.Library) }, onClick = { onTabSelected(AppScreenTab.Library) },
icon = { icon = {
Icon( Icon(
painter = painterResource(Res.drawable.sidebar_library), painter = painterResource(Res.drawable.sidebar_library),
contentDescription = "Library", contentDescription = stringResource(Res.string.compose_nav_library),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Library) { tint = if (selectedTab == AppScreenTab.Library) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@ -2018,7 +2035,7 @@ private fun TabletFloatingTopBar(
onAddProfileRequested = onAddProfileRequested, onAddProfileRequested = onAddProfileRequested,
) )
Text( Text(
text = "Profile", text = stringResource(Res.string.compose_nav_profile),
modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) }, modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) },
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = if (selectedTab == AppScreenTab.Settings) { color = if (selectedTab == AppScreenTab.Settings) {
@ -2081,7 +2098,7 @@ private fun AppLaunchOverlay(
) { ) {
Image( Image(
painter = painterResource(Res.drawable.app_logo_wordmark), painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = "Nuvio", contentDescription = stringResource(Res.string.app_brand_name),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.48f) .fillMaxWidth(0.48f)
.height(44.dp), .height(44.dp),

View file

@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object AuthRepository { object AuthRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -89,7 +91,7 @@ object AuthRepository {
Unit Unit
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Email sign-up failed" } 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<Unit> = runCatching { suspend fun signInWithEmail(email: String, password: String): Result<Unit> = runCatching {
@ -100,7 +102,7 @@ object AuthRepository {
} }
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Email sign-in failed" } 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<Unit> = runCatching { suspend fun signOut(): Result<Unit> = runCatching {
@ -114,7 +116,7 @@ object AuthRepository {
LocalAccountDataCleaner.wipe() LocalAccountDataCleaner.wipe()
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Sign-out failed" } 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<Unit> = runCatching { suspend fun deleteAccount(): Result<Unit> = runCatching {
@ -124,7 +126,7 @@ object AuthRepository {
LocalAccountDataCleaner.wipe() LocalAccountDataCleaner.wipe()
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Account deletion failed" } 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() { fun clearError() {

View file

@ -1,19 +1,6 @@
package com.nuvio.app.core.format package com.nuvio.app.core.format
private val MONTH_NAMES = listOf( import com.nuvio.app.core.i18n.localizedMonthName
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
)
/** /**
* Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss) for UI as "2025 February 1". * 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 year = parts[0].toIntOrNull() ?: return raw
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: 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 val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw
return "$year ${MONTH_NAMES[month - 1]} $day" return "$year ${localizedMonthName(month)} $day"
} }
/** /**

View file

@ -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)
}
}

View file

@ -1,11 +1,32 @@
package com.nuvio.app.core.ui package com.nuvio.app.core.ui
enum class AppTheme(val displayName: String) { import nuvio.composeapp.generated.resources.Res
CRIMSON("Crimson"), import nuvio.composeapp.generated.resources.theme_amber
OCEAN("Ocean"), import nuvio.composeapp.generated.resources.theme_crimson
VIOLET("Violet"), import nuvio.composeapp.generated.resources.theme_emerald
EMERALD("Emerald"), import nuvio.composeapp.generated.resources.theme_ocean
AMBER("Amber"), import nuvio.composeapp.generated.resources.theme_rose
ROSE("Rose"), import nuvio.composeapp.generated.resources.theme_violet
WHITE("White"), 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
}

View file

@ -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
}

View file

@ -65,6 +65,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -141,7 +145,7 @@ fun NuvioScreenHeader(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
} }
@ -233,7 +237,7 @@ fun NuvioBackButton(
contentColor: Color = MaterialTheme.colorScheme.onSurface, contentColor: Color = MaterialTheme.colorScheme.onSurface,
buttonSize: Dp = 40.dp, buttonSize: Dp = 40.dp,
iconSize: Dp = 22.dp, iconSize: Dp = 22.dp,
contentDescription: String = "Back", contentDescription: String = stringResource(Res.string.action_back),
) { ) {
Box( Box(
modifier = modifier modifier = modifier
@ -375,7 +379,7 @@ fun NuvioStatusModal(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isVisible: Boolean, isVisible: Boolean,
isBusy: Boolean = false, isBusy: Boolean = false,
confirmText: String = "OK", confirmText: String = stringResource(Res.string.action_ok),
dismissText: String? = null, dismissText: String? = null,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null,

View file

@ -30,6 +30,12 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.coroutines.launch 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -70,14 +76,14 @@ fun NuvioContinueWatchingActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.Info, icon = Icons.Default.Info,
title = "Go to details", title = stringResource(Res.string.cw_action_go_to_details),
onClick = { dismissAfter(onOpenDetails) }, onClick = { dismissAfter(onOpenDetails) },
) )
if (showManualPlayOption && onPlayManually != null) { if (showManualPlayOption && onPlayManually != null) {
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
title = "Play manually", title = stringResource(Res.string.play_manually),
onClick = { dismissAfter(onPlayManually) }, onClick = { dismissAfter(onPlayManually) },
) )
} }
@ -85,14 +91,14 @@ fun NuvioContinueWatchingActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.Replay, icon = Icons.Default.Replay,
title = "Start from beginning", title = stringResource(Res.string.cw_action_start_from_beginning),
onClick = { dismissAfter(onStartFromBeginning) }, onClick = { dismissAfter(onStartFromBeginning) },
) )
} }
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.DeleteOutline, icon = Icons.Default.DeleteOutline,
title = "Remove", title = stringResource(Res.string.cw_action_remove),
onClick = { dismissAfter(onRemove) }, onClick = { dismissAfter(onRemove) },
) )
} }
@ -152,7 +158,7 @@ private fun ContinueWatchingSheetHeader(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = item.subtitle, text = localizedContinueWatchingSubtitle(item),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2, maxLines = 2,
@ -160,4 +166,4 @@ private fun ContinueWatchingSheetHeader(
) )
} }
} }
} }

View file

@ -52,6 +52,9 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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 import kotlin.math.roundToInt
private const val AutoDismissDelayMs = 15_000L private const val AutoDismissDelayMs = 15_000L
@ -202,7 +205,7 @@ fun NuvioFloatingPrompt(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = "Continue where you left off", text = stringResource(Res.string.floating_prompt_continue_where_left_off),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )

View file

@ -10,6 +10,9 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.messageForEmptyState import com.nuvio.app.core.network.messageForEmptyState
import com.nuvio.app.core.network.titleForEmptyState 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 @Composable
fun NuvioNetworkOfflineCard( fun NuvioNetworkOfflineCard(
@ -32,9 +35,9 @@ fun NuvioNetworkOfflineCard(
if (onRetry != null) { if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Retry", text = stringResource(Res.string.action_retry),
onClick = onRetry, onClick = onRetry,
) )
} }
} }
} }

View file

@ -37,6 +37,13 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -72,7 +79,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, 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 = { onClick = {
onToggleLibrary() onToggleLibrary()
coroutineScope.launch { coroutineScope.launch {
@ -86,7 +97,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, 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 = { onClick = {
onToggleWatched() onToggleWatched()
coroutineScope.launch { coroutineScope.launch {
@ -114,7 +129,7 @@ fun NuvioWatchedBadge(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = "Watched", contentDescription = stringResource(Res.string.episodes_cd_watched),
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp), modifier = Modifier.size(12.dp),
) )
@ -200,4 +215,3 @@ private fun PosterSheetHeader(
} }
} }
} }

View file

@ -33,6 +33,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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 { enum class NuvioPosterShape {
Poster, Poster,
@ -156,7 +160,7 @@ fun NuvioPosterCard(
if (!bottomLeftLogoUrl.isNullOrBlank()) { if (!bottomLeftLogoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = bottomLeftLogoUrl, model = bottomLeftLogoUrl,
contentDescription = "$title logo", contentDescription = stringResource(Res.string.poster_logo_content_description, title),
modifier = Modifier modifier = Modifier
.width(catalogLogoOverlaySize.width) .width(catalogLogoOverlaySize.width)
.height(catalogLogoOverlaySize.height), .height(catalogLogoOverlaySize.height),
@ -280,7 +284,7 @@ private fun NuvioViewAllPill(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "View All", text = stringResource(Res.string.home_view_all),
style = textStyle, style = textStyle,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )

View file

@ -28,6 +28,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.nuvio.app.features.trakt.TraktListTab 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -62,7 +68,7 @@ fun TraktListPickerDialog(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Choose where to save this title on Trakt", text = stringResource(Res.string.compose_trakt_list_picker_subtitle),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -91,7 +97,7 @@ fun TraktListPickerDialog(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
Text( Text(
text = "Loading your Trakt lists…", text = stringResource(Res.string.compose_trakt_list_picker_loading),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -151,7 +157,7 @@ fun TraktListPickerDialog(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
), ),
) { ) {
Text("Cancel") Text(stringResource(Res.string.action_cancel))
} }
Button( Button(
onClick = onSave, onClick = onSave,
@ -164,11 +170,11 @@ fun TraktListPickerDialog(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
} else { } else {
Text("Save") Text(stringResource(Res.string.action_save))
} }
} }
} }
} }
} }
} }
} }

View file

@ -1,5 +1,10 @@
package com.nuvio.app.features.addons 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( data class AddonManifest(
val id: String, val id: String,
val name: String, val name: String,
@ -54,7 +59,9 @@ data class ManagedAddon(
val displayTitle: String val displayTitle: String
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name } get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
?: manifest?.name ?: manifest?.name
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" } ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
runBlocking { getString(Res.string.generic_addon) }
}
} }
data class AddonsUiState( data class AddonsUiState(

View file

@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Serializable @Serializable
private data class AddonRow( private data class AddonRow(
@ -198,17 +200,17 @@ object AddonRepository {
suspend fun addAddon(rawUrl: String): AddAddonResult { suspend fun addAddon(rawUrl: String): AddAddonResult {
if (isUsingPrimaryAddonsFromSecondaryProfile()) { 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" } log.i { "addAddon() — rawUrl=$rawUrl" }
val manifestUrl = try { val manifestUrl = try {
normalizeManifestUrl(rawUrl) normalizeManifestUrl(rawUrl)
} catch (error: IllegalArgumentException) { } 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 }) { 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 { val manifest = try {
@ -220,7 +222,7 @@ object AddonRepository {
) )
} }
} catch (error: Throwable) { } 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 -> _uiState.update { current ->
@ -310,7 +312,7 @@ object AddonRepository {
onFailure = { error -> onFailure = { error ->
addon.copy( addon.copy(
isRefreshing = false, isRefreshing = false,
errorMessage = error.message ?: "Unable to load manifest", errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed),
) )
}, },
) )

View file

@ -53,17 +53,19 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun AddonsScreen( fun AddonsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String = "Addons", title: String? = null,
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
) { ) {
NuvioScreen(modifier = modifier) { NuvioScreen(modifier = modifier) {
stickyHeader { stickyHeader {
NuvioScreenHeader( NuvioScreenHeader(
title = title, title = title ?: stringResource(Res.string.addon_title),
onBack = onBack, onBack = onBack,
) { ) {
} }
@ -88,6 +90,7 @@ internal fun AddonsSettingsPageContent(
var addonUrl by rememberSaveable { mutableStateOf("") } var addonUrl by rememberSaveable { mutableStateOf("") }
var formMessage by rememberSaveable { mutableStateOf<String?>(null) } var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(null) } var installModalState by remember { mutableStateOf<AddonInstallModalState?>(null) }
val enterAddonUrlMessage = stringResource(Res.string.addons_error_enter_url)
val overview = remember(uiState.addons) { uiState.addons.toOverview() } val overview = remember(uiState.addons) { uiState.addons.toOverview() }
@ -95,10 +98,10 @@ internal fun AddonsSettingsPageContent(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
SectionHeader("OVERVIEW") SectionHeader(stringResource(Res.string.addons_section_overview))
OverviewCard(overview = overview) OverviewCard(overview = overview)
SectionHeader("ADD ADDON") SectionHeader(stringResource(Res.string.addons_section_add_addon))
AddAddonCard( AddAddonCard(
addonUrl = addonUrl, addonUrl = addonUrl,
formMessage = formMessage, formMessage = formMessage,
@ -109,7 +112,7 @@ internal fun AddonsSettingsPageContent(
onAddClick = { onAddClick = {
val requestedUrl = addonUrl.trim() val requestedUrl = addonUrl.trim()
if (requestedUrl.isBlank()) { if (requestedUrl.isBlank()) {
formMessage = "Enter an addon URL." formMessage = enterAddonUrlMessage
return@AddAddonCard return@AddAddonCard
} }
@ -131,7 +134,7 @@ internal fun AddonsSettingsPageContent(
}, },
) )
SectionHeader("INSTALLED ADDONS") SectionHeader(stringResource(Res.string.addons_section_installed))
if (uiState.addons.isEmpty()) { if (uiState.addons.isEmpty()) {
EmptyStateCard() EmptyStateCard()
} else { } else {
@ -171,12 +174,30 @@ internal fun AddonsSettingsPageContent(
val modalState = installModalState val modalState = installModalState
if (modalState != null) { 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( NuvioStatusModal(
title = modalState.title, title = modalTitle,
message = modalState.message, message = modalMessage,
isVisible = true, isVisible = true,
isBusy = modalState.isBusy, isBusy = modalState.isBusy,
confirmText = modalState.confirmText, confirmText = modalConfirmText,
onConfirm = { onConfirm = {
if (!modalState.isBusy) { if (!modalState.isBusy) {
installModalState = null installModalState = null
@ -200,19 +221,19 @@ private fun OverviewCard(overview: AddonOverview) {
) { ) {
OverviewStat( OverviewStat(
value = overview.totalAddons.toString(), value = overview.totalAddons.toString(),
label = "Addons", label = stringResource(Res.string.addons_overview_addons),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
VerticalSeparator() VerticalSeparator()
OverviewStat( OverviewStat(
value = overview.activeAddons.toString(), value = overview.activeAddons.toString(),
label = "Active", label = stringResource(Res.string.addons_overview_active),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
VerticalSeparator() VerticalSeparator()
OverviewStat( OverviewStat(
value = overview.totalCatalogs.toString(), value = overview.totalCatalogs.toString(),
label = "Catalogs", label = stringResource(Res.string.addons_overview_catalogs),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@ -264,11 +285,11 @@ private fun AddAddonCard(
NuvioInputField( NuvioInputField(
value = addonUrl, value = addonUrl,
onValueChange = onAddonUrlChange, onValueChange = onAddonUrlChange,
placeholder = "Addon URL", placeholder = stringResource(Res.string.addons_input_placeholder),
) )
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Install Addon", text = stringResource(Res.string.addons_install_button),
enabled = addonUrl.isNotBlank(), enabled = addonUrl.isNotBlank(),
onClick = onAddClick, onClick = onAddClick,
) )
@ -284,33 +305,21 @@ private fun AddAddonCard(
} }
private sealed interface AddonInstallModalState { private sealed interface AddonInstallModalState {
val title: String
val message: String
val confirmText: String
val isBusy: Boolean val isBusy: Boolean
data object Checking : AddonInstallModalState { 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 override val isBusy: Boolean = true
} }
data class Success( data class Success(
private val addonName: String, val addonName: String,
) : AddonInstallModalState { ) : 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 override val isBusy: Boolean = false
} }
data class Error( data class Error(
private val reason: String, val reason: String,
) : AddonInstallModalState { ) : AddonInstallModalState {
override val title: String = "Install Failed"
override val message: String = reason
override val confirmText: String = "Close"
override val isBusy: Boolean = false override val isBusy: Boolean = false
} }
} }
@ -319,13 +328,13 @@ private sealed interface AddonInstallModalState {
private fun EmptyStateCard() { private fun EmptyStateCard() {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( Text(
text = "No addons installed yet.", text = stringResource(Res.string.addons_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -365,7 +374,7 @@ private fun InstalledAddonCard(
manifest?.version?.let { version -> manifest?.version?.let { version ->
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Version $version", text = stringResource(Res.string.addons_version_format, version),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -383,7 +392,7 @@ private fun InstalledAddonCard(
onMoveUpClick?.let { onMoveUp -> onMoveUpClick?.let { onMoveUp ->
NuvioIconActionButton( NuvioIconActionButton(
icon = Icons.Rounded.ArrowUpward, icon = Icons.Rounded.ArrowUpward,
contentDescription = "Move addon up", contentDescription = stringResource(Res.string.addons_move_up),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = onMoveUp, onClick = onMoveUp,
) )
@ -391,28 +400,28 @@ private fun InstalledAddonCard(
onMoveDownClick?.let { onMoveDown -> onMoveDownClick?.let { onMoveDown ->
NuvioIconActionButton( NuvioIconActionButton(
icon = Icons.Rounded.ArrowDownward, icon = Icons.Rounded.ArrowDownward,
contentDescription = "Move addon down", contentDescription = stringResource(Res.string.addons_move_down),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = onMoveDown, onClick = onMoveDown,
) )
} }
NuvioIconActionButton( NuvioIconActionButton(
icon = Icons.Rounded.Refresh, icon = Icons.Rounded.Refresh,
contentDescription = "Refresh addon", contentDescription = stringResource(Res.string.addons_refresh),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
onClick = onRefreshClick, onClick = onRefreshClick,
) )
onConfigureClick?.let { onConfigure -> onConfigureClick?.let { onConfigure ->
NuvioIconActionButton( NuvioIconActionButton(
icon = Icons.Rounded.Settings, icon = Icons.Rounded.Settings,
contentDescription = "Configure addon", contentDescription = stringResource(Res.string.addons_configure),
tint = MaterialTheme.colorScheme.tertiary, tint = MaterialTheme.colorScheme.tertiary,
onClick = onConfigure, onClick = onConfigure,
) )
} }
NuvioIconActionButton( NuvioIconActionButton(
icon = Icons.Rounded.Delete, icon = Icons.Rounded.Delete,
contentDescription = "Delete addon", contentDescription = stringResource(Res.string.addons_delete),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
onClick = onDeleteClick, onClick = onDeleteClick,
) )
@ -429,16 +438,16 @@ private fun InstalledAddonCard(
) { ) {
NuvioInfoBadge( NuvioInfoBadge(
text = when { text = when {
addon.isRefreshing -> "Refreshing" addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing)
manifest != null -> "Active" manifest != null -> stringResource(Res.string.addons_badge_active)
else -> "Unavailable" else -> stringResource(Res.string.addons_badge_unavailable)
}, },
) )
manifest?.let { manifest?.let {
NuvioInfoBadge(text = "${it.resources.size} resources") NuvioInfoBadge(text = stringResource(Res.string.addons_badge_resources, it.resources.size))
NuvioInfoBadge(text = "${it.catalogs.size} catalogs") NuvioInfoBadge(text = stringResource(Res.string.addons_badge_catalogs, it.catalogs.size))
if (it.behaviorHints.configurable) { if (it.behaviorHints.configurable) {
NuvioInfoBadge(text = "Configurable") NuvioInfoBadge(text = stringResource(Res.string.addons_badge_configurable))
} }
} }
} }
@ -447,7 +456,7 @@ private fun InstalledAddonCard(
addon.isRefreshing -> { addon.isRefreshing -> {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Loading manifest details...", text = stringResource(Res.string.addons_loading_manifest_details),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -524,6 +533,7 @@ private fun AddonIconBadge(
} }
} }
@Composable
private fun manifestSummary(manifest: AddonManifest): String { private fun manifestSummary(manifest: AddonManifest): String {
val resources = manifest.resources.joinToString(separator = ", ") { it.name } val resources = manifest.resources.joinToString(separator = ", ") { it.name }
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) } val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
@ -533,7 +543,7 @@ private fun manifestSummary(manifest: AddonManifest): String {
append(resources) append(resources)
if (manifest.idPrefixes.isNotEmpty()) { if (manifest.idPrefixes.isNotEmpty()) {
append("") append("")
append("${manifest.idPrefixes.size} id rules") append(stringResource(Res.string.addons_summary_id_rules, manifest.idPrefixes.size))
} }
if (manifest.behaviorHints.p2p) { if (manifest.behaviorHints.p2p) {
append(" • P2P") append(" • P2P")

View file

@ -62,7 +62,22 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.app_logo_wordmark 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.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun AuthScreen( fun AuthScreen(
@ -97,7 +112,7 @@ fun AuthScreen(
) { ) {
Image( Image(
painter = painterResource(Res.drawable.app_logo_wordmark), painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = "Nuvio", contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.6f) .fillMaxWidth(0.6f)
.height(48.dp), .height(48.dp),
@ -105,7 +120,7 @@ fun AuthScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Stream everything, everywhere", text = stringResource(Res.string.compose_auth_tagline),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -119,7 +134,8 @@ fun AuthScreen(
label = "heading", label = "heading",
) { signUp -> ) { signUp ->
Text( 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, style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -131,8 +147,8 @@ fun AuthScreen(
label = "subtitle", label = "subtitle",
) { signUp -> ) { signUp ->
Text( Text(
text = if (signUp) "Sign up to sync your data across devices" text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
else "Sign in to access your library and progress", else stringResource(Res.string.compose_auth_sign_in_subtitle),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -150,7 +166,7 @@ fun AuthScreen(
singleLine = true, singleLine = true,
placeholder = { placeholder = {
Text( Text(
text = "Email", text = stringResource(Res.string.compose_auth_email),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
@ -183,7 +199,7 @@ fun AuthScreen(
singleLine = true, singleLine = true,
placeholder = { placeholder = {
Text( Text(
text = "Password", text = stringResource(Res.string.compose_auth_password),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
@ -240,7 +256,13 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
NuvioPrimaryButton( 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, enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
onClick = { onClick = {
isLoading = true isLoading = true
@ -279,7 +301,8 @@ fun AuthScreen(
label = "togglePrompt", label = "togglePrompt",
) { signUp -> ) { signUp ->
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -290,7 +313,8 @@ fun AuthScreen(
label = "toggleAction", label = "toggleAction",
) { signUp -> ) { signUp ->
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -317,7 +341,7 @@ fun AuthScreen(
.background(MaterialTheme.colorScheme.outline), .background(MaterialTheme.colorScheme.outline),
) )
Text( Text(
text = " or ", text = stringResource(Res.string.compose_auth_or_separator),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -346,7 +370,7 @@ fun AuthScreen(
), ),
) { ) {
Text( Text(
text = "Continue Without Account", text = stringResource(Res.string.compose_auth_continue_without_account),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@ -354,7 +378,7 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Your data will only be stored locally", text = stringResource(Res.string.compose_auth_store_locally),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library" const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
@ -92,7 +94,7 @@ object CatalogRepository {
items = emptyList(), items = emptyList(),
isLoading = false, isLoading = false,
nextSkip = null, 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, items = if (reset) emptyList() else current.items,
isLoading = false, isLoading = false,
nextSkip = null, nextSkip = null,
errorMessage = error.message ?: "Unable to load catalog items.", errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
) )
}, },
) )

View file

@ -56,6 +56,8 @@ import com.nuvio.app.features.home.stableKey
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun CatalogScreen( fun CatalogScreen(
@ -329,12 +331,12 @@ private fun CatalogEmptyState(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
text = "No titles found", text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( Text(
text = errorMessage ?: "This catalog did not return any items.", text = errorMessage ?: stringResource(Res.string.catalog_empty_message),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )

View file

@ -93,10 +93,10 @@ object CollectionEditorRepository {
} }
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
fun addFolder() { fun addFolder(defaultTitle: String) {
val newFolder = CollectionFolder( val newFolder = CollectionFolder(
id = Uuid.random().toString(), id = Uuid.random().toString(),
title = "New Folder", title = defaultTitle,
) )
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = newFolder, editingFolder = newFolder,
@ -177,13 +177,8 @@ object CollectionEditorRepository {
fun updateFolderTileShape(shape: PosterShape) { fun updateFolderTileShape(shape: PosterShape) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val shapeStr = when (shape) {
PosterShape.Poster -> "Poster"
PosterShape.Landscape -> "Landscape"
PosterShape.Square -> "Square"
}
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(tileShape = shapeStr), editingFolder = folder.copy(tileShape = shape.name.lowercase()),
) )
} }

View file

@ -64,6 +64,8 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.features.home.PosterShape 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.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -141,7 +143,11 @@ fun CollectionEditorScreen(
) { ) {
stickyHeader { stickyHeader {
NuvioScreenHeader( 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, onBack = onBack,
) )
} }
@ -150,7 +156,7 @@ fun CollectionEditorScreen(
NuvioInputField( NuvioInputField(
value = state.title, value = state.title,
onValueChange = { CollectionEditorRepository.setTitle(it) }, onValueChange = { CollectionEditorRepository.setTitle(it) },
placeholder = "Collection Title", placeholder = stringResource(Res.string.collections_editor_placeholder_name),
) )
} }
@ -158,7 +164,7 @@ fun CollectionEditorScreen(
NuvioInputField( NuvioInputField(
value = state.backdropImageUrl, value = state.backdropImageUrl,
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) }, 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)) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text( Text(
text = "Pin to Top", text = stringResource(Res.string.collections_editor_pin_above),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Display this collection above regular catalog rows.", text = stringResource(Res.string.collections_editor_pin_above_desc),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -204,7 +210,7 @@ fun CollectionEditorScreen(
item { item {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( Text(
text = "View Mode", text = stringResource(Res.string.collections_editor_view_mode),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@ -223,9 +229,9 @@ fun CollectionEditorScreen(
label = { label = {
Text( Text(
when (mode) { when (mode) {
FolderViewMode.TABBED_GRID -> "Tabbed Grid" FolderViewMode.TABBED_GRID -> stringResource(Res.string.collections_editor_view_mode_tabs)
FolderViewMode.ROWS -> "Rows" FolderViewMode.ROWS -> stringResource(Res.string.collections_editor_view_mode_rows)
FolderViewMode.FOLLOW_LAYOUT -> "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)) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text( Text(
text = "Show \"All\" Tab", text = stringResource(Res.string.collections_editor_show_all_tab),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Combine all folder catalogs into a single tab.", text = stringResource(Res.string.collections_editor_show_all_tab_desc),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -283,20 +289,23 @@ fun CollectionEditorScreen(
// Folders Section Header // Folders Section Header
item { item {
val newFolderTitle = stringResource(Res.string.collections_editor_new_folder)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
NuvioSectionLabel(text = "FOLDERS") NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders))
TextButton(onClick = { CollectionEditorRepository.addFolder() }) { TextButton(
onClick = { CollectionEditorRepository.addFolder(newFolderTitle) },
) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
Spacer(modifier = Modifier.width(4.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), modifier = Modifier.padding(top = 8.dp),
) { ) {
Text( Text(
text = "No folders yet", text = stringResource(Res.string.collections_editor_folder_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Add one to get started.", text = stringResource(Res.string.collections_editor_folder_empty_subtitle),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -352,7 +361,11 @@ fun CollectionEditorScreen(
.padding(bottom = bottomInset), .padding(bottom = bottomInset),
) { ) {
NuvioPrimaryButton( 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(), enabled = state.title.isNotBlank(),
onClick = { onClick = {
if (CollectionEditorRepository.save()) { if (CollectionEditorRepository.save()) {
@ -436,6 +449,11 @@ private fun FolderListItem(
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
val summary = stringResource(
Res.string.collections_editor_source_count,
folder.catalogSources.size,
posterShapeLabel(folder.posterShape),
)
Text( Text(
text = folder.title, text = folder.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -445,7 +463,7 @@ private fun FolderListItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}", text = summary,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -471,7 +489,7 @@ private fun FolderListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Menu, imageVector = Icons.Rounded.Menu,
contentDescription = "Reorder", contentDescription = stringResource(Res.string.action_reorder),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -480,7 +498,7 @@ private fun FolderListItem(
IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.Edit, imageVector = Icons.Rounded.Edit,
contentDescription = "Edit", contentDescription = stringResource(Res.string.action_edit),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
@ -488,7 +506,7 @@ private fun FolderListItem(
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.Delete, imageVector = Icons.Rounded.Delete,
contentDescription = "Delete", contentDescription = stringResource(Res.string.action_delete),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
@ -514,7 +532,11 @@ private fun FolderEditorPage(
NuvioScreen(modifier = Modifier.fillMaxSize()) { NuvioScreen(modifier = Modifier.fillMaxSize()) {
stickyHeader { stickyHeader {
NuvioScreenHeader( 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, onBack = onBack,
) )
} }
@ -522,7 +544,7 @@ private fun FolderEditorPage(
item { item {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -530,24 +552,24 @@ private fun FolderEditorPage(
} }
item { item {
FolderEditorSection(title = "BASICS") { FolderEditorSection(title = stringResource(Res.string.collections_editor_section_basics)) {
NuvioSurfaceCard { NuvioSurfaceCard {
NuvioInputField( NuvioInputField(
value = folder.title, value = folder.title,
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) }, onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
placeholder = "Folder Title", placeholder = stringResource(Res.string.collections_editor_placeholder_folder),
) )
} }
} }
} }
item { item {
FolderEditorSection(title = "APPEARANCE") { FolderEditorSection(title = stringResource(Res.string.collections_editor_section_appearance)) {
NuvioSurfaceCard { NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = "Cover", text = stringResource(Res.string.collections_editor_cover),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@ -559,7 +581,7 @@ private fun FolderEditorPage(
FilterChip( FilterChip(
selected = folder.coverEmoji == null && folder.coverImageUrl == null, selected = folder.coverEmoji == null && folder.coverImageUrl == null,
onClick = { CollectionEditorRepository.clearFolderCover() }, onClick = { CollectionEditorRepository.clearFolderCover() },
label = { Text("None") }, label = { Text(stringResource(Res.string.collections_editor_cover_none)) },
) )
FilterChip( FilterChip(
selected = folder.coverEmoji != null, selected = folder.coverEmoji != null,
@ -568,7 +590,7 @@ private fun FolderEditorPage(
CollectionEditorRepository.updateFolderCoverEmoji("📁") CollectionEditorRepository.updateFolderCoverEmoji("📁")
} }
}, },
label = { Text("Emoji") }, label = { Text(stringResource(Res.string.collections_editor_cover_emoji)) },
) )
FilterChip( FilterChip(
selected = folder.coverImageUrl != null, selected = folder.coverImageUrl != null,
@ -577,7 +599,7 @@ private fun FolderEditorPage(
CollectionEditorRepository.updateFolderCoverImage("") CollectionEditorRepository.updateFolderCoverImage("")
} }
}, },
label = { Text("Image") }, label = { Text(stringResource(Res.string.collections_editor_cover_image_url)) },
) )
} }
} }
@ -586,7 +608,7 @@ private fun FolderEditorPage(
NuvioInputField( NuvioInputField(
value = folder.coverEmoji, value = folder.coverEmoji,
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) }, onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
placeholder = "Emoji", placeholder = stringResource(Res.string.collections_editor_cover_emoji),
modifier = Modifier.width(100.dp), modifier = Modifier.width(100.dp),
) )
} }
@ -595,14 +617,14 @@ private fun FolderEditorPage(
NuvioInputField( NuvioInputField(
value = folder.coverImageUrl, value = folder.coverImageUrl,
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) }, onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
placeholder = "Image URL", placeholder = stringResource(Res.string.collections_editor_cover_image_url),
) )
} }
NuvioInputField( NuvioInputField(
value = folder.focusGifUrl.orEmpty(), value = folder.focusGifUrl.orEmpty(),
onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) }, 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(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = "Tile Shape", text = stringResource(Res.string.collections_editor_tile_shape),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@ -624,7 +646,7 @@ private fun FolderEditorPage(
FilterChip( FilterChip(
selected = folder.posterShape == shape, selected = folder.posterShape == shape,
onClick = { CollectionEditorRepository.updateFolderTileShape(shape) }, onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
label = { Text(shape.name) }, label = { Text(posterShapeLabel(shape)) },
leadingIcon = if (folder.posterShape == shape) { leadingIcon = if (folder.posterShape == shape) {
{ {
Icon( Icon(
@ -640,15 +662,15 @@ private fun FolderEditorPage(
} }
FolderEditorToggleRow( FolderEditorToggleRow(
title = "Show GIF When Configured", title = stringResource(Res.string.collections_editor_show_gif_when_configured),
subtitle = "Play the configured GIF instead of the static cover when available.", subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
checked = folder.focusGifEnabled, checked = folder.focusGifEnabled,
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) }, onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
) )
FolderEditorToggleRow( FolderEditorToggleRow(
title = "Hide Title", title = stringResource(Res.string.collections_editor_hide_title),
subtitle = "Only show the artwork or emoji for this folder tile.", subtitle = stringResource(Res.string.collections_editor_hide_title_desc),
checked = folder.hideTitle, checked = folder.hideTitle,
onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) }, onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
) )
@ -659,7 +681,7 @@ private fun FolderEditorPage(
item { item {
FolderEditorSection( FolderEditorSection(
title = "CATALOG SOURCES", title = stringResource(Res.string.collections_editor_section_catalog_sources),
actions = { actions = {
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon( Icon(
@ -668,7 +690,7 @@ private fun FolderEditorPage(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
Spacer(modifier = Modifier.width(4.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 { NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
text = "No catalog sources yet", text = stringResource(Res.string.collections_editor_catalog_sources_empty_title),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -725,7 +747,7 @@ private fun FolderEditorPage(
.padding(bottom = bottomInset), .padding(bottom = bottomInset),
) { ) {
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Save Folder", text = stringResource(Res.string.collections_editor_save),
enabled = folder.title.isNotBlank(), enabled = folder.title.isNotBlank(),
onClick = { CollectionEditorRepository.saveFolderEdit() }, onClick = { CollectionEditorRepository.saveFolderEdit() },
) )
@ -762,17 +784,17 @@ private fun CatalogPickerSheet(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Select Catalog Sources", text = stringResource(Res.string.collections_editor_select_catalogs),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text("Done") Text(stringResource(Res.string.collections_editor_done))
} }
} }
Text( Text(
text = "Choose the addon catalogs this folder should aggregate.", text = stringResource(Res.string.collections_editor_select_catalogs_description),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
@ -831,7 +853,7 @@ private fun CatalogPickerSheet(
if (isSelected) { if (isSelected) {
Icon( Icon(
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = "Selected", contentDescription = stringResource(Res.string.cd_selected),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
@ -872,7 +894,7 @@ private fun GenrePickerSheet(
item { item {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = "Genre Filter", text = stringResource(Res.string.collections_editor_genre_filter),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@ -888,7 +910,7 @@ private fun GenrePickerSheet(
if (allowAll) { if (allowAll) {
item { item {
GenrePickerOptionRow( GenrePickerOptionRow(
title = "All genres", title = stringResource(Res.string.collections_editor_all_genres),
selected = selectedGenre == null, selected = selectedGenre == null,
onClick = { onSelect(null) }, onClick = { onSelect(null) },
) )
@ -991,7 +1013,11 @@ private fun FolderCatalogSourceCard(
append(" · ${source.catalogId}") append(" · ${source.catalogId}")
} }
val genreOptions = matchingCatalog?.genreOptions.orEmpty() 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 { NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
@ -1019,7 +1045,7 @@ private fun FolderCatalogSourceCard(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = "Remove", contentDescription = stringResource(Res.string.action_remove),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
@ -1045,7 +1071,7 @@ private fun FolderCatalogSourceCard(
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
Text( Text(
text = "Genre Filter", text = stringResource(Res.string.collections_editor_genre_filter),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@ -1057,7 +1083,7 @@ private fun FolderCatalogSourceCard(
) )
} }
TextButton(onClick = onOpenGenrePicker) { 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 @Composable
private fun GenrePickerOptionRow( private fun GenrePickerOptionRow(
title: String, title: String,

View file

@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard 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.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -75,7 +77,7 @@ fun CollectionManagementScreen(
NuvioScreen { NuvioScreen {
stickyHeader { stickyHeader {
NuvioScreenHeader( NuvioScreenHeader(
title = "Collections", title = stringResource(Res.string.collections_header),
onBack = onBack, onBack = onBack,
) { ) {
IconButton(onClick = { IconButton(onClick = {
@ -84,14 +86,14 @@ fun CollectionManagementScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Rounded.ContentCopy, imageVector = Icons.Rounded.ContentCopy,
contentDescription = "Copy JSON", contentDescription = stringResource(Res.string.collections_copy_json),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
IconButton(onClick = { showImportDialog = true }) { IconButton(onClick = { showImportDialog = true }) {
Icon( Icon(
imageVector = Icons.Rounded.ContentPaste, imageVector = Icons.Rounded.ContentPaste,
contentDescription = "Import", contentDescription = stringResource(Res.string.collections_import),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@ -100,8 +102,11 @@ fun CollectionManagementScreen(
item { item {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( Text(
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " + text = stringResource(
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}", Res.string.collections_count_summary,
collections.size,
collections.sumOf { it.folders.size },
),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -110,13 +115,13 @@ fun CollectionManagementScreen(
item { item {
NuvioPrimaryButton( NuvioPrimaryButton(
text = "New Collection", text = stringResource(Res.string.collections_new),
onClick = { onNavigateToEditor(null) }, onClick = { onNavigateToEditor(null) },
) )
} }
if (collections.isNotEmpty()) { if (collections.isNotEmpty()) {
item { NuvioSectionLabel(text = "YOUR COLLECTIONS") } item { NuvioSectionLabel(text = stringResource(Res.string.collections_your_collections)) }
} }
if (collections.isNotEmpty()) { if (collections.isNotEmpty()) {
@ -142,13 +147,13 @@ fun CollectionManagementScreen(
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "No collections yet", text = stringResource(Res.string.collections_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Create one to organize your catalogs.", text = stringResource(Res.string.collections_empty_subtitle),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -187,11 +192,11 @@ fun CollectionManagementScreen(
val deleteId = showDeleteConfirm val deleteId = showDeleteConfirm
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } } val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
NuvioStatusModal( NuvioStatusModal(
title = "Delete Collection", title = stringResource(Res.string.collections_delete_title),
message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.", message = stringResource(Res.string.collections_delete_message, deleteCollection?.title.orEmpty()),
isVisible = deleteId != null, isVisible = deleteId != null,
confirmText = "Delete", confirmText = stringResource(Res.string.action_delete),
dismissText = "Cancel", dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
if (deleteId != null) { if (deleteId != null) {
CollectionRepository.removeCollection(deleteId) CollectionRepository.removeCollection(deleteId)
@ -261,6 +266,13 @@ private fun CollectionListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { 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(
text = collection.title, text = collection.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -271,8 +283,7 @@ private fun CollectionListItem(
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" + text = summary,
if (collection.pinToTop) " · Pinned" else "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -298,7 +309,7 @@ private fun CollectionListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Menu, imageVector = Icons.Rounded.Menu,
contentDescription = "Reorder", contentDescription = stringResource(Res.string.action_reorder),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -310,7 +321,7 @@ private fun CollectionListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Edit, imageVector = Icons.Rounded.Edit,
contentDescription = "Edit", contentDescription = stringResource(Res.string.action_edit),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
@ -321,7 +332,7 @@ private fun CollectionListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Delete, imageVector = Icons.Rounded.Delete,
contentDescription = "Delete", contentDescription = stringResource(Res.string.action_delete),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
@ -349,13 +360,13 @@ private fun ImportDialog(
) { ) {
Column(modifier = Modifier.padding(20.dp)) { Column(modifier = Modifier.padding(20.dp)) {
Text( Text(
text = "Import Collections", text = stringResource(Res.string.collections_import_header),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Paste your collections JSON below.", text = stringResource(Res.string.collections_import_paste_description),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -366,7 +377,12 @@ private fun ImportDialog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(160.dp), .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, isError = importError != null,
supportingText = importError?.let { supportingText = importError?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) } { Text(it, color = MaterialTheme.colorScheme.error) }
@ -399,7 +415,7 @@ private fun ImportDialog(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
), ),
) { ) {
Text("Cancel") Text(stringResource(Res.string.action_cancel))
} }
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
androidx.compose.material3.Button( androidx.compose.material3.Button(
@ -407,7 +423,7 @@ private fun ImportDialog(
enabled = importText.isNotBlank(), enabled = importText.isNotBlank(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
Text("Import") Text(stringResource(Res.string.action_import))
} }
} }
} }

View file

@ -39,7 +39,7 @@ data class CollectionFolder(
val focusGifUrl: String? = null, val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true, val focusGifEnabled: Boolean = true,
val coverEmoji: String? = null, val coverEmoji: String? = null,
val tileShape: String = "Poster", val tileShape: String = "poster",
val hideTitle: Boolean = false, val hideTitle: Boolean = false,
val catalogSources: List<CollectionCatalogSource> = emptyList(), val catalogSources: List<CollectionCatalogSource> = emptyList(),
) { ) {

View file

@ -6,9 +6,19 @@ import com.nuvio.app.features.addons.ManagedAddon
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -110,28 +120,68 @@ object CollectionRepository {
fun validateJson(jsonString: String): ValidationResult { fun validateJson(jsonString: String): ValidationResult {
if (jsonString.isBlank()) { 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 { return try {
val collections = json.decodeFromString<List<Collection>>(jsonString) val collections = json.decodeFromString<List<Collection>>(jsonString)
var totalFolders = 0 var totalFolders = 0
collections.forEachIndexed { ci, c -> collections.forEachIndexed { ci, c ->
if (c.id.isBlank()) { 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()) { 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 -> c.folders.forEachIndexed { fi, f ->
if (f.id.isBlank()) { 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()) { 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 -> f.catalogSources.forEachIndexed { si, s ->
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) { 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++ totalFolders++
@ -143,7 +193,12 @@ object CollectionRepository {
folderCount = totalFolders, folderCount = totalFolders,
) )
} catch (e: Exception) { } 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())
},
)
} }
} }

View file

@ -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.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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( data class FolderTab(
val label: String, val label: String,
@ -113,7 +119,13 @@ object FolderDetailRepository {
val tabs = buildList { val tabs = buildList {
if (showAll) { 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 -> folder.catalogSources.forEach { source ->
val addon = addons.find { it.manifest?.id == source.addonId } val addon = addons.find { it.manifest?.id == source.addonId }
@ -121,9 +133,7 @@ object FolderDetailRepository {
it.id == source.catalogId && it.type == source.type it.id == source.catalogId && it.type == source.type
} }
val label = catalog?.name ?: source.catalogId val label = catalog?.name ?: source.catalogId
val typeLabel = source.type.replaceFirstChar { val typeLabel = localizedMediaTypeLabel(source.type)
if (it.isLowerCase()) it.titlecase() else it.toString()
}
val genreSuffix = if (source.genre != null) " · ${source.genre}" else "" val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
add( add(
FolderTab( FolderTab(
@ -155,7 +165,14 @@ object FolderDetailRepository {
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val addon = addons.find { it.manifest?.id == source.addonId } val addon = addons.find { it.manifest?.id == source.addonId }
if (addon == null) { 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 return@forEachIndexed
} }

View file

@ -63,6 +63,11 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map 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 private val FolderCoverHeight = 176.dp
@ -143,7 +148,7 @@ fun FolderDetailScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "Folder not found", text = stringResource(Res.string.collections_folder_not_found),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -229,7 +234,11 @@ private fun TabbedGridContent(
onClick = { onTabSelected(index) }, onClick = { onTabSelected(index) },
text = { text = {
Text( Text(
text = tab.label, text = if (tab.isAllTab) {
stringResource(Res.string.collections_tab_all)
} else {
tab.label
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@ -395,7 +404,7 @@ private fun EmptyMessage() {
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No items found", text = stringResource(Res.string.collections_folder_empty_items),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders import com.nuvio.app.features.streams.StreamProxyHeaders
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -13,6 +14,8 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
internal object MetaDetailsParser { internal object MetaDetailsParser {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@ -248,10 +251,10 @@ internal object MetaDetailsParser {
MetaTrailer( MetaTrailer(
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey, id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
key = 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", site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
size = trailer.int("size"), 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, official = trailer.boolean("official") == true,
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"), publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"), seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
@ -273,7 +276,9 @@ internal object MetaDetailsParser {
?.objectValue("proxyHeaders") ?.objectValue("proxyHeaders")
?.toProxyHeaders() ?.toProxyHeaders()
val streamData = obj["streamData"] as? JsonObject 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( StreamItem(
name = obj.string("name"), name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"), description = obj.string("description") ?: obj.string("title"),

View file

@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object MetaDetailsRepository { object MetaDetailsRepository {
private data class CachedMetaEntry( private data class CachedMetaEntry(
@ -112,7 +114,7 @@ object MetaDetailsRepository {
if (manifests.isEmpty()) { if (manifests.isEmpty()) {
log.w { "No addon provides meta for type=$type id=$id" } log.w { "No addon provides meta for type=$type id=$id" }
_uiState.value = MetaDetailsUiState( _uiState.value = MetaDetailsUiState(
errorMessage = "No addon provides meta for this content.", errorMessage = getString(Res.string.details_no_addon_meta),
) )
activeRequestKey = null activeRequestKey = null
return@launch return@launch
@ -157,7 +159,7 @@ object MetaDetailsRepository {
} }
_uiState.value = MetaDetailsUiState( _uiState.value = MetaDetailsUiState(
errorMessage = "Could not load details from any addon.", errorMessage = getString(Res.string.details_load_failed_all_addons),
) )
activeRequestKey = null activeRequestKey = null
} }

View file

@ -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.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@ -186,7 +189,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = result.currentPage commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount commentsPageCount = result.pageCount
} catch (e: Exception) { } catch (e: Exception) {
commentsError = e.message ?: "Failed to load comments" commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
} }
isCommentsLoading = false isCommentsLoading = false
} }
@ -242,14 +245,14 @@ fun MetaDetailsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "Failed to load", text = stringResource(Res.string.details_failed_to_load),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( Text(
text = when (networkStatusUiState.condition) { text = when (networkStatusUiState.condition) {
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again." NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers." NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
else -> uiState.errorMessage.orEmpty() else -> uiState.errorMessage.orEmpty()
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -262,7 +265,7 @@ fun MetaDetailsScreen(
MetaDetailsRepository.load(type, id) 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) tab.key to (snapshot[tab.key] == true)
} }
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
} }
pickerPending = false pickerPending = false
} }
@ -394,7 +397,7 @@ fun MetaDetailsScreen(
} }
trailerPlaybackSource = resolvedSource trailerPlaybackSource = resolvedSource
trailerErrorMessage = if (resolvedSource == null) { trailerErrorMessage = if (resolvedSource == null) {
"No playable trailer stream found." getString(Res.string.trailer_no_playable_stream)
} else { } else {
null 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 { when {
(meta.type == "series" || hasEpisodes) && seriesAction != null -> (meta.type == "series" || hasEpisodes) && seriesAction != null ->
seriesAction.label seriesAction.label
meta.type != "series" && !hasEpisodes && movieProgress != null -> meta.type != "series" && !hasEpisodes && movieProgress != null ->
"Resume" resumeText
else -> "Play" else -> playText
} }
} }
val onPrimaryPlayClick: () -> Unit = { val onPrimaryPlayClick: () -> Unit = {
@ -660,7 +665,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = result.currentPage commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount commentsPageCount = result.pageCount
} catch (e: Exception) { } catch (e: Exception) {
commentsError = e.message ?: "Failed to load comments" commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
} }
isCommentsLoading = false isCommentsLoading = false
} }
@ -780,7 +785,9 @@ fun MetaDetailsScreen(
} }
EpisodeWatchedActionSheet( EpisodeWatchedActionSheet(
episode = selectedEpisode, 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, isEpisodeWatched = isSelectedEpisodeWatched,
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(), canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
arePreviousEpisodesWatched = arePreviousEpisodesWatched, arePreviousEpisodesWatched = arePreviousEpisodesWatched,
@ -865,7 +872,7 @@ fun MetaDetailsScreen(
}.onSuccess { }.onSuccess {
showLibraryListPicker = false showLibraryListPicker = false
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
} }
pickerPending = false pickerPending = false
} }
@ -993,7 +1000,11 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.ACTIONS -> { MetaScreenSectionKey.ACTIONS -> {
DetailActionButtons( DetailActionButtons(
playLabel = playButtonLabel, playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save", saveLabel = if (isSaved) {
stringResource(Res.string.action_saved)
} else {
stringResource(Res.string.action_save)
},
isSaved = isSaved, isSaved = isSaved,
isTablet = isTablet, isTablet = isTablet,
onPlayClick = onPrimaryPlayClick, onPlayClick = onPrimaryPlayClick,
@ -1072,7 +1083,7 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.MORE_LIKE_THIS -> { MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) { if (hasMoreLikeThisSection) {
DetailPosterRailSection( DetailPosterRailSection(
title = "More Like This", title = stringResource(Res.string.details_more_like_this),
items = meta.moreLikeThis, items = meta.moreLikeThis,
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
showHeader = showHeader, showHeader = showHeader,

View file

@ -3,11 +3,15 @@ package com.nuvio.app.features.details
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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 { enum class MetaScreenSectionKey {
ACTIONS, ACTIONS,
@ -81,8 +85,8 @@ private data class StoredMetaScreenSettingsPayload(
private data class MetaScreenSectionDefinition( private data class MetaScreenSectionDefinition(
val key: MetaScreenSectionKey, val key: MetaScreenSectionKey,
val title: String, val titleRes: StringResource,
val description: String, val descriptionRes: StringResource,
) )
object MetaScreenSettingsRepository { object MetaScreenSettingsRepository {
@ -94,53 +98,53 @@ object MetaScreenSettingsRepository {
private val definitions = listOf( private val definitions = listOf(
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.ACTIONS, key = MetaScreenSectionKey.ACTIONS,
title = "Actions", titleRes = Res.string.meta_section_actions_title,
description = "Play and save controls.", descriptionRes = Res.string.meta_section_actions_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.OVERVIEW, key = MetaScreenSectionKey.OVERVIEW,
title = "Overview", titleRes = Res.string.meta_section_overview_title,
description = "Synopsis, ratings, genres, and core credits.", descriptionRes = Res.string.meta_section_overview_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.PRODUCTION, key = MetaScreenSectionKey.PRODUCTION,
title = "Production", titleRes = Res.string.meta_section_production_title,
description = "Studios and networks.", descriptionRes = Res.string.meta_section_production_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.CAST, key = MetaScreenSectionKey.CAST,
title = "Cast", titleRes = Res.string.settings_meta_cast,
description = "Principal cast list.", descriptionRes = Res.string.meta_section_cast_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.COMMENTS, key = MetaScreenSectionKey.COMMENTS,
title = "Comments", titleRes = Res.string.settings_meta_comments,
description = "Trakt comments section.", descriptionRes = Res.string.meta_section_comments_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.TRAILERS, key = MetaScreenSectionKey.TRAILERS,
title = "Trailers", titleRes = Res.string.settings_meta_trailers,
description = "Trailer rail and playback shortcuts.", descriptionRes = Res.string.meta_section_trailers_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.EPISODES, key = MetaScreenSectionKey.EPISODES,
title = "Episodes", titleRes = Res.string.settings_meta_episodes,
description = "Seasons and episode list for series.", descriptionRes = Res.string.meta_section_episodes_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.DETAILS, key = MetaScreenSectionKey.DETAILS,
title = "Details", titleRes = Res.string.meta_section_details_title,
description = "Runtime, status, release, language, and related info.", descriptionRes = Res.string.meta_section_details_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.COLLECTION, key = MetaScreenSectionKey.COLLECTION,
title = "Collection", titleRes = Res.string.meta_section_collection_title,
description = "Related collection or franchise rail.", descriptionRes = Res.string.meta_section_collection_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.MORE_LIKE_THIS, key = MetaScreenSectionKey.MORE_LIKE_THIS,
title = "More Like This", titleRes = Res.string.meta_section_more_like_this_title,
description = "Recommendation rail.", descriptionRes = Res.string.meta_section_more_like_this_description,
), ),
) )
@ -152,6 +156,7 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -322,8 +327,8 @@ object MetaScreenSettingsRepository {
val preference = preferences[definition.key] val preference = preferences[definition.key]
MetaScreenSectionItem( MetaScreenSectionItem(
key = definition.key, key = definition.key,
title = definition.title, title = localizedString(definition.titleRes),
description = definition.description, description = localizedString(definition.descriptionRes),
enabled = preference?.enabled ?: true, enabled = preference?.enabled ?: true,
order = preference?.order ?: 0, order = preference?.order ?: 0,
tabGroup = preference?.tabGroup, tabGroup = preference?.tabGroup,
@ -347,4 +352,4 @@ object MetaScreenSettingsRepository {
), ),
) )
} }
} }

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.nuvio.app.core.i18n.localizedShortMonthName
import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState 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.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.watchprogress.CurrentDateProvider 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 { private sealed interface PersonDetailUiState {
data object Loading : PersonDetailUiState data object Loading : PersonDetailUiState
@ -96,7 +100,7 @@ fun PersonDetailScreen(
uiState = if (detail != null) { uiState = if (detail != null) {
PersonDetailUiState.Success(detail) PersonDetailUiState.Success(detail)
} else { } 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( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
@ -268,7 +272,7 @@ private fun PersonDetailContent(
if (popularCredits.isNotEmpty()) { if (popularCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection( DetailPosterRailSection(
title = "Popular", title = stringResource(Res.string.person_popular),
items = popularCredits, items = popularCredits,
watchedKeys = emptySet(), watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp, headerHorizontalPadding = 20.dp,
@ -279,7 +283,7 @@ private fun PersonDetailContent(
if (latestCredits.isNotEmpty()) { if (latestCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection( DetailPosterRailSection(
title = "Latest", title = stringResource(Res.string.person_latest),
items = latestCredits, items = latestCredits,
watchedKeys = emptySet(), watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp, headerHorizontalPadding = 20.dp,
@ -290,7 +294,7 @@ private fun PersonDetailContent(
if (upcomingCredits.isNotEmpty()) { if (upcomingCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection( DetailPosterRailSection(
title = "Upcoming", title = stringResource(Res.string.person_upcoming),
items = upcomingCredits, items = upcomingCredits,
watchedKeys = emptySet(), watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp, headerHorizontalPadding = 20.dp,
@ -405,18 +409,23 @@ private fun HeroSection(
val infoItems = buildList { val infoItems = buildList {
person.birthday?.let { bday -> person.birthday?.let { bday ->
val age = calculateAge(bday, person.deathday) 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 bdayDisplay = formatDateForDisplay(bday) ?: bday
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it } val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
val line = if (deathDisplay != null) { 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 { } else {
"Born $bdayDisplay$ageStr" stringResource(Res.string.person_born, bdayDisplay, ageStr)
} }
add(line) add(line)
} }
person.placeOfBirth?.let { add(it) } 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()) { if (infoItems.isNotEmpty()) {
infoItems.forEach { info -> infoItems.forEach { info ->
@ -682,7 +691,7 @@ private fun PersonDetailError(
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = "Something went wrong", text = stringResource(Res.string.person_something_wrong),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -700,7 +709,7 @@ private fun PersonDetailError(
contentColor = MaterialTheme.colorScheme.onPrimary, 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? { private fun formatDateForDisplay(date: String): String? {
val parts = date.split("-").mapNotNull { it.toIntOrNull() } val parts = date.split("-").mapNotNull { it.toIntOrNull() }
if (parts.size < 3) return null 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 month = parts[1]
val day = parts[2] val day = parts[2]
val year = parts[0] val year = parts[0]
return if (month in 1..12) { return if (month in 1..12) {
"${months[month - 1]} $day, $year" "${localizedShortMonthName(month)} $day, $year"
} else { } else {
null null
} }

View file

@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage 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.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
@ -73,6 +75,7 @@ fun TmdbEntityBrowseScreen(
var uiState by remember(entityKind, entityId) { var uiState by remember(entityKind, entityId) {
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading) mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
} }
val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName)
LaunchedEffect(entityKind, entityId) { LaunchedEffect(entityKind, entityId) {
uiState = EntityBrowseUiState.Loading uiState = EntityBrowseUiState.Loading
@ -85,7 +88,7 @@ fun TmdbEntityBrowseScreen(
uiState = if (data != null) { uiState = if (data != null) {
EntityBrowseUiState.Success(data) EntityBrowseUiState.Success(data)
} else { } else {
EntityBrowseUiState.Error("Could not load $entityName") EntityBrowseUiState.Error(loadFailedMessage)
} }
} }
@ -117,7 +120,7 @@ fun TmdbEntityBrowseScreen(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
@ -170,7 +173,7 @@ private fun EntityBrowseContent(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No titles found", text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -191,18 +194,16 @@ private fun EntityBrowseContent(
) )
data.rails.forEach { rail -> data.rails.forEach { rail ->
val railTitle = remember(rail.mediaType, rail.railType) { val mediaLabel = when (rail.mediaType) {
val mediaLabel = when (rail.mediaType) { TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
TmdbEntityMediaType.MOVIE -> "Movies" TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
TmdbEntityMediaType.TV -> "Series"
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> "Popular"
TmdbEntityRailType.TOP_RATED -> "Top Rated"
TmdbEntityRailType.RECENT -> "Recent"
}
"$mediaLabel$railLabel"
} }
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( DetailPosterRailSection(
title = railTitle, title = railTitle,
@ -230,8 +231,8 @@ private fun EntityHeroSection(
Column(modifier = modifier.padding(horizontal = 20.dp)) { Column(modifier = modifier.padding(horizontal = 20.dp)) {
Text( Text(
text = when (header.kind) { text = when (header.kind) {
TmdbEntityKind.COMPANY -> "Production Company" TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company)
TmdbEntityKind.NETWORK -> "Network" TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network)
}, },
style = MaterialTheme.typography.labelLarge.copy( style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -405,7 +406,7 @@ private fun EntityBrowseError(
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
), ),
) { ) {
Text("Retry") Text(stringResource(Res.string.action_retry))
} }
} }
} }

View file

@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -114,7 +116,7 @@ fun CommentDetailSheet(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
contentDescription = "Previous", contentDescription = stringResource(Res.string.action_previous),
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@ -140,7 +142,7 @@ fun CommentDetailSheet(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = "Next", contentDescription = stringResource(Res.string.action_next),
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@ -153,13 +155,13 @@ fun CommentDetailSheet(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (comment.review) { if (comment.review) {
CommentDetailChip(text = "Review") CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
} }
if (comment.hasSpoilerContent) { if (comment.hasSpoilerContent) {
CommentDetailChip(text = "Spoiler") CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
} }
comment.rating?.let { rating -> 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(
text = if (comment.hasSpoilerContent) { text = if (comment.hasSpoilerContent) {
"This comment contains spoilers and has been hidden." stringResource(Res.string.detail_comments_spoiler_hidden_sheet)
} else { } else {
comment.comment comment.comment
}, },
@ -189,7 +191,7 @@ fun CommentDetailSheet(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
text = "${comment.likes} likes", text = stringResource(Res.string.detail_comments_likes, comment.likes),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )

View file

@ -28,13 +28,17 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.AppIconResource
import com.nuvio.app.core.ui.appIconPainter 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun DetailActionButtons( fun DetailActionButtons(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playLabel: String = "Play", playLabel: String = stringResource(Res.string.action_play),
saveLabel: String = "Save", saveLabel: String = stringResource(Res.string.action_save),
isSaved: Boolean = false, isSaved: Boolean = false,
isTablet: Boolean = false, isTablet: Boolean = false,
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},

View file

@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.formatRuntimeForDisplay import com.nuvio.app.features.details.formatRuntimeForDisplay
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailAdditionalInfoSection( fun DetailAdditionalInfoSection(
@ -27,14 +29,24 @@ fun DetailAdditionalInfoSection(
showHeader: Boolean = true, showHeader: Boolean = true,
) { ) {
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null } 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 { val rows = buildList {
meta.status?.let { add("Status" to it) } meta.status?.let { add(stringResource(Res.string.details_status) to it) }
meta.releaseInfo?.let { add("Release Info" to formatReleaseDateForDisplay(it)) } meta.releaseInfo?.let {
formatRuntimeForDisplay(meta.runtime)?.let { add("Runtime" to it) } add(stringResource(Res.string.details_release_info) to formatReleaseDateForDisplay(it))
meta.ageRating?.let { add("Certification" to it) } }
meta.country?.let { add("Origin Country" to it) } formatRuntimeForDisplay(meta.runtime)?.let {
meta.language?.let { add("Original Language" to it.uppercase()) } 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 if (rows.isEmpty()) return

View file

@ -34,6 +34,8 @@ import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.castAvatarSharedTransitionKey import com.nuvio.app.features.details.castAvatarSharedTransitionKey
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@ -48,7 +50,7 @@ fun DetailCastSection(
if (cast.isEmpty()) return if (cast.isEmpty()) return
DetailSection( DetailSection(
title = "Cast", title = stringResource(Res.string.settings_meta_cast),
modifier = modifier, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {

View file

@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailCommentsSection( fun DetailCommentsSection(
@ -101,14 +103,14 @@ fun DetailCommentsSection(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
), ),
) { ) {
Text("Retry") Text(stringResource(Res.string.action_retry))
} }
} }
} }
comments.isEmpty() -> { comments.isEmpty() -> {
Text( Text(
text = "No comments yet.", text = stringResource(Res.string.detail_comments_empty),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -144,7 +146,7 @@ private fun CommentsHeader() {
val titleSize = if (isTablet) 22.sp else 20.sp val titleSize = if (isTablet) 22.sp else 20.sp
Text( Text(
text = "Trakt Comments", text = stringResource(Res.string.detail_comments_title),
style = MaterialTheme.typography.titleLarge.copy( style = MaterialTheme.typography.titleLarge.copy(
fontSize = titleSize, fontSize = titleSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -163,7 +165,7 @@ private fun CommentCard(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505) val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505)
val bodyText = if (review.hasSpoilerContent) { val bodyText = if (review.hasSpoilerContent) {
"This comment contains spoilers." stringResource(Res.string.detail_comments_spoiler_card)
} else { } else {
review.comment review.comment
} }
@ -199,13 +201,13 @@ private fun CommentCard(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (review.review) { if (review.review) {
CommentChip(text = "Review") CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
} }
if (review.hasSpoilerContent) { if (review.hasSpoilerContent) {
CommentChip(text = "Spoiler") CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
} }
review.rating?.let { rating -> 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(
text = "${review.likes} likes", text = stringResource(Res.string.detail_comments_likes, review.likes),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,

View file

@ -41,6 +41,8 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.isIos import com.nuvio.app.isIos
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailFloatingHeader( fun DetailFloatingHeader(
@ -112,7 +114,7 @@ fun DetailFloatingHeader(
if (meta.logo != null && !logoLoadError) { if (meta.logo != null && !logoLoadError) {
AsyncImage( AsyncImage(
model = meta.logo, model = meta.logo,
contentDescription = "${meta.name} logo", contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier modifier = Modifier
.width(logoWidth) .width(logoWidth)
.widthIn(max = 240.dp) .widthIn(max = 240.dp)
@ -166,7 +168,11 @@ private fun DetailFloatingHeaderAction(
) { ) {
Icon( Icon(
imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, 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, tint = MaterialTheme.colorScheme.onBackground,
) )
} }

View file

@ -25,6 +25,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailHero( fun DetailHero(
@ -103,7 +105,7 @@ fun DetailHero(
if (meta.logo != null) { if (meta.logo != null) {
AsyncImage( AsyncImage(
model = meta.logo, model = meta.logo,
contentDescription = "${meta.name} logo", contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier modifier = Modifier
.fillMaxWidth(if (isTablet) 0.56f else 0.6f) .fillMaxWidth(if (isTablet) 0.56f else 0.6f)
.widthIn(max = contentMaxWidth) .widthIn(max = contentMaxWidth)

View file

@ -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_TMDB
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT 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_audience_score
import nuvio.composeapp.generated.resources.rating_imdb import nuvio.composeapp.generated.resources.rating_imdb
import nuvio.composeapp.generated.resources.rating_letterboxd 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_tmdb
import nuvio.composeapp.generated.resources.rating_trakt import nuvio.composeapp.generated.resources.rating_trakt
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -114,7 +117,7 @@ fun DetailMetaInfo(
color = ImdbYellow, color = ImdbYellow,
) { ) {
Text( Text(
text = "IMDb", text = stringResource(Res.string.source_imdb),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelMedium.copy( style = MaterialTheme.typography.labelMedium.copy(
fontSize = 10.sp, fontSize = 10.sp,
@ -148,14 +151,14 @@ fun DetailMetaInfo(
if (meta.director.isNotEmpty()) { if (meta.director.isNotEmpty()) {
MetaLabelValueRow( MetaLabelValueRow(
label = "Director", label = stringResource(Res.string.details_director),
value = meta.director.joinToString(", "), value = meta.director.joinToString(", "),
) )
} }
if (meta.writer.isNotEmpty()) { if (meta.writer.isNotEmpty()) {
MetaLabelValueRow( MetaLabelValueRow(
label = "Writer", label = stringResource(Res.string.details_writer),
value = meta.writer.joinToString(", "), value = meta.writer.joinToString(", "),
) )
} }
@ -182,7 +185,11 @@ fun DetailMetaInfo(
if (canExpand) { if (canExpand) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { expanded = !expanded }, modifier = Modifier.clickable { expanded = !expanded },
@ -341,7 +348,7 @@ private val ratingVisuals = listOf(
), ),
RatingVisuals( RatingVisuals(
source = PROVIDER_AUDIENCE, source = PROVIDER_AUDIENCE,
displayName = "Audience Score", displayName = runBlocking { getString(Res.string.rating_audience_score) },
logo = Res.drawable.rating_audience_score, logo = Res.drawable.rating_audience_score,
logoWidth = 16.dp, logoWidth = 16.dp,
valueColor = Color(0xFFFA320A), valueColor = Color(0xFFFA320A),

View file

@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaCompany
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -54,7 +56,11 @@ fun DetailProductionSection(
if (displayItems.isEmpty()) return if (displayItems.isEmpty()) return
DetailSection( 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, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {

View file

@ -57,6 +57,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.format.formatReleaseDateForDisplay 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.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.features.details.MetaDetails 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.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingState 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") private val log = Logger.withTag("SeriesContent")
@ -92,16 +97,16 @@ fun DetailSeriesContent(
if (meta.videos.isEmpty()) { if (meta.videos.isEmpty()) {
DetailSection( DetailSection(
title = "Episodes", title = stringResource(Res.string.settings_meta_episodes),
modifier = modifier, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {
Text( Text(
text = when { text = when {
meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos -> 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 -> else ->
"This addon did not provide episode metadata for this series." stringResource(Res.string.details_series_no_metadata)
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -132,12 +137,12 @@ fun DetailSeriesContent(
if (groupedEpisodes.isEmpty()) { if (groupedEpisodes.isEmpty()) {
if (meta.type == "series") { if (meta.type == "series") {
DetailSection( DetailSection(
title = "Episodes", title = stringResource(Res.string.settings_meta_episodes),
modifier = modifier, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -182,7 +187,7 @@ fun DetailSeriesContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Seasons", text = stringResource(Res.string.details_seasons),
style = MaterialTheme.typography.titleLarge.copy( style = MaterialTheme.typography.titleLarge.copy(
fontSize = sizing.seasonHeaderSize, fontSize = sizing.seasonHeaderSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -250,7 +255,7 @@ fun DetailSeriesContent(
label = "season_episodes", label = "season_episodes",
) { seasonForContent -> ) { seasonForContent ->
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) { val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
"Videos" stringResource(Res.string.details_videos)
} else { } else {
seasonForContent.label() seasonForContent.label()
} }
@ -336,7 +341,11 @@ private fun SeasonViewModeToggle(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( 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( style = MaterialTheme.typography.labelLarge.copy(
fontSize = sizing.seasonToggleTextSize, fontSize = sizing.seasonToggleTextSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -1187,14 +1196,14 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
private fun Int.label(): String = private fun Int.label(): String =
if (this <= 0) { if (this <= 0) {
"Specials" runBlocking { getString(Res.string.episodes_specials) }
} else { } else {
"Season $this" runBlocking { getString(Res.string.episodes_season, this@label) }
} }
private fun MetaVideo.episodeBadge(): String = private fun MetaVideo.episodeBadge(): String =
when { when {
episode != null -> "E${episode.toString().padStart(2, '0')}" episode != null || season != null ->
season != null -> "S${season.toString().padStart(2, '0')}" localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
else -> "FILE" else -> runBlocking { getString(Res.string.details_episode_badge_file) }
} }

View file

@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaTrailer 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 @Composable
fun DetailTrailersSection( fun DetailTrailersSection(
@ -48,10 +53,11 @@ fun DetailTrailersSection(
) { ) {
if (trailers.isEmpty()) return if (trailers.isEmpty()) return
val trailerLabel = stringResource(Res.string.detail_tab_trailer)
val grouped = remember(trailers) { val grouped = remember(trailers) {
linkedMapOf<String, MutableList<MetaTrailer>>().apply { linkedMapOf<String, MutableList<MetaTrailer>>().apply {
trailers.forEach { trailer -> trailers.forEach { trailer ->
val category = trailer.type.ifBlank { "Trailer" } val category = trailer.type.ifBlank { trailerLabel }
getOrPut(category) { mutableListOf() }.add(trailer) getOrPut(category) { mutableListOf() }.add(trailer)
} }
} }
@ -60,7 +66,7 @@ fun DetailTrailersSection(
if (grouped.isEmpty()) return if (grouped.isEmpty()) return
val initialCategory = remember(grouped) { val initialCategory = remember(grouped) {
grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) } grouped.keys.firstOrNull { it.equals(trailerLabel, ignoreCase = true) }
?: grouped.keys.first() ?: grouped.keys.first()
} }
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) } var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
@ -82,7 +88,7 @@ fun DetailTrailersSection(
) { ) {
if (showHeader) { if (showHeader) {
DetailSectionTitle( DetailSectionTitle(
title = "Trailers", title = stringResource(Res.string.detail_trailers_title),
fullWidth = false, fullWidth = false,
) )
} }
@ -131,7 +137,7 @@ fun DetailTrailersSection(
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
text = "$category ($count)", text = stringResource(Res.string.detail_trailer_category_count, category, count),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
}, },

View file

@ -29,8 +29,11 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -71,7 +74,11 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.CheckCircle, 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 = { onClick = {
onToggleWatched() onToggleWatched()
coroutineScope.launch { coroutineScope.launch {
@ -84,9 +91,9 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.DoneAll, icon = Icons.Default.DoneAll,
title = if (arePreviousEpisodesWatched) { title = if (arePreviousEpisodesWatched) {
"Mark previous as unwatched" stringResource(Res.string.episode_mark_previous_unwatched)
} else { } else {
"Mark previous as watched" stringResource(Res.string.episode_mark_previous_watched)
}, },
onClick = { onClick = {
onTogglePreviousWatched() onTogglePreviousWatched()
@ -100,9 +107,9 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.PlaylistAddCheckCircle, icon = Icons.Default.PlaylistAddCheckCircle,
title = if (isSeasonWatched) { title = if (isSeasonWatched) {
"Mark $seasonLabel as unwatched" stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
} else { } else {
"Mark $seasonLabel as watched" stringResource(Res.string.episode_mark_season_watched, seasonLabel)
}, },
onClick = { onClick = {
onToggleSeasonWatched() onToggleSeasonWatched()
@ -115,7 +122,7 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
title = "Play manually", title = stringResource(Res.string.play_manually),
onClick = { onClick = {
onPlayManually() onPlayManually()
coroutineScope.launch { coroutineScope.launch {
@ -149,8 +156,11 @@ private fun EpisodeActionSheetHeader(
) )
Text( Text(
text = buildString { text = buildString {
if (episode.season != null && episode.episode != null) { localizedSeasonEpisodeCode(
append("S${episode.season}E${episode.episode}") seasonNumber = episode.season,
episodeNumber = episode.episode,
)?.let {
append(it)
append("") append("")
} }
append(seasonLabel) append(seasonLabel)
@ -162,4 +172,3 @@ private fun EpisodeActionSheetHeader(
) )
} }
} }

View file

@ -39,6 +39,8 @@ import com.nuvio.app.features.player.PlatformPlayerSurface
import com.nuvio.app.features.player.PlayerResizeMode import com.nuvio.app.features.player.PlayerResizeMode
import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.trailer.TrailerPlaybackSource
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -55,7 +57,7 @@ fun TrailerPlayerPopup(
) { ) {
if (!visible) return if (!visible) return
val headerType = trailerType.trim().ifBlank { "Trailer" } val headerType = trailerType.trim().ifBlank { stringResource(Res.string.detail_tab_trailer) }
val headerSubtitle = buildList { val headerSubtitle = buildList {
if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) { if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) {
add(trailerTitle) add(trailerTitle)
@ -119,7 +121,7 @@ fun TrailerPlayerPopup(
IconButton(onClick = dismissSheet) { IconButton(onClick = dismissSheet) {
Icon( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = "Close trailer", contentDescription = stringResource(Res.string.trailer_close),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
@ -147,7 +149,7 @@ fun TrailerPlayerPopup(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "Unable to play trailer", text = stringResource(Res.string.trailer_unable_to_play),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -160,7 +162,7 @@ fun TrailerPlayerPopup(
) )
if (onRetry != null) { if (onRetry != null) {
TextButton(onClick = onRetry) { TextButton(onClick = onRetry) {
Text("Retry") Text(stringResource(Res.string.action_retry))
} }
} }
} }

View file

@ -1,6 +1,13 @@
package com.nuvio.app.features.downloads package com.nuvio.app.features.downloads
import kotlinx.serialization.Serializable 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 @Serializable
enum class DownloadStatus { enum class DownloadStatus {
@ -48,22 +55,7 @@ data class DownloadItem(
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank() get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
val displaySubtitle: String val displaySubtitle: String
get() = if (isEpisode) { get() = episodeTitle.orEmpty()
buildString {
append("S")
append(seasonNumber)
append("E")
append(episodeNumber)
episodeTitle
?.takeIf { it.isNotBlank() }
?.let {
append("")
append(it)
}
}
} else {
"Movie"
}
val progressFraction: Float val progressFraction: Float
get() { get() {
@ -91,11 +83,18 @@ data class DownloadsUiState(
get() = items.filter { it.status == DownloadStatus.Completed } get() = items.filter { it.status == DownloadStatus.Completed }
} }
enum class DownloadEnqueueResult( enum class DownloadEnqueueResult {
val toastMessage: String, Started,
) { Replaced,
Started("Download started"), MissingUrl,
Replaced("Replaced previous download"), UnsupportedFormat;
MissingUrl("No direct stream link available"),
UnsupportedFormat("Unsupported stream format for downloads"), 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)
}
}
} }

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.downloads package com.nuvio.app.features.downloads
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -9,6 +10,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object DownloadsRepository { object DownloadsRepository {
private val _uiState = MutableStateFlow(DownloadsUiState()) private val _uiState = MutableStateFlow(DownloadsUiState())
@ -294,7 +297,7 @@ object DownloadsRepository {
} else { } else {
current.copy( current.copy(
status = DownloadStatus.Failed, status = DownloadStatus.Failed,
errorMessage = message.ifBlank { "Download failed" }, errorMessage = message.ifBlank { runBlocking { getString(Res.string.download_failed) } },
updatedAtEpochMs = DownloadsClock.nowEpochMs(), updatedAtEpochMs = DownloadsClock.nowEpochMs(),
) )
} }

View file

@ -35,8 +35,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DownloadsScreen( fun DownloadsScreen(
@ -66,9 +69,9 @@ fun DownloadsScreen(
stickyHeader { stickyHeader {
NuvioScreenHeader( NuvioScreenHeader(
title = if (selectedShowId == null) { title = if (selectedShowId == null) {
"Downloads" stringResource(Res.string.compose_settings_root_downloads_title)
} else { } else {
selectedShowTitle ?: "Show Downloads" selectedShowTitle ?: stringResource(Res.string.downloads_show_downloads)
}, },
onBack = { onBack = {
if (selectedShowId != null) { if (selectedShowId != null) {
@ -115,7 +118,7 @@ private fun LazyListScope.downloadsRootContent(
if (activeItems.isNotEmpty()) { if (activeItems.isNotEmpty()) {
item { item {
SectionTitle("ACTIVE") SectionTitle(stringResource(Res.string.downloads_section_active))
} }
items( items(
items = activeItems, items = activeItems,
@ -134,7 +137,7 @@ private fun LazyListScope.downloadsRootContent(
if (completedMovies.isNotEmpty()) { if (completedMovies.isNotEmpty()) {
item { item {
SectionTitle("MOVIES") SectionTitle(stringResource(Res.string.downloads_section_movies))
} }
items( items(
items = completedMovies, items = completedMovies,
@ -153,7 +156,7 @@ private fun LazyListScope.downloadsRootContent(
if (completedShows.isNotEmpty()) { if (completedShows.isNotEmpty()) {
item { item {
SectionTitle("SHOWS") SectionTitle(stringResource(Res.string.downloads_section_shows))
} }
items( items(
items = completedShows, items = completedShows,
@ -186,7 +189,7 @@ private fun LazyListScope.downloadsRootContent(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -210,7 +213,7 @@ private fun LazyListScope.downloadsRootContent(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No downloads yet", text = stringResource(Res.string.downloads_empty_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -245,7 +248,7 @@ private fun LazyListScope.downloadsShowContent(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No completed episodes", text = stringResource(Res.string.downloads_empty_episodes),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -255,13 +258,14 @@ private fun LazyListScope.downloadsShowContent(
} }
seasons.forEach { (seasonNumber, entries) -> seasons.forEach { (seasonNumber, entries) ->
val seasonTitle = if (seasonNumber == 0) {
"Specials"
} else {
"Season $seasonNumber"
}
item { item {
SectionTitle(seasonTitle) SectionTitle(
if (seasonNumber == 0) {
stringResource(Res.string.episodes_specials)
} else {
stringResource(Res.string.episodes_season, seasonNumber)
},
)
} }
val sortedEpisodes = entries.sortedWith( val sortedEpisodes = entries.sortedWith(
@ -345,7 +349,7 @@ private fun DownloadRow(
IconButton(onClick = onPause) { IconButton(onClick = onPause) {
Icon( Icon(
imageVector = Icons.Rounded.Pause, imageVector = Icons.Rounded.Pause,
contentDescription = "Pause", contentDescription = stringResource(Res.string.compose_action_pause),
) )
} }
} }
@ -353,7 +357,7 @@ private fun DownloadRow(
IconButton(onClick = onResume) { IconButton(onClick = onResume) {
Icon( Icon(
imageVector = Icons.Rounded.PlayArrow, imageVector = Icons.Rounded.PlayArrow,
contentDescription = "Resume", contentDescription = stringResource(Res.string.action_resume),
) )
} }
} }
@ -361,7 +365,7 @@ private fun DownloadRow(
IconButton(onClick = onRetry) { IconButton(onClick = onRetry) {
Icon( Icon(
imageVector = Icons.Rounded.Refresh, imageVector = Icons.Rounded.Refresh,
contentDescription = "Retry", contentDescription = stringResource(Res.string.action_retry),
) )
} }
} }
@ -369,7 +373,7 @@ private fun DownloadRow(
IconButton(onClick = onOpen) { IconButton(onClick = onOpen) {
Icon( Icon(
imageVector = Icons.Rounded.PlayArrow, imageVector = Icons.Rounded.PlayArrow,
contentDescription = "Play", contentDescription = stringResource(Res.string.action_play),
) )
} }
} }
@ -377,7 +381,7 @@ private fun DownloadRow(
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon( Icon(
imageVector = Icons.Rounded.Delete, 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 { private fun statusText(item: DownloadItem): String {
val size = if (item.totalBytes != null && item.totalBytes > 0L) { val size = if (item.totalBytes != null && item.totalBytes > 0L) {
"${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}" "${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}"
@ -418,23 +423,26 @@ private fun statusText(item: DownloadItem): String {
} }
return when (item.status) { return when (item.status) {
DownloadStatus.Downloading -> "Downloading • $size" DownloadStatus.Downloading -> stringResource(Res.string.downloads_status_downloading, size)
DownloadStatus.Paused -> "Paused • $size" DownloadStatus.Paused -> stringResource(Res.string.downloads_status_paused, size)
DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}" DownloadStatus.Completed -> stringResource(
DownloadStatus.Failed -> item.errorMessage ?: "Failed" 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 { private fun formatBytes(bytes: Long): String {
if (bytes <= 0L) return "0 B" if (bytes <= 0L) return "0 ${localizedByteUnit("B")}"
val kib = 1024.0 val kib = 1024.0
val mib = kib * 1024.0 val mib = kib * 1024.0
val gib = mib * 1024.0 val gib = mib * 1024.0
val value = bytes.toDouble() val value = bytes.toDouble()
return when { return when {
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB" value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}"
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB" value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB" value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
else -> "$bytes B" else -> "$bytes ${localizedByteUnit("B")}"
} }
} }

View file

@ -1,7 +1,12 @@
package com.nuvio.app.features.home 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.addons.ManagedAddon
import com.nuvio.app.features.catalog.supportsPagination 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( data class HomeCatalogDefinition(
val key: String, val key: String,
@ -23,7 +28,13 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
.map { catalog -> .map { catalog ->
HomeCatalogDefinition( HomeCatalogDefinition(
key = "${manifest.id}:${catalog.type}:${catalog.id}", 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, addonName = addon.displayTitle,
manifestUrl = addon.manifestUrl, manifestUrl = addon.manifestUrl,
type = catalog.type, type = catalog.type,
@ -33,7 +44,4 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
} }
}.distinctBy(HomeCatalogDefinition::key) }.distinctBy(HomeCatalogDefinition::key)
internal fun String.displayLabel(): String = internal fun String.displayLabel(): String = localizedMediaTypeLabel(this)
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -10,6 +11,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
data class HomeCatalogSettingsItem( data class HomeCatalogSettingsItem(
val key: String, val key: String,
@ -480,7 +483,7 @@ internal fun buildCollectionDefinitions(collections: List<Collection>): List<Col
key = "collection_${collection.id}", key = "collection_${collection.id}",
collectionId = collection.id, collectionId = collection.id,
title = collection.title, title = collection.title,
subtitle = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}", subtitle = runBlocking { getString(Res.string.collections_folder_count, collection.folders.size) },
isPinnedToTop = collection.pinToTop, isPinnedToTop = collection.pinToTop,
) )
} }

View file

@ -55,6 +55,8 @@ import kotlinx.coroutines.sync.withPermit
import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.home.components.ContinueWatchingLayout
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@ -417,8 +419,8 @@ fun HomeScreen(
item { item {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
title = "No active addons", title = stringResource(Res.string.compose_search_empty_no_active_addons_title),
message = "Install and validate at least one addon before loading catalog rows on Home.", message = stringResource(Res.string.home_empty_no_active_addons_message),
) )
} }
} }
@ -457,9 +459,9 @@ fun HomeScreen(
} else { } else {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available", title = stringResource(Res.string.home_empty_no_rows_title),
message = homeUiState.errorMessage message = homeUiState.errorMessage
?: "Installed addons do not currently expose board-compatible catalogs without required extras.", ?: stringResource(Res.string.home_empty_no_rows_message),
) )
} }
} }
@ -610,25 +612,12 @@ private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
isNextUp || progressFraction < 0.995f isNextUp || progressFraction < 0.995f
private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
val subtitle = buildString {
append("Up Next")
if (season != null && episode != null) {
append(" • S")
append(season)
append("E")
append(episode)
}
episodeTitle?.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
}
return ContinueWatchingItem( return ContinueWatchingItem(
parentMetaId = contentId, parentMetaId = contentId,
parentMetaType = contentType, parentMetaType = contentType,
videoId = videoId, videoId = videoId,
title = name, title = name,
subtitle = subtitle, subtitle = episodeTitle.orEmpty(),
imageUrl = episodeThumbnail ?: backdrop ?: poster, imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo, logo = logo,
poster = poster, poster = poster,
@ -649,20 +638,6 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
} }
private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem { private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem {
val subtitle = if (season != null && episode != null) {
buildString {
append("S")
append(season)
append("E")
append(episode)
episodeTitle?.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
}
} else {
"Movie"
}
val explicitResumeProgressFraction = progressPercent val explicitResumeProgressFraction = progressPercent
?.takeIf { duration <= 0L && it > 0f } ?.takeIf { duration <= 0L && it > 0f }
?.let { (it / 100f).coerceIn(0f, 1f) } ?.let { (it / 100f).coerceIn(0f, 1f) }
@ -679,7 +654,7 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
parentMetaType = contentType, parentMetaType = contentType,
videoId = videoId, videoId = videoId,
title = name, title = name,
subtitle = subtitle, subtitle = episodeTitle.orEmpty(),
imageUrl = episodeThumbnail ?: backdrop ?: poster, imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo, logo = logo,
poster = poster, poster = poster,

View file

@ -37,12 +37,15 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlin.math.roundToInt import kotlin.math.roundToInt
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private fun continueWatchingProgressPercent(progressFraction: Float): Int = private fun continueWatchingProgressPercent(progressFraction: Float): Int =
(progressFraction * 100f).roundToInt().coerceIn(1, 99) (progressFraction * 100f).roundToInt().coerceIn(1, 99)
@ -95,7 +98,7 @@ private fun HomeContinueWatchingSectionContent(
onItemLongPress: ((ContinueWatchingItem) -> Unit)?, onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
) { ) {
NuvioShelfSection( NuvioShelfSection(
title = "Continue Watching", title = stringResource(Res.string.compose_settings_page_continue_watching),
entries = items, entries = items,
modifier = modifier, modifier = modifier,
headerHorizontalPadding = sectionPadding, headerHorizontalPadding = sectionPadding,
@ -305,11 +308,7 @@ private fun ContinueWatchingWideCard(
) { ) {
val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null
val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank() val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank()
val wideMetaLine = when { val wideMetaLine = localizedContinueWatchingSubtitle(item)
item.progressFraction <= 0f && isEpisodeCard -> "Up Next • S${item.seasonNumber}E${item.episodeNumber}"
isEpisodeCard -> "S${item.seasonNumber}E${item.episodeNumber}"
else -> item.subtitle
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -364,7 +363,10 @@ private fun ContinueWatchingWideCard(
trackColor = Color.White.copy(alpha = 0.10f), trackColor = Color.White.copy(alpha = 0.10f),
) )
Text( Text(
text = "${continueWatchingProgressPercent(item.progressFraction)}% watched", text = stringResource(
Res.string.home_continue_watching_watched,
continueWatchingProgressPercent(item.progressFraction),
),
style = MaterialTheme.typography.labelSmall.copy( style = MaterialTheme.typography.labelSmall.copy(
fontSize = layout.progressLabelSize, fontSize = layout.progressLabelSize,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -466,7 +468,11 @@ private fun ContinueWatchingPosterCard(
} }
if (item.seasonNumber != null && item.episodeNumber != null) { if (item.seasonNumber != null && item.episodeNumber != null) {
Text( 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), modifier = Modifier.padding(start = 6.dp),
style = MaterialTheme.typography.labelSmall.copy( style = MaterialTheme.typography.labelSmall.copy(
fontSize = layout.posterMetaSize, fontSize = layout.posterMetaSize,
@ -519,7 +525,7 @@ private fun UpNextBadge(
), ),
) { ) {
Text( Text(
text = "Up next", text = stringResource(Res.string.home_continue_watching_up_next),
style = MaterialTheme.typography.labelSmall.copy( style = MaterialTheme.typography.labelSmall.copy(
fontSize = textSize, fontSize = textSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,

View file

@ -52,6 +52,8 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs import kotlin.math.abs
private const val HERO_BACKGROUND_PARALLAX = 0.055f private const val HERO_BACKGROUND_PARALLAX = 0.055f
@ -256,7 +258,7 @@ fun HomeHeroSection(
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
) { ) {
Text( Text(
text = "View Details", text = stringResource(Res.string.home_view_details),
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,

View file

@ -11,6 +11,7 @@ import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -24,6 +25,9 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put 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 @Serializable
private data class StoredLibraryPayload( private data class StoredLibraryPayload(
@ -366,7 +370,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
internal fun String.toLibraryDisplayTitle(): String { internal fun String.toLibraryDisplayTitle(): String {
val normalized = trim() val normalized = trim()
if (normalized.isBlank()) return "Other" if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
return normalized return normalized
.split('-', '_', ' ') .split('-', '_', ' ')
@ -374,5 +378,5 @@ internal fun String.toLibraryDisplayTitle(): String {
.joinToString(" ") { token -> .joinToString(" ") { token ->
token.lowercase().replaceFirstChar { char -> char.uppercase() } token.lowercase().replaceFirstChar { char -> char.uppercase() }
} }
.ifBlank { "Other" } .ifBlank { runBlocking { getString(Res.string.library_other) } }
} }

View file

@ -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.home.components.HomeSkeletonRow
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
@ -84,7 +86,11 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
NuvioScreenHeader( 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), modifier = Modifier.padding(horizontal = 16.dp),
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@ -116,7 +122,11 @@ fun LibraryScreen(
} else { } else {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp), 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(), message = uiState.errorMessage.orEmpty(),
) )
} }
@ -139,11 +149,15 @@ fun LibraryScreen(
} else { } else {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty", title = if (isTraktSource) {
message = if (isTraktSource) { stringResource(Res.string.library_trakt_empty_title)
"Connect Trakt and save titles to your watchlist or personal lists."
} else { } 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( NuvioStatusModal(
title = "Remove from Library?", title = stringResource(Res.string.library_remove_title),
message = pendingRemovalItem?.let { "Remove ${it.name} from your library?" }.orEmpty(), message = pendingRemovalItem?.let {
stringResource(Res.string.library_remove_message, it.name)
}.orEmpty(),
isVisible = pendingRemovalItem != null, isVisible = pendingRemovalItem != null,
confirmText = "Remove", confirmText = stringResource(Res.string.library_remove_confirm),
dismissText = "Cancel", dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
pendingRemovalItem?.id?.let(LibraryRepository::remove) pendingRemovalItem?.id?.let(LibraryRepository::remove)
pendingRemovalItem = null pendingRemovalItem = null

View file

@ -1,6 +1,15 @@
package com.nuvio.app.features.notifications package com.nuvio.app.features.notifications
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable 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 import kotlin.math.abs
data class EpisodeReleaseNotificationsUiState( data class EpisodeReleaseNotificationsUiState(
@ -76,16 +85,24 @@ internal fun buildEpisodeReleaseNotificationBody(
seasonNumber: Int?, seasonNumber: Int?,
episodeNumber: Int?, episodeNumber: Int?,
episodeTitle: String?, episodeTitle: String?,
): String { ): String = runBlocking {
val seasonLabel = seasonNumber?.let { season -> "S${season.toString().padStart(2, '0')}" } val code = when {
val episodeLabel = episodeNumber?.let { episode -> "E${episode.toString().padStart(2, '0')}" } seasonNumber != null && episodeNumber != null ->
val code = listOfNotNull(seasonLabel, episodeLabel).joinToString(separator = "") 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() } val title = episodeTitle?.trim().takeUnless { it.isNullOrBlank() }
return when { when {
code.isNotBlank() && title != null -> "$code$title is out now" code.isNotBlank() && title != null ->
code.isNotBlank() -> "$code is out now" getString(Res.string.notifications_episode_release_body_code_title, code, title)
title != null -> "$title is out now" code.isNotBlank() ->
else -> "A new episode is out now" 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)
} }
} }

View file

@ -28,6 +28,8 @@ import kotlin.concurrent.Volatile
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
object EpisodeReleaseNotificationsRepository { object EpisodeReleaseNotificationsRepository {
@ -149,7 +151,7 @@ object EpisodeReleaseNotificationsRepository {
permissionGranted = false, permissionGranted = false,
scheduledCount = 0, scheduledCount = 0,
statusMessage = null, statusMessage = null,
errorMessage = "Notifications permission is disabled for Nuvio.", errorMessage = getString(Res.string.settings_notifications_permission_disabled),
) )
persist() persist()
return@launch return@launch
@ -175,7 +177,7 @@ object EpisodeReleaseNotificationsRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isSendingTest = false, isSendingTest = false,
statusMessage = null, 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 return@launch
} }
@ -197,7 +199,7 @@ object EpisodeReleaseNotificationsRepository {
isSendingTest = false, isSendingTest = false,
permissionGranted = false, permissionGranted = false,
statusMessage = null, statusMessage = null,
errorMessage = "Notifications permission is disabled for Nuvio.", errorMessage = getString(Res.string.settings_notifications_permission_disabled),
) )
return@launch return@launch
} }
@ -205,7 +207,7 @@ object EpisodeReleaseNotificationsRepository {
val request = EpisodeReleaseNotificationRequest( val request = EpisodeReleaseNotificationRequest(
requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}", requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}",
notificationTitle = target.name, notificationTitle = target.name,
notificationBody = "Preview episode release alert.", notificationBody = getString(Res.string.notifications_test_preview_body),
releaseDateIso = CurrentDateProvider.todayIsoDate(), releaseDateIso = CurrentDateProvider.todayIsoDate(),
deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id), deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id),
backdropUrl = target.banner ?: target.poster, backdropUrl = target.banner ?: target.poster,
@ -219,7 +221,7 @@ object EpisodeReleaseNotificationsRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isSendingTest = false, isSendingTest = false,
permissionGranted = true, permissionGranted = true,
statusMessage = "Test notification sent for ${target.name}.", statusMessage = getString(Res.string.notifications_test_sent_for, target.name),
errorMessage = null, errorMessage = null,
) )
}.onFailure { }.onFailure {
@ -227,7 +229,7 @@ object EpisodeReleaseNotificationsRepository {
isSendingTest = false, isSendingTest = false,
permissionGranted = true, permissionGranted = true,
statusMessage = null, statusMessage = null,
errorMessage = "Failed to send a test notification.", errorMessage = getString(Res.string.notifications_test_send_failed),
) )
} }
} }
@ -467,4 +469,4 @@ object EpisodeReleaseNotificationsRepository {
) )
} }
} }
} }

View file

@ -38,6 +38,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 @Composable
fun AudioTrackModal( fun AudioTrackModal(
@ -93,7 +97,7 @@ fun AudioTrackModal(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Audio Tracks", text = stringResource(Res.string.compose_player_audio_tracks),
color = colorScheme.onSurface, color = colorScheme.onSurface,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@ -148,7 +152,7 @@ private fun AudioTrackRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = getTrackDisplayName(track.label, track.language, track.index), text = localizedTrackDisplayName(track.label, track.language, track.index),
color = textColor, color = textColor,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = weight, fontWeight = weight,
@ -184,7 +188,7 @@ private fun AudioEmptyState() {
.then(Modifier), .then(Modifier),
) )
Text( Text(
text = "No audio tracks available", text = stringResource(Res.string.compose_player_no_audio_tracks_available),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp), modifier = Modifier.padding(top = 10.dp),
) )

View file

@ -52,6 +52,8 @@ import com.nuvio.app.core.ui.AppIconResource
import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.appIconPainter import com.nuvio.app.core.ui.appIconPainter
import com.nuvio.app.core.ui.nuvioTypeScale import com.nuvio.app.core.ui.nuvioTypeScale
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
internal fun PlayerControlsShell( internal fun PlayerControlsShell(
@ -212,7 +214,12 @@ private fun PlayerHeader(
) )
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
Text( Text(
text = "S${seasonNumber}E${episodeNumber}$episodeTitle", text = stringResource(
Res.string.compose_player_episode_title_format,
seasonNumber,
episodeNumber,
episodeTitle,
),
style = typeScale.bodyMd.copy( style = typeScale.bodyMd.copy(
fontSize = metrics.episodeInfoSize, fontSize = metrics.episodeInfoSize,
lineHeight = metrics.episodeInfoSize * 1.3f, lineHeight = metrics.episodeInfoSize * 1.3f,
@ -256,7 +263,11 @@ private fun PlayerHeader(
) { ) {
PlayerHeaderIconButton( PlayerHeaderIconButton(
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, 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, buttonSize = metrics.headerIconSize + 16.dp,
iconSize = metrics.headerIconSize, iconSize = metrics.headerIconSize,
onClick = onLockToggle, onClick = onLockToggle,
@ -267,7 +278,7 @@ private fun PlayerHeader(
contentColor = Color.White, contentColor = Color.White,
buttonSize = metrics.headerIconSize + 16.dp, buttonSize = metrics.headerIconSize + 16.dp,
iconSize = metrics.headerIconSize, iconSize = metrics.headerIconSize,
contentDescription = "Close player", contentDescription = stringResource(Res.string.compose_player_close),
) )
} }
} }
@ -315,7 +326,7 @@ private fun CenterControls(
) { ) {
SideControlButton( SideControlButton(
icon = Icons.Rounded.Replay10, icon = Icons.Rounded.Replay10,
contentDescription = "Seek backward 10 seconds", contentDescription = stringResource(Res.string.compose_player_seek_back_10),
metrics = metrics, metrics = metrics,
onClick = onSeekBack, onClick = onSeekBack,
) )
@ -327,7 +338,7 @@ private fun CenterControls(
) )
SideControlButton( SideControlButton(
icon = Icons.Rounded.Forward10, icon = Icons.Rounded.Forward10,
contentDescription = "Seek forward 10 seconds", contentDescription = stringResource(Res.string.compose_player_seek_forward_10),
metrics = metrics, metrics = metrics,
onClick = onSeekForward, onClick = onSeekForward,
) )
@ -384,7 +395,11 @@ private fun PlayPauseControlButton(
} else { } else {
Icon( Icon(
painter = playPausePainter, 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, tint = Color.White,
modifier = Modifier.size(metrics.playIconSize), modifier = Modifier.size(metrics.playIconSize),
) )
@ -454,7 +469,7 @@ private fun ProgressControls(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
PlayerActionPillButton( PlayerActionPillButton(
label = resizeMode.label, label = stringResource(resizeMode.labelRes),
painter = aspectRatioPainter, painter = aspectRatioPainter,
onClick = onResizeModeClick, onClick = onResizeModeClick,
) )
@ -464,25 +479,25 @@ private fun ProgressControls(
onClick = onSpeedClick, onClick = onSpeedClick,
) )
PlayerActionPillButton( PlayerActionPillButton(
label = "Subs", label = stringResource(Res.string.compose_player_subs),
painter = subtitlesPainter, painter = subtitlesPainter,
onClick = onSubtitleClick, onClick = onSubtitleClick,
) )
PlayerActionPillButton( PlayerActionPillButton(
label = "Audio", label = stringResource(Res.string.compose_player_audio),
painter = audioPainter, painter = audioPainter,
onClick = onAudioClick, onClick = onAudioClick,
) )
if (onSourcesClick != null) { if (onSourcesClick != null) {
PlayerActionPillButton( PlayerActionPillButton(
label = "Sources", label = stringResource(Res.string.compose_player_sources),
icon = Icons.Rounded.SwapHoriz, icon = Icons.Rounded.SwapHoriz,
onClick = onSourcesClick, onClick = onSourcesClick,
) )
} }
if (onEpisodesClick != null) { if (onEpisodesClick != null) {
PlayerActionPillButton( PlayerActionPillButton(
label = "Episodes", label = stringResource(Res.string.compose_player_episodes),
icon = Icons.Rounded.VideoLibrary, icon = Icons.Rounded.VideoLibrary,
onClick = onEpisodesClick, onClick = onEpisodesClick,
) )
@ -545,14 +560,14 @@ internal fun LockedPlayerOverlay(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Lock, imageVector = Icons.Rounded.Lock,
contentDescription = "Unlock player controls", contentDescription = stringResource(Res.string.compose_player_unlock_controls),
tint = Color.White, tint = Color.White,
modifier = Modifier.size(34.dp), modifier = Modifier.size(34.dp),
) )
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Tap to unlock", text = stringResource(Res.string.compose_player_tap_to_unlock),
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
color = Color.White.copy(alpha = 0.92f), color = Color.White.copy(alpha = 0.92f),
) )

View file

@ -60,6 +60,8 @@ import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState 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. * Episode selection panel shown inside the player.
@ -232,12 +234,12 @@ private fun EpisodesListSubView(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Episodes", text = stringResource(Res.string.compose_player_panel_episodes),
color = colorScheme.onSurface, color = colorScheme.onSurface,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
PanelChipButton(label = "Close", onClick = onDismiss) PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
} }
// Season tabs // Season tabs
@ -251,7 +253,11 @@ private fun EpisodesListSubView(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(availableSeasons, key = { season -> season }) { season -> 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( AddonFilterChip(
label = label, label = label,
isSelected = selectedSeason == season, isSelected = selectedSeason == season,
@ -273,7 +279,7 @@ private fun EpisodesListSubView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No episodes available", text = stringResource(Res.string.compose_player_no_episodes_available),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
) )
@ -345,9 +351,15 @@ private fun EpisodeRow(
) { ) {
val episodeLabel = buildString { val episodeLabel = buildString {
if (episode.season != null && episode.episode != null) { 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) { } else if (episode.episode != null) {
append("E${episode.episode}") append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode))
} }
} }
if (episodeLabel.isNotBlank()) { if (episodeLabel.isNotBlank()) {
@ -366,7 +378,7 @@ private fun EpisodeRow(
.padding(horizontal = 6.dp, vertical = 2.dp), .padding(horizontal = 6.dp, vertical = 2.dp),
) { ) {
Text( Text(
text = "Playing", text = stringResource(Res.string.compose_player_playing),
color = colorScheme.onPrimaryContainer, color = colorScheme.onPrimaryContainer,
fontSize = 9.sp, fontSize = 9.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -421,12 +433,12 @@ private fun EpisodeStreamsSubView(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Streams", text = stringResource(Res.string.compose_player_panel_streams),
color = colorScheme.onSurface, color = colorScheme.onSurface,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
PanelChipButton(label = "Close", onClick = onDismiss) PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
} }
// Back + reload + episode info // Back + reload + episode info
@ -439,19 +451,25 @@ private fun EpisodeStreamsSubView(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
PanelChipButton( PanelChipButton(
label = "Back", label = stringResource(Res.string.action_back),
icon = Icons.AutoMirrored.Rounded.ArrowBack, icon = Icons.AutoMirrored.Rounded.ArrowBack,
onClick = onBack, onClick = onBack,
) )
PanelChipButton( PanelChipButton(
label = "Reload", label = stringResource(Res.string.compose_action_reload),
icon = Icons.Rounded.Refresh, icon = Icons.Rounded.Refresh,
onClick = onReload, onClick = onReload,
) )
Text( Text(
text = buildString { text = buildString {
if (episode.season != null && episode.episode != null) { 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 (episode.title.isNotBlank()) {
if (isNotEmpty()) append("") if (isNotEmpty()) append("")
@ -480,7 +498,7 @@ private fun EpisodeStreamsSubView(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
AddonFilterChip( AddonFilterChip(
label = "All", label = stringResource(Res.string.collections_tab_all),
isSelected = streamsUiState.selectedFilter == null, isSelected = streamsUiState.selectedFilter == null,
onClick = { onFilterSelected(null) }, onClick = { onFilterSelected(null) },
) )
@ -522,7 +540,7 @@ private fun EpisodeStreamsSubView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No streams found", text = stringResource(Res.string.compose_player_no_streams_found),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
) )

View file

@ -1,8 +1,97 @@
package com.nuvio.app.features.player 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( data class LanguagePreferenceOption(
val code: String, val code: String,
val label: String, val labelRes: StringResource,
) )
object AudioLanguageOption { object AudioLanguageOption {
@ -17,84 +106,84 @@ object SubtitleLanguageOption {
} }
val AvailableLanguageOptions: List<LanguagePreferenceOption> = listOf( val AvailableLanguageOptions: List<LanguagePreferenceOption> = listOf(
LanguagePreferenceOption("af", "Afrikaans"), LanguagePreferenceOption("af", Res.string.lang_afrikaans),
LanguagePreferenceOption("sq", "Albanian"), LanguagePreferenceOption("sq", Res.string.lang_albanian),
LanguagePreferenceOption("am", "Amharic"), LanguagePreferenceOption("am", Res.string.lang_amharic),
LanguagePreferenceOption("ar", "Arabic"), LanguagePreferenceOption("ar", Res.string.lang_arabic),
LanguagePreferenceOption("hy", "Armenian"), LanguagePreferenceOption("hy", Res.string.lang_armenian),
LanguagePreferenceOption("az", "Azerbaijani"), LanguagePreferenceOption("az", Res.string.lang_azerbaijani),
LanguagePreferenceOption("eu", "Basque"), LanguagePreferenceOption("eu", Res.string.lang_basque),
LanguagePreferenceOption("be", "Belarusian"), LanguagePreferenceOption("be", Res.string.lang_belarusian),
LanguagePreferenceOption("bn", "Bengali"), LanguagePreferenceOption("bn", Res.string.lang_bengali),
LanguagePreferenceOption("bs", "Bosnian"), LanguagePreferenceOption("bs", Res.string.lang_bosnian),
LanguagePreferenceOption("bg", "Bulgarian"), LanguagePreferenceOption("bg", Res.string.lang_bulgarian),
LanguagePreferenceOption("my", "Burmese"), LanguagePreferenceOption("my", Res.string.lang_burmese),
LanguagePreferenceOption("ca", "Catalan"), LanguagePreferenceOption("ca", Res.string.lang_catalan),
LanguagePreferenceOption("zh", "Chinese"), LanguagePreferenceOption("zh", Res.string.lang_chinese),
LanguagePreferenceOption("zh-CN", "Chinese (Simplified)"), LanguagePreferenceOption("zh-CN", Res.string.lang_chinese_simplified),
LanguagePreferenceOption("zh-TW", "Chinese (Traditional)"), LanguagePreferenceOption("zh-TW", Res.string.lang_chinese_traditional),
LanguagePreferenceOption("hr", "Croatian"), LanguagePreferenceOption("hr", Res.string.lang_croatian),
LanguagePreferenceOption("cs", "Czech"), LanguagePreferenceOption("cs", Res.string.lang_czech),
LanguagePreferenceOption("da", "Danish"), LanguagePreferenceOption("da", Res.string.lang_danish),
LanguagePreferenceOption("nl", "Dutch"), LanguagePreferenceOption("nl", Res.string.lang_dutch),
LanguagePreferenceOption("en", "English"), LanguagePreferenceOption("en", Res.string.lang_english),
LanguagePreferenceOption("et", "Estonian"), LanguagePreferenceOption("et", Res.string.lang_estonian),
LanguagePreferenceOption("tl", "Filipino"), LanguagePreferenceOption("tl", Res.string.lang_filipino),
LanguagePreferenceOption("fi", "Finnish"), LanguagePreferenceOption("fi", Res.string.lang_finnish),
LanguagePreferenceOption("fr", "French"), LanguagePreferenceOption("fr", Res.string.lang_french),
LanguagePreferenceOption("gl", "Galician"), LanguagePreferenceOption("gl", Res.string.lang_galician),
LanguagePreferenceOption("ka", "Georgian"), LanguagePreferenceOption("ka", Res.string.lang_georgian),
LanguagePreferenceOption("de", "German"), LanguagePreferenceOption("de", Res.string.lang_german),
LanguagePreferenceOption("el", "Greek"), LanguagePreferenceOption("el", Res.string.lang_greek),
LanguagePreferenceOption("gu", "Gujarati"), LanguagePreferenceOption("gu", Res.string.lang_gujarati),
LanguagePreferenceOption("he", "Hebrew"), LanguagePreferenceOption("he", Res.string.lang_hebrew),
LanguagePreferenceOption("hi", "Hindi"), LanguagePreferenceOption("hi", Res.string.lang_hindi),
LanguagePreferenceOption("hu", "Hungarian"), LanguagePreferenceOption("hu", Res.string.lang_hungarian),
LanguagePreferenceOption("is", "Icelandic"), LanguagePreferenceOption("is", Res.string.lang_icelandic),
LanguagePreferenceOption("id", "Indonesian"), LanguagePreferenceOption("id", Res.string.lang_indonesian),
LanguagePreferenceOption("ga", "Irish"), LanguagePreferenceOption("ga", Res.string.lang_irish),
LanguagePreferenceOption("it", "Italian"), LanguagePreferenceOption("it", Res.string.lang_italian),
LanguagePreferenceOption("ja", "Japanese"), LanguagePreferenceOption("ja", Res.string.lang_japanese),
LanguagePreferenceOption("kn", "Kannada"), LanguagePreferenceOption("kn", Res.string.lang_kannada),
LanguagePreferenceOption("kk", "Kazakh"), LanguagePreferenceOption("kk", Res.string.lang_kazakh),
LanguagePreferenceOption("km", "Khmer"), LanguagePreferenceOption("km", Res.string.lang_khmer),
LanguagePreferenceOption("ko", "Korean"), LanguagePreferenceOption("ko", Res.string.lang_korean),
LanguagePreferenceOption("lo", "Lao"), LanguagePreferenceOption("lo", Res.string.lang_lao),
LanguagePreferenceOption("lv", "Latvian"), LanguagePreferenceOption("lv", Res.string.lang_latvian),
LanguagePreferenceOption("lt", "Lithuanian"), LanguagePreferenceOption("lt", Res.string.lang_lithuanian),
LanguagePreferenceOption("mk", "Macedonian"), LanguagePreferenceOption("mk", Res.string.lang_macedonian),
LanguagePreferenceOption("ms", "Malay"), LanguagePreferenceOption("ms", Res.string.lang_malay),
LanguagePreferenceOption("ml", "Malayalam"), LanguagePreferenceOption("ml", Res.string.lang_malayalam),
LanguagePreferenceOption("mt", "Maltese"), LanguagePreferenceOption("mt", Res.string.lang_maltese),
LanguagePreferenceOption("mr", "Marathi"), LanguagePreferenceOption("mr", Res.string.lang_marathi),
LanguagePreferenceOption("mn", "Mongolian"), LanguagePreferenceOption("mn", Res.string.lang_mongolian),
LanguagePreferenceOption("ne", "Nepali"), LanguagePreferenceOption("ne", Res.string.lang_nepali),
LanguagePreferenceOption("no", "Norwegian"), LanguagePreferenceOption("no", Res.string.lang_norwegian),
LanguagePreferenceOption("pa", "Punjabi"), LanguagePreferenceOption("pa", Res.string.lang_punjabi),
LanguagePreferenceOption("fa", "Persian"), LanguagePreferenceOption("fa", Res.string.lang_persian),
LanguagePreferenceOption("pl", "Polish"), LanguagePreferenceOption("pl", Res.string.lang_polish),
LanguagePreferenceOption("pt", "Portuguese (Portugal)"), LanguagePreferenceOption("pt", Res.string.lang_portuguese_portugal),
LanguagePreferenceOption("pt-BR", "Portuguese (Brazil)"), LanguagePreferenceOption("pt-BR", Res.string.lang_portuguese_brazil),
LanguagePreferenceOption("ro", "Romanian"), LanguagePreferenceOption("ro", Res.string.lang_romanian),
LanguagePreferenceOption("ru", "Russian"), LanguagePreferenceOption("ru", Res.string.lang_russian),
LanguagePreferenceOption("sr", "Serbian"), LanguagePreferenceOption("sr", Res.string.lang_serbian),
LanguagePreferenceOption("si", "Sinhala"), LanguagePreferenceOption("si", Res.string.lang_sinhala),
LanguagePreferenceOption("sk", "Slovak"), LanguagePreferenceOption("sk", Res.string.lang_slovak),
LanguagePreferenceOption("sl", "Slovenian"), LanguagePreferenceOption("sl", Res.string.lang_slovenian),
LanguagePreferenceOption("es", "Spanish"), LanguagePreferenceOption("es", Res.string.lang_spanish),
LanguagePreferenceOption("es-419", "Spanish (Latin America)"), LanguagePreferenceOption("es-419", Res.string.lang_spanish_latin_america),
LanguagePreferenceOption("sw", "Swahili"), LanguagePreferenceOption("sw", Res.string.lang_swahili),
LanguagePreferenceOption("sv", "Swedish"), LanguagePreferenceOption("sv", Res.string.lang_swedish),
LanguagePreferenceOption("ta", "Tamil"), LanguagePreferenceOption("ta", Res.string.lang_tamil),
LanguagePreferenceOption("te", "Telugu"), LanguagePreferenceOption("te", Res.string.lang_telugu),
LanguagePreferenceOption("th", "Thai"), LanguagePreferenceOption("th", Res.string.lang_thai),
LanguagePreferenceOption("tr", "Turkish"), LanguagePreferenceOption("tr", Res.string.lang_turkish),
LanguagePreferenceOption("uk", "Ukrainian"), LanguagePreferenceOption("uk", Res.string.lang_ukrainian),
LanguagePreferenceOption("ur", "Urdu"), LanguagePreferenceOption("ur", Res.string.lang_urdu),
LanguagePreferenceOption("uz", "Uzbek"), LanguagePreferenceOption("uz", Res.string.lang_uzbek),
LanguagePreferenceOption("vi", "Vietnamese"), LanguagePreferenceOption("vi", Res.string.lang_vietnamese),
LanguagePreferenceOption("cy", "Welsh"), LanguagePreferenceOption("cy", Res.string.lang_welsh),
LanguagePreferenceOption("zu", "Zulu"), LanguagePreferenceOption("zu", Res.string.lang_zulu),
) )
private val Iso639Aliases = mapOf( private val Iso639Aliases = mapOf(
@ -149,12 +238,40 @@ fun languageMatchesPreference(trackLanguage: String?, targetLanguage: String): B
return trackPrimary == targetPrimary return trackPrimary == targetPrimary
} }
fun languageLabelForCode(code: String?): String { private fun languageLabelResForCode(code: String?): StringResource? {
if (code.isNullOrBlank()) return "None" val normalized = normalizeLanguageCode(code) ?: return null
if (code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true)) return "Forced"
return AvailableLanguageOptions.firstOrNull { return AvailableLanguageOptions.firstOrNull {
it.code.equals(code, ignoreCase = true) normalizeLanguageCode(it.code) == normalized
}?.label ?: formatLanguage(code) }?.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( fun resolvePreferredAudioLanguageTargets(

View file

@ -9,6 +9,11 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 import kotlin.math.max
internal data class PlayerLayoutMetrics( internal data class PlayerLayoutMetrics(
@ -124,11 +129,11 @@ internal fun PlayerResizeMode.next(): PlayerResizeMode =
PlayerResizeMode.Zoom -> PlayerResizeMode.Fit PlayerResizeMode.Zoom -> PlayerResizeMode.Fit
} }
internal val PlayerResizeMode.label: String internal val PlayerResizeMode.labelRes: StringResource
get() = when (this) { get() = when (this) {
PlayerResizeMode.Fit -> "Fit" PlayerResizeMode.Fit -> Res.string.compose_player_resize_fit
PlayerResizeMode.Fill -> "Fill" PlayerResizeMode.Fill -> Res.string.compose_player_resize_fill
PlayerResizeMode.Zoom -> "Zoom" PlayerResizeMode.Zoom -> Res.string.compose_player_resize_zoom
} }
internal fun formatPlaybackTime(positionMs: Long): String { internal fun formatPlaybackTime(positionMs: Long): String {

View file

@ -59,6 +59,14 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.nuvioTypeScale 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 import kotlin.math.max
internal enum class GestureFeedbackIcon { internal enum class GestureFeedbackIcon {
@ -71,10 +79,14 @@ internal enum class GestureFeedbackIcon {
} }
internal data class GestureFeedbackState( internal data class GestureFeedbackState(
val message: String, val message: String? = null,
val messageRes: StringResource? = null,
val messageArgs: List<Any> = emptyList(),
val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed, val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed,
val isDanger: Boolean = false, val isDanger: Boolean = false,
val secondaryMessage: String? = null, val secondaryMessage: String? = null,
val secondaryMessageRes: StringResource? = null,
val secondaryMessageArgs: List<Any> = emptyList(),
val secondaryMessageColor: Color? = null, val secondaryMessageColor: Color? = null,
) )
@ -141,7 +153,7 @@ internal fun OpeningOverlay(
contentColor = Color.White, contentColor = Color.White,
buttonSize = 44.dp, buttonSize = 44.dp,
iconSize = 24.dp, iconSize = 24.dp,
contentDescription = "Close player", contentDescription = stringResource(Res.string.compose_player_close),
) )
Column( Column(
@ -218,6 +230,12 @@ internal fun GestureFeedbackPill(
GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind
} }
val iconTint = if (feedback.isDanger) Color(0xFFFFC1C1) else Color.White 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( Row(
modifier = modifier modifier = modifier
@ -242,11 +260,11 @@ internal fun GestureFeedbackPill(
) )
} }
Text( Text(
text = feedback.message, text = messageText,
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold),
color = Color.White, color = Color.White,
) )
feedback.secondaryMessage?.let { secondaryMessage -> secondaryMessageText?.let { secondaryMessage ->
Text( Text(
text = secondaryMessage, text = secondaryMessage,
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
@ -290,7 +308,7 @@ internal fun PauseMetadataOverlay(
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.Bottom,
) { ) {
Text( Text(
text = "You're watching", text = stringResource(Res.string.compose_player_youre_watching),
style = MaterialTheme.nuvioTypeScale.bodyLg, style = MaterialTheme.nuvioTypeScale.bodyLg,
color = Color(0xFFB8B8B8), color = Color(0xFFB8B8B8),
) )
@ -318,7 +336,7 @@ internal fun PauseMetadataOverlay(
} }
val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) { val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) {
"S${seasonNumber}E${episodeNumber}" stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
} else { } else {
providerName providerName
} }
@ -377,7 +395,7 @@ internal fun ErrorModal(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text( Text(
text = "Playback error", text = stringResource(Res.string.compose_player_playback_error),
style = MaterialTheme.nuvioTypeScale.displaySm.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.nuvioTypeScale.displaySm.copy(fontWeight = FontWeight.Bold),
color = Color.White, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -399,7 +417,7 @@ internal fun ErrorModal(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) { ) {
Text( Text(
text = "Go back", text = stringResource(Res.string.compose_player_go_back),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 12.dp), .padding(vertical = 12.dp),

View file

@ -62,6 +62,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToLong import kotlin.math.roundToLong
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -153,6 +155,12 @@ fun PlayerScreen(
val overlayBottomPadding = sliderOverlayBottomPadding(metrics) val overlayBottomPadding = sliderOverlayBottomPadding(metrics)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val hapticFeedback = LocalHapticFeedback.current 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() val gestureController = rememberPlayerGestureController()
var controlsVisible by rememberSaveable { mutableStateOf(true) } var controlsVisible by rememberSaveable { mutableStateOf(true) }
var playerControlsLocked by rememberSaveable { mutableStateOf(false) } var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
@ -534,7 +542,12 @@ fun PlayerScreen(
if (seconds <= 0L) return if (seconds <= 0L) return
showGestureFeedback( showGestureFeedback(
GestureFeedbackState( 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) { icon = if (direction == PlayerSeekDirection.Forward) {
GestureFeedbackIcon.SeekForward GestureFeedbackIcon.SeekForward
} else { } else {
@ -554,11 +567,12 @@ fun PlayerScreen(
} else { } else {
GestureFeedbackIcon.SeekBackward GestureFeedbackIcon.SeekBackward
}, },
secondaryMessage = buildString { secondaryMessageRes = if (deltaMs >= 0L) {
if (deltaMs >= 0L) append("+") Res.string.compose_player_seek_delta_forward
append((abs(deltaMs) / 1000f).roundToInt()) } else {
append("s") Res.string.compose_player_seek_delta_backward
}, },
secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()),
secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) {
Color(0xFF6EE7A8) Color(0xFF6EE7A8)
} else { } else {
@ -571,7 +585,8 @@ fun PlayerScreen(
val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt()
showGestureFeedback( showGestureFeedback(
GestureFeedbackState( GestureFeedbackState(
message = "Brightness $percentage%", messageRes = Res.string.compose_player_brightness_level,
messageArgs = listOf(percentage),
icon = GestureFeedbackIcon.Brightness, icon = GestureFeedbackIcon.Brightness,
), ),
) )
@ -581,7 +596,12 @@ fun PlayerScreen(
val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
showGestureFeedback( showGestureFeedback(
GestureFeedbackState( 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, icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
isDanger = level.isMuted, isDanger = level.isMuted,
), ),
@ -650,7 +670,13 @@ fun PlayerScreen(
val nextMode = resizeMode.next() val nextMode = resizeMode.next()
resizeMode = nextMode resizeMode = nextMode
PlayerSettingsRepository.setResizeMode(nextMode) PlayerSettingsRepository.setResizeMode(nextMode)
showGestureMessage(nextMode.label) showGestureMessage(
when (nextMode) {
PlayerResizeMode.Fit -> resizeModeFitLabel
PlayerResizeMode.Fill -> resizeModeFillLabel
PlayerResizeMode.Zoom -> resizeModeZoomLabel
},
)
controlsVisible = true controlsVisible = true
} }
@ -872,7 +898,7 @@ fun PlayerScreen(
episode.title.ifBlank { title } episode.title.ifBlank { title }
} }
activeStreamSubtitle = downloadItem.streamSubtitle activeStreamSubtitle = downloadItem.streamSubtitle
activeProviderName = downloadItem.providerName.ifBlank { "Downloaded" } activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel }
activeProviderAddonId = downloadItem.providerAddonId activeProviderAddonId = downloadItem.providerAddonId
currentStreamBingeGroup = null currentStreamBingeGroup = null
activeSeasonNumber = episode.season activeSeasonNumber = episode.season
@ -1268,7 +1294,7 @@ fun PlayerScreen(
released = nextVideo.released, released = nextVideo.released,
hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
"Airs ${nextVideo.released ?: "TBA"}" "$airsPrefix ${nextVideo.released ?: tbaLabel}"
} else null, } else null,
) )
} else null } else null

View file

@ -48,6 +48,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.StreamsUiState
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun PlayerSourcesPanel( fun PlayerSourcesPanel(
@ -108,19 +110,19 @@ fun PlayerSourcesPanel(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Sources", text = stringResource(Res.string.compose_player_panel_sources),
color = colorScheme.onSurface, color = colorScheme.onSurface,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PanelChipButton( PanelChipButton(
label = "Reload", label = stringResource(Res.string.compose_action_reload),
icon = Icons.Rounded.Refresh, icon = Icons.Rounded.Refresh,
onClick = onReload, onClick = onReload,
) )
PanelChipButton( PanelChipButton(
label = "Close", label = stringResource(Res.string.action_close),
onClick = onDismiss, onClick = onDismiss,
) )
} }
@ -140,7 +142,7 @@ fun PlayerSourcesPanel(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
AddonFilterChip( AddonFilterChip(
label = "All", label = stringResource(Res.string.collections_tab_all),
isSelected = streamsUiState.selectedFilter == null, isSelected = streamsUiState.selectedFilter == null,
onClick = { onFilterSelected(null) }, onClick = { onFilterSelected(null) },
) )
@ -182,7 +184,7 @@ fun PlayerSourcesPanel(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No streams found", text = stringResource(Res.string.compose_player_no_streams_found),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
) )
@ -270,7 +272,7 @@ private fun SourceStreamRow(
.padding(horizontal = 8.dp, vertical = 3.dp), .padding(horizontal = 8.dp, vertical = 3.dp),
) { ) {
Text( Text(
text = "Playing", text = stringResource(Res.string.compose_player_playing),
color = colorScheme.onPrimaryContainer, color = colorScheme.onPrimaryContainer,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -301,7 +303,7 @@ private fun SourceStreamRow(
if (isCurrent) { if (isCurrent) {
Icon( Icon(
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = "Currently playing", contentDescription = stringResource(Res.string.compose_player_currently_playing),
tint = colorScheme.primary, tint = colorScheme.primary,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )

View file

@ -1,6 +1,10 @@
package com.nuvio.app.features.player package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color 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 import kotlin.math.roundToInt
data class AudioTrack( data class AudioTrack(
@ -109,103 +113,9 @@ data class SubtitleAudioUiState(
val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn, 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 (!label.isNullOrBlank()) return label
if (!language.isNullOrBlank()) return formatLanguage(language) if (!language.isNullOrBlank()) return languageLabelForCode(language)
return "Track ${index + 1}" 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",
)

View file

@ -43,6 +43,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 @Composable
fun SubtitleModal( fun SubtitleModal(
@ -110,7 +118,7 @@ fun SubtitleModal(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Subtitles", text = stringResource(Res.string.compose_player_subtitles),
color = colorScheme.onSurface, color = colorScheme.onSurface,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@ -191,9 +199,9 @@ private fun SubtitleTabBar(
) { ) {
Text( Text(
text = when (tab) { text = when (tab) {
SubtitleTab.BuiltIn -> "Built-in" SubtitleTab.BuiltIn -> stringResource(Res.string.compose_player_built_in)
SubtitleTab.Addons -> "Addons" SubtitleTab.Addons -> stringResource(Res.string.addon_title)
SubtitleTab.Style -> "Style" SubtitleTab.Style -> stringResource(Res.string.compose_player_style)
}, },
color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant, color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant,
fontSize = 13.sp, fontSize = 13.sp,
@ -230,7 +238,7 @@ private fun BuiltInSubtitleList(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "None", text = stringResource(Res.string.compose_player_none),
color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -258,7 +266,7 @@ private fun BuiltInSubtitleList(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( 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, color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
@ -324,7 +332,7 @@ private fun AddonSubtitleList(
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) )
Text( Text(
text = "Tap to fetch subtitles", text = stringResource(Res.string.compose_player_fetch_subtitles),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp), modifier = Modifier.padding(top = 10.dp),
) )
@ -360,7 +368,7 @@ private fun AddonSubtitleList(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
text = formatLanguage(sub.language), text = languageLabelForCode(sub.language),
color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant, color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant,
fontSize = 11.sp, fontSize = 11.sp,
modifier = Modifier.padding(bottom = 3.dp), modifier = Modifier.padding(bottom = 3.dp),

View file

@ -18,6 +18,9 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive 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 { object SubtitleRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -76,7 +79,7 @@ object SubtitleRepository {
id = id, id = id,
url = url, url = url,
language = lang, language = lang,
display = "${formatLanguage(lang)} (${addon.displayTitle})", display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})",
) )
) )
} }
@ -86,7 +89,7 @@ object SubtitleRepository {
_addonSubtitles.value = allSubs _addonSubtitles.value = allSubs
if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) { 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 _isLoading.value = false
} }

View file

@ -28,6 +28,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun SubtitleStylePanel( fun SubtitleStylePanel(
@ -73,7 +75,7 @@ private fun StyleControlsCard(
) { ) {
SectionHeader( SectionHeader(
icon = Icons.Rounded.Tune, icon = Icons.Rounded.Tune,
label = "Style", label = stringResource(Res.string.compose_player_style),
) )
Row( Row(
@ -82,13 +84,13 @@ private fun StyleControlsCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Font Size", text = stringResource(Res.string.compose_player_font_size),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
StepperControl( StepperControl(
value = "${style.fontSizeSp}sp", value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp),
onMinus = { onMinus = {
onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12)))
}, },
@ -109,7 +111,7 @@ private fun StyleControlsCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Outline", text = stringResource(Res.string.compose_player_outline),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -126,7 +128,8 @@ private fun StyleControlsCard(
.padding(horizontal = 10.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 8.dp),
) { ) {
Text( 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, color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 13.sp, fontSize = 13.sp,
@ -140,7 +143,7 @@ private fun StyleControlsCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Bottom Offset", text = stringResource(Res.string.compose_player_bottom_offset),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -163,7 +166,7 @@ private fun StyleControlsCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Color", text = stringResource(Res.string.compose_player_color),
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, 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), .padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp),
) { ) {
Text( Text(
text = "Reset Defaults", text = stringResource(Res.string.compose_player_reset_defaults),
color = colorScheme.onSurface, color = colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = if (isCompact) 12.sp else 14.sp, fontSize = if (isCompact) 12.sp else 14.sp,

View file

@ -38,6 +38,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage 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 @Composable
fun NextEpisodeCard( fun NextEpisodeCard(
@ -81,7 +90,7 @@ fun NextEpisodeCard(
) { ) {
AsyncImage( AsyncImage(
model = nextEpisode.thumbnail, model = nextEpisode.thumbnail,
contentDescription = "Next episode thumbnail", contentDescription = stringResource(Res.string.player_next_episode_thumbnail),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
@ -107,14 +116,19 @@ fun NextEpisodeCard(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
text = "Next Episode", text = stringResource(Res.string.player_next_episode),
color = Color.White.copy(alpha = 0.8f), color = Color.White.copy(alpha = 0.8f),
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( 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, color = Color.White,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
@ -123,9 +137,13 @@ fun NextEpisodeCard(
) )
val autoPlayStatus = when { val autoPlayStatus = when {
!isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage !isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage
isAutoPlaySearching -> "Finding source…" isAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source)
!autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null -> !autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null ->
"Playing via $autoPlaySourceName in $autoPlayCountdownSec" stringResource(
Res.string.player_next_episode_playing_via_countdown,
autoPlaySourceName,
autoPlayCountdownSec,
)
else -> null else -> null
} }
if (autoPlayStatus != null) { if (autoPlayStatus != null) {
@ -156,7 +174,11 @@ fun NextEpisodeCard(
modifier = Modifier.size(13.dp), modifier = Modifier.size(13.dp),
) )
Text( 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), color = if (isPlayable) Color.White else Color.White.copy(alpha = 0.72f),
fontSize = 11.sp, fontSize = 11.sp,
modifier = Modifier.padding(start = 3.dp), modifier = Modifier.padding(start = 3.dp),

View file

@ -37,6 +37,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 @Composable
fun SkipIntroButton( fun SkipIntroButton(
@ -112,7 +118,7 @@ fun SkipIntroButton(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
Text( Text(
text = getSkipLabel(lastType), text = skipLabel(lastType),
color = Color.White, color = Color.White,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@ -140,11 +146,11 @@ fun SkipIntroButton(
} }
} }
private fun getSkipLabel(type: String?): String { @Composable
return when (type?.lowercase()) { private fun skipLabel(type: String?): String =
"intro", "op", "mixed-op" -> "Skip Intro" when (type?.lowercase()) {
"outro", "ed", "mixed-ed", "credits" -> "Skip Outro" "intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro)
"recap" -> "Skip Recap" "outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro)
else -> "Skip" "recap" -> stringResource(Res.string.player_skip_recap)
else -> stringResource(Res.string.player_skip)
} }
}

View file

@ -48,6 +48,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -75,7 +78,7 @@ fun PinEntryDialog(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = "Enter PIN", text = stringResource(Res.string.pin_enter),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@ -128,9 +131,12 @@ fun PinEntryDialog(
} else { } else {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
error = result.message ?: if (result.retryAfterSeconds > 0) { error = result.message ?: if (result.retryAfterSeconds > 0) {
"Locked. Try again in ${result.retryAfterSeconds}s" getString(
Res.string.pin_locked_try_again,
result.retryAfterSeconds,
)
} else { } else {
"Incorrect PIN" getString(Res.string.pin_incorrect)
} }
pin = "" pin = ""
} }
@ -151,7 +157,7 @@ fun PinEntryDialog(
if (onForgotPin != null) { if (onForgotPin != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Forgot PIN?", text = stringResource(Res.string.pin_forgot),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,

View file

@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -104,7 +106,11 @@ fun ProfileEditScreen(
NuvioScreen(modifier = modifier) { NuvioScreen(modifier = modifier) {
stickyHeader { stickyHeader {
NuvioScreenHeader( 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, onBack = onBack,
) )
} }
@ -127,13 +133,17 @@ fun ProfileEditScreen(
NuvioSurfaceCard { NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text( Text(
text = "Choose an avatar", text = stringResource(Res.string.profile_choose_avatar),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = selectedAvatarItem?.displayName 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -171,27 +181,27 @@ fun ProfileEditScreen(
NuvioSurfaceCard { NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text( Text(
text = "Security", text = stringResource(Res.string.profile_security),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = if (currentProfile?.pinEnabled == true) { text = if (currentProfile?.pinEnabled == true) {
"This profile is protected with a PIN." stringResource(Res.string.profile_security_pin_enabled)
} else { } 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (currentProfile?.pinEnabled == true) { if (currentProfile?.pinEnabled == true) {
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Remove PIN Lock", text = stringResource(Res.string.profile_remove_pin_lock),
onClick = { showPinClear = true }, onClick = { showPinClear = true },
) )
} else { } else {
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Set PIN Lock", text = stringResource(Res.string.profile_set_pin_lock),
onClick = { showPinSetup = true }, onClick = { showPinSetup = true },
) )
} }
@ -203,7 +213,13 @@ fun ProfileEditScreen(
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
NuvioPrimaryButton( 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, enabled = name.isNotBlank() && !isSaving,
onClick = { onClick = {
isSaving = true isSaving = true
@ -247,7 +263,7 @@ fun ProfileEditScreen(
), ),
) { ) {
Text( Text(
text = "Delete Profile", text = stringResource(Res.string.profile_delete_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@ -257,11 +273,14 @@ fun ProfileEditScreen(
} }
NuvioStatusModal( NuvioStatusModal(
title = "Delete Profile?", title = stringResource(Res.string.profile_delete_title),
message = "All data for \"${currentProfile?.name}\" will be permanently deleted.", message = stringResource(
Res.string.profile_delete_confirm_message,
currentProfile?.name.orEmpty(),
),
isVisible = showDeleteConfirm, isVisible = showDeleteConfirm,
confirmText = "Delete", confirmText = stringResource(Res.string.action_delete),
dismissText = "Cancel", dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
showDeleteConfirm = false showDeleteConfirm = false
scope.launch { scope.launch {
@ -290,7 +309,7 @@ fun ProfileEditScreen(
if (showPinClear && currentProfile != null) { if (showPinClear && currentProfile != null) {
PinEntryDialog( PinEntryDialog(
profileName = "Remove PIN for ${currentProfile.name}", profileName = stringResource(Res.string.profile_remove_pin_for, currentProfile.name),
onVerify = { pin -> ProfileRepository.clearPin(currentProfile.profileIndex, pin) }, onVerify = { pin -> ProfileRepository.clearPin(currentProfile.profileIndex, pin) },
onVerified = { onVerified = {
showPinClear = false showPinClear = false
@ -364,24 +383,39 @@ private fun ProfileIdentityCard(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text( 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, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
text = listOf( text = listOf(
if (isNew) "New profile" else (profileIndex?.let { "Profile $it" } ?: "Profile"), if (isNew) {
if (usesPrimaryAddons) "Primary addons on" else "Primary addons off", 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(" | "), ).joinToString(" | "),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
text = when { text = when {
selectedAvatar != null -> "Avatar: ${selectedAvatar.displayName}" selectedAvatar != null -> stringResource(
hasAvatarChoices -> "Choose an avatar below." Res.string.profile_avatar_selected,
else -> "Avatar options will appear here when the catalog loads." selectedAvatar.displayName,
)
hasAvatarChoices -> stringResource(Res.string.profile_choose_avatar_below)
else -> stringResource(Res.string.profile_avatar_options_pending)
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -392,12 +426,12 @@ private fun ProfileIdentityCard(
NuvioInputField( NuvioInputField(
value = name, value = name,
onValueChange = onNameChange, onValueChange = onNameChange,
placeholder = "Profile name", placeholder = stringResource(Res.string.profile_name_placeholder),
) )
ProfileOptionRow( ProfileOptionRow(
title = "Use Primary Addons", title = stringResource(Res.string.profile_use_primary_addons),
description = "Share the main profile's addon setup instead of managing a separate list.", description = stringResource(Res.string.profile_use_primary_addons_description),
checked = usesPrimaryAddons, checked = usesPrimaryAddons,
onCheckedChange = onUsesPrimaryAddonsChange, onCheckedChange = onUsesPrimaryAddonsChange,
) )
@ -510,7 +544,7 @@ fun PinSetupDialog(
when (step) { when (step) {
"current" -> PinEntryDialog( "current" -> PinEntryDialog(
profileName = "Enter current PIN", profileName = stringResource(Res.string.profile_enter_current_pin),
onVerify = { pin -> ProfileRepository.verifyPin(profileIndex, pin) }, onVerify = { pin -> ProfileRepository.verifyPin(profileIndex, pin) },
onVerified = { pin -> onVerified = { pin ->
currentPin = pin currentPin = pin
@ -520,7 +554,7 @@ fun PinSetupDialog(
) )
"new" -> PinEntryDialog( "new" -> PinEntryDialog(
profileName = "Enter new PIN", profileName = stringResource(Res.string.profile_enter_new_pin),
onVerify = { pin -> onVerify = { pin ->
ProfileRepository.setPin( ProfileRepository.setPin(
profileIndex = profileIndex, profileIndex = profileIndex,

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -40,6 +41,9 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@Serializable @Serializable
private data class StoredProfilePayload( private data class StoredProfilePayload(
@ -52,6 +56,7 @@ object ProfileRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("ProfileRepository") private val log = Logger.withTag("ProfileRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
private val _state = MutableStateFlow(ProfileState()) private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow() val state: StateFlow<ProfileState> = _state.asStateFlow()
@ -274,7 +279,7 @@ object ProfileRepository {
suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult { suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) { 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 { return runCatching {
@ -290,13 +295,13 @@ object ProfileRepository {
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to set pin" } log.e(e) { "Failed to set pin" }
}.getOrElse { }.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 { suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) { 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 { return runCatching {
@ -311,7 +316,7 @@ object ProfileRepository {
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to clear pin" } log.e(e) { "Failed to clear pin" }
}.getOrElse { }.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()) { if (payload.isEmpty()) {
return PinVerifyResult( return PinVerifyResult(
unlocked = false, 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<CachedProfilePinPayload>(payload) json.decodeFromString<CachedProfilePinPayload>(payload)
}.getOrNull() ?: return PinVerifyResult( }.getOrNull() ?: return PinVerifyResult(
unlocked = false, 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 ( if (
@ -426,7 +431,7 @@ object ProfileRepository {
ProfilePinCacheStorage.removePayload(profileIndex) ProfilePinCacheStorage.removePayload(profileIndex)
return PinVerifyResult( return PinVerifyResult(
unlocked = false, 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) { return if (digest == cached.digest) {
PinVerifyResult(unlocked = true) PinVerifyResult(unlocked = true)
} else { } else {
PinVerifyResult(unlocked = false, message = "Incorrect PIN") PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect))
} }
} }

View file

@ -61,6 +61,8 @@ import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.AuthState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun ProfileSelectionScreen( fun ProfileSelectionScreen(
@ -132,7 +134,7 @@ fun ProfileSelectionScreen(
Spacer(modifier = Modifier.height(if (isTabletLayout) 0.dp else 60.dp)) Spacer(modifier = Modifier.height(if (isTabletLayout) 0.dp else 60.dp))
Text( Text(
text = "Who's watching?", text = stringResource(Res.string.profile_who_is_watching),
style = MaterialTheme.typography.headlineLarge.copy( style = MaterialTheme.typography.headlineLarge.copy(
fontSize = 30.sp, fontSize = 30.sp,
letterSpacing = (-0.5).sp, letterSpacing = (-0.5).sp,
@ -258,7 +260,11 @@ fun ProfileSelectionScreen(
.padding(horizontal = 24.dp, vertical = 10.dp), .padding(horizontal = 24.dp, vertical = 10.dp),
) { ) {
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = if (isEditMode) MaterialTheme.colorScheme.primary color = if (isEditMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
@ -429,7 +435,9 @@ private fun ProfileAvatarCard(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( 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), style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -506,7 +514,7 @@ private fun AddProfileCard(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Add Profile", text = stringResource(Res.string.compose_profile_add_profile),
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,

View file

@ -66,6 +66,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun ProfileSwitcherTab( fun ProfileSwitcherTab(
@ -305,7 +308,7 @@ private fun PopupAddProfileBubble(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = "Add Profile", contentDescription = stringResource(Res.string.compose_profile_add_profile),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
@ -314,7 +317,7 @@ private fun PopupAddProfileBubble(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Add", text = stringResource(Res.string.compose_profile_add_profile),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -466,7 +469,9 @@ private fun PopupProfileBubble(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( 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), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = if (isSelected) MaterialTheme.colorScheme.primary color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
@ -501,7 +506,7 @@ private fun InlinePinEntry(
modifier = Modifier.padding(top = 16.dp), modifier = Modifier.padding(top = 16.dp),
) { ) {
Text( Text(
text = "Enter PIN for $profileName", text = stringResource(Res.string.pin_enter_for, profileName),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -579,9 +584,9 @@ private fun InlinePinEntry(
onVerified() onVerified()
} else { } else {
error = if (result.retryAfterSeconds > 0) { error = if (result.retryAfterSeconds > 0) {
"Locked. Try again in ${result.retryAfterSeconds}s" getString(Res.string.pin_locked_try_again, result.retryAfterSeconds)
} else { } else {
"Wrong PIN" getString(Res.string.pin_incorrect)
} }
pin = "" pin = ""
} }
@ -601,7 +606,7 @@ private fun InlinePinEntry(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Cancel", text = stringResource(Res.string.pin_cancel),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -645,7 +650,7 @@ private fun CompactPinKeypad(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.Backspace, imageVector = Icons.AutoMirrored.Rounded.Backspace,
contentDescription = "Backspace", contentDescription = stringResource(Res.string.pin_backspace),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
@ -685,7 +690,7 @@ fun ActiveProfileMiniAvatar(
if (profile == null) { if (profile == null) {
Icon( Icon(
imageVector = Icons.Rounded.Person, imageVector = Icons.Rounded.Person,
contentDescription = "Profile", contentDescription = stringResource(Res.string.compose_nav_profile),
modifier = Modifier.size(size.dp), modifier = Modifier.size(size.dp),
) )
return return

View file

@ -63,6 +63,8 @@ import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.discoverContent( internal fun LazyListScope.discoverContent(
state: DiscoverUiState, state: DiscoverUiState,
@ -91,7 +93,11 @@ internal fun LazyListScope.discoverContent(
state.selectedCatalog?.let { selectedCatalog -> state.selectedCatalog?.let { selectedCatalog ->
item { item {
Text( 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), modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp, fontSize = 14.sp,
@ -149,7 +155,7 @@ internal fun LazyListScope.discoverContent(
@Composable @Composable
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) { private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
Text( Text(
text = "Discover", text = stringResource(Res.string.compose_search_discover_title),
modifier = modifier, modifier = modifier,
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
@ -169,16 +175,16 @@ private fun DiscoverFilterRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
DiscoverDropdownChip( DiscoverDropdownChip(
title = "Select Type", title = stringResource(Res.string.discover_select_type),
label = state.selectedType?.displayTypeLabel() ?: "Type", label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type),
selectedKey = state.selectedType, selectedKey = state.selectedType,
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) }, options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
enabled = state.typeOptions.isNotEmpty(), enabled = state.typeOptions.isNotEmpty(),
onSelected = { onTypeSelected(it.key) }, onSelected = { onTypeSelected(it.key) },
) )
DiscoverDropdownChip( DiscoverDropdownChip(
title = "Select Catalog", title = stringResource(Res.string.discover_select_catalog),
label = state.selectedCatalog?.catalogName ?: "Catalog", label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog),
selectedKey = state.selectedCatalogKey, selectedKey = state.selectedCatalogKey,
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) }, options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
enabled = state.catalogOptions.isNotEmpty(), enabled = state.catalogOptions.isNotEmpty(),
@ -188,13 +194,13 @@ private fun DiscoverFilterRow(
val selectedCatalog = state.selectedCatalog val selectedCatalog = state.selectedCatalog
val genreOptions = buildList { val genreOptions = buildList {
if (selectedCatalog?.genreRequired != true) { 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) }) addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
} }
DiscoverDropdownChip( DiscoverDropdownChip(
title = "Select Genre", title = stringResource(Res.string.discover_select_genre),
label = state.selectedGenre ?: "All Genres", label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres),
selectedKey = state.selectedGenre ?: "", selectedKey = state.selectedGenre ?: "",
options = genreOptions, options = genreOptions,
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true, enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
@ -490,23 +496,23 @@ private fun DiscoverEmptyStateCard(
when (reason) { when (reason) {
DiscoverEmptyStateReason.NoActiveAddons -> { DiscoverEmptyStateReason.NoActiveAddons -> {
title = "No active addons" title = stringResource(Res.string.compose_search_empty_no_active_addons_title)
message = "Install and validate at least one addon before browsing discover catalogs." message = stringResource(Res.string.discover_empty_no_active_addons_message)
} }
DiscoverEmptyStateReason.NoDiscoverCatalogs -> { DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
title = "No discover catalogs" title = stringResource(Res.string.discover_empty_no_catalogs_title)
message = "Installed addons do not expose board-compatible catalogs for discover." message = stringResource(Res.string.discover_empty_no_catalogs_message)
} }
DiscoverEmptyStateReason.RequestFailed -> { DiscoverEmptyStateReason.RequestFailed -> {
title = "Could not load discover" title = stringResource(Res.string.discover_empty_load_failed_title)
message = errorMessage ?: "The selected catalog failed to return discover items." message = errorMessage ?: stringResource(Res.string.discover_empty_load_failed_message)
} }
DiscoverEmptyStateReason.NoResults, null -> { DiscoverEmptyStateReason.NoResults, null -> {
title = "No titles found" title = stringResource(Res.string.discover_empty_no_results_title)
message = "The selected catalog and filters did not return any items." message = stringResource(Res.string.discover_empty_no_results_message)
} }
} }
@ -522,13 +528,14 @@ private data class DiscoverOptionItem(
val label: String, val label: String,
) )
@Composable
private fun String.displayTypeLabel(): String = private fun String.displayTypeLabel(): String =
when (lowercase()) { when (lowercase()) {
"movie" -> "Movies" "movie" -> stringResource(Res.string.media_movies)
"series" -> "Series" "series" -> stringResource(Res.string.media_series)
"anime" -> "Anime" "anime" -> stringResource(Res.string.media_anime)
"channel" -> "Channels" "channel" -> stringResource(Res.string.media_channels)
"tv" -> "TV" "tv" -> stringResource(Res.string.media_tv)
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
} }

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.search package com.nuvio.app.features.search
import co.touchlab.kermit.Logger 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.AddonCatalog
import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.AddonExtraProperty
import com.nuvio.app.features.addons.ManagedAddon 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object SearchRepository { object SearchRepository {
private val log = Logger.withTag("SearchRepository") private val log = Logger.withTag("SearchRepository")
@ -313,7 +316,7 @@ object SearchRepository {
return HomeCatalogSection( return HomeCatalogSection(
key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}", 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, subtitle = addon.displayTitle,
addonName = addon.displayTitle, addonName = addon.displayTitle,
type = type, type = type,
@ -410,7 +413,7 @@ object SearchRepository {
isLoading = false, isLoading = false,
nextSkip = null, nextSkip = null,
emptyStateReason = DiscoverEmptyStateReason.RequestFailed, 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<MetaPreview>.previewNames(limit: Int = 5): String {
} }
private fun String.displayLabel(): String = private fun String.displayLabel(): String =
replaceFirstChar { char -> localizedMediaTypeLabel(this)
if (char.isLowerCase()) char.titlecase() else char.toString()
}
private fun String.typeSortKey(): String = private fun String.typeSortKey(): String =
when (lowercase()) { when (lowercase()) {

View file

@ -55,6 +55,22 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map 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 @Composable
fun SearchScreen( fun SearchScreen(
@ -78,14 +94,9 @@ fun SearchScreen(
var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) } var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) }
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val headerTitle by remember(query, listState) { val discoverInFocus by remember(query, listState) {
derivedStateOf { derivedStateOf {
if (query.isNotBlank()) { query.isBlank() && listState.firstVisibleItemIndex > 0
"Search"
} else {
val discoverInFocus = listState.firstVisibleItemIndex > 0
if (discoverInFocus) "Discover" else "Search"
}
} }
} }
@ -191,6 +202,11 @@ fun SearchScreen(
val homeSectionPadding = remember(maxWidth) { val homeSectionPadding = remember(maxWidth) {
homeSectionHorizontalPaddingForWidth(maxWidth.value) 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( NuvioScreen(
horizontalPadding = 0.dp, horizontalPadding = 0.dp,
@ -212,13 +228,13 @@ fun SearchScreen(
NuvioInputField( NuvioInputField(
value = query, value = query,
onValueChange = { query = it }, onValueChange = { query = it },
placeholder = "Search movies, shows...", placeholder = stringResource(Res.string.compose_search_placeholder),
trailingContent = if (query.isNotBlank()) { trailingContent = if (query.isNotBlank()) {
{ {
IconButton(onClick = { query = "" }) { IconButton(onClick = { query = "" }) {
Icon( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = "Clear search", contentDescription = stringResource(Res.string.compose_search_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@ -336,23 +352,23 @@ private fun SearchEmptyStateCard(
when (reason) { when (reason) {
SearchEmptyStateReason.NoActiveAddons -> { SearchEmptyStateReason.NoActiveAddons -> {
title = "No active addons" title = stringResource(Res.string.compose_search_empty_no_active_addons_title)
message = "Install and validate at least one addon before searching." message = stringResource(Res.string.compose_search_empty_no_active_addons_message)
} }
SearchEmptyStateReason.NoSearchCatalogs -> { SearchEmptyStateReason.NoSearchCatalogs -> {
title = "No searchable catalogs" title = stringResource(Res.string.compose_search_empty_no_search_catalogs_title)
message = "Your installed addons do not expose catalog search." message = stringResource(Res.string.compose_search_empty_no_search_catalogs_message)
} }
SearchEmptyStateReason.RequestFailed -> { SearchEmptyStateReason.RequestFailed -> {
title = "Search failed" title = stringResource(Res.string.compose_search_empty_failed_title)
message = errorMessage ?: "Installed addons failed to return valid search results." message = errorMessage ?: stringResource(Res.string.compose_search_empty_failed_message)
} }
SearchEmptyStateReason.NoResults, null -> { SearchEmptyStateReason.NoResults, null -> {
title = "No results found" title = stringResource(Res.string.compose_search_empty_no_results_title)
message = "Installed searchable catalogs did not return any matches for this query." message = stringResource(Res.string.compose_search_empty_no_results_message)
} }
} }
@ -377,7 +393,7 @@ private fun SearchRecentSection(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = "Recent Searches", text = stringResource(Res.string.compose_search_recent_searches),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
@ -439,7 +455,7 @@ private fun SearchRecentRow(
IconButton(onClick = onRemovePress) { IconButton(onClick = onRemovePress) {
Icon( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = "Remove recent search", contentDescription = stringResource(Res.string.compose_search_remove_recent_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }

View file

@ -30,6 +30,23 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch 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( internal fun LazyListScope.accountSettingsContent(
isTablet: Boolean, isTablet: Boolean,
@ -51,7 +68,7 @@ private fun AccountSettingsBody(
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( Text(
text = "Account", text = stringResource(Res.string.compose_settings_page_account),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -65,12 +82,16 @@ private fun AccountSettingsBody(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
text = "Status", text = stringResource(Res.string.settings_account_status),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -83,7 +104,7 @@ private fun AccountSettingsBody(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
text = "Email", text = stringResource(Res.string.settings_account_email),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -98,7 +119,7 @@ private fun AccountSettingsBody(
} }
else -> { else -> {
Text( Text(
text = "Not signed in", text = stringResource(Res.string.settings_account_not_signed_in),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -107,7 +128,7 @@ private fun AccountSettingsBody(
} }
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Sign Out", text = stringResource(Res.string.settings_account_sign_out),
onClick = { showSignOutConfirm = true }, onClick = { showSignOutConfirm = true },
) )
@ -126,13 +147,13 @@ private fun AccountSettingsBody(
), ),
) { ) {
Text( Text(
text = "Delete Account", text = stringResource(Res.string.settings_account_delete_account),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -142,11 +163,11 @@ private fun AccountSettingsBody(
} }
NuvioStatusModal( NuvioStatusModal(
title = "Sign Out?", title = stringResource(Res.string.settings_account_sign_out_confirm_title),
message = "You will be returned to the login screen.", message = stringResource(Res.string.settings_account_sign_out_confirm_message),
isVisible = showSignOutConfirm, isVisible = showSignOutConfirm,
confirmText = "Sign Out", confirmText = stringResource(Res.string.settings_account_sign_out),
dismissText = "Cancel", dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
showSignOutConfirm = false showSignOutConfirm = false
scope.launch { AuthRepository.signOut() } scope.launch { AuthRepository.signOut() }
@ -155,11 +176,11 @@ private fun AccountSettingsBody(
) )
NuvioStatusModal( NuvioStatusModal(
title = "Delete Account?", title = stringResource(Res.string.settings_account_delete_confirm_title),
message = "This action cannot be undone. All your data, profiles, and sync history will be permanently removed.", message = stringResource(Res.string.settings_account_delete_confirm_message),
isVisible = showDeleteConfirm, isVisible = showDeleteConfirm,
confirmText = "Delete", confirmText = stringResource(Res.string.action_delete),
dismissText = "Cancel", dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
showDeleteConfirm = false showDeleteConfirm = false
scope.launch { AuthRepository.deleteAccount() } scope.launch { AuthRepository.deleteAccount() }

View file

@ -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
}
}

View file

@ -18,12 +18,18 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check 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.Style
import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.AppTheme 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 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) @OptIn(ExperimentalLayoutApi::class)
internal fun LazyListScope.appearanceSettingsContent( internal fun LazyListScope.appearanceSettingsContent(
@ -41,12 +70,14 @@ internal fun LazyListScope.appearanceSettingsContent(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit,
onContinueWatchingClick: () -> Unit, onContinueWatchingClick: () -> Unit,
onPosterCustomizationClick: () -> Unit, onPosterCustomizationClick: () -> Unit,
) { ) {
item { item {
SettingsSection( SettingsSection(
title = "THEME", title = stringResource(Res.string.settings_appearance_section_theme),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
@ -74,39 +105,59 @@ internal fun LazyListScope.appearanceSettingsContent(
} }
item { item {
var showLanguageSheet by remember { mutableStateOf(false) }
SettingsSection( SettingsSection(
title = "DISPLAY", title = stringResource(Res.string.settings_appearance_section_display),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow( SettingsSwitchRow(
title = "AMOLED Black", title = stringResource(Res.string.settings_appearance_amoled_black),
description = "Use pure black backgrounds for OLED screens.", description = stringResource(Res.string.settings_appearance_amoled_description),
checked = amoledEnabled, checked = amoledEnabled,
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = onAmoledToggle, 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 { item {
SettingsSection( SettingsSection(
title = "HOME", title = stringResource(Res.string.settings_appearance_section_home),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow( SettingsNavigationRow(
title = "Continue Watching", title = stringResource(Res.string.compose_settings_page_continue_watching),
description = "Show, hide, and style the Continue Watching shelf.", description = stringResource(Res.string.settings_appearance_continue_watching_description),
icon = Icons.Rounded.Style, icon = Icons.Rounded.Style,
isTablet = isTablet, isTablet = isTablet,
onClick = onContinueWatchingClick, onClick = onContinueWatchingClick,
) )
SettingsGroupDivider(isTablet = isTablet) SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow( SettingsNavigationRow(
title = "Poster Customization", title = stringResource(Res.string.compose_settings_page_poster_customization),
description = "Adjust shared poster card width and corner radius presets.", description = stringResource(Res.string.settings_appearance_poster_customization_description),
icon = Icons.Rounded.Tune, icon = Icons.Rounded.Tune,
isTablet = isTablet, isTablet = isTablet,
onClick = onPosterCustomizationClick, 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 @Composable
private fun ThemeChip( private fun ThemeChip(
theme: AppTheme, theme: AppTheme,
@ -152,7 +275,7 @@ private fun ThemeChip(
if (isSelected) { if (isSelected) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = "Selected", contentDescription = stringResource(Res.string.cd_selected),
tint = palette.onSecondary, tint = palette.onSecondary,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
@ -162,7 +285,7 @@ private fun ThemeChip(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = theme.displayName, text = stringResource(theme.labelRes),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = if (isSelected) { color = if (isSelected) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface

View file

@ -7,6 +7,20 @@ import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Hub import androidx.compose.material.icons.rounded.Hub
import androidx.compose.material.icons.rounded.Tune 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( internal fun LazyListScope.contentDiscoveryContent(
isTablet: Boolean, isTablet: Boolean,
@ -19,21 +33,21 @@ internal fun LazyListScope.contentDiscoveryContent(
) { ) {
item { item {
SettingsSection( SettingsSection(
title = "SOURCES", title = stringResource(Res.string.settings_content_discovery_section_sources),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow( SettingsNavigationRow(
title = "Addons", title = stringResource(Res.string.compose_settings_page_addons),
description = "Install, remove, refresh, and sort your content sources.", description = stringResource(Res.string.settings_content_discovery_addons_description),
icon = Icons.Rounded.Extension, icon = Icons.Rounded.Extension,
isTablet = isTablet, isTablet = isTablet,
onClick = onAddonsClick, onClick = onAddonsClick,
) )
if (showPluginsEntry) { if (showPluginsEntry) {
SettingsNavigationRow( SettingsNavigationRow(
title = "Plugins", title = stringResource(Res.string.compose_settings_page_plugins),
description = "Install JavaScript scraper repositories and test providers internally.", description = stringResource(Res.string.settings_content_discovery_plugins_description),
icon = Icons.Rounded.Hub, icon = Icons.Rounded.Hub,
isTablet = isTablet, isTablet = isTablet,
onClick = onPluginsClick, onClick = onPluginsClick,
@ -44,27 +58,27 @@ internal fun LazyListScope.contentDiscoveryContent(
} }
item { item {
SettingsSection( SettingsSection(
title = "HOME", title = stringResource(Res.string.settings_content_discovery_section_home),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow( SettingsNavigationRow(
title = "Homescreen", title = stringResource(Res.string.compose_settings_page_homescreen),
description = "Control which catalogs appear on Home and in what order.", description = stringResource(Res.string.settings_content_discovery_homescreen_description),
icon = Icons.Rounded.Home, icon = Icons.Rounded.Home,
isTablet = isTablet, isTablet = isTablet,
onClick = onHomescreenClick, onClick = onHomescreenClick,
) )
SettingsNavigationRow( SettingsNavigationRow(
title = "Meta Screen", title = stringResource(Res.string.compose_settings_page_meta_screen),
description = "Disable detail sections and reorder everything below Hero.", description = stringResource(Res.string.settings_content_discovery_meta_screen_description),
icon = Icons.Rounded.Tune, icon = Icons.Rounded.Tune,
isTablet = isTablet, isTablet = isTablet,
onClick = onMetaScreenClick, onClick = onMetaScreenClick,
) )
SettingsNavigationRow( SettingsNavigationRow(
title = "Collections", title = stringResource(Res.string.collections_header),
description = "Create custom catalog groupings with folders shown on Home.", description = stringResource(Res.string.settings_content_discovery_collections_description),
icon = Icons.Rounded.CollectionsBookmark, icon = Icons.Rounded.CollectionsBookmark,
isTablet = isTablet, isTablet = isTablet,
onClick = onCollectionsClick, onClick = onCollectionsClick,

View file

@ -25,6 +25,23 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle 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( internal fun LazyListScope.continueWatchingSettingsContent(
isTablet: Boolean, isTablet: Boolean,
@ -35,13 +52,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
) { ) {
item { item {
SettingsSection( SettingsSection(
title = "VISIBILITY", title = stringResource(Res.string.settings_continue_watching_section_visibility),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow( SettingsSwitchRow(
title = "Show Continue Watching", title = stringResource(Res.string.settings_continue_watching_show_title),
description = "Display the Continue Watching shelf on the Home screen.", description = stringResource(Res.string.settings_continue_watching_show_description),
checked = isVisible, checked = isVisible,
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setVisible, onCheckedChange = ContinueWatchingPreferencesRepository::setVisible,
@ -51,7 +68,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
} }
item { item {
SettingsSection( SettingsSection(
title = "CARD STYLE", title = stringResource(Res.string.settings_continue_watching_section_card_style),
isTablet = isTablet, isTablet = isTablet,
) { ) {
ContinueWatchingStyleSelector( ContinueWatchingStyleSelector(
@ -63,13 +80,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
} }
item { item {
SettingsSection( SettingsSection(
title = "UP NEXT BEHAVIOR", title = stringResource(Res.string.settings_continue_watching_section_up_next_behavior),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow( SettingsSwitchRow(
title = "Up Next from furthest episode", title = stringResource(Res.string.settings_continue_watching_up_next_title),
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.", description = stringResource(Res.string.settings_continue_watching_up_next_description),
checked = upNextFromFurthestEpisode, checked = upNextFromFurthestEpisode,
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
@ -79,13 +96,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
} }
item { item {
SettingsSection( SettingsSection(
title = "ON LAUNCH", title = stringResource(Res.string.settings_continue_watching_section_on_launch),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow( SettingsSwitchRow(
title = "Resume prompt on launch", title = stringResource(Res.string.settings_continue_watching_resume_prompt_title),
description = "Show a popup to continue where you left off when opening the app after leaving from the player.", description = stringResource(Res.string.settings_continue_watching_resume_prompt_description),
checked = showResumePromptOnLaunch, checked = showResumePromptOnLaunch,
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch, onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch,
@ -173,20 +190,28 @@ private fun ContinueWatchingStyleOption(
) )
} }
Text( Text(
text = style.name.lowercase().replaceFirstChar(Char::uppercase), text = stringResource(style.labelRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
text = if (style == ContinueWatchingSectionStyle.Wide) { text = stringResource(style.descriptionRes),
"Info-dense horizontal card"
} else {
"Artwork-first poster card"
},
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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
}

View file

@ -36,6 +36,25 @@ import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard 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.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -57,13 +76,13 @@ internal fun LazyListScope.homescreenSettingsContent(
} }
item { item {
SettingsSection( SettingsSection(
title = "HERO", title = stringResource(Res.string.settings_homescreen_section_hero),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow( SettingsSwitchRow(
title = "Show Hero", title = stringResource(Res.string.settings_homescreen_show_hero),
description = "Display a featured hero carousel at the top of Home. Choose up to 2 source catalogs below.", description = stringResource(Res.string.settings_homescreen_show_hero_description),
checked = heroEnabled, checked = heroEnabled,
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled,
@ -76,7 +95,7 @@ internal fun LazyListScope.homescreenSettingsContent(
if (heroEnabled && catalogOnlyItems.isNotEmpty()) { if (heroEnabled && catalogOnlyItems.isNotEmpty()) {
var heroSourcesExpanded by remember { mutableStateOf(false) } var heroSourcesExpanded by remember { mutableStateOf(false) }
SettingsSection( SettingsSection(
title = "HERO SOURCES", title = stringResource(Res.string.settings_homescreen_section_hero_sources),
isTablet = isTablet, isTablet = isTablet,
) { ) {
HeroSourcesDropdown( HeroSourcesDropdown(
@ -93,35 +112,36 @@ internal fun LazyListScope.homescreenSettingsContent(
if (items.isEmpty()) { if (items.isEmpty()) {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
title = "No home catalogs", title = stringResource(Res.string.settings_homescreen_empty_title),
message = "Install an addon with board-compatible catalogs to configure Homescreen rows.", message = stringResource(Res.string.settings_homescreen_empty_message),
) )
} else { } else {
val catalogCount = items.count { !it.isCollection } val catalogCount = items.count { !it.isCollection }
val collectionCount = items.count { it.isCollection } val collectionCount = items.count { it.isCollection }
val sectionTitle = when { val sectionTitle = when {
collectionCount > 0 && catalogCount > 0 -> "CATALOGS & COLLECTIONS" collectionCount > 0 && catalogCount > 0 -> stringResource(Res.string.settings_homescreen_section_catalogs_collections)
collectionCount > 0 -> "COLLECTIONS" collectionCount > 0 -> stringResource(Res.string.settings_homescreen_section_collections)
else -> "CATALOGS" else -> stringResource(Res.string.settings_homescreen_section_catalogs)
} }
SettingsSection( SettingsSection(
title = sectionTitle, title = sectionTitle,
isTablet = isTablet, isTablet = isTablet,
actions = { actions = {
NuvioActionLabel( NuvioActionLabel(
text = "Reset", text = stringResource(Res.string.action_reset),
onClick = HomeCatalogSettingsRepository::resetToDefaults, onClick = HomeCatalogSettingsRepository::resetToDefaults,
) )
}, },
) { ) {
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val pinToMoveToast = stringResource(Res.string.settings_homescreen_pin_to_move_toast)
HomescreenCatalogList( HomescreenCatalogList(
isTablet = isTablet, isTablet = isTablet,
items = items, items = items,
onPinnedDragAttempt = { onPinnedDragAttempt = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) 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, expanded: Boolean,
onExpandedChange: (Boolean) -> Unit, onExpandedChange: (Boolean) -> Unit,
) { ) {
val noSourcesSelected = stringResource(Res.string.settings_homescreen_no_sources_selected)
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -150,7 +171,11 @@ private fun HeroSourcesDropdown(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -158,7 +183,7 @@ private fun HeroSourcesDropdown(
Text( Text(
text = items.filter { it.heroSourceEnabled } text = items.filter { it.heroSourceEnabled }
.joinToString(separator = ", ") { it.displayTitle } .joinToString(separator = ", ") { it.displayTitle }
.ifBlank { "No hero sources selected" }, .ifBlank { noSourcesSelected },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -182,7 +207,11 @@ private fun HeroSourcesDropdown(
description = if (!item.heroSourceEnabled && description = if (!item.heroSourceEnabled &&
selectedHeroSourceCount >= HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT 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 { } else {
item.addonName item.addonName
}, },
@ -211,18 +240,23 @@ private fun HomescreenSummaryCard(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text( Text(
text = "Keep Home focused", text = stringResource(Res.string.settings_homescreen_keep_home_focused),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )

View file

@ -1,6 +1,13 @@
package com.nuvio.app.features.settings package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope 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( internal fun LazyListScope.integrationsContent(
isTablet: Boolean, isTablet: Boolean,
@ -9,21 +16,21 @@ internal fun LazyListScope.integrationsContent(
) { ) {
item { item {
SettingsSection( SettingsSection(
title = "INTEGRATIONS", title = stringResource(Res.string.settings_integrations_section_title),
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow( SettingsNavigationRow(
title = "TMDB Enrichment", title = stringResource(Res.string.compose_settings_page_tmdb_enrichment),
description = "Enhance detail pages with TMDB artwork, credits, episode metadata, and more.", description = stringResource(Res.string.settings_integrations_tmdb_description),
iconPainter = integrationLogoPainter(IntegrationLogo.Tmdb), iconPainter = integrationLogoPainter(IntegrationLogo.Tmdb),
isTablet = isTablet, isTablet = isTablet,
onClick = onTmdbClick, onClick = onTmdbClick,
) )
SettingsGroupDivider(isTablet = isTablet) SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow( SettingsNavigationRow(
title = "MDBList Ratings", title = stringResource(Res.string.compose_settings_page_mdblist_ratings),
description = "Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages.", description = stringResource(Res.string.settings_integrations_mdblist_description),
iconPainter = integrationLogoPainter(IntegrationLogo.MdbList), iconPainter = integrationLogoPainter(IntegrationLogo.MdbList),
isTablet = isTablet, isTablet = isTablet,
onClick = onMdbListClick, onClick = onMdbListClick,

Some files were not shown because too many files have changed in this diff Show more