mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: localization
This commit is contained in:
parent
5fb414ea2f
commit
23080c4344
128 changed files with 5205 additions and 1556 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
TmdbSettingsStorage.initialize(applicationContext)
|
||||
MdbListSettingsStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URI
|
||||
|
|
@ -44,7 +47,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
scope.launch {
|
||||
val context = appContext
|
||||
if (context == null) {
|
||||
onFailure("Download system is not initialized")
|
||||
onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +72,9 @@ internal actual object DownloadsPlatformDownloader {
|
|||
var attemptedRangeRequest = resumeFromBytes > 0L
|
||||
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
|
||||
call = downloadHttpClient.newCall(httpRequest)
|
||||
var response = call?.execute() ?: error("Download request failed")
|
||||
var response = call?.execute() ?: error(
|
||||
runBlocking { getString(Res.string.downloads_error_request_failed) },
|
||||
)
|
||||
|
||||
if (attemptedRangeRequest && response.code == 416) {
|
||||
response.close()
|
||||
|
|
@ -78,12 +83,18 @@ internal actual object DownloadsPlatformDownloader {
|
|||
attemptedRangeRequest = false
|
||||
httpRequest = buildRequest(null)
|
||||
call = downloadHttpClient.newCall(httpRequest)
|
||||
response = call?.execute() ?: error("Download request failed")
|
||||
response = call?.execute() ?: error(
|
||||
runBlocking { getString(Res.string.downloads_error_request_failed) },
|
||||
)
|
||||
}
|
||||
|
||||
response.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Request failed with HTTP ${response.code}")
|
||||
error(
|
||||
runBlocking {
|
||||
getString(Res.string.downloads_error_http_failed, response.code)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
|
||||
|
|
@ -94,7 +105,9 @@ internal actual object DownloadsPlatformDownloader {
|
|||
tempFile.delete()
|
||||
}
|
||||
|
||||
val body = response.body ?: error("Empty response body")
|
||||
val body = response.body ?: error(
|
||||
runBlocking { getString(Res.string.downloads_error_empty_body) },
|
||||
)
|
||||
val totalBytes = resolveTotalBytes(
|
||||
startingBytes = startingBytes,
|
||||
isPartialResume = isPartialResume,
|
||||
|
|
@ -131,7 +144,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
onFailure(error.message ?: "Download failed")
|
||||
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
composeApp/src/androidMain/res/values-es/strings.xml
Normal file
4
composeApp/src/androidMain/res/values-es/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Nuvio</string>
|
||||
</resources>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
5
composeApp/src/androidMain/res/xml/locale_config.xml
Normal file
5
composeApp/src/androidMain/res/xml/locale_config.xml
Normal 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>
|
||||
1043
composeApp/src/commonMain/composeResources/values-es/strings.xml
Normal file
1043
composeApp/src/commonMain/composeResources/values-es/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1043
composeApp/src/commonMain/composeResources/values/strings.xml
Normal file
1043
composeApp/src/commonMain/composeResources/values/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,19 @@ import com.nuvio.app.features.addons.ManagedAddon
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_empty_json
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
|
|
@ -110,28 +120,68 @@ object CollectionRepository {
|
|||
|
||||
fun validateJson(jsonString: String): ValidationResult {
|
||||
if (jsonString.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "JSON is empty.")
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking { getString(Res.string.collections_import_error_empty_json) },
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val collections = json.decodeFromString<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 +193,12 @@ object CollectionRepository {
|
|||
folderCount = totalFolders,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
|
||||
ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun PlayerSourcesPanel(
|
||||
|
|
@ -108,19 +110,19 @@ fun PlayerSourcesPanel(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Sources",
|
||||
text = stringResource(Res.string.compose_player_panel_sources),
|
||||
color = colorScheme.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PanelChipButton(
|
||||
label = "Reload",
|
||||
label = stringResource(Res.string.compose_action_reload),
|
||||
icon = Icons.Rounded.Refresh,
|
||||
onClick = onReload,
|
||||
)
|
||||
PanelChipButton(
|
||||
label = "Close",
|
||||
label = stringResource(Res.string.action_close),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
|
|
@ -140,7 +142,7 @@ fun PlayerSourcesPanel(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AddonFilterChip(
|
||||
label = "All",
|
||||
label = stringResource(Res.string.collections_tab_all),
|
||||
isSelected = streamsUiState.selectedFilter == null,
|
||||
onClick = { onFilterSelected(null) },
|
||||
)
|
||||
|
|
@ -182,7 +184,7 @@ fun PlayerSourcesPanel(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No streams found",
|
||||
text = stringResource(Res.string.compose_player_no_streams_found),
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
|
|
@ -270,7 +272,7 @@ private fun SourceStreamRow(
|
|||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Playing",
|
||||
text = stringResource(Res.string.compose_player_playing),
|
||||
color = colorScheme.onPrimaryContainer,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -301,7 +303,7 @@ private fun SourceStreamRow(
|
|||
if (isCurrent) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = "Currently playing",
|
||||
contentDescription = stringResource(Res.string.compose_player_currently_playing),
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,25 @@ import com.nuvio.app.core.ui.NuvioToastController
|
|||
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_reset
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_pin_to_move_toast
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_section_catalogs
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_section_catalogs_collections
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_section_collections
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_section_hero
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_section_hero_sources
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_selected_count
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_show_hero
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_show_hero_description
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_summary
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_summary_hint
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
|
@ -57,13 +76,13 @@ internal fun LazyListScope.homescreenSettingsContent(
|
|||
}
|
||||
item {
|
||||
SettingsSection(
|
||||
title = "HERO",
|
||||
title = stringResource(Res.string.settings_homescreen_section_hero),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = "Show Hero",
|
||||
description = "Display a featured hero carousel at the top of Home. Choose up to 2 source catalogs below.",
|
||||
title = stringResource(Res.string.settings_homescreen_show_hero),
|
||||
description = stringResource(Res.string.settings_homescreen_show_hero_description),
|
||||
checked = heroEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled,
|
||||
|
|
@ -76,7 +95,7 @@ internal fun LazyListScope.homescreenSettingsContent(
|
|||
if (heroEnabled && catalogOnlyItems.isNotEmpty()) {
|
||||
var heroSourcesExpanded by remember { mutableStateOf(false) }
|
||||
SettingsSection(
|
||||
title = "HERO SOURCES",
|
||||
title = stringResource(Res.string.settings_homescreen_section_hero_sources),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
HeroSourcesDropdown(
|
||||
|
|
@ -93,35 +112,36 @@ internal fun LazyListScope.homescreenSettingsContent(
|
|||
if (items.isEmpty()) {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
title = "No home catalogs",
|
||||
message = "Install an addon with board-compatible catalogs to configure Homescreen rows.",
|
||||
title = stringResource(Res.string.settings_homescreen_empty_title),
|
||||
message = stringResource(Res.string.settings_homescreen_empty_message),
|
||||
)
|
||||
} else {
|
||||
val catalogCount = items.count { !it.isCollection }
|
||||
val collectionCount = items.count { it.isCollection }
|
||||
val sectionTitle = when {
|
||||
collectionCount > 0 && catalogCount > 0 -> "CATALOGS & COLLECTIONS"
|
||||
collectionCount > 0 -> "COLLECTIONS"
|
||||
else -> "CATALOGS"
|
||||
collectionCount > 0 && catalogCount > 0 -> stringResource(Res.string.settings_homescreen_section_catalogs_collections)
|
||||
collectionCount > 0 -> stringResource(Res.string.settings_homescreen_section_collections)
|
||||
else -> stringResource(Res.string.settings_homescreen_section_catalogs)
|
||||
}
|
||||
SettingsSection(
|
||||
title = sectionTitle,
|
||||
isTablet = isTablet,
|
||||
actions = {
|
||||
NuvioActionLabel(
|
||||
text = "Reset",
|
||||
text = stringResource(Res.string.action_reset),
|
||||
onClick = HomeCatalogSettingsRepository::resetToDefaults,
|
||||
)
|
||||
},
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val pinToMoveToast = stringResource(Res.string.settings_homescreen_pin_to_move_toast)
|
||||
|
||||
HomescreenCatalogList(
|
||||
isTablet = isTablet,
|
||||
items = items,
|
||||
onPinnedDragAttempt = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
NuvioToastController.show("Remove pin to top from collection to move")
|
||||
NuvioToastController.show(pinToMoveToast)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -137,6 +157,7 @@ private fun HeroSourcesDropdown(
|
|||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val noSourcesSelected = stringResource(Res.string.settings_homescreen_no_sources_selected)
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -150,7 +171,11 @@ private fun HeroSourcesDropdown(
|
|||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$selectedHeroSourceCount of ${HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT} selected",
|
||||
text = stringResource(
|
||||
Res.string.settings_homescreen_selected_count,
|
||||
selectedHeroSourceCount,
|
||||
HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
|
@ -158,7 +183,7 @@ private fun HeroSourcesDropdown(
|
|||
Text(
|
||||
text = items.filter { it.heroSourceEnabled }
|
||||
.joinToString(separator = ", ") { it.displayTitle }
|
||||
.ifBlank { "No hero sources selected" },
|
||||
.ifBlank { noSourcesSelected },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -182,7 +207,11 @@ private fun HeroSourcesDropdown(
|
|||
description = if (!item.heroSourceEnabled &&
|
||||
selectedHeroSourceCount >= HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT
|
||||
) {
|
||||
"${item.addonName} • Limit reached (max 2)"
|
||||
stringResource(
|
||||
Res.string.settings_homescreen_limit_reached,
|
||||
item.addonName,
|
||||
HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT,
|
||||
)
|
||||
} else {
|
||||
item.addonName
|
||||
},
|
||||
|
|
@ -211,18 +240,23 @@ private fun HomescreenSummaryCard(
|
|||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Keep Home focused",
|
||||
text = stringResource(Res.string.settings_homescreen_keep_home_focused),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "$enabledCatalogCount of $totalCatalogCount catalogs visible • $selectedHeroSourceCount hero sources selected",
|
||||
text = stringResource(
|
||||
Res.string.settings_homescreen_summary,
|
||||
enabledCatalogCount,
|
||||
totalCatalogCount,
|
||||
selectedHeroSourceCount,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Open a catalog only when you need to rename or reorder it.",
|
||||
text = stringResource(Res.string.settings_homescreen_summary_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
||||
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
|
||||
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
|
||||
import nuvio.composeapp.generated.resources.settings_integrations_section_title
|
||||
import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
internal fun LazyListScope.integrationsContent(
|
||||
isTablet: Boolean,
|
||||
|
|
@ -9,21 +16,21 @@ internal fun LazyListScope.integrationsContent(
|
|||
) {
|
||||
item {
|
||||
SettingsSection(
|
||||
title = "INTEGRATIONS",
|
||||
title = stringResource(Res.string.settings_integrations_section_title),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsNavigationRow(
|
||||
title = "TMDB Enrichment",
|
||||
description = "Enhance detail pages with TMDB artwork, credits, episode metadata, and more.",
|
||||
title = stringResource(Res.string.compose_settings_page_tmdb_enrichment),
|
||||
description = stringResource(Res.string.settings_integrations_tmdb_description),
|
||||
iconPainter = integrationLogoPainter(IntegrationLogo.Tmdb),
|
||||
isTablet = isTablet,
|
||||
onClick = onTmdbClick,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = "MDBList Ratings",
|
||||
description = "Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages.",
|
||||
title = stringResource(Res.string.compose_settings_page_mdblist_ratings),
|
||||
description = stringResource(Res.string.settings_integrations_mdblist_description),
|
||||
iconPainter = integrationLogoPainter(IntegrationLogo.MdbList),
|
||||
isTablet = isTablet,
|
||||
onClick = onMdbListClick,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue