Merge branch 'NuvioMedia:cmp-rewrite' into Doh

This commit is contained in:
paregi12 2026-04-25 15:53:09 +05:30 committed by GitHub
commit 03aaeae185
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 5419 additions and 1596 deletions

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage
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.WatchProgressStorage
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge(
@ -52,6 +53,7 @@ class MainActivity : ComponentActivity() {
scrim = 0xFF020404.toInt(),
),
)
ThemeSettingsStorage.initialize(applicationContext)
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext)
@ -66,7 +68,6 @@ class MainActivity : ComponentActivity() {
ProfilePinCacheStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
com.nuvio.app.features.settings.globalNetworkSettingsRepository = com.nuvio.app.features.settings.NetworkSettingsRepository(com.nuvio.app.features.settings.AndroidNetworkSettingsStorage(applicationContext))
TmdbSettingsStorage.initialize(applicationContext)

View file

@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
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
internal actual object DownloadsLiveStatusPlatform {
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 trackedDownloadIdsKey = "tracked_download_ids"
@ -143,7 +144,7 @@ internal actual object DownloadsLiveStatusPlatform {
.setProgress(0, 0, false)
.addAction(
0,
"Resume",
runBlocking { getString(Res.string.action_resume) },
buildActionPendingIntent(
context = context,
action = DownloadsNotificationActionReceiver.actionResume,
@ -163,15 +164,15 @@ internal actual object DownloadsLiveStatusPlatform {
val downloaded = formatBytes(item.downloadedBytes)
val total = item.totalBytes?.let(::formatBytes)
if (total != null) {
"Downloading $detail$downloaded / $total"
runBlocking { getString(Res.string.downloads_live_downloading_with_total, detail, downloaded, total) }
} else {
"Downloading $detail$downloaded"
runBlocking { getString(Res.string.downloads_live_downloading, detail, downloaded) }
}
}
DownloadStatus.Paused -> "Paused $detail"
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed"
DownloadStatus.Completed -> "Download completed"
DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) }
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) }
DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) }
}
}
@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform {
if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel(
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply {
description = channelDescription
NotificationChannel(
channelId,
runBlocking { getString(Res.string.downloads_channel_name) },
NotificationManager.IMPORTANCE_LOW,
).apply {
description = runBlocking { getString(Res.string.downloads_channel_description) }
},
)
}

View file

@ -8,9 +8,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import java.io.File
import java.io.FileOutputStream
import java.net.URI
@ -45,7 +48,7 @@ internal actual object DownloadsPlatformDownloader {
scope.launch {
val context = appContext
if (context == null) {
onFailure("Download system is not initialized")
onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
return@launch
}
@ -70,7 +73,9 @@ internal actual object DownloadsPlatformDownloader {
var attemptedRangeRequest = resumeFromBytes > 0L
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
call = downloadHttpClient.newCall(httpRequest)
var response = call?.execute() ?: error("Download request failed")
var response = call?.execute() ?: error(
runBlocking { getString(Res.string.downloads_error_request_failed) },
)
if (attemptedRangeRequest && response.code == 416) {
response.close()
@ -79,12 +84,18 @@ internal actual object DownloadsPlatformDownloader {
attemptedRangeRequest = false
httpRequest = buildRequest(null)
call = downloadHttpClient.newCall(httpRequest)
response = call?.execute() ?: error("Download request failed")
response = call?.execute() ?: error(
runBlocking { getString(Res.string.downloads_error_request_failed) },
)
}
response.use { response ->
if (!response.isSuccessful) {
error("Request failed with HTTP ${response.code}")
error(
runBlocking {
getString(Res.string.downloads_error_http_failed, response.code)
},
)
}
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
@ -95,7 +106,9 @@ internal actual object DownloadsPlatformDownloader {
tempFile.delete()
}
val body = response.body ?: error("Empty response body")
val body = response.body ?: error(
runBlocking { getString(Res.string.downloads_error_empty_body) },
)
val totalBytes = resolveTotalBytes(
startingBytes = startingBytes,
isPartialResume = isPartialResume,
@ -132,7 +145,7 @@ internal actual object DownloadsPlatformDownloader {
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
}
} catch (error: Throwable) {
onFailure(error.message ?: "Download failed")
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
}
}

View file

@ -23,6 +23,9 @@ import androidx.work.WorkManager
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import java.time.LocalDate
@ -285,13 +288,13 @@ internal actual object EpisodeReleaseNotificationPlatform {
val channel = NotificationChannel(
channelId,
"Episode Releases",
runBlocking { getString(Res.string.notifications_channel_episode_releases_name) },
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Alerts when a saved show's new episode is released."
description = runBlocking { getString(Res.string.notifications_channel_episode_releases_description) }
}
notificationManager.createNotificationChannel(channel)
}
private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId"
}
}

View file

@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C
@ -184,7 +187,7 @@ actual fun PlatformPlayerSurface(
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
latestOnError.value(error.localizedMessage ?: "Unable to play this stream.")
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -585,7 +588,10 @@ private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
else -> null
}
val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } }
val baseName = format.label?.takeIf { it.isNotBlank() } ?: resolvedLanguage ?: format.language ?: "Track ${idx + 1}"
val baseName = format.label?.takeIf { it.isNotBlank() }
?: resolvedLanguage
?: format.language
?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) }
val suffix = listOfNotNull(channelLabel, codecLabel)
.joinToString(" ")
.let { if (it.isNotBlank()) " ($it)" else "" }

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
@ -15,12 +17,14 @@ actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey)
private const val selectedAppLanguageKey = "selected_app_language"
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey, selectedAppLanguageKey)
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
actual fun loadSelectedTheme(): String? =
@ -46,9 +50,26 @@ actual object ThemeSettingsStorage {
?.apply()
}
actual fun loadSelectedAppLanguage(): String? =
preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null)
actual fun saveSelectedAppLanguage(languageCode: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(selectedAppLanguageKey), languageCode)
?.apply()
}
actual fun applySelectedAppLanguage(languageCode: String) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(languageCode),
)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -58,5 +79,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
}

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"?>
<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>
</style>
@ -9,4 +9,4 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.Nuvio</item>
</style>
</resources>
</resources>

View file

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

View file

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

View file

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

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
enum class AppTheme(val displayName: String) {
CRIMSON("Crimson"),
OCEAN("Ocean"),
VIOLET("Violet"),
EMERALD("Emerald"),
AMBER("Amber"),
ROSE("Rose"),
WHITE("White"),
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.theme_amber
import nuvio.composeapp.generated.resources.theme_crimson
import nuvio.composeapp.generated.resources.theme_emerald
import nuvio.composeapp.generated.resources.theme_ocean
import nuvio.composeapp.generated.resources.theme_rose
import nuvio.composeapp.generated.resources.theme_violet
import nuvio.composeapp.generated.resources.theme_white
import org.jetbrains.compose.resources.StringResource
enum class AppTheme {
CRIMSON,
OCEAN,
VIOLET,
EMERALD,
AMBER,
ROSE,
WHITE,
}
val AppTheme.labelRes: StringResource
get() = when (this) {
AppTheme.CRIMSON -> Res.string.theme_crimson
AppTheme.OCEAN -> Res.string.theme_ocean
AppTheme.VIOLET -> Res.string.theme_violet
AppTheme.EMERALD -> Res.string.theme_emerald
AppTheme.AMBER -> Res.string.theme_amber
AppTheme.ROSE -> Res.string.theme_rose
AppTheme.WHITE -> Res.string.theme_white
}

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

View file

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

View file

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

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

View file

@ -37,6 +37,13 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.episodes_cd_watched
import nuvio.composeapp.generated.resources.hero_add_to_library
import nuvio.composeapp.generated.resources.hero_mark_unwatched
import nuvio.composeapp.generated.resources.hero_mark_watched
import nuvio.composeapp.generated.resources.hero_remove_from_library
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -72,7 +79,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
title = if (isSaved) "Remove from Library" else "Add to Library",
title = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
stringResource(Res.string.hero_add_to_library)
},
onClick = {
onToggleLibrary()
coroutineScope.launch {
@ -86,7 +97,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
title = if (isWatched) "Mark as Unwatched" else "Mark as Watched",
title = if (isWatched) {
stringResource(Res.string.hero_mark_unwatched)
} else {
stringResource(Res.string.hero_mark_watched)
},
onClick = {
onToggleWatched()
coroutineScope.launch {
@ -114,7 +129,7 @@ fun NuvioWatchedBadge(
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Watched",
contentDescription = stringResource(Res.string.episodes_cd_watched),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp),
)
@ -200,4 +215,3 @@ private fun PosterSheetHeader(
}
}
}

View file

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

View file

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

View file

@ -1,5 +1,10 @@
package com.nuvio.app.features.addons
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.generic_addon
import org.jetbrains.compose.resources.getString
data class AddonManifest(
val id: String,
val name: String,
@ -54,7 +59,9 @@ data class ManagedAddon(
val displayTitle: String
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
?: manifest?.name
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" }
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
runBlocking { getString(Res.string.generic_addon) }
}
}
data class AddonsUiState(

View file

@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Serializable
private data class AddonRow(
@ -198,17 +200,17 @@ object AddonRepository {
suspend fun addAddon(rawUrl: String): AddAddonResult {
if (isUsingPrimaryAddonsFromSecondaryProfile()) {
return AddAddonResult.Error("This profile uses primary addons.")
return AddAddonResult.Error(getString(Res.string.profile_primary_addons_required))
}
log.i { "addAddon() — rawUrl=$rawUrl" }
val manifestUrl = try {
normalizeManifestUrl(rawUrl)
} catch (error: IllegalArgumentException) {
return AddAddonResult.Error(error.message ?: "Enter a valid addon URL")
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_invalid_url))
}
if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) {
return AddAddonResult.Error("That addon is already installed.")
return AddAddonResult.Error(getString(Res.string.addon_already_installed))
}
val manifest = try {
@ -220,7 +222,7 @@ object AddonRepository {
)
}
} catch (error: Throwable) {
return AddAddonResult.Error(error.message ?: "Unable to load manifest")
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_load_manifest_failed))
}
_uiState.update { current ->
@ -310,7 +312,7 @@ object AddonRepository {
onFailure = { error ->
addon.copy(
isRefreshing = false,
errorMessage = error.message ?: "Unable to load manifest",
errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed),
)
},
)

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,114 @@
package com.nuvio.app.features.collection
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object CollectionJsonPreserver {
fun merge(
json: Json,
rawCollectionsJson: JsonElement,
collections: List<Collection>,
): JsonArray {
val rawById = rawCollectionsJson.asObjectArrayById()
return buildJsonArray {
collections.forEach { collection ->
add(
mergeCollection(
json = json,
raw = rawById[collection.id],
collection = collection,
),
)
}
}
}
private fun mergeCollection(
json: Json,
raw: JsonObject?,
collection: Collection,
): JsonObject {
val encoded = json.encodeToJsonElement(Collection.serializer(), collection).jsonObject
val rawFoldersById = raw?.get("folders").asObjectArrayById()
val mergedFolders = buildJsonArray {
collection.folders.forEach { folder ->
add(
mergeFolder(
json = json,
raw = rawFoldersById[folder.id],
folder = folder,
),
)
}
}
return mergeObjects(raw, encoded, mapOf("folders" to mergedFolders))
}
private fun mergeFolder(
json: Json,
raw: JsonObject?,
folder: CollectionFolder,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject
val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey)
val mergedSources = buildJsonArray {
folder.catalogSources.forEach { source ->
val sourceElement =
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
add(
mergeSource(
json = json,
raw = rawSourcesByKey[sourceKey(sourceElement)],
source = source,
),
)
}
}
return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources))
}
private fun mergeSource(
json: Json,
raw: JsonObject?,
source: CollectionCatalogSource,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source).jsonObject
return mergeObjects(raw, encoded)
}
private fun mergeObjects(
raw: JsonObject?,
encoded: JsonObject,
overrides: Map<String, JsonElement> = emptyMap(),
): JsonObject = buildJsonObject {
raw?.forEach { (key, value) -> put(key, value) }
encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) }
}
private fun JsonElement?.asObjectArrayById(): Map<String, JsonObject> =
asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull }
private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map<String, JsonObject> =
(this as? JsonArray)
?.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
keySelector(obj)?.let { key -> key to obj }
}
?.toMap()
.orEmpty()
private fun sourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
return "$addonId|$type|$catalogId"
}
}

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

View file

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

View file

@ -6,9 +6,21 @@ import com.nuvio.app.features.addons.ManagedAddon
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_empty_json
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
import org.jetbrains.compose.resources.getString
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@ -21,6 +33,7 @@ object CollectionRepository {
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
private var rawCollectionsJson: JsonElement = JsonArray(emptyList())
private var hasLoaded = false
@ -31,6 +44,8 @@ object CollectionRepository {
if (payload.isNullOrBlank()) return
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
@ -40,11 +55,13 @@ object CollectionRepository {
fun onProfileChanged() {
hasLoaded = false
_collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
}
fun clearLocalState() {
hasLoaded = false
_collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
}
fun getCollection(id: String): Collection? =
@ -71,6 +88,7 @@ object CollectionRepository {
}
fun setCollections(collections: List<Collection>) {
ensureLoaded()
_collections.value = collections
persist()
}
@ -96,11 +114,12 @@ object CollectionRepository {
fun exportToJson(): String {
ensureLoaded()
return json.encodeToString(_collections.value)
return mergedCollectionsJson().toString()
}
fun importFromJson(jsonString: String): Result<List<Collection>> {
return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported
persist()
@ -110,28 +129,68 @@ object CollectionRepository {
fun validateJson(jsonString: String): ValidationResult {
if (jsonString.isBlank()) {
return ValidationResult(valid = false, error = "JSON is empty.")
return ValidationResult(
valid = false,
error = runBlocking { getString(Res.string.collections_import_error_empty_json) },
)
}
return try {
val collections = json.decodeFromString<List<Collection>>(jsonString)
var totalFolders = 0
collections.forEachIndexed { ci, c ->
if (c.id.isBlank()) {
return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(Res.string.collections_import_error_collection_blank_id, ci + 1)
},
)
}
if (c.title.isBlank()) {
return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(Res.string.collections_import_error_collection_blank_title, c.id)
},
)
}
c.folders.forEachIndexed { fi, f ->
if (f.id.isBlank()) {
return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_folder_blank_id,
fi + 1,
c.title,
)
},
)
}
if (f.title.isBlank()) {
return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_folder_blank_title,
f.id,
c.title,
)
},
)
}
f.catalogSources.forEachIndexed { si, s ->
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_source_blank_fields,
si + 1,
f.title,
)
},
)
}
}
totalFolders++
@ -143,7 +202,12 @@ object CollectionRepository {
folderCount = totalFolders,
)
} catch (e: Exception) {
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
ValidationResult(
valid = false,
error = runBlocking {
getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty())
},
)
}
}
@ -173,7 +237,8 @@ object CollectionRepository {
}
}
internal fun applyFromRemote(collections: List<Collection>) {
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
rawCollectionsJson = rawJson
_collections.value = collections
persist()
}
@ -184,9 +249,14 @@ object CollectionRepository {
private fun persist() {
runCatching {
CollectionStorage.savePayload(json.encodeToString(_collections.value))
CollectionStorage.savePayload(mergedCollectionsJson().toString())
}.onFailure { e ->
log.e(e) { "Failed to persist collections" }
}
}
private fun mergedCollectionsJson(): JsonArray =
CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also {
rawCollectionsJson = it
}
}

View file

@ -80,7 +80,7 @@ object CollectionSyncService {
if (remoteCollections != null) {
isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections)
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else {

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.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey
@ -17,6 +18,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
import nuvio.composeapp.generated.resources.collections_tab_all
import org.jetbrains.compose.resources.getString
data class FolderTab(
val label: String,
@ -113,7 +119,13 @@ object FolderDetailRepository {
val tabs = buildList {
if (showAll) {
add(FolderTab(label = "All", isAllTab = true, isLoading = true))
add(
FolderTab(
label = runBlocking { getString(Res.string.collections_tab_all) },
isAllTab = true,
isLoading = true,
),
)
}
folder.catalogSources.forEach { source ->
val addon = addons.find { it.manifest?.id == source.addonId }
@ -121,9 +133,7 @@ object FolderDetailRepository {
it.id == source.catalogId && it.type == source.type
}
val label = catalog?.name ?: source.catalogId
val typeLabel = source.type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
val typeLabel = localizedMediaTypeLabel(source.type)
val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
add(
FolderTab(
@ -155,7 +165,14 @@ object FolderDetailRepository {
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val addon = addons.find { it.manifest?.id == source.addonId }
if (addon == null) {
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") }
updateTab(tabIndex) {
it.copy(
isLoading = false,
error = runBlocking {
getString(Res.string.collections_folder_addon_not_found, source.addonId)
},
)
}
return@forEachIndexed
}

View file

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

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

View file

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

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

View file

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

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import com.nuvio.app.core.i18n.localizedShortMonthName
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
@ -63,6 +64,9 @@ import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
private sealed interface PersonDetailUiState {
data object Loading : PersonDetailUiState
@ -96,7 +100,7 @@ fun PersonDetailScreen(
uiState = if (detail != null) {
PersonDetailUiState.Success(detail)
} else {
PersonDetailUiState.Error("Could not load details for $personName")
PersonDetailUiState.Error(getString(Res.string.person_load_failed, personName))
}
}
@ -141,7 +145,7 @@ fun PersonDetailScreen(
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
}
@ -268,7 +272,7 @@ private fun PersonDetailContent(
if (popularCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = "Popular",
title = stringResource(Res.string.person_popular),
items = popularCredits,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
@ -279,7 +283,7 @@ private fun PersonDetailContent(
if (latestCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = "Latest",
title = stringResource(Res.string.person_latest),
items = latestCredits,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
@ -290,7 +294,7 @@ private fun PersonDetailContent(
if (upcomingCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = "Upcoming",
title = stringResource(Res.string.person_upcoming),
items = upcomingCredits,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
@ -405,18 +409,23 @@ private fun HeroSection(
val infoItems = buildList {
person.birthday?.let { bday ->
val age = calculateAge(bday, person.deathday)
val ageStr = if (age != null) " (age $age)" else ""
val ageStr = if (age != null) stringResource(Res.string.person_age, age) else ""
val bdayDisplay = formatDateForDisplay(bday) ?: bday
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
val line = if (deathDisplay != null) {
"Born $bdayDisplay — Died $deathDisplay$ageStr"
buildString {
append(stringResource(Res.string.person_born, bdayDisplay, ""))
append("")
append(stringResource(Res.string.person_died, deathDisplay))
append(ageStr)
}
} else {
"Born $bdayDisplay$ageStr"
stringResource(Res.string.person_born, bdayDisplay, ageStr)
}
add(line)
}
person.placeOfBirth?.let { add(it) }
person.knownFor?.let { add("Known for: $it") }
person.knownFor?.let { add(stringResource(Res.string.person_known_for, it)) }
}
if (infoItems.isNotEmpty()) {
infoItems.forEach { info ->
@ -682,7 +691,7 @@ private fun PersonDetailError(
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Something went wrong",
text = stringResource(Res.string.person_something_wrong),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@ -700,7 +709,7 @@ private fun PersonDetailError(
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text("Retry")
Text(stringResource(Res.string.action_retry))
}
}
}
@ -741,15 +750,11 @@ private fun calculateAge(birthday: String, deathday: String?): Int? {
private fun formatDateForDisplay(date: String): String? {
val parts = date.split("-").mapNotNull { it.toIntOrNull() }
if (parts.size < 3) return null
val months = arrayOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
val month = parts[1]
val day = parts[2]
val year = parts[0]
return if (month in 1..12) {
"${months[month - 1]} $day, $year"
"${localizedShortMonthName(month)} $day, $year"
} else {
null
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaCompany
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
@ -54,7 +56,11 @@ fun DetailProductionSection(
if (displayItems.isEmpty()) return
DetailSection(
title = if (isSeriesLike) "Network" else "Production",
title = if (isSeriesLike) {
stringResource(Res.string.details_networks)
} else {
stringResource(Res.string.meta_section_production_title)
},
modifier = modifier,
showHeader = showHeader,
) {

View file

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

View file

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

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

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

View file

@ -1,6 +1,13 @@
package com.nuvio.app.features.downloads
import kotlinx.serialization.Serializable
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.downloads_enqueue_missing_url
import nuvio.composeapp.generated.resources.downloads_enqueue_replaced
import nuvio.composeapp.generated.resources.downloads_enqueue_started
import nuvio.composeapp.generated.resources.downloads_enqueue_unsupported_format
import org.jetbrains.compose.resources.getString
@Serializable
enum class DownloadStatus {
@ -48,22 +55,7 @@ data class DownloadItem(
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
val displaySubtitle: String
get() = if (isEpisode) {
buildString {
append("S")
append(seasonNumber)
append("E")
append(episodeNumber)
episodeTitle
?.takeIf { it.isNotBlank() }
?.let {
append("")
append(it)
}
}
} else {
"Movie"
}
get() = episodeTitle.orEmpty()
val progressFraction: Float
get() {
@ -91,11 +83,18 @@ data class DownloadsUiState(
get() = items.filter { it.status == DownloadStatus.Completed }
}
enum class DownloadEnqueueResult(
val toastMessage: String,
) {
Started("Download started"),
Replaced("Replaced previous download"),
MissingUrl("No direct stream link available"),
UnsupportedFormat("Unsupported stream format for downloads"),
enum class DownloadEnqueueResult {
Started,
Replaced,
MissingUrl,
UnsupportedFormat;
fun toastMessage(): String = runBlocking {
when (this@DownloadEnqueueResult) {
Started -> getString(Res.string.downloads_enqueue_started)
Replaced -> getString(Res.string.downloads_enqueue_replaced)
MissingUrl -> getString(Res.string.downloads_enqueue_missing_url)
UnsupportedFormat -> getString(Res.string.downloads_enqueue_unsupported_format)
}
}
}

View file

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

View file

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

View file

@ -1,7 +1,12 @@
package com.nuvio.app.features.home
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.supportsPagination
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.home_catalog_default_title
import org.jetbrains.compose.resources.getString
data class HomeCatalogDefinition(
val key: String,
@ -23,7 +28,13 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
.map { catalog ->
HomeCatalogDefinition(
key = "${manifest.id}:${catalog.type}:${catalog.id}",
defaultTitle = "${catalog.name} - ${catalog.type.displayLabel()}",
defaultTitle = runBlocking {
getString(
Res.string.home_catalog_default_title,
catalog.name,
localizedMediaTypeLabel(catalog.type),
)
},
addonName = addon.displayTitle,
manifestUrl = addon.manifestUrl,
type = catalog.type,
@ -33,7 +44,4 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
}
}.distinctBy(HomeCatalogDefinition::key)
internal fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
internal fun String.displayLabel(): String = localizedMediaTypeLabel(this)

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -10,6 +11,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
data class HomeCatalogSettingsItem(
val key: String,
@ -480,7 +483,7 @@ internal fun buildCollectionDefinitions(collections: List<Collection>): List<Col
key = "collection_${collection.id}",
collectionId = collection.id,
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,
)
}

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.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
@ -417,8 +419,8 @@ fun HomeScreen(
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No active addons",
message = "Install and validate at least one addon before loading catalog rows on Home.",
title = stringResource(Res.string.compose_search_empty_no_active_addons_title),
message = stringResource(Res.string.home_empty_no_active_addons_message),
)
}
}
@ -457,9 +459,9 @@ fun HomeScreen(
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available",
title = stringResource(Res.string.home_empty_no_rows_title),
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
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(
parentMetaId = contentId,
parentMetaType = contentType,
videoId = videoId,
title = name,
subtitle = subtitle,
subtitle = episodeTitle.orEmpty(),
imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo,
poster = poster,
@ -649,20 +638,6 @@ private fun CachedNextUpItem.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
?.takeIf { duration <= 0L && it > 0f }
?.let { (it / 100f).coerceIn(0f, 1f) }
@ -679,7 +654,7 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
parentMetaType = contentType,
videoId = videoId,
title = name,
subtitle = subtitle,
subtitle = episodeTitle.orEmpty(),
imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo,
poster = poster,

View file

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

View file

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

View file

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

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.profiles.ProfileRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun LibraryScreen(
@ -84,7 +86,11 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background),
) {
NuvioScreenHeader(
title = if (isTraktSource) "Trakt Library" else "Library",
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_title)
} else {
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(6.dp))
@ -116,7 +122,11 @@ fun LibraryScreen(
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Couldn't load Trakt library" else "Couldn't load library",
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_load_failed)
} else {
stringResource(Res.string.library_load_failed)
},
message = uiState.errorMessage.orEmpty(),
)
}
@ -139,11 +149,15 @@ fun LibraryScreen(
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty",
message = if (isTraktSource) {
"Connect Trakt and save titles to your watchlist or personal lists."
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_title)
} else {
"Saved titles will appear here after you tap Save on a details screen."
stringResource(Res.string.library_empty_title)
},
message = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_message)
} else {
stringResource(Res.string.library_empty_message)
},
)
}
@ -166,11 +180,13 @@ fun LibraryScreen(
}
NuvioStatusModal(
title = "Remove from Library?",
message = pendingRemovalItem?.let { "Remove ${it.name} from your library?" }.orEmpty(),
title = stringResource(Res.string.library_remove_title),
message = pendingRemovalItem?.let {
stringResource(Res.string.library_remove_message, it.name)
}.orEmpty(),
isVisible = pendingRemovalItem != null,
confirmText = "Remove",
dismissText = "Cancel",
confirmText = stringResource(Res.string.library_remove_confirm),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
pendingRemovalItem?.id?.let(LibraryRepository::remove)
pendingRemovalItem = null

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,14 +22,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@ -40,14 +40,19 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState
import kotlin.math.round
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun PlayerSourcesPanel(
@ -108,19 +113,19 @@ fun PlayerSourcesPanel(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Sources",
text = stringResource(Res.string.compose_player_panel_sources),
color = colorScheme.onSurface,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PanelChipButton(
label = "Reload",
label = stringResource(Res.string.compose_action_reload),
icon = Icons.Rounded.Refresh,
onClick = onReload,
)
PanelChipButton(
label = "Close",
label = stringResource(Res.string.action_close),
onClick = onDismiss,
)
}
@ -140,7 +145,7 @@ fun PlayerSourcesPanel(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AddonFilterChip(
label = "All",
label = stringResource(Res.string.collections_tab_all),
isSelected = streamsUiState.selectedFilter == null,
onClick = { onFilterSelected(null) },
)
@ -182,7 +187,7 @@ fun PlayerSourcesPanel(
contentAlignment = Alignment.Center,
) {
Text(
text = "No streams found",
text = stringResource(Res.string.compose_player_no_streams_found),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
)
@ -228,24 +233,32 @@ private fun SourceStreamRow(
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val cardShape = RoundedCornerShape(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.heightIn(min = 68.dp)
.shadow(
elevation = 2.dp,
shape = cardShape,
ambientColor = Color.Black.copy(alpha = 0.04f),
spotColor = Color.Black.copy(alpha = 0.04f),
)
.clip(cardShape)
.background(
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent,
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f),
)
.then(
if (isCurrent) {
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape)
} else {
Modifier
},
)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
.padding(14.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
@ -256,11 +269,13 @@ private fun SourceStreamRow(
Text(
text = stream.streamLabel,
color = colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Box(
@ -270,7 +285,7 @@ private fun SourceStreamRow(
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = "Playing",
text = stringResource(Res.string.compose_player_playing),
color = colorScheme.onPrimaryContainer,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
@ -278,34 +293,66 @@ private fun SourceStreamRow(
}
}
}
stream.streamSubtitle?.let { subtitle ->
if (subtitle != stream.streamLabel) {
Text(
text = subtitle,
color = colorScheme.onSurfaceVariant,
val subtitle = stream.streamSubtitle
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
lineHeight = 18.sp,
),
color = colorScheme.onSurfaceVariant,
)
}
Text(
text = stream.addonName,
color = colorScheme.onSurfaceVariant,
Spacer(modifier = Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlayerStreamFileSizeBadge(stream = stream)
Text(
text = stream.addonName,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun PlayerStreamFileSizeBadge(stream: StreamItem) {
val bytes = stream.behaviorHints.videoSize ?: return
val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0)
val sizeLabel = if (gib >= 1.0) {
val roundedGiB = round(gib * 10.0) / 10.0
"$roundedGiB ${localizedByteUnit("GB")}"
} else {
val mib = bytes.toDouble() / (1024.0 * 1024.0)
"${round(mib).toInt()} ${localizedByteUnit("MB")}"
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF0A0C0C))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = stringResource(Res.string.streams_size, sizeLabel),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (isCurrent) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = "Currently playing",
tint = colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
),
color = Color.White,
)
}
}

View file

@ -1,6 +1,10 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_player_track_number
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
data class AudioTrack(
@ -109,103 +113,9 @@ data class SubtitleAudioUiState(
val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn,
)
fun getTrackDisplayName(label: String?, language: String?, index: Int): String {
@Composable
fun localizedTrackDisplayName(label: String?, language: String?, index: Int): String {
if (!label.isNullOrBlank()) return label
if (!language.isNullOrBlank()) return formatLanguage(language)
return "Track ${index + 1}"
if (!language.isNullOrBlank()) return languageLabelForCode(language)
return stringResource(Res.string.compose_player_track_number, index + 1)
}
fun formatLanguage(code: String): String {
val lower = code.lowercase()
return LanguageNames[lower] ?: lower.replaceFirstChar { it.uppercase() }
}
private val LanguageNames = mapOf(
"en" to "English",
"eng" to "English",
"es" to "Spanish",
"spa" to "Spanish",
"fr" to "French",
"fre" to "French",
"fra" to "French",
"de" to "German",
"ger" to "German",
"deu" to "German",
"it" to "Italian",
"ita" to "Italian",
"pt" to "Portuguese",
"por" to "Portuguese",
"ru" to "Russian",
"rus" to "Russian",
"ja" to "Japanese",
"jpn" to "Japanese",
"ko" to "Korean",
"kor" to "Korean",
"zh" to "Chinese",
"chi" to "Chinese",
"zho" to "Chinese",
"ar" to "Arabic",
"ara" to "Arabic",
"hi" to "Hindi",
"hin" to "Hindi",
"nl" to "Dutch",
"nld" to "Dutch",
"dut" to "Dutch",
"pl" to "Polish",
"pol" to "Polish",
"sv" to "Swedish",
"swe" to "Swedish",
"tr" to "Turkish",
"tur" to "Turkish",
"he" to "Hebrew",
"heb" to "Hebrew",
"th" to "Thai",
"tha" to "Thai",
"vi" to "Vietnamese",
"vie" to "Vietnamese",
"cs" to "Czech",
"ces" to "Czech",
"cze" to "Czech",
"ro" to "Romanian",
"ron" to "Romanian",
"rum" to "Romanian",
"hu" to "Hungarian",
"hun" to "Hungarian",
"el" to "Greek",
"ell" to "Greek",
"gre" to "Greek",
"da" to "Danish",
"dan" to "Danish",
"fi" to "Finnish",
"fin" to "Finnish",
"no" to "Norwegian",
"nor" to "Norwegian",
"uk" to "Ukrainian",
"ukr" to "Ukrainian",
"bg" to "Bulgarian",
"bul" to "Bulgarian",
"hr" to "Croatian",
"hrv" to "Croatian",
"sr" to "Serbian",
"srp" to "Serbian",
"sk" to "Slovak",
"slk" to "Slovak",
"slo" to "Slovak",
"sl" to "Slovenian",
"slv" to "Slovenian",
"id" to "Indonesian",
"ind" to "Indonesian",
"ms" to "Malay",
"msa" to "Malay",
"may" to "Malay",
"ta" to "Tamil",
"tam" to "Tamil",
"te" to "Telugu",
"tel" to "Telugu",
"ml" to "Malayalam",
"mal" to "Malayalam",
"bn" to "Bengali",
"ben" to "Bengali",
"ur" to "Urdu",
"urd" to "Urdu",
)

View file

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

View file

@ -18,6 +18,9 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found
import org.jetbrains.compose.resources.getString
object SubtitleRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -76,7 +79,7 @@ object SubtitleRepository {
id = id,
url = url,
language = lang,
display = "${formatLanguage(lang)} (${addon.displayTitle})",
display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})",
)
)
}
@ -86,7 +89,7 @@ object SubtitleRepository {
_addonSubtitles.value = allSubs
if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) {
_error.value = "No subtitles found"
_error.value = getString(Res.string.compose_player_no_subtitles_found)
}
_isLoading.value = false
}

View file

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

View file

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

View file

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

View file

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

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

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@ -40,6 +41,9 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@Serializable
private data class StoredProfilePayload(
@ -52,6 +56,7 @@ object ProfileRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("ProfileRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow()
@ -274,7 +279,7 @@ object ProfileRepository {
suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) {
return PinVerifyResult(unlocked = false, message = "Connect to the internet to set a PIN.")
return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_requires_internet))
}
return runCatching {
@ -290,13 +295,13 @@ object ProfileRepository {
}.onFailure { e ->
log.e(e) { "Failed to set pin" }
}.getOrElse {
PinVerifyResult(unlocked = false, message = "Couldn't set PIN. Try again.")
PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_failed))
}
}
suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) {
return PinVerifyResult(unlocked = false, message = "Connect to the internet to remove the PIN lock.")
return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_requires_internet))
}
return runCatching {
@ -311,7 +316,7 @@ object ProfileRepository {
}.onFailure { e ->
log.e(e) { "Failed to clear pin" }
}.getOrElse {
PinVerifyResult(unlocked = false, message = "Couldn't remove PIN lock. Try again.")
PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_failed))
}
}
@ -407,7 +412,7 @@ object ProfileRepository {
if (payload.isEmpty()) {
return PinVerifyResult(
unlocked = false,
message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.",
message = localizedString(Res.string.profile_pin_offline_verification_requires_online),
)
}
@ -415,7 +420,7 @@ object ProfileRepository {
json.decodeFromString<CachedProfilePinPayload>(payload)
}.getOrNull() ?: return PinVerifyResult(
unlocked = false,
message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.",
message = localizedString(Res.string.profile_pin_offline_verification_requires_online),
)
if (
@ -426,7 +431,7 @@ object ProfileRepository {
ProfilePinCacheStorage.removePayload(profileIndex)
return PinVerifyResult(
unlocked = false,
message = "This profile PIN changed. Connect once to refresh the lock on this device.",
message = localizedString(Res.string.profile_pin_changed_requires_refresh),
)
}
@ -434,7 +439,7 @@ object ProfileRepository {
return if (digest == cached.digest) {
PinVerifyResult(unlocked = true)
} else {
PinVerifyResult(unlocked = false, message = "Incorrect PIN")
PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect))
}
}

View file

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

View file

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

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

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.search
import co.touchlab.kermit.Logger
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.AddonExtraProperty
import com.nuvio.app.features.addons.ManagedAddon
@ -21,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object SearchRepository {
private val log = Logger.withTag("SearchRepository")
@ -313,7 +316,7 @@ object SearchRepository {
return HomeCatalogSection(
key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}",
title = "$catalogName - ${type.displayLabel()}",
title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()),
subtitle = addon.displayTitle,
addonName = addon.displayTitle,
type = type,
@ -410,7 +413,7 @@ object SearchRepository {
isLoading = false,
nextSkip = null,
emptyStateReason = DiscoverEmptyStateReason.RequestFailed,
errorMessage = error.message ?: "Unable to load discover items.",
errorMessage = error.message ?: getString(Res.string.discover_empty_load_failed_message),
)
},
)
@ -486,9 +489,7 @@ private fun List<MetaPreview>.previewNames(limit: Int = 5): String {
}
private fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
localizedMediaTypeLabel(this)
private fun String.typeSortKey(): String =
when (lowercase()) {

View file

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

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

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.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Style
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -32,7 +38,30 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.AppTheme
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.labelRes
import com.nuvio.app.core.ui.ThemeColors
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.cd_selected
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization
import nuvio.composeapp.generated.resources.settings_appearance_app_language
import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
import nuvio.composeapp.generated.resources.settings_appearance_section_display
import nuvio.composeapp.generated.resources.settings_appearance_section_home
import nuvio.composeapp.generated.resources.settings_appearance_section_theme
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
@OptIn(ExperimentalLayoutApi::class)
internal fun LazyListScope.appearanceSettingsContent(
@ -41,12 +70,14 @@ internal fun LazyListScope.appearanceSettingsContent(
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit,
onContinueWatchingClick: () -> Unit,
onPosterCustomizationClick: () -> Unit,
) {
item {
SettingsSection(
title = "THEME",
title = stringResource(Res.string.settings_appearance_section_theme),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
@ -74,39 +105,59 @@ internal fun LazyListScope.appearanceSettingsContent(
}
item {
var showLanguageSheet by remember { mutableStateOf(false) }
SettingsSection(
title = "DISPLAY",
title = stringResource(Res.string.settings_appearance_section_display),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "AMOLED Black",
description = "Use pure black backgrounds for OLED screens.",
title = stringResource(Res.string.settings_appearance_amoled_black),
description = stringResource(Res.string.settings_appearance_amoled_description),
checked = amoledEnabled,
isTablet = isTablet,
onCheckedChange = onAmoledToggle,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_appearance_app_language),
description = stringResource(selectedAppLanguage.labelRes),
icon = Icons.Rounded.Language,
isTablet = isTablet,
onClick = { showLanguageSheet = true },
)
}
}
if (showLanguageSheet) {
AppearanceLanguageBottomSheet(
selectedLanguage = selectedAppLanguage,
onLanguageSelected = {
onAppLanguageSelected(it)
showLanguageSheet = false
},
onDismiss = { showLanguageSheet = false },
)
}
}
item {
SettingsSection(
title = "HOME",
title = stringResource(Res.string.settings_appearance_section_home),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow(
title = "Continue Watching",
description = "Show, hide, and style the Continue Watching shelf.",
title = stringResource(Res.string.compose_settings_page_continue_watching),
description = stringResource(Res.string.settings_appearance_continue_watching_description),
icon = Icons.Rounded.Style,
isTablet = isTablet,
onClick = onContinueWatchingClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Poster Customization",
description = "Adjust shared poster card width and corner radius presets.",
title = stringResource(Res.string.compose_settings_page_poster_customization),
description = stringResource(Res.string.settings_appearance_poster_customization_description),
icon = Icons.Rounded.Tune,
isTablet = isTablet,
onClick = onPosterCustomizationClick,
@ -116,6 +167,78 @@ internal fun LazyListScope.appearanceSettingsContent(
}
}
private data class AppLanguageSheetOption(
val language: AppLanguage,
val labelRes: StringResource,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppearanceLanguageBottomSheet(
selectedLanguage: AppLanguage,
onLanguageSelected: (AppLanguage) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
val options = remember {
AppLanguage.entries.map { language ->
AppLanguageSheetOption(
language = language,
labelRes = language.labelRes,
)
}
}
NuvioModalBottomSheet(
onDismissRequest = {
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
Text(
text = stringResource(Res.string.settings_appearance_app_language_sheet_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
options.forEachIndexed { index, option ->
if (index > 0) {
NuvioBottomSheetDivider()
}
NuvioBottomSheetActionRow(
title = stringResource(option.labelRes),
onClick = {
onLanguageSelected(option.language)
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
trailingContent = {
if (option.language == selectedLanguage) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(Res.string.cd_selected),
tint = MaterialTheme.colorScheme.primary,
)
}
},
)
}
}
}
}
@Composable
private fun ThemeChip(
theme: AppTheme,
@ -152,7 +275,7 @@ private fun ThemeChip(
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
contentDescription = stringResource(Res.string.cd_selected),
tint = palette.onSecondary,
modifier = Modifier.size(22.dp),
)
@ -162,7 +285,7 @@ private fun ThemeChip(
Spacer(modifier = Modifier.height(6.dp))
Text(
text = theme.displayName,
text = stringResource(theme.labelRes),
style = MaterialTheme.typography.labelMedium,
color = if (isSelected) {
MaterialTheme.colorScheme.onSurface

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

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.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.continueWatchingSettingsContent(
isTablet: Boolean,
@ -35,13 +52,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
) {
item {
SettingsSection(
title = "VISIBILITY",
title = stringResource(Res.string.settings_continue_watching_section_visibility),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Show Continue Watching",
description = "Display the Continue Watching shelf on the Home screen.",
title = stringResource(Res.string.settings_continue_watching_show_title),
description = stringResource(Res.string.settings_continue_watching_show_description),
checked = isVisible,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setVisible,
@ -51,7 +68,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
item {
SettingsSection(
title = "CARD STYLE",
title = stringResource(Res.string.settings_continue_watching_section_card_style),
isTablet = isTablet,
) {
ContinueWatchingStyleSelector(
@ -63,13 +80,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
item {
SettingsSection(
title = "UP NEXT BEHAVIOR",
title = stringResource(Res.string.settings_continue_watching_section_up_next_behavior),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Up Next from furthest episode",
description = "When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode. useful if you rewatch earlier episodes.",
title = stringResource(Res.string.settings_continue_watching_up_next_title),
description = stringResource(Res.string.settings_continue_watching_up_next_description),
checked = upNextFromFurthestEpisode,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
@ -79,13 +96,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
item {
SettingsSection(
title = "ON LAUNCH",
title = stringResource(Res.string.settings_continue_watching_section_on_launch),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Resume prompt on launch",
description = "Show a popup to continue where you left off when opening the app after leaving from the player.",
title = stringResource(Res.string.settings_continue_watching_resume_prompt_title),
description = stringResource(Res.string.settings_continue_watching_resume_prompt_description),
checked = showResumePromptOnLaunch,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch,
@ -173,20 +190,28 @@ private fun ContinueWatchingStyleOption(
)
}
Text(
text = style.name.lowercase().replaceFirstChar(Char::uppercase),
text = stringResource(style.labelRes),
style = MaterialTheme.typography.bodyMedium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (style == ContinueWatchingSectionStyle.Wide) {
"Info-dense horizontal card"
} else {
"Artwork-first poster card"
},
text = stringResource(style.descriptionRes),
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private val ContinueWatchingSectionStyle.labelRes: StringResource
get() = when (this) {
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster
}
private val ContinueWatchingSectionStyle.descriptionRes: StringResource
get() = when (this) {
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
}

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