mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into Doh
This commit is contained in:
commit
03aaeae185
130 changed files with 5419 additions and 1596 deletions
|
|
@ -219,6 +219,7 @@ kotlin {
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.androidx.work.runtime)
|
implementation(libs.androidx.work.runtime)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:localeConfig="@xml/locale_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Nuvio">
|
android:theme="@style/Theme.Nuvio">
|
||||||
<activity
|
<activity
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.nuvio.app.core.auth.AuthStorage
|
import com.nuvio.app.core.auth.AuthStorage
|
||||||
import com.nuvio.app.core.deeplink.handleAppUrl
|
import com.nuvio.app.core.deeplink.handleAppUrl
|
||||||
|
|
@ -44,7 +45,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||||
import com.nuvio.app.features.watchprogress.ResumePromptStorage
|
import com.nuvio.app.features.watchprogress.ResumePromptStorage
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressStorage
|
import com.nuvio.app.features.watchprogress.WatchProgressStorage
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge(
|
enableEdgeToEdge(
|
||||||
|
|
@ -52,6 +53,7 @@ class MainActivity : ComponentActivity() {
|
||||||
scrim = 0xFF020404.toInt(),
|
scrim = 0xFF020404.toInt(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
ThemeSettingsStorage.initialize(applicationContext)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
window.setBackgroundDrawableResource(R.color.nuvio_background)
|
window.setBackgroundDrawableResource(R.color.nuvio_background)
|
||||||
AddonStorage.initialize(applicationContext)
|
AddonStorage.initialize(applicationContext)
|
||||||
|
|
@ -66,7 +68,6 @@ class MainActivity : ComponentActivity() {
|
||||||
ProfilePinCacheStorage.initialize(applicationContext)
|
ProfilePinCacheStorage.initialize(applicationContext)
|
||||||
SearchHistoryStorage.initialize(applicationContext)
|
SearchHistoryStorage.initialize(applicationContext)
|
||||||
SeasonViewModeStorage.initialize(applicationContext)
|
SeasonViewModeStorage.initialize(applicationContext)
|
||||||
ThemeSettingsStorage.initialize(applicationContext)
|
|
||||||
PosterCardStyleStorage.initialize(applicationContext)
|
PosterCardStyleStorage.initialize(applicationContext)
|
||||||
com.nuvio.app.features.settings.globalNetworkSettingsRepository = com.nuvio.app.features.settings.NetworkSettingsRepository(com.nuvio.app.features.settings.AndroidNetworkSettingsStorage(applicationContext))
|
com.nuvio.app.features.settings.globalNetworkSettingsRepository = com.nuvio.app.features.settings.NetworkSettingsRepository(com.nuvio.app.features.settings.AndroidNetworkSettingsStorage(applicationContext))
|
||||||
TmdbSettingsStorage.initialize(applicationContext)
|
TmdbSettingsStorage.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
|
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
internal actual object DownloadsLiveStatusPlatform {
|
internal actual object DownloadsLiveStatusPlatform {
|
||||||
private const val channelId = "downloads_live_status"
|
private const val channelId = "downloads_live_status"
|
||||||
private const val channelName = "Downloads"
|
|
||||||
private const val channelDescription = "Shows live download progress and controls."
|
|
||||||
private const val notificationsPrefName = "nuvio_download_live_notifications"
|
private const val notificationsPrefName = "nuvio_download_live_notifications"
|
||||||
private const val trackedDownloadIdsKey = "tracked_download_ids"
|
private const val trackedDownloadIdsKey = "tracked_download_ids"
|
||||||
|
|
||||||
|
|
@ -143,7 +144,7 @@ internal actual object DownloadsLiveStatusPlatform {
|
||||||
.setProgress(0, 0, false)
|
.setProgress(0, 0, false)
|
||||||
.addAction(
|
.addAction(
|
||||||
0,
|
0,
|
||||||
"Resume",
|
runBlocking { getString(Res.string.action_resume) },
|
||||||
buildActionPendingIntent(
|
buildActionPendingIntent(
|
||||||
context = context,
|
context = context,
|
||||||
action = DownloadsNotificationActionReceiver.actionResume,
|
action = DownloadsNotificationActionReceiver.actionResume,
|
||||||
|
|
@ -163,15 +164,15 @@ internal actual object DownloadsLiveStatusPlatform {
|
||||||
val downloaded = formatBytes(item.downloadedBytes)
|
val downloaded = formatBytes(item.downloadedBytes)
|
||||||
val total = item.totalBytes?.let(::formatBytes)
|
val total = item.totalBytes?.let(::formatBytes)
|
||||||
if (total != null) {
|
if (total != null) {
|
||||||
"Downloading $detail • $downloaded / $total"
|
runBlocking { getString(Res.string.downloads_live_downloading_with_total, detail, downloaded, total) }
|
||||||
} else {
|
} else {
|
||||||
"Downloading $detail • $downloaded"
|
runBlocking { getString(Res.string.downloads_live_downloading, detail, downloaded) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadStatus.Paused -> "Paused $detail"
|
DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) }
|
||||||
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed"
|
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) }
|
||||||
DownloadStatus.Completed -> "Download completed"
|
DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform {
|
||||||
if (manager.getNotificationChannel(channelId) != null) return
|
if (manager.getNotificationChannel(channelId) != null) return
|
||||||
|
|
||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply {
|
NotificationChannel(
|
||||||
description = channelDescription
|
channelId,
|
||||||
|
runBlocking { getString(Res.string.downloads_channel_name) },
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = runBlocking { getString(Res.string.downloads_channel_description) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
@ -45,7 +48,7 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val context = appContext
|
val context = appContext
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
onFailure("Download system is not initialized")
|
onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +73,9 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
var attemptedRangeRequest = resumeFromBytes > 0L
|
var attemptedRangeRequest = resumeFromBytes > 0L
|
||||||
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
|
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
|
||||||
call = downloadHttpClient.newCall(httpRequest)
|
call = downloadHttpClient.newCall(httpRequest)
|
||||||
var response = call?.execute() ?: error("Download request failed")
|
var response = call?.execute() ?: error(
|
||||||
|
runBlocking { getString(Res.string.downloads_error_request_failed) },
|
||||||
|
)
|
||||||
|
|
||||||
if (attemptedRangeRequest && response.code == 416) {
|
if (attemptedRangeRequest && response.code == 416) {
|
||||||
response.close()
|
response.close()
|
||||||
|
|
@ -79,12 +84,18 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
attemptedRangeRequest = false
|
attemptedRangeRequest = false
|
||||||
httpRequest = buildRequest(null)
|
httpRequest = buildRequest(null)
|
||||||
call = downloadHttpClient.newCall(httpRequest)
|
call = downloadHttpClient.newCall(httpRequest)
|
||||||
response = call?.execute() ?: error("Download request failed")
|
response = call?.execute() ?: error(
|
||||||
|
runBlocking { getString(Res.string.downloads_error_request_failed) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
response.use { response ->
|
response.use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
error("Request failed with HTTP ${response.code}")
|
error(
|
||||||
|
runBlocking {
|
||||||
|
getString(Res.string.downloads_error_http_failed, response.code)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
|
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
|
||||||
|
|
@ -95,7 +106,9 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body ?: error("Empty response body")
|
val body = response.body ?: error(
|
||||||
|
runBlocking { getString(Res.string.downloads_error_empty_body) },
|
||||||
|
)
|
||||||
val totalBytes = resolveTotalBytes(
|
val totalBytes = resolveTotalBytes(
|
||||||
startingBytes = startingBytes,
|
startingBytes = startingBytes,
|
||||||
isPartialResume = isPartialResume,
|
isPartialResume = isPartialResume,
|
||||||
|
|
@ -132,7 +145,7 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
|
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
|
||||||
}
|
}
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
onFailure(error.message ?: "Download failed")
|
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import androidx.work.WorkManager
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.engine.android.Android
|
import io.ktor.client.engine.android.Android
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
@ -285,10 +288,10 @@ internal actual object EpisodeReleaseNotificationPlatform {
|
||||||
|
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
channelId,
|
channelId,
|
||||||
"Episode Releases",
|
runBlocking { getString(Res.string.notifications_channel_episode_releases_name) },
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Alerts when a saved show's new episode is released."
|
description = runBlocking { getString(Res.string.notifications_channel_episode_releases_description) }
|
||||||
}
|
}
|
||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
|
|
@ -184,7 +187,7 @@ actual fun PlatformPlayerSurface(
|
||||||
|
|
||||||
val listener = object : Player.Listener {
|
val listener = object : Player.Listener {
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
latestOnError.value(error.localizedMessage ?: "Unable to play this stream.")
|
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
|
@ -585,7 +588,10 @@ private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } }
|
val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } }
|
||||||
val baseName = format.label?.takeIf { it.isNotBlank() } ?: resolvedLanguage ?: format.language ?: "Track ${idx + 1}"
|
val baseName = format.label?.takeIf { it.isNotBlank() }
|
||||||
|
?: resolvedLanguage
|
||||||
|
?: format.language
|
||||||
|
?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) }
|
||||||
val suffix = listOfNotNull(channelLabel, codecLabel)
|
val suffix = listOfNotNull(channelLabel, codecLabel)
|
||||||
.joinToString(" ")
|
.joinToString(" ")
|
||||||
.let { if (it.isNotBlank()) " ($it)" else "" }
|
.let { if (it.isNotBlank()) " ($it)" else "" }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package com.nuvio.app.features.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.nuvio.app.core.sync.decodeSyncBoolean
|
import com.nuvio.app.core.sync.decodeSyncBoolean
|
||||||
import com.nuvio.app.core.sync.decodeSyncString
|
import com.nuvio.app.core.sync.decodeSyncString
|
||||||
import com.nuvio.app.core.sync.encodeSyncBoolean
|
import com.nuvio.app.core.sync.encodeSyncBoolean
|
||||||
|
|
@ -15,12 +17,14 @@ actual object ThemeSettingsStorage {
|
||||||
private const val preferencesName = "nuvio_theme_settings"
|
private const val preferencesName = "nuvio_theme_settings"
|
||||||
private const val selectedThemeKey = "selected_theme"
|
private const val selectedThemeKey = "selected_theme"
|
||||||
private const val amoledEnabledKey = "amoled_enabled"
|
private const val amoledEnabledKey = "amoled_enabled"
|
||||||
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey)
|
private const val selectedAppLanguageKey = "selected_app_language"
|
||||||
|
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey, selectedAppLanguageKey)
|
||||||
|
|
||||||
private var preferences: SharedPreferences? = null
|
private var preferences: SharedPreferences? = null
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||||
|
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun loadSelectedTheme(): String? =
|
actual fun loadSelectedTheme(): String? =
|
||||||
|
|
@ -46,9 +50,26 @@ actual object ThemeSettingsStorage {
|
||||||
?.apply()
|
?.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun loadSelectedAppLanguage(): String? =
|
||||||
|
preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null)
|
||||||
|
|
||||||
|
actual fun saveSelectedAppLanguage(languageCode: String) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.putString(ProfileScopedKey.of(selectedAppLanguageKey), languageCode)
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun applySelectedAppLanguage(languageCode: String) {
|
||||||
|
AppCompatDelegate.setApplicationLocales(
|
||||||
|
LocaleListCompat.forLanguageTags(languageCode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||||
|
|
@ -58,5 +79,7 @@ actual object ThemeSettingsStorage {
|
||||||
|
|
||||||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||||
|
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
||||||
|
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar">
|
<style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowBackground">@color/nuvio_background</item>
|
<item name="android:windowBackground">@color/nuvio_background</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar">
|
<style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowBackground">@color/nuvio_background</item>
|
<item name="android:windowBackground">@color/nuvio_background</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
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.NuvioFloatingPrompt
|
||||||
import com.nuvio.app.core.ui.TraktListPickerDialog
|
import com.nuvio.app.core.ui.TraktListPickerDialog
|
||||||
import com.nuvio.app.core.ui.NuvioTheme
|
import com.nuvio.app.core.ui.NuvioTheme
|
||||||
|
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
||||||
import com.nuvio.app.features.auth.AuthScreen
|
import com.nuvio.app.features.auth.AuthScreen
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogRepository
|
import com.nuvio.app.features.catalog.CatalogRepository
|
||||||
|
|
@ -167,12 +168,20 @@ import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.*
|
||||||
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
||||||
|
import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library
|
||||||
|
import nuvio.composeapp.generated.resources.compose_catalog_subtitle_trakt_library
|
||||||
|
import nuvio.composeapp.generated.resources.compose_nav_home
|
||||||
|
import nuvio.composeapp.generated.resources.compose_nav_library
|
||||||
|
import nuvio.composeapp.generated.resources.compose_nav_profile
|
||||||
|
import nuvio.composeapp.generated.resources.compose_nav_search
|
||||||
import nuvio.composeapp.generated.resources.sidebar_library
|
import nuvio.composeapp.generated.resources.sidebar_library
|
||||||
import nuvio.composeapp.generated.resources.sidebar_search
|
import nuvio.composeapp.generated.resources.sidebar_search
|
||||||
import org.jetbrains.compose.resources.DrawableResource
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object TabsRoute
|
object TabsRoute
|
||||||
|
|
@ -278,7 +287,6 @@ fun App() {
|
||||||
ThemeSettingsRepository.selectedTheme
|
ThemeSettingsRepository.selectedTheme
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
|
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
AuthRepository.initialize()
|
AuthRepository.initialize()
|
||||||
|
|
@ -499,6 +507,7 @@ private fun MainAppContent(
|
||||||
val networkStatusUiState by remember {
|
val networkStatusUiState by remember {
|
||||||
NetworkStatusRepository.uiState
|
NetworkStatusRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
|
||||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||||
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
||||||
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -542,11 +551,11 @@ private fun MainAppContent(
|
||||||
|
|
||||||
when (condition) {
|
when (condition) {
|
||||||
NetworkCondition.NoInternet -> {
|
NetworkCondition.NoInternet -> {
|
||||||
NuvioToastController.show("No internet connection")
|
NuvioToastController.show(getString(Res.string.network_no_internet_connection))
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkCondition.ServersUnreachable -> {
|
NetworkCondition.ServersUnreachable -> {
|
||||||
NuvioToastController.show("Cannot reach servers")
|
NuvioToastController.show(getString(Res.string.network_cannot_reach_servers))
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkCondition.Online -> {
|
NetworkCondition.Online -> {
|
||||||
|
|
@ -554,7 +563,7 @@ private fun MainAppContent(
|
||||||
previousConditionName == NetworkCondition.NoInternet.name ||
|
previousConditionName == NetworkCondition.NoInternet.name ||
|
||||||
previousConditionName == NetworkCondition.ServersUnreachable.name
|
previousConditionName == NetworkCondition.ServersUnreachable.name
|
||||||
) {
|
) {
|
||||||
NuvioToastController.show("Back online")
|
NuvioToastController.show(getString(Res.string.network_back_online))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -698,7 +707,7 @@ private fun MainAppContent(
|
||||||
streamTitle = downloadedItem.streamTitle.ifBlank { title },
|
streamTitle = downloadedItem.streamTitle.ifBlank { title },
|
||||||
streamSubtitle = downloadedItem.streamSubtitle,
|
streamSubtitle = downloadedItem.streamSubtitle,
|
||||||
pauseDescription = pauseDescription,
|
pauseDescription = pauseDescription,
|
||||||
providerName = downloadedItem.providerName.ifBlank { "Downloaded" },
|
providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel },
|
||||||
providerAddonId = downloadedItem.providerAddonId,
|
providerAddonId = downloadedItem.providerAddonId,
|
||||||
contentType = type,
|
contentType = type,
|
||||||
videoId = videoId,
|
videoId = videoId,
|
||||||
|
|
@ -798,15 +807,17 @@ private fun MainAppContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
|
||||||
|
stringResource(Res.string.compose_catalog_subtitle_trakt_library)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.compose_catalog_subtitle_library)
|
||||||
|
}
|
||||||
|
|
||||||
val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
|
val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
CatalogRoute(
|
CatalogRoute(
|
||||||
title = section.displayTitle,
|
title = section.displayTitle,
|
||||||
subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
|
subtitle = librarySectionSubtitle,
|
||||||
"Trakt Library"
|
|
||||||
} else {
|
|
||||||
"Library"
|
|
||||||
},
|
|
||||||
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
|
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
|
||||||
type = section.items.firstOrNull()?.type ?: "movie",
|
type = section.items.firstOrNull()?.type ?: "movie",
|
||||||
catalogId = section.type,
|
catalogId = section.type,
|
||||||
|
|
@ -899,19 +910,19 @@ private fun MainAppContent(
|
||||||
selected = selectedTab == AppScreenTab.Home,
|
selected = selectedTab == AppScreenTab.Home,
|
||||||
onClick = { selectedTab = AppScreenTab.Home },
|
onClick = { selectedTab = AppScreenTab.Home },
|
||||||
icon = Icons.Filled.Home,
|
icon = Icons.Filled.Home,
|
||||||
contentDescription = "Home",
|
contentDescription = stringResource(Res.string.compose_nav_home),
|
||||||
)
|
)
|
||||||
NavItem(
|
NavItem(
|
||||||
selected = selectedTab == AppScreenTab.Search,
|
selected = selectedTab == AppScreenTab.Search,
|
||||||
onClick = { selectedTab = AppScreenTab.Search },
|
onClick = { selectedTab = AppScreenTab.Search },
|
||||||
icon = Res.drawable.sidebar_search,
|
icon = Res.drawable.sidebar_search,
|
||||||
contentDescription = "Search",
|
contentDescription = stringResource(Res.string.compose_nav_search),
|
||||||
)
|
)
|
||||||
NavItem(
|
NavItem(
|
||||||
selected = selectedTab == AppScreenTab.Library,
|
selected = selectedTab == AppScreenTab.Library,
|
||||||
onClick = { selectedTab = AppScreenTab.Library },
|
onClick = { selectedTab = AppScreenTab.Library },
|
||||||
icon = Res.drawable.sidebar_library,
|
icon = Res.drawable.sidebar_library,
|
||||||
contentDescription = "Library",
|
contentDescription = stringResource(Res.string.compose_nav_library),
|
||||||
)
|
)
|
||||||
NavItem(
|
NavItem(
|
||||||
selected = selectedTab == AppScreenTab.Settings,
|
selected = selectedTab == AppScreenTab.Settings,
|
||||||
|
|
@ -994,6 +1005,9 @@ private fun MainAppContent(
|
||||||
}
|
}
|
||||||
composable<DetailRoute> { backStackEntry ->
|
composable<DetailRoute> { backStackEntry ->
|
||||||
val route = backStackEntry.toRoute<DetailRoute>()
|
val route = backStackEntry.toRoute<DetailRoute>()
|
||||||
|
val directorRole = stringResource(Res.string.person_role_director)
|
||||||
|
val writerRole = stringResource(Res.string.person_role_writer)
|
||||||
|
val creatorRole = stringResource(Res.string.person_role_creator)
|
||||||
MetaDetailsScreen(
|
MetaDetailsScreen(
|
||||||
type = route.type,
|
type = route.type,
|
||||||
id = route.id,
|
id = route.id,
|
||||||
|
|
@ -1034,8 +1048,11 @@ private fun MainAppContent(
|
||||||
castAvatarTransitionKey = avatarTransitionKey,
|
castAvatarTransitionKey = avatarTransitionKey,
|
||||||
preferCrew = person.role?.let {
|
preferCrew = person.role?.let {
|
||||||
it.equals("Director", ignoreCase = true) ||
|
it.equals("Director", ignoreCase = true) ||
|
||||||
|
it.equals(directorRole, ignoreCase = true) ||
|
||||||
it.equals("Writer", ignoreCase = true) ||
|
it.equals("Writer", ignoreCase = true) ||
|
||||||
|
it.equals(writerRole, ignoreCase = true) ||
|
||||||
it.equals("Creator", ignoreCase = true)
|
it.equals("Creator", ignoreCase = true)
|
||||||
|
|| it.equals(creatorRole, ignoreCase = true)
|
||||||
} ?: false,
|
} ?: false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -1662,7 +1679,7 @@ private fun MainAppContent(
|
||||||
tab.key to (snapshot[tab.key] == true)
|
tab.key to (snapshot[tab.key] == true)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
pickerError = error.message ?: "Failed to load Trakt lists"
|
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||||
}
|
}
|
||||||
pickerPending = false
|
pickerPending = false
|
||||||
}
|
}
|
||||||
|
|
@ -1748,7 +1765,7 @@ private fun MainAppContent(
|
||||||
pickerItem = null
|
pickerItem = null
|
||||||
pickerError = null
|
pickerError = null
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
pickerError = error.message ?: "Failed to update Trakt lists"
|
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
|
||||||
}
|
}
|
||||||
pickerPending = false
|
pickerPending = false
|
||||||
}
|
}
|
||||||
|
|
@ -1756,11 +1773,11 @@ private fun MainAppContent(
|
||||||
)
|
)
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = "Exit app",
|
title = stringResource(Res.string.app_exit_title),
|
||||||
message = "Do you want to exit the app?",
|
message = stringResource(Res.string.app_exit_message),
|
||||||
isVisible = showExitConfirmation,
|
isVisible = showExitConfirmation,
|
||||||
confirmText = "Yes",
|
confirmText = stringResource(Res.string.action_yes),
|
||||||
dismissText = "No",
|
dismissText = stringResource(Res.string.action_no),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showExitConfirmation = false
|
showExitConfirmation = false
|
||||||
platformExitApp()
|
platformExitApp()
|
||||||
|
|
@ -1791,9 +1808,9 @@ private fun MainAppContent(
|
||||||
visible = resumePromptItem != null,
|
visible = resumePromptItem != null,
|
||||||
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
|
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
|
||||||
title = resumePromptItem?.title.orEmpty(),
|
title = resumePromptItem?.title.orEmpty(),
|
||||||
subtitle = resumePromptItem?.subtitle.orEmpty(),
|
subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
|
||||||
progressFraction = resumePromptItem?.progressFraction ?: 0f,
|
progressFraction = resumePromptItem?.progressFraction ?: 0f,
|
||||||
actionLabel = "Resume",
|
actionLabel = stringResource(Res.string.resume_prompt_action),
|
||||||
onAction = {
|
onAction = {
|
||||||
val item = resumePromptItem ?: return@NuvioFloatingPrompt
|
val item = resumePromptItem ?: return@NuvioFloatingPrompt
|
||||||
resumePromptItem = null
|
resumePromptItem = null
|
||||||
|
|
@ -1948,13 +1965,13 @@ private fun TabletFloatingTopBar(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
TabletTopPillItem(
|
TabletTopPillItem(
|
||||||
label = "Home",
|
label = stringResource(Res.string.compose_nav_home),
|
||||||
selected = selectedTab == AppScreenTab.Home,
|
selected = selectedTab == AppScreenTab.Home,
|
||||||
onClick = { onTabSelected(AppScreenTab.Home) },
|
onClick = { onTabSelected(AppScreenTab.Home) },
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Home,
|
imageVector = Icons.Filled.Home,
|
||||||
contentDescription = "Home",
|
contentDescription = stringResource(Res.string.compose_nav_home),
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
tint = if (selectedTab == AppScreenTab.Home) {
|
tint = if (selectedTab == AppScreenTab.Home) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
@ -1965,13 +1982,13 @@ private fun TabletFloatingTopBar(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
TabletTopPillItem(
|
TabletTopPillItem(
|
||||||
label = "Search",
|
label = stringResource(Res.string.compose_nav_search),
|
||||||
selected = selectedTab == AppScreenTab.Search,
|
selected = selectedTab == AppScreenTab.Search,
|
||||||
onClick = { onTabSelected(AppScreenTab.Search) },
|
onClick = { onTabSelected(AppScreenTab.Search) },
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.sidebar_search),
|
painter = painterResource(Res.drawable.sidebar_search),
|
||||||
contentDescription = "Search",
|
contentDescription = stringResource(Res.string.compose_nav_search),
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
tint = if (selectedTab == AppScreenTab.Search) {
|
tint = if (selectedTab == AppScreenTab.Search) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
@ -1982,13 +1999,13 @@ private fun TabletFloatingTopBar(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
TabletTopPillItem(
|
TabletTopPillItem(
|
||||||
label = "Library",
|
label = stringResource(Res.string.compose_nav_library),
|
||||||
selected = selectedTab == AppScreenTab.Library,
|
selected = selectedTab == AppScreenTab.Library,
|
||||||
onClick = { onTabSelected(AppScreenTab.Library) },
|
onClick = { onTabSelected(AppScreenTab.Library) },
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.sidebar_library),
|
painter = painterResource(Res.drawable.sidebar_library),
|
||||||
contentDescription = "Library",
|
contentDescription = stringResource(Res.string.compose_nav_library),
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
tint = if (selectedTab == AppScreenTab.Library) {
|
tint = if (selectedTab == AppScreenTab.Library) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
@ -2018,7 +2035,7 @@ private fun TabletFloatingTopBar(
|
||||||
onAddProfileRequested = onAddProfileRequested,
|
onAddProfileRequested = onAddProfileRequested,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Profile",
|
text = stringResource(Res.string.compose_nav_profile),
|
||||||
modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) },
|
modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) },
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = if (selectedTab == AppScreenTab.Settings) {
|
color = if (selectedTab == AppScreenTab.Settings) {
|
||||||
|
|
@ -2081,7 +2098,7 @@ private fun AppLaunchOverlay(
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(Res.drawable.app_logo_wordmark),
|
painter = painterResource(Res.drawable.app_logo_wordmark),
|
||||||
contentDescription = "Nuvio",
|
contentDescription = stringResource(Res.string.app_brand_name),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.48f)
|
.fillMaxWidth(0.48f)
|
||||||
.height(44.dp),
|
.height(44.dp),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
object AuthRepository {
|
object AuthRepository {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
@ -89,7 +91,7 @@ object AuthRepository {
|
||||||
Unit
|
Unit
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Email sign-up failed" }
|
log.e(e) { "Email sign-up failed" }
|
||||||
_error.value = e.message ?: "Sign-up failed"
|
_error.value = e.message ?: getString(Res.string.auth_sign_up_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signInWithEmail(email: String, password: String): Result<Unit> = runCatching {
|
suspend fun signInWithEmail(email: String, password: String): Result<Unit> = runCatching {
|
||||||
|
|
@ -100,7 +102,7 @@ object AuthRepository {
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Email sign-in failed" }
|
log.e(e) { "Email sign-in failed" }
|
||||||
_error.value = e.message ?: "Sign-in failed"
|
_error.value = e.message ?: getString(Res.string.auth_sign_in_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signOut(): Result<Unit> = runCatching {
|
suspend fun signOut(): Result<Unit> = runCatching {
|
||||||
|
|
@ -114,7 +116,7 @@ object AuthRepository {
|
||||||
LocalAccountDataCleaner.wipe()
|
LocalAccountDataCleaner.wipe()
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Sign-out failed" }
|
log.e(e) { "Sign-out failed" }
|
||||||
_error.value = e.message ?: "Sign-out failed"
|
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteAccount(): Result<Unit> = runCatching {
|
suspend fun deleteAccount(): Result<Unit> = runCatching {
|
||||||
|
|
@ -124,7 +126,7 @@ object AuthRepository {
|
||||||
LocalAccountDataCleaner.wipe()
|
LocalAccountDataCleaner.wipe()
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Account deletion failed" }
|
log.e(e) { "Account deletion failed" }
|
||||||
_error.value = e.message ?: "Account deletion failed"
|
_error.value = e.message ?: getString(Res.string.auth_account_deletion_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,6 @@
|
||||||
package com.nuvio.app.core.format
|
package com.nuvio.app.core.format
|
||||||
|
|
||||||
private val MONTH_NAMES = listOf(
|
import com.nuvio.app.core.i18n.localizedMonthName
|
||||||
"January",
|
|
||||||
"February",
|
|
||||||
"March",
|
|
||||||
"April",
|
|
||||||
"May",
|
|
||||||
"June",
|
|
||||||
"July",
|
|
||||||
"August",
|
|
||||||
"September",
|
|
||||||
"October",
|
|
||||||
"November",
|
|
||||||
"December",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1".
|
* Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1".
|
||||||
|
|
@ -28,7 +15,7 @@ fun formatReleaseDateForDisplay(raw: String): String {
|
||||||
val year = parts[0].toIntOrNull() ?: return raw
|
val year = parts[0].toIntOrNull() ?: return raw
|
||||||
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw
|
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw
|
||||||
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw
|
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw
|
||||||
return "$year ${MONTH_NAMES[month - 1]} $day"
|
return "$year ${localizedMonthName(month)} $day"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.nuvio.app.core.ui
|
||||||
|
|
||||||
enum class AppTheme(val displayName: String) {
|
import nuvio.composeapp.generated.resources.Res
|
||||||
CRIMSON("Crimson"),
|
import nuvio.composeapp.generated.resources.theme_amber
|
||||||
OCEAN("Ocean"),
|
import nuvio.composeapp.generated.resources.theme_crimson
|
||||||
VIOLET("Violet"),
|
import nuvio.composeapp.generated.resources.theme_emerald
|
||||||
EMERALD("Emerald"),
|
import nuvio.composeapp.generated.resources.theme_ocean
|
||||||
AMBER("Amber"),
|
import nuvio.composeapp.generated.resources.theme_rose
|
||||||
ROSE("Rose"),
|
import nuvio.composeapp.generated.resources.theme_violet
|
||||||
WHITE("White"),
|
import nuvio.composeapp.generated.resources.theme_white
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
|
||||||
|
enum class AppTheme {
|
||||||
|
CRIMSON,
|
||||||
|
OCEAN,
|
||||||
|
VIOLET,
|
||||||
|
EMERALD,
|
||||||
|
AMBER,
|
||||||
|
ROSE,
|
||||||
|
WHITE,
|
||||||
|
}
|
||||||
|
|
||||||
|
val AppTheme.labelRes: StringResource
|
||||||
|
get() = when (this) {
|
||||||
|
AppTheme.CRIMSON -> Res.string.theme_crimson
|
||||||
|
AppTheme.OCEAN -> Res.string.theme_ocean
|
||||||
|
AppTheme.VIOLET -> Res.string.theme_violet
|
||||||
|
AppTheme.EMERALD -> Res.string.theme_emerald
|
||||||
|
AppTheme.AMBER -> Res.string.theme_amber
|
||||||
|
AppTheme.ROSE -> Res.string.theme_rose
|
||||||
|
AppTheme.WHITE -> Res.string.theme_white
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.ui.unit.dp
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.action_back
|
||||||
|
import nuvio.composeapp.generated.resources.action_ok
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -141,7 +145,7 @@ fun NuvioScreenHeader(
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = stringResource(Res.string.action_back),
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +237,7 @@ fun NuvioBackButton(
|
||||||
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
buttonSize: Dp = 40.dp,
|
buttonSize: Dp = 40.dp,
|
||||||
iconSize: Dp = 22.dp,
|
iconSize: Dp = 22.dp,
|
||||||
contentDescription: String = "Back",
|
contentDescription: String = stringResource(Res.string.action_back),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -375,7 +379,7 @@ fun NuvioStatusModal(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
isBusy: Boolean = false,
|
isBusy: Boolean = false,
|
||||||
confirmText: String = "OK",
|
confirmText: String = stringResource(Res.string.action_ok),
|
||||||
dismissText: String? = null,
|
dismissText: String? = null,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: (() -> Unit)? = null,
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.cw_action_go_to_details
|
||||||
|
import nuvio.composeapp.generated.resources.cw_action_remove
|
||||||
|
import nuvio.composeapp.generated.resources.cw_action_start_from_beginning
|
||||||
|
import nuvio.composeapp.generated.resources.play_manually
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -70,14 +76,14 @@ fun NuvioContinueWatchingActionSheet(
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.Info,
|
icon = Icons.Default.Info,
|
||||||
title = "Go to details",
|
title = stringResource(Res.string.cw_action_go_to_details),
|
||||||
onClick = { dismissAfter(onOpenDetails) },
|
onClick = { dismissAfter(onOpenDetails) },
|
||||||
)
|
)
|
||||||
if (showManualPlayOption && onPlayManually != null) {
|
if (showManualPlayOption && onPlayManually != null) {
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
title = "Play manually",
|
title = stringResource(Res.string.play_manually),
|
||||||
onClick = { dismissAfter(onPlayManually) },
|
onClick = { dismissAfter(onPlayManually) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -85,14 +91,14 @@ fun NuvioContinueWatchingActionSheet(
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.Replay,
|
icon = Icons.Default.Replay,
|
||||||
title = "Start from beginning",
|
title = stringResource(Res.string.cw_action_start_from_beginning),
|
||||||
onClick = { dismissAfter(onStartFromBeginning) },
|
onClick = { dismissAfter(onStartFromBeginning) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.DeleteOutline,
|
icon = Icons.Default.DeleteOutline,
|
||||||
title = "Remove",
|
title = stringResource(Res.string.cw_action_remove),
|
||||||
onClick = { dismissAfter(onRemove) },
|
onClick = { dismissAfter(onRemove) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +158,7 @@ private fun ContinueWatchingSheetHeader(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.subtitle,
|
text = localizedContinueWatchingSubtitle(item),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.floating_prompt_continue_where_left_off
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val AutoDismissDelayMs = 15_000L
|
private const val AutoDismissDelayMs = 15_000L
|
||||||
|
|
@ -202,7 +205,7 @@ fun NuvioFloatingPrompt(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Continue where you left off",
|
text = stringResource(Res.string.floating_prompt_continue_where_left_off),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.core.network.NetworkCondition
|
import com.nuvio.app.core.network.NetworkCondition
|
||||||
import com.nuvio.app.core.network.messageForEmptyState
|
import com.nuvio.app.core.network.messageForEmptyState
|
||||||
import com.nuvio.app.core.network.titleForEmptyState
|
import com.nuvio.app.core.network.titleForEmptyState
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.action_retry
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NuvioNetworkOfflineCard(
|
fun NuvioNetworkOfflineCard(
|
||||||
|
|
@ -32,7 +35,7 @@ fun NuvioNetworkOfflineCard(
|
||||||
if (onRetry != null) {
|
if (onRetry != null) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "Retry",
|
text = stringResource(Res.string.action_retry),
|
||||||
onClick = onRetry,
|
onClick = onRetry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@ import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.episodes_cd_watched
|
||||||
|
import nuvio.composeapp.generated.resources.hero_add_to_library
|
||||||
|
import nuvio.composeapp.generated.resources.hero_mark_unwatched
|
||||||
|
import nuvio.composeapp.generated.resources.hero_mark_watched
|
||||||
|
import nuvio.composeapp.generated.resources.hero_remove_from_library
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -72,7 +79,11 @@ fun NuvioPosterActionSheet(
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||||
title = if (isSaved) "Remove from Library" else "Add to Library",
|
title = if (isSaved) {
|
||||||
|
stringResource(Res.string.hero_remove_from_library)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.hero_add_to_library)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleLibrary()
|
onToggleLibrary()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -86,7 +97,11 @@ fun NuvioPosterActionSheet(
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
|
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
|
||||||
title = if (isWatched) "Mark as Unwatched" else "Mark as Watched",
|
title = if (isWatched) {
|
||||||
|
stringResource(Res.string.hero_mark_unwatched)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.hero_mark_watched)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleWatched()
|
onToggleWatched()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -114,7 +129,7 @@ fun NuvioWatchedBadge(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Check,
|
imageVector = Icons.Default.Check,
|
||||||
contentDescription = "Watched",
|
contentDescription = stringResource(Res.string.episodes_cd_watched),
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
modifier = Modifier.size(12.dp),
|
modifier = Modifier.size(12.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -200,4 +215,3 @@ private fun PosterSheetHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.home_view_all
|
||||||
|
import nuvio.composeapp.generated.resources.poster_logo_content_description
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
enum class NuvioPosterShape {
|
enum class NuvioPosterShape {
|
||||||
Poster,
|
Poster,
|
||||||
|
|
@ -156,7 +160,7 @@ fun NuvioPosterCard(
|
||||||
if (!bottomLeftLogoUrl.isNullOrBlank()) {
|
if (!bottomLeftLogoUrl.isNullOrBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = bottomLeftLogoUrl,
|
model = bottomLeftLogoUrl,
|
||||||
contentDescription = "$title logo",
|
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(catalogLogoOverlaySize.width)
|
.width(catalogLogoOverlaySize.width)
|
||||||
.height(catalogLogoOverlaySize.height),
|
.height(catalogLogoOverlaySize.height),
|
||||||
|
|
@ -280,7 +284,7 @@ private fun NuvioViewAllPill(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "View All",
|
text = stringResource(Res.string.home_view_all),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.features.trakt.TraktListTab
|
import com.nuvio.app.features.trakt.TraktListTab
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.action_cancel
|
||||||
|
import nuvio.composeapp.generated.resources.action_save
|
||||||
|
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading
|
||||||
|
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -62,7 +68,7 @@ fun TraktListPickerDialog(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Choose where to save this title on Trakt",
|
text = stringResource(Res.string.compose_trakt_list_picker_subtitle),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -91,7 +97,7 @@ fun TraktListPickerDialog(
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Loading your Trakt lists…",
|
text = stringResource(Res.string.compose_trakt_list_picker_loading),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -151,7 +157,7 @@ fun TraktListPickerDialog(
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("Cancel")
|
Text(stringResource(Res.string.action_cancel))
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onSave,
|
onClick = onSave,
|
||||||
|
|
@ -164,7 +170,7 @@ fun TraktListPickerDialog(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Save")
|
Text(stringResource(Res.string.action_save))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
package com.nuvio.app.features.addons
|
package com.nuvio.app.features.addons
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.generic_addon
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
data class AddonManifest(
|
data class AddonManifest(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|
@ -54,7 +59,9 @@ data class ManagedAddon(
|
||||||
val displayTitle: String
|
val displayTitle: String
|
||||||
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
|
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
|
||||||
?: manifest?.name
|
?: manifest?.name
|
||||||
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" }
|
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
|
||||||
|
runBlocking { getString(Res.string.generic_addon) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AddonsUiState(
|
data class AddonsUiState(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class AddonRow(
|
private data class AddonRow(
|
||||||
|
|
@ -198,17 +200,17 @@ object AddonRepository {
|
||||||
|
|
||||||
suspend fun addAddon(rawUrl: String): AddAddonResult {
|
suspend fun addAddon(rawUrl: String): AddAddonResult {
|
||||||
if (isUsingPrimaryAddonsFromSecondaryProfile()) {
|
if (isUsingPrimaryAddonsFromSecondaryProfile()) {
|
||||||
return AddAddonResult.Error("This profile uses primary addons.")
|
return AddAddonResult.Error(getString(Res.string.profile_primary_addons_required))
|
||||||
}
|
}
|
||||||
log.i { "addAddon() — rawUrl=$rawUrl" }
|
log.i { "addAddon() — rawUrl=$rawUrl" }
|
||||||
val manifestUrl = try {
|
val manifestUrl = try {
|
||||||
normalizeManifestUrl(rawUrl)
|
normalizeManifestUrl(rawUrl)
|
||||||
} catch (error: IllegalArgumentException) {
|
} catch (error: IllegalArgumentException) {
|
||||||
return AddAddonResult.Error(error.message ?: "Enter a valid addon URL")
|
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_invalid_url))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) {
|
if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) {
|
||||||
return AddAddonResult.Error("That addon is already installed.")
|
return AddAddonResult.Error(getString(Res.string.addon_already_installed))
|
||||||
}
|
}
|
||||||
|
|
||||||
val manifest = try {
|
val manifest = try {
|
||||||
|
|
@ -220,7 +222,7 @@ object AddonRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
return AddAddonResult.Error(error.message ?: "Unable to load manifest")
|
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_load_manifest_failed))
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
|
|
@ -310,7 +312,7 @@ object AddonRepository {
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
addon.copy(
|
addon.copy(
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
errorMessage = error.message ?: "Unable to load manifest",
|
errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -53,17 +53,19 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddonsScreen(
|
fun AddonsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
title: String = "Addons",
|
title: String? = null,
|
||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
NuvioScreen(modifier = modifier) {
|
NuvioScreen(modifier = modifier) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = title,
|
title = title ?: stringResource(Res.string.addon_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +90,7 @@ internal fun AddonsSettingsPageContent(
|
||||||
var addonUrl by rememberSaveable { mutableStateOf("") }
|
var addonUrl by rememberSaveable { mutableStateOf("") }
|
||||||
var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(null) }
|
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(null) }
|
||||||
|
val enterAddonUrlMessage = stringResource(Res.string.addons_error_enter_url)
|
||||||
|
|
||||||
val overview = remember(uiState.addons) { uiState.addons.toOverview() }
|
val overview = remember(uiState.addons) { uiState.addons.toOverview() }
|
||||||
|
|
||||||
|
|
@ -95,10 +98,10 @@ internal fun AddonsSettingsPageContent(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
SectionHeader("OVERVIEW")
|
SectionHeader(stringResource(Res.string.addons_section_overview))
|
||||||
OverviewCard(overview = overview)
|
OverviewCard(overview = overview)
|
||||||
|
|
||||||
SectionHeader("ADD ADDON")
|
SectionHeader(stringResource(Res.string.addons_section_add_addon))
|
||||||
AddAddonCard(
|
AddAddonCard(
|
||||||
addonUrl = addonUrl,
|
addonUrl = addonUrl,
|
||||||
formMessage = formMessage,
|
formMessage = formMessage,
|
||||||
|
|
@ -109,7 +112,7 @@ internal fun AddonsSettingsPageContent(
|
||||||
onAddClick = {
|
onAddClick = {
|
||||||
val requestedUrl = addonUrl.trim()
|
val requestedUrl = addonUrl.trim()
|
||||||
if (requestedUrl.isBlank()) {
|
if (requestedUrl.isBlank()) {
|
||||||
formMessage = "Enter an addon URL."
|
formMessage = enterAddonUrlMessage
|
||||||
return@AddAddonCard
|
return@AddAddonCard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +134,7 @@ internal fun AddonsSettingsPageContent(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
SectionHeader("INSTALLED ADDONS")
|
SectionHeader(stringResource(Res.string.addons_section_installed))
|
||||||
if (uiState.addons.isEmpty()) {
|
if (uiState.addons.isEmpty()) {
|
||||||
EmptyStateCard()
|
EmptyStateCard()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -171,12 +174,30 @@ internal fun AddonsSettingsPageContent(
|
||||||
|
|
||||||
val modalState = installModalState
|
val modalState = installModalState
|
||||||
if (modalState != null) {
|
if (modalState != null) {
|
||||||
|
val modalTitle = when (modalState) {
|
||||||
|
AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_title)
|
||||||
|
is AddonInstallModalState.Success -> stringResource(Res.string.addons_modal_success_title)
|
||||||
|
is AddonInstallModalState.Error -> stringResource(Res.string.addons_modal_failure_title)
|
||||||
|
}
|
||||||
|
val modalMessage = when (modalState) {
|
||||||
|
AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_message)
|
||||||
|
is AddonInstallModalState.Success -> stringResource(
|
||||||
|
Res.string.addons_modal_success_message,
|
||||||
|
modalState.addonName,
|
||||||
|
)
|
||||||
|
is AddonInstallModalState.Error -> modalState.reason
|
||||||
|
}
|
||||||
|
val modalConfirmText = when (modalState) {
|
||||||
|
AddonInstallModalState.Checking -> stringResource(Res.string.addon_installing)
|
||||||
|
is AddonInstallModalState.Success -> stringResource(Res.string.action_done)
|
||||||
|
is AddonInstallModalState.Error -> stringResource(Res.string.action_close)
|
||||||
|
}
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = modalState.title,
|
title = modalTitle,
|
||||||
message = modalState.message,
|
message = modalMessage,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
isBusy = modalState.isBusy,
|
isBusy = modalState.isBusy,
|
||||||
confirmText = modalState.confirmText,
|
confirmText = modalConfirmText,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
if (!modalState.isBusy) {
|
if (!modalState.isBusy) {
|
||||||
installModalState = null
|
installModalState = null
|
||||||
|
|
@ -200,19 +221,19 @@ private fun OverviewCard(overview: AddonOverview) {
|
||||||
) {
|
) {
|
||||||
OverviewStat(
|
OverviewStat(
|
||||||
value = overview.totalAddons.toString(),
|
value = overview.totalAddons.toString(),
|
||||||
label = "Addons",
|
label = stringResource(Res.string.addons_overview_addons),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
VerticalSeparator()
|
VerticalSeparator()
|
||||||
OverviewStat(
|
OverviewStat(
|
||||||
value = overview.activeAddons.toString(),
|
value = overview.activeAddons.toString(),
|
||||||
label = "Active",
|
label = stringResource(Res.string.addons_overview_active),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
VerticalSeparator()
|
VerticalSeparator()
|
||||||
OverviewStat(
|
OverviewStat(
|
||||||
value = overview.totalCatalogs.toString(),
|
value = overview.totalCatalogs.toString(),
|
||||||
label = "Catalogs",
|
label = stringResource(Res.string.addons_overview_catalogs),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -264,11 +285,11 @@ private fun AddAddonCard(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = addonUrl,
|
value = addonUrl,
|
||||||
onValueChange = onAddonUrlChange,
|
onValueChange = onAddonUrlChange,
|
||||||
placeholder = "Addon URL",
|
placeholder = stringResource(Res.string.addons_input_placeholder),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "Install Addon",
|
text = stringResource(Res.string.addons_install_button),
|
||||||
enabled = addonUrl.isNotBlank(),
|
enabled = addonUrl.isNotBlank(),
|
||||||
onClick = onAddClick,
|
onClick = onAddClick,
|
||||||
)
|
)
|
||||||
|
|
@ -284,33 +305,21 @@ private fun AddAddonCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed interface AddonInstallModalState {
|
private sealed interface AddonInstallModalState {
|
||||||
val title: String
|
|
||||||
val message: String
|
|
||||||
val confirmText: String
|
|
||||||
val isBusy: Boolean
|
val isBusy: Boolean
|
||||||
|
|
||||||
data object Checking : AddonInstallModalState {
|
data object Checking : AddonInstallModalState {
|
||||||
override val title: String = "Checking Addon"
|
|
||||||
override val message: String = "Validating the manifest URL and loading addon details before install."
|
|
||||||
override val confirmText: String = "Installing"
|
|
||||||
override val isBusy: Boolean = true
|
override val isBusy: Boolean = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Success(
|
data class Success(
|
||||||
private val addonName: String,
|
val addonName: String,
|
||||||
) : AddonInstallModalState {
|
) : AddonInstallModalState {
|
||||||
override val title: String = "Addon Installed"
|
|
||||||
override val message: String = "$addonName was validated and added successfully."
|
|
||||||
override val confirmText: String = "Done"
|
|
||||||
override val isBusy: Boolean = false
|
override val isBusy: Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Error(
|
data class Error(
|
||||||
private val reason: String,
|
val reason: String,
|
||||||
) : AddonInstallModalState {
|
) : AddonInstallModalState {
|
||||||
override val title: String = "Install Failed"
|
|
||||||
override val message: String = reason
|
|
||||||
override val confirmText: String = "Close"
|
|
||||||
override val isBusy: Boolean = false
|
override val isBusy: Boolean = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -319,13 +328,13 @@ private sealed interface AddonInstallModalState {
|
||||||
private fun EmptyStateCard() {
|
private fun EmptyStateCard() {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Text(
|
Text(
|
||||||
text = "No addons installed yet.",
|
text = stringResource(Res.string.addons_empty_title),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.",
|
text = stringResource(Res.string.addons_empty_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -365,7 +374,7 @@ private fun InstalledAddonCard(
|
||||||
manifest?.version?.let { version ->
|
manifest?.version?.let { version ->
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Version $version",
|
text = stringResource(Res.string.addons_version_format, version),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -383,7 +392,7 @@ private fun InstalledAddonCard(
|
||||||
onMoveUpClick?.let { onMoveUp ->
|
onMoveUpClick?.let { onMoveUp ->
|
||||||
NuvioIconActionButton(
|
NuvioIconActionButton(
|
||||||
icon = Icons.Rounded.ArrowUpward,
|
icon = Icons.Rounded.ArrowUpward,
|
||||||
contentDescription = "Move addon up",
|
contentDescription = stringResource(Res.string.addons_move_up),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onClick = onMoveUp,
|
onClick = onMoveUp,
|
||||||
)
|
)
|
||||||
|
|
@ -391,28 +400,28 @@ private fun InstalledAddonCard(
|
||||||
onMoveDownClick?.let { onMoveDown ->
|
onMoveDownClick?.let { onMoveDown ->
|
||||||
NuvioIconActionButton(
|
NuvioIconActionButton(
|
||||||
icon = Icons.Rounded.ArrowDownward,
|
icon = Icons.Rounded.ArrowDownward,
|
||||||
contentDescription = "Move addon down",
|
contentDescription = stringResource(Res.string.addons_move_down),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onClick = onMoveDown,
|
onClick = onMoveDown,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NuvioIconActionButton(
|
NuvioIconActionButton(
|
||||||
icon = Icons.Rounded.Refresh,
|
icon = Icons.Rounded.Refresh,
|
||||||
contentDescription = "Refresh addon",
|
contentDescription = stringResource(Res.string.addons_refresh),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
onClick = onRefreshClick,
|
onClick = onRefreshClick,
|
||||||
)
|
)
|
||||||
onConfigureClick?.let { onConfigure ->
|
onConfigureClick?.let { onConfigure ->
|
||||||
NuvioIconActionButton(
|
NuvioIconActionButton(
|
||||||
icon = Icons.Rounded.Settings,
|
icon = Icons.Rounded.Settings,
|
||||||
contentDescription = "Configure addon",
|
contentDescription = stringResource(Res.string.addons_configure),
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
onClick = onConfigure,
|
onClick = onConfigure,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NuvioIconActionButton(
|
NuvioIconActionButton(
|
||||||
icon = Icons.Rounded.Delete,
|
icon = Icons.Rounded.Delete,
|
||||||
contentDescription = "Delete addon",
|
contentDescription = stringResource(Res.string.addons_delete),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
onClick = onDeleteClick,
|
onClick = onDeleteClick,
|
||||||
)
|
)
|
||||||
|
|
@ -429,16 +438,16 @@ private fun InstalledAddonCard(
|
||||||
) {
|
) {
|
||||||
NuvioInfoBadge(
|
NuvioInfoBadge(
|
||||||
text = when {
|
text = when {
|
||||||
addon.isRefreshing -> "Refreshing"
|
addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing)
|
||||||
manifest != null -> "Active"
|
manifest != null -> stringResource(Res.string.addons_badge_active)
|
||||||
else -> "Unavailable"
|
else -> stringResource(Res.string.addons_badge_unavailable)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
manifest?.let {
|
manifest?.let {
|
||||||
NuvioInfoBadge(text = "${it.resources.size} resources")
|
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_resources, it.resources.size))
|
||||||
NuvioInfoBadge(text = "${it.catalogs.size} catalogs")
|
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_catalogs, it.catalogs.size))
|
||||||
if (it.behaviorHints.configurable) {
|
if (it.behaviorHints.configurable) {
|
||||||
NuvioInfoBadge(text = "Configurable")
|
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_configurable))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -447,7 +456,7 @@ private fun InstalledAddonCard(
|
||||||
addon.isRefreshing -> {
|
addon.isRefreshing -> {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Loading manifest details...",
|
text = stringResource(Res.string.addons_loading_manifest_details),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -524,6 +533,7 @@ private fun AddonIconBadge(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun manifestSummary(manifest: AddonManifest): String {
|
private fun manifestSummary(manifest: AddonManifest): String {
|
||||||
val resources = manifest.resources.joinToString(separator = ", ") { it.name }
|
val resources = manifest.resources.joinToString(separator = ", ") { it.name }
|
||||||
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
|
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
|
||||||
|
|
@ -533,7 +543,7 @@ private fun manifestSummary(manifest: AddonManifest): String {
|
||||||
append(resources)
|
append(resources)
|
||||||
if (manifest.idPrefixes.isNotEmpty()) {
|
if (manifest.idPrefixes.isNotEmpty()) {
|
||||||
append(" • ")
|
append(" • ")
|
||||||
append("${manifest.idPrefixes.size} id rules")
|
append(stringResource(Res.string.addons_summary_id_rules, manifest.idPrefixes.size))
|
||||||
}
|
}
|
||||||
if (manifest.behaviorHints.p2p) {
|
if (manifest.behaviorHints.p2p) {
|
||||||
append(" • P2P")
|
append(" • P2P")
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,22 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_already_have_account
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_continue_without_account
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_create_account
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_dont_have_account
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_email
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_or_separator
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_password
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_sign_in
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_sign_in_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_sign_up
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_sign_up_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_store_locally
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_tagline
|
||||||
|
import nuvio.composeapp.generated.resources.compose_auth_welcome_back
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthScreen(
|
fun AuthScreen(
|
||||||
|
|
@ -97,7 +112,7 @@ fun AuthScreen(
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(Res.drawable.app_logo_wordmark),
|
painter = painterResource(Res.drawable.app_logo_wordmark),
|
||||||
contentDescription = "Nuvio",
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.6f)
|
.fillMaxWidth(0.6f)
|
||||||
.height(48.dp),
|
.height(48.dp),
|
||||||
|
|
@ -105,7 +120,7 @@ fun AuthScreen(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Stream everything, everywhere",
|
text = stringResource(Res.string.compose_auth_tagline),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -119,7 +134,8 @@ fun AuthScreen(
|
||||||
label = "heading",
|
label = "heading",
|
||||||
) { signUp ->
|
) { signUp ->
|
||||||
Text(
|
Text(
|
||||||
text = if (signUp) "Create Account" else "Welcome Back",
|
text = if (signUp) stringResource(Res.string.compose_auth_create_account)
|
||||||
|
else stringResource(Res.string.compose_auth_welcome_back),
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
@ -131,8 +147,8 @@ fun AuthScreen(
|
||||||
label = "subtitle",
|
label = "subtitle",
|
||||||
) { signUp ->
|
) { signUp ->
|
||||||
Text(
|
Text(
|
||||||
text = if (signUp) "Sign up to sync your data across devices"
|
text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
|
||||||
else "Sign in to access your library and progress",
|
else stringResource(Res.string.compose_auth_sign_in_subtitle),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -150,7 +166,7 @@ fun AuthScreen(
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = "Email",
|
text = stringResource(Res.string.compose_auth_email),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -183,7 +199,7 @@ fun AuthScreen(
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = "Password",
|
text = stringResource(Res.string.compose_auth_password),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -240,7 +256,13 @@ fun AuthScreen(
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = if (isLoading) "" else if (isSignUp) "Create Account" else "Sign In",
|
text = if (isLoading) {
|
||||||
|
""
|
||||||
|
} else if (isSignUp) {
|
||||||
|
stringResource(Res.string.compose_auth_create_account)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.compose_auth_sign_in)
|
||||||
|
},
|
||||||
enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
|
enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
|
||||||
onClick = {
|
onClick = {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
@ -279,7 +301,8 @@ fun AuthScreen(
|
||||||
label = "togglePrompt",
|
label = "togglePrompt",
|
||||||
) { signUp ->
|
) { signUp ->
|
||||||
Text(
|
Text(
|
||||||
text = if (signUp) "Already have an account? " else "Don't have an account? ",
|
text = if (signUp) stringResource(Res.string.compose_auth_already_have_account)
|
||||||
|
else stringResource(Res.string.compose_auth_dont_have_account),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -290,7 +313,8 @@ fun AuthScreen(
|
||||||
label = "toggleAction",
|
label = "toggleAction",
|
||||||
) { signUp ->
|
) { signUp ->
|
||||||
Text(
|
Text(
|
||||||
text = if (signUp) "Sign In" else "Sign Up",
|
text = if (signUp) stringResource(Res.string.compose_auth_sign_in)
|
||||||
|
else stringResource(Res.string.compose_auth_sign_up),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -317,7 +341,7 @@ fun AuthScreen(
|
||||||
.background(MaterialTheme.colorScheme.outline),
|
.background(MaterialTheme.colorScheme.outline),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = " or ",
|
text = stringResource(Res.string.compose_auth_or_separator),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -346,7 +370,7 @@ fun AuthScreen(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Continue Without Account",
|
text = stringResource(Res.string.compose_auth_continue_without_account),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
@ -354,7 +378,7 @@ fun AuthScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Your data will only be stored locally",
|
text = stringResource(Res.string.compose_auth_store_locally),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
|
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
|
||||||
|
|
||||||
|
|
@ -92,7 +94,7 @@ object CatalogRepository {
|
||||||
items = emptyList(),
|
items = emptyList(),
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
nextSkip = null,
|
nextSkip = null,
|
||||||
errorMessage = error.message ?: "Unable to load catalog items.",
|
errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -148,7 +150,7 @@ object CatalogRepository {
|
||||||
items = if (reset) emptyList() else current.items,
|
items = if (reset) emptyList() else current.items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
nextSkip = null,
|
nextSkip = null,
|
||||||
errorMessage = error.message ?: "Unable to load catalog items.",
|
errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ import com.nuvio.app.features.home.stableKey
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CatalogScreen(
|
fun CatalogScreen(
|
||||||
|
|
@ -329,12 +331,12 @@ private fun CatalogEmptyState(
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No titles found",
|
text = stringResource(Res.string.catalog_empty_title),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = errorMessage ?: "This catalog did not return any items.",
|
text = errorMessage ?: stringResource(Res.string.catalog_empty_message),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,10 @@ object CollectionEditorRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalUuidApi::class)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
fun addFolder() {
|
fun addFolder(defaultTitle: String) {
|
||||||
val newFolder = CollectionFolder(
|
val newFolder = CollectionFolder(
|
||||||
id = Uuid.random().toString(),
|
id = Uuid.random().toString(),
|
||||||
title = "New Folder",
|
title = defaultTitle,
|
||||||
)
|
)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
editingFolder = newFolder,
|
editingFolder = newFolder,
|
||||||
|
|
@ -177,13 +177,8 @@ object CollectionEditorRepository {
|
||||||
|
|
||||||
fun updateFolderTileShape(shape: PosterShape) {
|
fun updateFolderTileShape(shape: PosterShape) {
|
||||||
val folder = _uiState.value.editingFolder ?: return
|
val folder = _uiState.value.editingFolder ?: return
|
||||||
val shapeStr = when (shape) {
|
|
||||||
PosterShape.Poster -> "Poster"
|
|
||||||
PosterShape.Landscape -> "Landscape"
|
|
||||||
PosterShape.Square -> "Square"
|
|
||||||
}
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
editingFolder = folder.copy(tileShape = shapeStr),
|
editingFolder = folder.copy(tileShape = shape.name.lowercase()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
@ -141,7 +143,11 @@ fun CollectionEditorScreen(
|
||||||
) {
|
) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = if (state.isNew) "New Collection" else "Edit Collection",
|
title = if (state.isNew) {
|
||||||
|
stringResource(Res.string.collections_new)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.collections_editor_edit_collection)
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +156,7 @@ fun CollectionEditorScreen(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = state.title,
|
value = state.title,
|
||||||
onValueChange = { CollectionEditorRepository.setTitle(it) },
|
onValueChange = { CollectionEditorRepository.setTitle(it) },
|
||||||
placeholder = "Collection Title",
|
placeholder = stringResource(Res.string.collections_editor_placeholder_name),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +164,7 @@ fun CollectionEditorScreen(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = state.backdropImageUrl,
|
value = state.backdropImageUrl,
|
||||||
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) },
|
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) },
|
||||||
placeholder = "Backdrop Image URL (optional)",
|
placeholder = stringResource(Res.string.collections_editor_placeholder_backdrop),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,13 +179,13 @@ fun CollectionEditorScreen(
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Pin to Top",
|
text = stringResource(Res.string.collections_editor_pin_above),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Display this collection above regular catalog rows.",
|
text = stringResource(Res.string.collections_editor_pin_above_desc),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -204,7 +210,7 @@ fun CollectionEditorScreen(
|
||||||
item {
|
item {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Text(
|
Text(
|
||||||
text = "View Mode",
|
text = stringResource(Res.string.collections_editor_view_mode),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -223,9 +229,9 @@ fun CollectionEditorScreen(
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
FolderViewMode.TABBED_GRID -> "Tabbed Grid"
|
FolderViewMode.TABBED_GRID -> stringResource(Res.string.collections_editor_view_mode_tabs)
|
||||||
FolderViewMode.ROWS -> "Rows"
|
FolderViewMode.ROWS -> stringResource(Res.string.collections_editor_view_mode_rows)
|
||||||
FolderViewMode.FOLLOW_LAYOUT -> "Rows"
|
FolderViewMode.FOLLOW_LAYOUT -> stringResource(Res.string.collections_editor_view_mode_rows)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -256,13 +262,13 @@ fun CollectionEditorScreen(
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Show \"All\" Tab",
|
text = stringResource(Res.string.collections_editor_show_all_tab),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Combine all folder catalogs into a single tab.",
|
text = stringResource(Res.string.collections_editor_show_all_tab_desc),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -283,20 +289,23 @@ fun CollectionEditorScreen(
|
||||||
|
|
||||||
// Folders Section Header
|
// Folders Section Header
|
||||||
item {
|
item {
|
||||||
|
val newFolderTitle = stringResource(Res.string.collections_editor_new_folder)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
NuvioSectionLabel(text = "FOLDERS")
|
NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders))
|
||||||
TextButton(onClick = { CollectionEditorRepository.addFolder() }) {
|
TextButton(
|
||||||
|
onClick = { CollectionEditorRepository.addFolder(newFolderTitle) },
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Add,
|
imageVector = Icons.Rounded.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Add Folder")
|
Text(stringResource(Res.string.collections_editor_add_folder))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,13 +327,13 @@ fun CollectionEditorScreen(
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No folders yet",
|
text = stringResource(Res.string.collections_editor_folder_empty_title),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Add one to get started.",
|
text = stringResource(Res.string.collections_editor_folder_empty_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -352,7 +361,11 @@ fun CollectionEditorScreen(
|
||||||
.padding(bottom = bottomInset),
|
.padding(bottom = bottomInset),
|
||||||
) {
|
) {
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = if (state.isNew) "Create Collection" else "Save Changes",
|
text = if (state.isNew) {
|
||||||
|
stringResource(Res.string.collections_editor_create_collection)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.collections_editor_save_changes)
|
||||||
|
},
|
||||||
enabled = state.title.isNotBlank(),
|
enabled = state.title.isNotBlank(),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (CollectionEditorRepository.save()) {
|
if (CollectionEditorRepository.save()) {
|
||||||
|
|
@ -436,6 +449,11 @@ private fun FolderListItem(
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
val summary = stringResource(
|
||||||
|
Res.string.collections_editor_source_count,
|
||||||
|
folder.catalogSources.size,
|
||||||
|
posterShapeLabel(folder.posterShape),
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = folder.title,
|
text = folder.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|
@ -445,7 +463,7 @@ private fun FolderListItem(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}",
|
text = summary,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -471,7 +489,7 @@ private fun FolderListItem(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Menu,
|
imageVector = Icons.Rounded.Menu,
|
||||||
contentDescription = "Reorder",
|
contentDescription = stringResource(Res.string.action_reorder),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -480,7 +498,7 @@ private fun FolderListItem(
|
||||||
IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) {
|
IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Edit,
|
imageVector = Icons.Rounded.Edit,
|
||||||
contentDescription = "Edit",
|
contentDescription = stringResource(Res.string.action_edit),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
|
@ -488,7 +506,7 @@ private fun FolderListItem(
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {
|
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Delete,
|
imageVector = Icons.Rounded.Delete,
|
||||||
contentDescription = "Delete",
|
contentDescription = stringResource(Res.string.action_delete),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
|
@ -514,7 +532,11 @@ private fun FolderEditorPage(
|
||||||
NuvioScreen(modifier = Modifier.fillMaxSize()) {
|
NuvioScreen(modifier = Modifier.fillMaxSize()) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = if (state.folders.any { it.id == folder.id }) "Edit Folder" else "New Folder",
|
title = if (state.folders.any { it.id == folder.id }) {
|
||||||
|
stringResource(Res.string.collections_editor_edit_folder)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.collections_editor_new_folder)
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -522,7 +544,7 @@ private fun FolderEditorPage(
|
||||||
item {
|
item {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Text(
|
Text(
|
||||||
text = "Set the folder identity, presentation, and catalog sources with the same structure as the main collections editor.",
|
text = stringResource(Res.string.collections_editor_folder_editor_help),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -530,24 +552,24 @@ private fun FolderEditorPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FolderEditorSection(title = "BASICS") {
|
FolderEditorSection(title = stringResource(Res.string.collections_editor_section_basics)) {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = folder.title,
|
value = folder.title,
|
||||||
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
|
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
|
||||||
placeholder = "Folder Title",
|
placeholder = stringResource(Res.string.collections_editor_placeholder_folder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FolderEditorSection(title = "APPEARANCE") {
|
FolderEditorSection(title = stringResource(Res.string.collections_editor_section_appearance)) {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Cover",
|
text = stringResource(Res.string.collections_editor_cover),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -559,7 +581,7 @@ private fun FolderEditorPage(
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = folder.coverEmoji == null && folder.coverImageUrl == null,
|
selected = folder.coverEmoji == null && folder.coverImageUrl == null,
|
||||||
onClick = { CollectionEditorRepository.clearFolderCover() },
|
onClick = { CollectionEditorRepository.clearFolderCover() },
|
||||||
label = { Text("None") },
|
label = { Text(stringResource(Res.string.collections_editor_cover_none)) },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = folder.coverEmoji != null,
|
selected = folder.coverEmoji != null,
|
||||||
|
|
@ -568,7 +590,7 @@ private fun FolderEditorPage(
|
||||||
CollectionEditorRepository.updateFolderCoverEmoji("📁")
|
CollectionEditorRepository.updateFolderCoverEmoji("📁")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = { Text("Emoji") },
|
label = { Text(stringResource(Res.string.collections_editor_cover_emoji)) },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = folder.coverImageUrl != null,
|
selected = folder.coverImageUrl != null,
|
||||||
|
|
@ -577,7 +599,7 @@ private fun FolderEditorPage(
|
||||||
CollectionEditorRepository.updateFolderCoverImage("")
|
CollectionEditorRepository.updateFolderCoverImage("")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = { Text("Image") },
|
label = { Text(stringResource(Res.string.collections_editor_cover_image_url)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -586,7 +608,7 @@ private fun FolderEditorPage(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = folder.coverEmoji,
|
value = folder.coverEmoji,
|
||||||
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
|
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
|
||||||
placeholder = "Emoji",
|
placeholder = stringResource(Res.string.collections_editor_cover_emoji),
|
||||||
modifier = Modifier.width(100.dp),
|
modifier = Modifier.width(100.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -595,14 +617,14 @@ private fun FolderEditorPage(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = folder.coverImageUrl,
|
value = folder.coverImageUrl,
|
||||||
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
|
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
|
||||||
placeholder = "Image URL",
|
placeholder = stringResource(Res.string.collections_editor_cover_image_url),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = folder.focusGifUrl.orEmpty(),
|
value = folder.focusGifUrl.orEmpty(),
|
||||||
onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) },
|
onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) },
|
||||||
placeholder = "Always-play GIF URL (optional)",
|
placeholder = stringResource(Res.string.collections_editor_placeholder_gif),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +633,7 @@ private fun FolderEditorPage(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Tile Shape",
|
text = stringResource(Res.string.collections_editor_tile_shape),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -624,7 +646,7 @@ private fun FolderEditorPage(
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = folder.posterShape == shape,
|
selected = folder.posterShape == shape,
|
||||||
onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
|
onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
|
||||||
label = { Text(shape.name) },
|
label = { Text(posterShapeLabel(shape)) },
|
||||||
leadingIcon = if (folder.posterShape == shape) {
|
leadingIcon = if (folder.posterShape == shape) {
|
||||||
{
|
{
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -640,15 +662,15 @@ private fun FolderEditorPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderEditorToggleRow(
|
FolderEditorToggleRow(
|
||||||
title = "Show GIF When Configured",
|
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
|
||||||
subtitle = "Play the configured GIF instead of the static cover when available.",
|
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
|
||||||
checked = folder.focusGifEnabled,
|
checked = folder.focusGifEnabled,
|
||||||
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
|
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
FolderEditorToggleRow(
|
FolderEditorToggleRow(
|
||||||
title = "Hide Title",
|
title = stringResource(Res.string.collections_editor_hide_title),
|
||||||
subtitle = "Only show the artwork or emoji for this folder tile.",
|
subtitle = stringResource(Res.string.collections_editor_hide_title_desc),
|
||||||
checked = folder.hideTitle,
|
checked = folder.hideTitle,
|
||||||
onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
|
onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
|
||||||
)
|
)
|
||||||
|
|
@ -659,7 +681,7 @@ private fun FolderEditorPage(
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FolderEditorSection(
|
FolderEditorSection(
|
||||||
title = "CATALOG SOURCES",
|
title = stringResource(Res.string.collections_editor_section_catalog_sources),
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
|
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -668,7 +690,7 @@ private fun FolderEditorPage(
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Add")
|
Text(stringResource(Res.string.collections_editor_add_catalog))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -676,13 +698,13 @@ private fun FolderEditorPage(
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "No catalog sources yet",
|
text = stringResource(Res.string.collections_editor_catalog_sources_empty_title),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Add catalogs from your installed addons to define what this folder shows.",
|
text = stringResource(Res.string.collections_editor_catalog_sources_empty_subtitle),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -725,7 +747,7 @@ private fun FolderEditorPage(
|
||||||
.padding(bottom = bottomInset),
|
.padding(bottom = bottomInset),
|
||||||
) {
|
) {
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "Save Folder",
|
text = stringResource(Res.string.collections_editor_save),
|
||||||
enabled = folder.title.isNotBlank(),
|
enabled = folder.title.isNotBlank(),
|
||||||
onClick = { CollectionEditorRepository.saveFolderEdit() },
|
onClick = { CollectionEditorRepository.saveFolderEdit() },
|
||||||
)
|
)
|
||||||
|
|
@ -762,17 +784,17 @@ private fun CatalogPickerSheet(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Select Catalog Sources",
|
text = stringResource(Res.string.collections_editor_select_catalogs),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Done")
|
Text(stringResource(Res.string.collections_editor_done))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Choose the addon catalogs this folder should aggregate.",
|
text = stringResource(Res.string.collections_editor_select_catalogs_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
|
@ -831,7 +853,7 @@ private fun CatalogPickerSheet(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Check,
|
imageVector = Icons.Rounded.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = stringResource(Res.string.cd_selected),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -872,7 +894,7 @@ private fun GenrePickerSheet(
|
||||||
item {
|
item {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Genre Filter",
|
text = stringResource(Res.string.collections_editor_genre_filter),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -888,7 +910,7 @@ private fun GenrePickerSheet(
|
||||||
if (allowAll) {
|
if (allowAll) {
|
||||||
item {
|
item {
|
||||||
GenrePickerOptionRow(
|
GenrePickerOptionRow(
|
||||||
title = "All genres",
|
title = stringResource(Res.string.collections_editor_all_genres),
|
||||||
selected = selectedGenre == null,
|
selected = selectedGenre == null,
|
||||||
onClick = { onSelect(null) },
|
onClick = { onSelect(null) },
|
||||||
)
|
)
|
||||||
|
|
@ -991,7 +1013,11 @@ private fun FolderCatalogSourceCard(
|
||||||
append(" · ${source.catalogId}")
|
append(" · ${source.catalogId}")
|
||||||
}
|
}
|
||||||
val genreOptions = matchingCatalog?.genreOptions.orEmpty()
|
val genreOptions = matchingCatalog?.genreOptions.orEmpty()
|
||||||
val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) "Select genre" else "All genres"
|
val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) {
|
||||||
|
stringResource(Res.string.collections_editor_select_genre)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.collections_editor_all_genres)
|
||||||
|
}
|
||||||
|
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
|
@ -1019,7 +1045,7 @@ private fun FolderCatalogSourceCard(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Close,
|
imageVector = Icons.Rounded.Close,
|
||||||
contentDescription = "Remove",
|
contentDescription = stringResource(Res.string.action_remove),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
|
@ -1045,7 +1071,7 @@ private fun FolderCatalogSourceCard(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Genre Filter",
|
text = stringResource(Res.string.collections_editor_genre_filter),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -1057,7 +1083,7 @@ private fun FolderCatalogSourceCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TextButton(onClick = onOpenGenrePicker) {
|
TextButton(onClick = onOpenGenrePicker) {
|
||||||
Text("Choose")
|
Text(stringResource(Res.string.collections_editor_choose_genre))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1065,6 +1091,14 @@ private fun FolderCatalogSourceCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun posterShapeLabel(shape: PosterShape): String =
|
||||||
|
when (shape) {
|
||||||
|
PosterShape.Poster -> stringResource(Res.string.collections_editor_shape_poster)
|
||||||
|
PosterShape.Square -> stringResource(Res.string.collections_editor_shape_square)
|
||||||
|
PosterShape.Landscape -> stringResource(Res.string.collections_editor_shape_wide)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GenrePickerOptionRow(
|
private fun GenrePickerOptionRow(
|
||||||
title: String,
|
title: String,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package com.nuvio.app.features.collection
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
internal object CollectionJsonPreserver {
|
||||||
|
fun merge(
|
||||||
|
json: Json,
|
||||||
|
rawCollectionsJson: JsonElement,
|
||||||
|
collections: List<Collection>,
|
||||||
|
): JsonArray {
|
||||||
|
val rawById = rawCollectionsJson.asObjectArrayById()
|
||||||
|
return buildJsonArray {
|
||||||
|
collections.forEach { collection ->
|
||||||
|
add(
|
||||||
|
mergeCollection(
|
||||||
|
json = json,
|
||||||
|
raw = rawById[collection.id],
|
||||||
|
collection = collection,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeCollection(
|
||||||
|
json: Json,
|
||||||
|
raw: JsonObject?,
|
||||||
|
collection: Collection,
|
||||||
|
): JsonObject {
|
||||||
|
val encoded = json.encodeToJsonElement(Collection.serializer(), collection).jsonObject
|
||||||
|
val rawFoldersById = raw?.get("folders").asObjectArrayById()
|
||||||
|
val mergedFolders = buildJsonArray {
|
||||||
|
collection.folders.forEach { folder ->
|
||||||
|
add(
|
||||||
|
mergeFolder(
|
||||||
|
json = json,
|
||||||
|
raw = rawFoldersById[folder.id],
|
||||||
|
folder = folder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeObjects(raw, encoded, mapOf("folders" to mergedFolders))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeFolder(
|
||||||
|
json: Json,
|
||||||
|
raw: JsonObject?,
|
||||||
|
folder: CollectionFolder,
|
||||||
|
): JsonObject {
|
||||||
|
val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject
|
||||||
|
val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey)
|
||||||
|
val mergedSources = buildJsonArray {
|
||||||
|
folder.catalogSources.forEach { source ->
|
||||||
|
val sourceElement =
|
||||||
|
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
|
||||||
|
add(
|
||||||
|
mergeSource(
|
||||||
|
json = json,
|
||||||
|
raw = rawSourcesByKey[sourceKey(sourceElement)],
|
||||||
|
source = source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeSource(
|
||||||
|
json: Json,
|
||||||
|
raw: JsonObject?,
|
||||||
|
source: CollectionCatalogSource,
|
||||||
|
): JsonObject {
|
||||||
|
val encoded = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source).jsonObject
|
||||||
|
return mergeObjects(raw, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeObjects(
|
||||||
|
raw: JsonObject?,
|
||||||
|
encoded: JsonObject,
|
||||||
|
overrides: Map<String, JsonElement> = emptyMap(),
|
||||||
|
): JsonObject = buildJsonObject {
|
||||||
|
raw?.forEach { (key, value) -> put(key, value) }
|
||||||
|
encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectArrayById(): Map<String, JsonObject> =
|
||||||
|
asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull }
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map<String, JsonObject> =
|
||||||
|
(this as? JsonArray)
|
||||||
|
?.mapNotNull { element ->
|
||||||
|
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
keySelector(obj)?.let { key -> key to obj }
|
||||||
|
}
|
||||||
|
?.toMap()
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
private fun sourceKey(element: JsonElement): String? {
|
||||||
|
val obj = element as? JsonObject ?: return null
|
||||||
|
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
|
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
|
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
|
return "$addonId|$type|$catalogId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
@ -75,7 +77,7 @@ fun CollectionManagementScreen(
|
||||||
NuvioScreen {
|
NuvioScreen {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = "Collections",
|
title = stringResource(Res.string.collections_header),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
|
|
@ -84,14 +86,14 @@ fun CollectionManagementScreen(
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.ContentCopy,
|
imageVector = Icons.Rounded.ContentCopy,
|
||||||
contentDescription = "Copy JSON",
|
contentDescription = stringResource(Res.string.collections_copy_json),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showImportDialog = true }) {
|
IconButton(onClick = { showImportDialog = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.ContentPaste,
|
imageVector = Icons.Rounded.ContentPaste,
|
||||||
contentDescription = "Import",
|
contentDescription = stringResource(Res.string.collections_import),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -100,8 +102,11 @@ fun CollectionManagementScreen(
|
||||||
item {
|
item {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Text(
|
Text(
|
||||||
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
|
text = stringResource(
|
||||||
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
|
Res.string.collections_count_summary,
|
||||||
|
collections.size,
|
||||||
|
collections.sumOf { it.folders.size },
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -110,13 +115,13 @@ fun CollectionManagementScreen(
|
||||||
|
|
||||||
item {
|
item {
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "New Collection",
|
text = stringResource(Res.string.collections_new),
|
||||||
onClick = { onNavigateToEditor(null) },
|
onClick = { onNavigateToEditor(null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collections.isNotEmpty()) {
|
if (collections.isNotEmpty()) {
|
||||||
item { NuvioSectionLabel(text = "YOUR COLLECTIONS") }
|
item { NuvioSectionLabel(text = stringResource(Res.string.collections_your_collections)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collections.isNotEmpty()) {
|
if (collections.isNotEmpty()) {
|
||||||
|
|
@ -142,13 +147,13 @@ fun CollectionManagementScreen(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "No collections yet",
|
text = stringResource(Res.string.collections_empty_title),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Create one to organize your catalogs.",
|
text = stringResource(Res.string.collections_empty_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -187,11 +192,11 @@ fun CollectionManagementScreen(
|
||||||
val deleteId = showDeleteConfirm
|
val deleteId = showDeleteConfirm
|
||||||
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
|
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = "Delete Collection",
|
title = stringResource(Res.string.collections_delete_title),
|
||||||
message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.",
|
message = stringResource(Res.string.collections_delete_message, deleteCollection?.title.orEmpty()),
|
||||||
isVisible = deleteId != null,
|
isVisible = deleteId != null,
|
||||||
confirmText = "Delete",
|
confirmText = stringResource(Res.string.action_delete),
|
||||||
dismissText = "Cancel",
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
if (deleteId != null) {
|
if (deleteId != null) {
|
||||||
CollectionRepository.removeCollection(deleteId)
|
CollectionRepository.removeCollection(deleteId)
|
||||||
|
|
@ -261,6 +266,13 @@ private fun CollectionListItem(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
val summary = buildString {
|
||||||
|
append(stringResource(Res.string.collections_folder_count, collection.folders.size))
|
||||||
|
if (collection.pinToTop) {
|
||||||
|
append(" · ")
|
||||||
|
append(stringResource(Res.string.collections_pinned))
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = collection.title,
|
text = collection.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|
@ -271,8 +283,7 @@ private fun CollectionListItem(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
|
text = summary,
|
||||||
if (collection.pinToTop) " · Pinned" else "",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -298,7 +309,7 @@ private fun CollectionListItem(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Menu,
|
imageVector = Icons.Rounded.Menu,
|
||||||
contentDescription = "Reorder",
|
contentDescription = stringResource(Res.string.action_reorder),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -310,7 +321,7 @@ private fun CollectionListItem(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Edit,
|
imageVector = Icons.Rounded.Edit,
|
||||||
contentDescription = "Edit",
|
contentDescription = stringResource(Res.string.action_edit),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
|
@ -321,7 +332,7 @@ private fun CollectionListItem(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Delete,
|
imageVector = Icons.Rounded.Delete,
|
||||||
contentDescription = "Delete",
|
contentDescription = stringResource(Res.string.action_delete),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
|
@ -349,13 +360,13 @@ private fun ImportDialog(
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Import Collections",
|
text = stringResource(Res.string.collections_import_header),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Paste your collections JSON below.",
|
text = stringResource(Res.string.collections_import_paste_description),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -366,7 +377,12 @@ private fun ImportDialog(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(160.dp),
|
.height(160.dp),
|
||||||
placeholder = { Text("JSON", style = MaterialTheme.typography.bodyLarge) },
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.collections_import_json_placeholder),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
isError = importError != null,
|
isError = importError != null,
|
||||||
supportingText = importError?.let {
|
supportingText = importError?.let {
|
||||||
{ Text(it, color = MaterialTheme.colorScheme.error) }
|
{ Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
|
@ -399,7 +415,7 @@ private fun ImportDialog(
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("Cancel")
|
Text(stringResource(Res.string.action_cancel))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
androidx.compose.material3.Button(
|
androidx.compose.material3.Button(
|
||||||
|
|
@ -407,7 +423,7 @@ private fun ImportDialog(
|
||||||
enabled = importText.isNotBlank(),
|
enabled = importText.isNotBlank(),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
) {
|
) {
|
||||||
Text("Import")
|
Text(stringResource(Res.string.action_import))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ data class CollectionFolder(
|
||||||
val focusGifUrl: String? = null,
|
val focusGifUrl: String? = null,
|
||||||
val focusGifEnabled: Boolean = true,
|
val focusGifEnabled: Boolean = true,
|
||||||
val coverEmoji: String? = null,
|
val coverEmoji: String? = null,
|
||||||
val tileShape: String = "Poster",
|
val tileShape: String = "poster",
|
||||||
val hideTitle: Boolean = false,
|
val hideTitle: Boolean = false,
|
||||||
val catalogSources: List<CollectionCatalogSource> = emptyList(),
|
val catalogSources: List<CollectionCatalogSource> = emptyList(),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,21 @@ import com.nuvio.app.features.addons.ManagedAddon
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_empty_json
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
|
@ -21,6 +33,7 @@ object CollectionRepository {
|
||||||
|
|
||||||
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
|
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
|
||||||
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
|
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
|
||||||
|
private var rawCollectionsJson: JsonElement = JsonArray(emptyList())
|
||||||
|
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
|
@ -31,6 +44,8 @@ object CollectionRepository {
|
||||||
if (payload.isNullOrBlank()) return
|
if (payload.isNullOrBlank()) return
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
|
val parsed = json.parseToJsonElement(payload)
|
||||||
|
rawCollectionsJson = parsed
|
||||||
_collections.value = json.decodeFromString<List<Collection>>(payload)
|
_collections.value = json.decodeFromString<List<Collection>>(payload)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to load collections from storage" }
|
log.e(e) { "Failed to load collections from storage" }
|
||||||
|
|
@ -40,11 +55,13 @@ object CollectionRepository {
|
||||||
fun onProfileChanged() {
|
fun onProfileChanged() {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
_collections.value = emptyList()
|
_collections.value = emptyList()
|
||||||
|
rawCollectionsJson = JsonArray(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearLocalState() {
|
fun clearLocalState() {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
_collections.value = emptyList()
|
_collections.value = emptyList()
|
||||||
|
rawCollectionsJson = JsonArray(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCollection(id: String): Collection? =
|
fun getCollection(id: String): Collection? =
|
||||||
|
|
@ -71,6 +88,7 @@ object CollectionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCollections(collections: List<Collection>) {
|
fun setCollections(collections: List<Collection>) {
|
||||||
|
ensureLoaded()
|
||||||
_collections.value = collections
|
_collections.value = collections
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
@ -96,11 +114,12 @@ object CollectionRepository {
|
||||||
|
|
||||||
fun exportToJson(): String {
|
fun exportToJson(): String {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
return json.encodeToString(_collections.value)
|
return mergedCollectionsJson().toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importFromJson(jsonString: String): Result<List<Collection>> {
|
fun importFromJson(jsonString: String): Result<List<Collection>> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||||
_collections.value = imported
|
_collections.value = imported
|
||||||
persist()
|
persist()
|
||||||
|
|
@ -110,28 +129,68 @@ object CollectionRepository {
|
||||||
|
|
||||||
fun validateJson(jsonString: String): ValidationResult {
|
fun validateJson(jsonString: String): ValidationResult {
|
||||||
if (jsonString.isBlank()) {
|
if (jsonString.isBlank()) {
|
||||||
return ValidationResult(valid = false, error = "JSON is empty.")
|
return ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking { getString(Res.string.collections_import_error_empty_json) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val collections = json.decodeFromString<List<Collection>>(jsonString)
|
val collections = json.decodeFromString<List<Collection>>(jsonString)
|
||||||
var totalFolders = 0
|
var totalFolders = 0
|
||||||
collections.forEachIndexed { ci, c ->
|
collections.forEachIndexed { ci, c ->
|
||||||
if (c.id.isBlank()) {
|
if (c.id.isBlank()) {
|
||||||
return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.")
|
return ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(Res.string.collections_import_error_collection_blank_id, ci + 1)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (c.title.isBlank()) {
|
if (c.title.isBlank()) {
|
||||||
return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.")
|
return ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(Res.string.collections_import_error_collection_blank_title, c.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
c.folders.forEachIndexed { fi, f ->
|
c.folders.forEachIndexed { fi, f ->
|
||||||
if (f.id.isBlank()) {
|
if (f.id.isBlank()) {
|
||||||
return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.")
|
return ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(
|
||||||
|
Res.string.collections_import_error_folder_blank_id,
|
||||||
|
fi + 1,
|
||||||
|
c.title,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (f.title.isBlank()) {
|
if (f.title.isBlank()) {
|
||||||
return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.")
|
return ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(
|
||||||
|
Res.string.collections_import_error_folder_blank_title,
|
||||||
|
f.id,
|
||||||
|
c.title,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
f.catalogSources.forEachIndexed { si, s ->
|
f.catalogSources.forEachIndexed { si, s ->
|
||||||
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
|
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
|
||||||
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.")
|
return ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(
|
||||||
|
Res.string.collections_import_error_source_blank_fields,
|
||||||
|
si + 1,
|
||||||
|
f.title,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totalFolders++
|
totalFolders++
|
||||||
|
|
@ -143,7 +202,12 @@ object CollectionRepository {
|
||||||
folderCount = totalFolders,
|
folderCount = totalFolders,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
|
ValidationResult(
|
||||||
|
valid = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty())
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +237,8 @@ object CollectionRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun applyFromRemote(collections: List<Collection>) {
|
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
||||||
|
rawCollectionsJson = rawJson
|
||||||
_collections.value = collections
|
_collections.value = collections
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
@ -184,9 +249,14 @@ object CollectionRepository {
|
||||||
|
|
||||||
private fun persist() {
|
private fun persist() {
|
||||||
runCatching {
|
runCatching {
|
||||||
CollectionStorage.savePayload(json.encodeToString(_collections.value))
|
CollectionStorage.savePayload(mergedCollectionsJson().toString())
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to persist collections" }
|
log.e(e) { "Failed to persist collections" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun mergedCollectionsJson(): JsonArray =
|
||||||
|
CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also {
|
||||||
|
rawCollectionsJson = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ object CollectionSyncService {
|
||||||
|
|
||||||
if (remoteCollections != null) {
|
if (remoteCollections != null) {
|
||||||
isSyncingFromRemote = true
|
isSyncingFromRemote = true
|
||||||
CollectionRepository.applyFromRemote(remoteCollections)
|
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
|
||||||
isSyncingFromRemote = false
|
isSyncingFromRemote = false
|
||||||
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||||
import com.nuvio.app.features.catalog.supportsPagination
|
import com.nuvio.app.features.catalog.supportsPagination
|
||||||
|
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||||
import com.nuvio.app.features.home.HomeCatalogSection
|
import com.nuvio.app.features.home.HomeCatalogSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
|
|
@ -17,6 +18,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
|
||||||
|
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
data class FolderTab(
|
data class FolderTab(
|
||||||
val label: String,
|
val label: String,
|
||||||
|
|
@ -113,7 +119,13 @@ object FolderDetailRepository {
|
||||||
|
|
||||||
val tabs = buildList {
|
val tabs = buildList {
|
||||||
if (showAll) {
|
if (showAll) {
|
||||||
add(FolderTab(label = "All", isAllTab = true, isLoading = true))
|
add(
|
||||||
|
FolderTab(
|
||||||
|
label = runBlocking { getString(Res.string.collections_tab_all) },
|
||||||
|
isAllTab = true,
|
||||||
|
isLoading = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
folder.catalogSources.forEach { source ->
|
folder.catalogSources.forEach { source ->
|
||||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||||
|
|
@ -121,9 +133,7 @@ object FolderDetailRepository {
|
||||||
it.id == source.catalogId && it.type == source.type
|
it.id == source.catalogId && it.type == source.type
|
||||||
}
|
}
|
||||||
val label = catalog?.name ?: source.catalogId
|
val label = catalog?.name ?: source.catalogId
|
||||||
val typeLabel = source.type.replaceFirstChar {
|
val typeLabel = localizedMediaTypeLabel(source.type)
|
||||||
if (it.isLowerCase()) it.titlecase() else it.toString()
|
|
||||||
}
|
|
||||||
val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
|
val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
|
||||||
add(
|
add(
|
||||||
FolderTab(
|
FolderTab(
|
||||||
|
|
@ -155,7 +165,14 @@ object FolderDetailRepository {
|
||||||
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
||||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||||
if (addon == null) {
|
if (addon == null) {
|
||||||
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") }
|
updateTab(tabIndex) {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = runBlocking {
|
||||||
|
getString(Res.string.collections_folder_addon_not_found, source.addonId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
return@forEachIndexed
|
return@forEachIndexed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.collections_folder_empty_items
|
||||||
|
import nuvio.composeapp.generated.resources.collections_folder_not_found
|
||||||
|
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
private val FolderCoverHeight = 176.dp
|
private val FolderCoverHeight = 176.dp
|
||||||
|
|
||||||
|
|
@ -143,7 +148,7 @@ fun FolderDetailScreen(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Folder not found",
|
text = stringResource(Res.string.collections_folder_not_found),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -229,7 +234,11 @@ private fun TabbedGridContent(
|
||||||
onClick = { onTabSelected(index) },
|
onClick = { onTabSelected(index) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = tab.label,
|
text = if (tab.isAllTab) {
|
||||||
|
stringResource(Res.string.collections_tab_all)
|
||||||
|
} else {
|
||||||
|
tab.label
|
||||||
|
},
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
@ -395,7 +404,7 @@ private fun EmptyMessage() {
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No items found",
|
text = stringResource(Res.string.collections_folder_empty_items),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.details
|
||||||
import com.nuvio.app.features.streams.StreamBehaviorHints
|
import com.nuvio.app.features.streams.StreamBehaviorHints
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamProxyHeaders
|
import com.nuvio.app.features.streams.StreamProxyHeaders
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
@ -13,6 +14,8 @@ import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
import kotlinx.serialization.json.longOrNull
|
import kotlinx.serialization.json.longOrNull
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
internal object MetaDetailsParser {
|
internal object MetaDetailsParser {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
@ -248,10 +251,10 @@ internal object MetaDetailsParser {
|
||||||
MetaTrailer(
|
MetaTrailer(
|
||||||
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
|
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
|
||||||
key = normalizedKey,
|
key = normalizedKey,
|
||||||
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: "Trailer",
|
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) },
|
||||||
site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
|
site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
|
||||||
size = trailer.int("size"),
|
size = trailer.int("size"),
|
||||||
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "Trailer",
|
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) },
|
||||||
official = trailer.boolean("official") == true,
|
official = trailer.boolean("official") == true,
|
||||||
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
|
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
|
||||||
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
|
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
|
||||||
|
|
@ -273,7 +276,9 @@ internal object MetaDetailsParser {
|
||||||
?.objectValue("proxyHeaders")
|
?.objectValue("proxyHeaders")
|
||||||
?.toProxyHeaders()
|
?.toProxyHeaders()
|
||||||
val streamData = obj["streamData"] as? JsonObject
|
val streamData = obj["streamData"] as? JsonObject
|
||||||
val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded"
|
val addonName = streamData?.string("addon")
|
||||||
|
?: obj.string("name")
|
||||||
|
?: runBlocking { getString(Res.string.source_embedded) }
|
||||||
StreamItem(
|
StreamItem(
|
||||||
name = obj.string("name"),
|
name = obj.string("name"),
|
||||||
description = obj.string("description") ?: obj.string("title"),
|
description = obj.string("description") ?: obj.string("title"),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
object MetaDetailsRepository {
|
object MetaDetailsRepository {
|
||||||
private data class CachedMetaEntry(
|
private data class CachedMetaEntry(
|
||||||
|
|
@ -112,7 +114,7 @@ object MetaDetailsRepository {
|
||||||
if (manifests.isEmpty()) {
|
if (manifests.isEmpty()) {
|
||||||
log.w { "No addon provides meta for type=$type id=$id" }
|
log.w { "No addon provides meta for type=$type id=$id" }
|
||||||
_uiState.value = MetaDetailsUiState(
|
_uiState.value = MetaDetailsUiState(
|
||||||
errorMessage = "No addon provides meta for this content.",
|
errorMessage = getString(Res.string.details_no_addon_meta),
|
||||||
)
|
)
|
||||||
activeRequestKey = null
|
activeRequestKey = null
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -157,7 +159,7 @@ object MetaDetailsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.value = MetaDetailsUiState(
|
_uiState.value = MetaDetailsUiState(
|
||||||
errorMessage = "Could not load details from any addon.",
|
errorMessage = getString(Res.string.details_load_failed_all_addons),
|
||||||
)
|
)
|
||||||
activeRequestKey = null
|
activeRequestKey = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
|
||||||
import com.nuvio.app.features.watching.application.WatchingActions
|
import com.nuvio.app.features.watching.application.WatchingActions
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
|
@ -186,7 +189,7 @@ fun MetaDetailsScreen(
|
||||||
commentsCurrentPage = result.currentPage
|
commentsCurrentPage = result.currentPage
|
||||||
commentsPageCount = result.pageCount
|
commentsPageCount = result.pageCount
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
commentsError = e.message ?: "Failed to load comments"
|
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
|
||||||
}
|
}
|
||||||
isCommentsLoading = false
|
isCommentsLoading = false
|
||||||
}
|
}
|
||||||
|
|
@ -242,14 +245,14 @@ fun MetaDetailsScreen(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Failed to load",
|
text = stringResource(Res.string.details_failed_to_load),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = when (networkStatusUiState.condition) {
|
text = when (networkStatusUiState.condition) {
|
||||||
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
|
NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
|
||||||
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
|
NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
|
||||||
else -> uiState.errorMessage.orEmpty()
|
else -> uiState.errorMessage.orEmpty()
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|
@ -262,7 +265,7 @@ fun MetaDetailsScreen(
|
||||||
MetaDetailsRepository.load(type, id)
|
MetaDetailsRepository.load(type, id)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text("Retry")
|
Text(stringResource(Res.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +303,7 @@ fun MetaDetailsScreen(
|
||||||
tab.key to (snapshot[tab.key] == true)
|
tab.key to (snapshot[tab.key] == true)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
pickerError = error.message ?: "Failed to load Trakt lists"
|
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||||
}
|
}
|
||||||
pickerPending = false
|
pickerPending = false
|
||||||
}
|
}
|
||||||
|
|
@ -394,7 +397,7 @@ fun MetaDetailsScreen(
|
||||||
}
|
}
|
||||||
trailerPlaybackSource = resolvedSource
|
trailerPlaybackSource = resolvedSource
|
||||||
trailerErrorMessage = if (resolvedSource == null) {
|
trailerErrorMessage = if (resolvedSource == null) {
|
||||||
"No playable trailer stream found."
|
getString(Res.string.trailer_no_playable_stream)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -403,13 +406,15 @@ fun MetaDetailsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) {
|
val playText = stringResource(Res.string.action_play)
|
||||||
|
val resumeText = stringResource(Res.string.action_resume)
|
||||||
|
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes, playText, resumeText) {
|
||||||
when {
|
when {
|
||||||
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
|
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
|
||||||
seriesAction.label
|
seriesAction.label
|
||||||
meta.type != "series" && !hasEpisodes && movieProgress != null ->
|
meta.type != "series" && !hasEpisodes && movieProgress != null ->
|
||||||
"Resume"
|
resumeText
|
||||||
else -> "Play"
|
else -> playText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onPrimaryPlayClick: () -> Unit = {
|
val onPrimaryPlayClick: () -> Unit = {
|
||||||
|
|
@ -660,7 +665,7 @@ fun MetaDetailsScreen(
|
||||||
commentsCurrentPage = result.currentPage
|
commentsCurrentPage = result.currentPage
|
||||||
commentsPageCount = result.pageCount
|
commentsPageCount = result.pageCount
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
commentsError = e.message ?: "Failed to load comments"
|
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
|
||||||
}
|
}
|
||||||
isCommentsLoading = false
|
isCommentsLoading = false
|
||||||
}
|
}
|
||||||
|
|
@ -780,7 +785,9 @@ fun MetaDetailsScreen(
|
||||||
}
|
}
|
||||||
EpisodeWatchedActionSheet(
|
EpisodeWatchedActionSheet(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
|
seasonLabel = selectedEpisode.season?.let {
|
||||||
|
stringResource(Res.string.episodes_season, it)
|
||||||
|
} ?: stringResource(Res.string.episodes_specials),
|
||||||
isEpisodeWatched = isSelectedEpisodeWatched,
|
isEpisodeWatched = isSelectedEpisodeWatched,
|
||||||
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
|
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
|
||||||
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
|
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
|
||||||
|
|
@ -865,7 +872,7 @@ fun MetaDetailsScreen(
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
showLibraryListPicker = false
|
showLibraryListPicker = false
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
pickerError = error.message ?: "Failed to update Trakt lists"
|
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
|
||||||
}
|
}
|
||||||
pickerPending = false
|
pickerPending = false
|
||||||
}
|
}
|
||||||
|
|
@ -993,7 +1000,11 @@ private fun ConfiguredMetaSections(
|
||||||
MetaScreenSectionKey.ACTIONS -> {
|
MetaScreenSectionKey.ACTIONS -> {
|
||||||
DetailActionButtons(
|
DetailActionButtons(
|
||||||
playLabel = playButtonLabel,
|
playLabel = playButtonLabel,
|
||||||
saveLabel = if (isSaved) "Saved" else "Save",
|
saveLabel = if (isSaved) {
|
||||||
|
stringResource(Res.string.action_saved)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.action_save)
|
||||||
|
},
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onPlayClick = onPrimaryPlayClick,
|
onPlayClick = onPrimaryPlayClick,
|
||||||
|
|
@ -1072,7 +1083,7 @@ private fun ConfiguredMetaSections(
|
||||||
MetaScreenSectionKey.MORE_LIKE_THIS -> {
|
MetaScreenSectionKey.MORE_LIKE_THIS -> {
|
||||||
if (hasMoreLikeThisSection) {
|
if (hasMoreLikeThisSection) {
|
||||||
DetailPosterRailSection(
|
DetailPosterRailSection(
|
||||||
title = "More Like This",
|
title = stringResource(Res.string.details_more_like_this),
|
||||||
items = meta.moreLikeThis,
|
items = meta.moreLikeThis,
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
showHeader = showHeader,
|
showHeader = showHeader,
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@ package com.nuvio.app.features.details
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
enum class MetaScreenSectionKey {
|
enum class MetaScreenSectionKey {
|
||||||
ACTIONS,
|
ACTIONS,
|
||||||
|
|
@ -81,8 +85,8 @@ private data class StoredMetaScreenSettingsPayload(
|
||||||
|
|
||||||
private data class MetaScreenSectionDefinition(
|
private data class MetaScreenSectionDefinition(
|
||||||
val key: MetaScreenSectionKey,
|
val key: MetaScreenSectionKey,
|
||||||
val title: String,
|
val titleRes: StringResource,
|
||||||
val description: String,
|
val descriptionRes: StringResource,
|
||||||
)
|
)
|
||||||
|
|
||||||
object MetaScreenSettingsRepository {
|
object MetaScreenSettingsRepository {
|
||||||
|
|
@ -94,53 +98,53 @@ object MetaScreenSettingsRepository {
|
||||||
private val definitions = listOf(
|
private val definitions = listOf(
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.ACTIONS,
|
key = MetaScreenSectionKey.ACTIONS,
|
||||||
title = "Actions",
|
titleRes = Res.string.meta_section_actions_title,
|
||||||
description = "Play and save controls.",
|
descriptionRes = Res.string.meta_section_actions_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.OVERVIEW,
|
key = MetaScreenSectionKey.OVERVIEW,
|
||||||
title = "Overview",
|
titleRes = Res.string.meta_section_overview_title,
|
||||||
description = "Synopsis, ratings, genres, and core credits.",
|
descriptionRes = Res.string.meta_section_overview_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.PRODUCTION,
|
key = MetaScreenSectionKey.PRODUCTION,
|
||||||
title = "Production",
|
titleRes = Res.string.meta_section_production_title,
|
||||||
description = "Studios and networks.",
|
descriptionRes = Res.string.meta_section_production_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.CAST,
|
key = MetaScreenSectionKey.CAST,
|
||||||
title = "Cast",
|
titleRes = Res.string.settings_meta_cast,
|
||||||
description = "Principal cast list.",
|
descriptionRes = Res.string.meta_section_cast_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.COMMENTS,
|
key = MetaScreenSectionKey.COMMENTS,
|
||||||
title = "Comments",
|
titleRes = Res.string.settings_meta_comments,
|
||||||
description = "Trakt comments section.",
|
descriptionRes = Res.string.meta_section_comments_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.TRAILERS,
|
key = MetaScreenSectionKey.TRAILERS,
|
||||||
title = "Trailers",
|
titleRes = Res.string.settings_meta_trailers,
|
||||||
description = "Trailer rail and playback shortcuts.",
|
descriptionRes = Res.string.meta_section_trailers_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.EPISODES,
|
key = MetaScreenSectionKey.EPISODES,
|
||||||
title = "Episodes",
|
titleRes = Res.string.settings_meta_episodes,
|
||||||
description = "Seasons and episode list for series.",
|
descriptionRes = Res.string.meta_section_episodes_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.DETAILS,
|
key = MetaScreenSectionKey.DETAILS,
|
||||||
title = "Details",
|
titleRes = Res.string.meta_section_details_title,
|
||||||
description = "Runtime, status, release, language, and related info.",
|
descriptionRes = Res.string.meta_section_details_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.COLLECTION,
|
key = MetaScreenSectionKey.COLLECTION,
|
||||||
title = "Collection",
|
titleRes = Res.string.meta_section_collection_title,
|
||||||
description = "Related collection or franchise rail.",
|
descriptionRes = Res.string.meta_section_collection_description,
|
||||||
),
|
),
|
||||||
MetaScreenSectionDefinition(
|
MetaScreenSectionDefinition(
|
||||||
key = MetaScreenSectionKey.MORE_LIKE_THIS,
|
key = MetaScreenSectionKey.MORE_LIKE_THIS,
|
||||||
title = "More Like This",
|
titleRes = Res.string.meta_section_more_like_this_title,
|
||||||
description = "Recommendation rail.",
|
descriptionRes = Res.string.meta_section_more_like_this_description,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -152,6 +156,7 @@ object MetaScreenSettingsRepository {
|
||||||
private var cinematicBackground: Boolean = false
|
private var cinematicBackground: Boolean = false
|
||||||
private var tabLayout: Boolean = false
|
private var tabLayout: Boolean = false
|
||||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
if (hasLoaded) return
|
if (hasLoaded) return
|
||||||
|
|
@ -322,8 +327,8 @@ object MetaScreenSettingsRepository {
|
||||||
val preference = preferences[definition.key]
|
val preference = preferences[definition.key]
|
||||||
MetaScreenSectionItem(
|
MetaScreenSectionItem(
|
||||||
key = definition.key,
|
key = definition.key,
|
||||||
title = definition.title,
|
title = localizedString(definition.titleRes),
|
||||||
description = definition.description,
|
description = localizedString(definition.descriptionRes),
|
||||||
enabled = preference?.enabled ?: true,
|
enabled = preference?.enabled ?: true,
|
||||||
order = preference?.order ?: 0,
|
order = preference?.order ?: 0,
|
||||||
tabGroup = preference?.tabGroup,
|
tabGroup = preference?.tabGroup,
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.LocalPlatformContext
|
import coil3.compose.LocalPlatformContext
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
|
import com.nuvio.app.core.i18n.localizedShortMonthName
|
||||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
|
|
@ -63,6 +64,9 @@ import com.nuvio.app.features.details.components.DetailPosterRailSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
private sealed interface PersonDetailUiState {
|
private sealed interface PersonDetailUiState {
|
||||||
data object Loading : PersonDetailUiState
|
data object Loading : PersonDetailUiState
|
||||||
|
|
@ -96,7 +100,7 @@ fun PersonDetailScreen(
|
||||||
uiState = if (detail != null) {
|
uiState = if (detail != null) {
|
||||||
PersonDetailUiState.Success(detail)
|
PersonDetailUiState.Success(detail)
|
||||||
} else {
|
} else {
|
||||||
PersonDetailUiState.Error("Could not load details for $personName")
|
PersonDetailUiState.Error(getString(Res.string.person_load_failed, personName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +145,7 @@ fun PersonDetailScreen(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = stringResource(Res.string.action_back),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +272,7 @@ private fun PersonDetailContent(
|
||||||
if (popularCredits.isNotEmpty()) {
|
if (popularCredits.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
DetailPosterRailSection(
|
DetailPosterRailSection(
|
||||||
title = "Popular",
|
title = stringResource(Res.string.person_popular),
|
||||||
items = popularCredits,
|
items = popularCredits,
|
||||||
watchedKeys = emptySet(),
|
watchedKeys = emptySet(),
|
||||||
headerHorizontalPadding = 20.dp,
|
headerHorizontalPadding = 20.dp,
|
||||||
|
|
@ -279,7 +283,7 @@ private fun PersonDetailContent(
|
||||||
if (latestCredits.isNotEmpty()) {
|
if (latestCredits.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
DetailPosterRailSection(
|
DetailPosterRailSection(
|
||||||
title = "Latest",
|
title = stringResource(Res.string.person_latest),
|
||||||
items = latestCredits,
|
items = latestCredits,
|
||||||
watchedKeys = emptySet(),
|
watchedKeys = emptySet(),
|
||||||
headerHorizontalPadding = 20.dp,
|
headerHorizontalPadding = 20.dp,
|
||||||
|
|
@ -290,7 +294,7 @@ private fun PersonDetailContent(
|
||||||
if (upcomingCredits.isNotEmpty()) {
|
if (upcomingCredits.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
DetailPosterRailSection(
|
DetailPosterRailSection(
|
||||||
title = "Upcoming",
|
title = stringResource(Res.string.person_upcoming),
|
||||||
items = upcomingCredits,
|
items = upcomingCredits,
|
||||||
watchedKeys = emptySet(),
|
watchedKeys = emptySet(),
|
||||||
headerHorizontalPadding = 20.dp,
|
headerHorizontalPadding = 20.dp,
|
||||||
|
|
@ -405,18 +409,23 @@ private fun HeroSection(
|
||||||
val infoItems = buildList {
|
val infoItems = buildList {
|
||||||
person.birthday?.let { bday ->
|
person.birthday?.let { bday ->
|
||||||
val age = calculateAge(bday, person.deathday)
|
val age = calculateAge(bday, person.deathday)
|
||||||
val ageStr = if (age != null) " (age $age)" else ""
|
val ageStr = if (age != null) stringResource(Res.string.person_age, age) else ""
|
||||||
val bdayDisplay = formatDateForDisplay(bday) ?: bday
|
val bdayDisplay = formatDateForDisplay(bday) ?: bday
|
||||||
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
|
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
|
||||||
val line = if (deathDisplay != null) {
|
val line = if (deathDisplay != null) {
|
||||||
"Born $bdayDisplay — Died $deathDisplay$ageStr"
|
buildString {
|
||||||
|
append(stringResource(Res.string.person_born, bdayDisplay, ""))
|
||||||
|
append(" — ")
|
||||||
|
append(stringResource(Res.string.person_died, deathDisplay))
|
||||||
|
append(ageStr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
"Born $bdayDisplay$ageStr"
|
stringResource(Res.string.person_born, bdayDisplay, ageStr)
|
||||||
}
|
}
|
||||||
add(line)
|
add(line)
|
||||||
}
|
}
|
||||||
person.placeOfBirth?.let { add(it) }
|
person.placeOfBirth?.let { add(it) }
|
||||||
person.knownFor?.let { add("Known for: $it") }
|
person.knownFor?.let { add(stringResource(Res.string.person_known_for, it)) }
|
||||||
}
|
}
|
||||||
if (infoItems.isNotEmpty()) {
|
if (infoItems.isNotEmpty()) {
|
||||||
infoItems.forEach { info ->
|
infoItems.forEach { info ->
|
||||||
|
|
@ -682,7 +691,7 @@ private fun PersonDetailError(
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = "Something went wrong",
|
text = stringResource(Res.string.person_something_wrong),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
@ -700,7 +709,7 @@ private fun PersonDetailError(
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("Retry")
|
Text(stringResource(Res.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -741,15 +750,11 @@ private fun calculateAge(birthday: String, deathday: String?): Int? {
|
||||||
private fun formatDateForDisplay(date: String): String? {
|
private fun formatDateForDisplay(date: String): String? {
|
||||||
val parts = date.split("-").mapNotNull { it.toIntOrNull() }
|
val parts = date.split("-").mapNotNull { it.toIntOrNull() }
|
||||||
if (parts.size < 3) return null
|
if (parts.size < 3) return null
|
||||||
val months = arrayOf(
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
)
|
|
||||||
val month = parts[1]
|
val month = parts[1]
|
||||||
val day = parts[2]
|
val day = parts[2]
|
||||||
val year = parts[0]
|
val year = parts[0]
|
||||||
return if (month in 1..12) {
|
return if (month in 1..12) {
|
||||||
"${months[month - 1]} $day, $year"
|
"${localizedShortMonthName(month)} $day, $year"
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
|
|
@ -73,6 +75,7 @@ fun TmdbEntityBrowseScreen(
|
||||||
var uiState by remember(entityKind, entityId) {
|
var uiState by remember(entityKind, entityId) {
|
||||||
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
|
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
|
||||||
}
|
}
|
||||||
|
val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName)
|
||||||
|
|
||||||
LaunchedEffect(entityKind, entityId) {
|
LaunchedEffect(entityKind, entityId) {
|
||||||
uiState = EntityBrowseUiState.Loading
|
uiState = EntityBrowseUiState.Loading
|
||||||
|
|
@ -85,7 +88,7 @@ fun TmdbEntityBrowseScreen(
|
||||||
uiState = if (data != null) {
|
uiState = if (data != null) {
|
||||||
EntityBrowseUiState.Success(data)
|
EntityBrowseUiState.Success(data)
|
||||||
} else {
|
} else {
|
||||||
EntityBrowseUiState.Error("Could not load $entityName")
|
EntityBrowseUiState.Error(loadFailedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +120,7 @@ fun TmdbEntityBrowseScreen(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = stringResource(Res.string.action_back),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +173,7 @@ private fun EntityBrowseContent(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No titles found",
|
text = stringResource(Res.string.catalog_empty_title),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -191,18 +194,16 @@ private fun EntityBrowseContent(
|
||||||
)
|
)
|
||||||
|
|
||||||
data.rails.forEach { rail ->
|
data.rails.forEach { rail ->
|
||||||
val railTitle = remember(rail.mediaType, rail.railType) {
|
|
||||||
val mediaLabel = when (rail.mediaType) {
|
val mediaLabel = when (rail.mediaType) {
|
||||||
TmdbEntityMediaType.MOVIE -> "Movies"
|
TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
|
||||||
TmdbEntityMediaType.TV -> "Series"
|
TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
|
||||||
}
|
}
|
||||||
val railLabel = when (rail.railType) {
|
val railLabel = when (rail.railType) {
|
||||||
TmdbEntityRailType.POPULAR -> "Popular"
|
TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular)
|
||||||
TmdbEntityRailType.TOP_RATED -> "Top Rated"
|
TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated)
|
||||||
TmdbEntityRailType.RECENT -> "Recent"
|
TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent)
|
||||||
}
|
|
||||||
"$mediaLabel • $railLabel"
|
|
||||||
}
|
}
|
||||||
|
val railTitle = stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel)
|
||||||
|
|
||||||
DetailPosterRailSection(
|
DetailPosterRailSection(
|
||||||
title = railTitle,
|
title = railTitle,
|
||||||
|
|
@ -230,8 +231,8 @@ private fun EntityHeroSection(
|
||||||
Column(modifier = modifier.padding(horizontal = 20.dp)) {
|
Column(modifier = modifier.padding(horizontal = 20.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = when (header.kind) {
|
text = when (header.kind) {
|
||||||
TmdbEntityKind.COMPANY -> "Production Company"
|
TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company)
|
||||||
TmdbEntityKind.NETWORK -> "Network"
|
TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelLarge.copy(
|
style = MaterialTheme.typography.labelLarge.copy(
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -405,7 +406,7 @@ private fun EntityBrowseError(
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("Retry")
|
Text(stringResource(Res.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -114,7 +116,7 @@ fun CommentDetailSheet(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
|
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
|
||||||
contentDescription = "Previous",
|
contentDescription = stringResource(Res.string.action_previous),
|
||||||
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
|
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
|
|
@ -140,7 +142,7 @@ fun CommentDetailSheet(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||||
contentDescription = "Next",
|
contentDescription = stringResource(Res.string.action_next),
|
||||||
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
|
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
|
|
@ -153,13 +155,13 @@ fun CommentDetailSheet(
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (comment.review) {
|
if (comment.review) {
|
||||||
CommentDetailChip(text = "Review")
|
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||||
}
|
}
|
||||||
if (comment.hasSpoilerContent) {
|
if (comment.hasSpoilerContent) {
|
||||||
CommentDetailChip(text = "Spoiler")
|
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
|
||||||
}
|
}
|
||||||
comment.rating?.let { rating ->
|
comment.rating?.let { rating ->
|
||||||
CommentDetailChip(text = "Rating $rating/10")
|
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +175,7 @@ fun CommentDetailSheet(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (comment.hasSpoilerContent) {
|
text = if (comment.hasSpoilerContent) {
|
||||||
"This comment contains spoilers and has been hidden."
|
stringResource(Res.string.detail_comments_spoiler_hidden_sheet)
|
||||||
} else {
|
} else {
|
||||||
comment.comment
|
comment.comment
|
||||||
},
|
},
|
||||||
|
|
@ -189,7 +191,7 @@ fun CommentDetailSheet(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "${comment.likes} likes",
|
text = stringResource(Res.string.detail_comments_likes, comment.likes),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,17 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.core.ui.AppIconResource
|
import com.nuvio.app.core.ui.AppIconResource
|
||||||
import com.nuvio.app.core.ui.appIconPainter
|
import com.nuvio.app.core.ui.appIconPainter
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.action_play
|
||||||
|
import nuvio.composeapp.generated.resources.action_save
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailActionButtons(
|
fun DetailActionButtons(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
playLabel: String = "Play",
|
playLabel: String = stringResource(Res.string.action_play),
|
||||||
saveLabel: String = "Save",
|
saveLabel: String = stringResource(Res.string.action_save),
|
||||||
isSaved: Boolean = false,
|
isSaved: Boolean = false,
|
||||||
isTablet: Boolean = false,
|
isTablet: Boolean = false,
|
||||||
onPlayClick: () -> Unit = {},
|
onPlayClick: () -> Unit = {},
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
import com.nuvio.app.features.details.MetaDetails
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
import com.nuvio.app.features.details.formatRuntimeForDisplay
|
import com.nuvio.app.features.details.formatRuntimeForDisplay
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailAdditionalInfoSection(
|
fun DetailAdditionalInfoSection(
|
||||||
|
|
@ -27,14 +29,24 @@ fun DetailAdditionalInfoSection(
|
||||||
showHeader: Boolean = true,
|
showHeader: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
|
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
|
||||||
val title = if (isSeriesLike) "Show Details" else "Movie Details"
|
val title = if (isSeriesLike) {
|
||||||
|
stringResource(Res.string.details_show_details)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.details_movie_details)
|
||||||
|
}
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
meta.status?.let { add("Status" to it) }
|
meta.status?.let { add(stringResource(Res.string.details_status) to it) }
|
||||||
meta.releaseInfo?.let { add("Release Info" to formatReleaseDateForDisplay(it)) }
|
meta.releaseInfo?.let {
|
||||||
formatRuntimeForDisplay(meta.runtime)?.let { add("Runtime" to it) }
|
add(stringResource(Res.string.details_release_info) to formatReleaseDateForDisplay(it))
|
||||||
meta.ageRating?.let { add("Certification" to it) }
|
}
|
||||||
meta.country?.let { add("Origin Country" to it) }
|
formatRuntimeForDisplay(meta.runtime)?.let {
|
||||||
meta.language?.let { add("Original Language" to it.uppercase()) }
|
add(stringResource(Res.string.details_runtime) to it)
|
||||||
|
}
|
||||||
|
meta.ageRating?.let { add(stringResource(Res.string.details_certification) to it) }
|
||||||
|
meta.country?.let { add(stringResource(Res.string.details_origin_country) to it) }
|
||||||
|
meta.language?.let {
|
||||||
|
add(stringResource(Res.string.details_original_language) to it.uppercase())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ import coil3.compose.LocalPlatformContext
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import com.nuvio.app.features.details.MetaPerson
|
import com.nuvio.app.features.details.MetaPerson
|
||||||
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
|
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
|
@ -48,7 +50,7 @@ fun DetailCastSection(
|
||||||
if (cast.isEmpty()) return
|
if (cast.isEmpty()) return
|
||||||
|
|
||||||
DetailSection(
|
DetailSection(
|
||||||
title = "Cast",
|
title = stringResource(Res.string.settings_meta_cast),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
showHeader = showHeader,
|
showHeader = showHeader,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailCommentsSection(
|
fun DetailCommentsSection(
|
||||||
|
|
@ -101,14 +103,14 @@ fun DetailCommentsSection(
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("Retry")
|
Text(stringResource(Res.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
comments.isEmpty() -> {
|
comments.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
text = "No comments yet.",
|
text = stringResource(Res.string.detail_comments_empty),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -144,7 +146,7 @@ private fun CommentsHeader() {
|
||||||
val titleSize = if (isTablet) 22.sp else 20.sp
|
val titleSize = if (isTablet) 22.sp else 20.sp
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Trakt Comments",
|
text = stringResource(Res.string.detail_comments_title),
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
fontSize = titleSize,
|
fontSize = titleSize,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -163,7 +165,7 @@ private fun CommentCard(
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505)
|
val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505)
|
||||||
val bodyText = if (review.hasSpoilerContent) {
|
val bodyText = if (review.hasSpoilerContent) {
|
||||||
"This comment contains spoilers."
|
stringResource(Res.string.detail_comments_spoiler_card)
|
||||||
} else {
|
} else {
|
||||||
review.comment
|
review.comment
|
||||||
}
|
}
|
||||||
|
|
@ -199,13 +201,13 @@ private fun CommentCard(
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (review.review) {
|
if (review.review) {
|
||||||
CommentChip(text = "Review")
|
CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||||
}
|
}
|
||||||
if (review.hasSpoilerContent) {
|
if (review.hasSpoilerContent) {
|
||||||
CommentChip(text = "Spoiler")
|
CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
|
||||||
}
|
}
|
||||||
review.rating?.let { rating ->
|
review.rating?.let { rating ->
|
||||||
CommentChip(text = "Rating $rating/10")
|
CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +221,7 @@ private fun CommentCard(
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "${review.likes} likes",
|
text = stringResource(Res.string.detail_comments_likes, review.likes),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.ui.NuvioBackButton
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
import com.nuvio.app.features.details.MetaDetails
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
import com.nuvio.app.isIos
|
import com.nuvio.app.isIos
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailFloatingHeader(
|
fun DetailFloatingHeader(
|
||||||
|
|
@ -112,7 +114,7 @@ fun DetailFloatingHeader(
|
||||||
if (meta.logo != null && !logoLoadError) {
|
if (meta.logo != null && !logoLoadError) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = meta.logo,
|
model = meta.logo,
|
||||||
contentDescription = "${meta.name} logo",
|
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(logoWidth)
|
.width(logoWidth)
|
||||||
.widthIn(max = 240.dp)
|
.widthIn(max = 240.dp)
|
||||||
|
|
@ -166,7 +168,11 @@ private fun DetailFloatingHeaderAction(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||||
contentDescription = if (isSaved) "Remove from Library" else "Add to Library",
|
contentDescription = if (isSaved) {
|
||||||
|
stringResource(Res.string.hero_remove_from_library)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.hero_add_to_library)
|
||||||
|
},
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.features.details.MetaDetails
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailHero(
|
fun DetailHero(
|
||||||
|
|
@ -103,7 +105,7 @@ fun DetailHero(
|
||||||
if (meta.logo != null) {
|
if (meta.logo != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = meta.logo,
|
model = meta.logo,
|
||||||
contentDescription = "${meta.name} logo",
|
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
|
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
|
||||||
.widthIn(max = contentMaxWidth)
|
.widthIn(max = contentMaxWidth)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB
|
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
|
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT
|
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.*
|
||||||
import nuvio.composeapp.generated.resources.rating_audience_score
|
import nuvio.composeapp.generated.resources.rating_audience_score
|
||||||
import nuvio.composeapp.generated.resources.rating_imdb
|
import nuvio.composeapp.generated.resources.rating_imdb
|
||||||
import nuvio.composeapp.generated.resources.rating_letterboxd
|
import nuvio.composeapp.generated.resources.rating_letterboxd
|
||||||
|
|
@ -58,7 +58,10 @@ import nuvio.composeapp.generated.resources.rating_rotten_tomatoes
|
||||||
import nuvio.composeapp.generated.resources.rating_tmdb
|
import nuvio.composeapp.generated.resources.rating_tmdb
|
||||||
import nuvio.composeapp.generated.resources.rating_trakt
|
import nuvio.composeapp.generated.resources.rating_trakt
|
||||||
import org.jetbrains.compose.resources.DrawableResource
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
@ -114,7 +117,7 @@ fun DetailMetaInfo(
|
||||||
color = ImdbYellow,
|
color = ImdbYellow,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "IMDb",
|
text = stringResource(Res.string.source_imdb),
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
|
|
@ -148,14 +151,14 @@ fun DetailMetaInfo(
|
||||||
|
|
||||||
if (meta.director.isNotEmpty()) {
|
if (meta.director.isNotEmpty()) {
|
||||||
MetaLabelValueRow(
|
MetaLabelValueRow(
|
||||||
label = "Director",
|
label = stringResource(Res.string.details_director),
|
||||||
value = meta.director.joinToString(", "),
|
value = meta.director.joinToString(", "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.writer.isNotEmpty()) {
|
if (meta.writer.isNotEmpty()) {
|
||||||
MetaLabelValueRow(
|
MetaLabelValueRow(
|
||||||
label = "Writer",
|
label = stringResource(Res.string.details_writer),
|
||||||
value = meta.writer.joinToString(", "),
|
value = meta.writer.joinToString(", "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +185,11 @@ fun DetailMetaInfo(
|
||||||
if (canExpand) {
|
if (canExpand) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (expanded) "Show Less" else "Show More ▾",
|
text = if (expanded) {
|
||||||
|
stringResource(Res.string.details_show_less)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.details_show_more)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.clickable { expanded = !expanded },
|
modifier = Modifier.clickable { expanded = !expanded },
|
||||||
|
|
@ -341,7 +348,7 @@ private val ratingVisuals = listOf(
|
||||||
),
|
),
|
||||||
RatingVisuals(
|
RatingVisuals(
|
||||||
source = PROVIDER_AUDIENCE,
|
source = PROVIDER_AUDIENCE,
|
||||||
displayName = "Audience Score",
|
displayName = runBlocking { getString(Res.string.rating_audience_score) },
|
||||||
logo = Res.drawable.rating_audience_score,
|
logo = Res.drawable.rating_audience_score,
|
||||||
logoWidth = 16.dp,
|
logoWidth = 16.dp,
|
||||||
valueColor = Color(0xFFFA320A),
|
valueColor = Color(0xFFFA320A),
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.features.details.MetaCompany
|
import com.nuvio.app.features.details.MetaCompany
|
||||||
import com.nuvio.app.features.details.MetaDetails
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -54,7 +56,11 @@ fun DetailProductionSection(
|
||||||
if (displayItems.isEmpty()) return
|
if (displayItems.isEmpty()) return
|
||||||
|
|
||||||
DetailSection(
|
DetailSection(
|
||||||
title = if (isSeriesLike) "Network" else "Production",
|
title = if (isSeriesLike) {
|
||||||
|
stringResource(Res.string.details_networks)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.meta_section_production_title)
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
showHeader = showHeader,
|
showHeader = showHeader,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
|
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||||
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
||||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||||
import com.nuvio.app.features.details.MetaDetails
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
|
|
@ -71,6 +72,10 @@ import com.nuvio.app.features.details.seasonSortKey
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
private val log = Logger.withTag("SeriesContent")
|
private val log = Logger.withTag("SeriesContent")
|
||||||
|
|
||||||
|
|
@ -92,16 +97,16 @@ fun DetailSeriesContent(
|
||||||
|
|
||||||
if (meta.videos.isEmpty()) {
|
if (meta.videos.isEmpty()) {
|
||||||
DetailSection(
|
DetailSection(
|
||||||
title = "Episodes",
|
title = stringResource(Res.string.settings_meta_episodes),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
showHeader = showHeader,
|
showHeader = showHeader,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos ->
|
meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos ->
|
||||||
"Episodes have not been published by this addon yet."
|
stringResource(Res.string.details_series_unpublished)
|
||||||
else ->
|
else ->
|
||||||
"This addon did not provide episode metadata for this series."
|
stringResource(Res.string.details_series_no_metadata)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -132,12 +137,12 @@ fun DetailSeriesContent(
|
||||||
if (groupedEpisodes.isEmpty()) {
|
if (groupedEpisodes.isEmpty()) {
|
||||||
if (meta.type == "series") {
|
if (meta.type == "series") {
|
||||||
DetailSection(
|
DetailSection(
|
||||||
title = "Episodes",
|
title = stringResource(Res.string.settings_meta_episodes),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
showHeader = showHeader,
|
showHeader = showHeader,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "This addon returned videos for the series, but none included season or episode numbers.",
|
text = stringResource(Res.string.details_series_missing_numbers),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -182,7 +187,7 @@ fun DetailSeriesContent(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Seasons",
|
text = stringResource(Res.string.details_seasons),
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
fontSize = sizing.seasonHeaderSize,
|
fontSize = sizing.seasonHeaderSize,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -250,7 +255,7 @@ fun DetailSeriesContent(
|
||||||
label = "season_episodes",
|
label = "season_episodes",
|
||||||
) { seasonForContent ->
|
) { seasonForContent ->
|
||||||
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
|
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
|
||||||
"Videos"
|
stringResource(Res.string.details_videos)
|
||||||
} else {
|
} else {
|
||||||
seasonForContent.label()
|
seasonForContent.label()
|
||||||
}
|
}
|
||||||
|
|
@ -336,7 +341,11 @@ private fun SeasonViewModeToggle(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isPosters) "Posters" else "Text",
|
text = if (isPosters) {
|
||||||
|
stringResource(Res.string.details_season_view_posters)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.details_season_view_text)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelLarge.copy(
|
style = MaterialTheme.typography.labelLarge.copy(
|
||||||
fontSize = sizing.seasonToggleTextSize,
|
fontSize = sizing.seasonToggleTextSize,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -1187,14 +1196,14 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
|
||||||
|
|
||||||
private fun Int.label(): String =
|
private fun Int.label(): String =
|
||||||
if (this <= 0) {
|
if (this <= 0) {
|
||||||
"Specials"
|
runBlocking { getString(Res.string.episodes_specials) }
|
||||||
} else {
|
} else {
|
||||||
"Season $this"
|
runBlocking { getString(Res.string.episodes_season, this@label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MetaVideo.episodeBadge(): String =
|
private fun MetaVideo.episodeBadge(): String =
|
||||||
when {
|
when {
|
||||||
episode != null -> "E${episode.toString().padStart(2, '0')}"
|
episode != null || season != null ->
|
||||||
season != null -> "S${season.toString().padStart(2, '0')}"
|
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||||
else -> "FILE"
|
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.features.details.MetaTrailer
|
import com.nuvio.app.features.details.MetaTrailer
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import nuvio.composeapp.generated.resources.detail_tab_trailer
|
||||||
|
import nuvio.composeapp.generated.resources.detail_trailer_category_count
|
||||||
|
import nuvio.composeapp.generated.resources.detail_trailers_title
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailTrailersSection(
|
fun DetailTrailersSection(
|
||||||
|
|
@ -48,10 +53,11 @@ fun DetailTrailersSection(
|
||||||
) {
|
) {
|
||||||
if (trailers.isEmpty()) return
|
if (trailers.isEmpty()) return
|
||||||
|
|
||||||
|
val trailerLabel = stringResource(Res.string.detail_tab_trailer)
|
||||||
val grouped = remember(trailers) {
|
val grouped = remember(trailers) {
|
||||||
linkedMapOf<String, MutableList<MetaTrailer>>().apply {
|
linkedMapOf<String, MutableList<MetaTrailer>>().apply {
|
||||||
trailers.forEach { trailer ->
|
trailers.forEach { trailer ->
|
||||||
val category = trailer.type.ifBlank { "Trailer" }
|
val category = trailer.type.ifBlank { trailerLabel }
|
||||||
getOrPut(category) { mutableListOf() }.add(trailer)
|
getOrPut(category) { mutableListOf() }.add(trailer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +66,7 @@ fun DetailTrailersSection(
|
||||||
if (grouped.isEmpty()) return
|
if (grouped.isEmpty()) return
|
||||||
|
|
||||||
val initialCategory = remember(grouped) {
|
val initialCategory = remember(grouped) {
|
||||||
grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) }
|
grouped.keys.firstOrNull { it.equals(trailerLabel, ignoreCase = true) }
|
||||||
?: grouped.keys.first()
|
?: grouped.keys.first()
|
||||||
}
|
}
|
||||||
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
|
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
|
||||||
|
|
@ -82,7 +88,7 @@ fun DetailTrailersSection(
|
||||||
) {
|
) {
|
||||||
if (showHeader) {
|
if (showHeader) {
|
||||||
DetailSectionTitle(
|
DetailSectionTitle(
|
||||||
title = "Trailers",
|
title = stringResource(Res.string.detail_trailers_title),
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +137,7 @@ fun DetailTrailersSection(
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = "$category ($count)",
|
text = stringResource(Res.string.detail_trailer_category_count, category, count),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,11 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
||||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||||
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
|
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -71,7 +74,11 @@ fun EpisodeWatchedActionSheet(
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.CheckCircle,
|
icon = Icons.Default.CheckCircle,
|
||||||
title = if (isEpisodeWatched) "Mark as unwatched" else "Mark as watched",
|
title = if (isEpisodeWatched) {
|
||||||
|
stringResource(Res.string.episode_mark_unwatched)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.episode_mark_watched)
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleWatched()
|
onToggleWatched()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -84,9 +91,9 @@ fun EpisodeWatchedActionSheet(
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.DoneAll,
|
icon = Icons.Default.DoneAll,
|
||||||
title = if (arePreviousEpisodesWatched) {
|
title = if (arePreviousEpisodesWatched) {
|
||||||
"Mark previous as unwatched"
|
stringResource(Res.string.episode_mark_previous_unwatched)
|
||||||
} else {
|
} else {
|
||||||
"Mark previous as watched"
|
stringResource(Res.string.episode_mark_previous_watched)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onTogglePreviousWatched()
|
onTogglePreviousWatched()
|
||||||
|
|
@ -100,9 +107,9 @@ fun EpisodeWatchedActionSheet(
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.PlaylistAddCheckCircle,
|
icon = Icons.Default.PlaylistAddCheckCircle,
|
||||||
title = if (isSeasonWatched) {
|
title = if (isSeasonWatched) {
|
||||||
"Mark $seasonLabel as unwatched"
|
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
|
||||||
} else {
|
} else {
|
||||||
"Mark $seasonLabel as watched"
|
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleSeasonWatched()
|
onToggleSeasonWatched()
|
||||||
|
|
@ -115,7 +122,7 @@ fun EpisodeWatchedActionSheet(
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
title = "Play manually",
|
title = stringResource(Res.string.play_manually),
|
||||||
onClick = {
|
onClick = {
|
||||||
onPlayManually()
|
onPlayManually()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -149,8 +156,11 @@ private fun EpisodeActionSheetHeader(
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
if (episode.season != null && episode.episode != null) {
|
localizedSeasonEpisodeCode(
|
||||||
append("S${episode.season}E${episode.episode}")
|
seasonNumber = episode.season,
|
||||||
|
episodeNumber = episode.episode,
|
||||||
|
)?.let {
|
||||||
|
append(it)
|
||||||
append(" • ")
|
append(" • ")
|
||||||
}
|
}
|
||||||
append(seasonLabel)
|
append(seasonLabel)
|
||||||
|
|
@ -162,4 +172,3 @@ private fun EpisodeActionSheetHeader(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import com.nuvio.app.features.player.PlatformPlayerSurface
|
||||||
import com.nuvio.app.features.player.PlayerResizeMode
|
import com.nuvio.app.features.player.PlayerResizeMode
|
||||||
import com.nuvio.app.features.trailer.TrailerPlaybackSource
|
import com.nuvio.app.features.trailer.TrailerPlaybackSource
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -55,7 +57,7 @@ fun TrailerPlayerPopup(
|
||||||
) {
|
) {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
val headerType = trailerType.trim().ifBlank { "Trailer" }
|
val headerType = trailerType.trim().ifBlank { stringResource(Res.string.detail_tab_trailer) }
|
||||||
val headerSubtitle = buildList {
|
val headerSubtitle = buildList {
|
||||||
if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) {
|
if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) {
|
||||||
add(trailerTitle)
|
add(trailerTitle)
|
||||||
|
|
@ -119,7 +121,7 @@ fun TrailerPlayerPopup(
|
||||||
IconButton(onClick = dismissSheet) {
|
IconButton(onClick = dismissSheet) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Close,
|
imageVector = Icons.Rounded.Close,
|
||||||
contentDescription = "Close trailer",
|
contentDescription = stringResource(Res.string.trailer_close),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +149,7 @@ fun TrailerPlayerPopup(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Unable to play trailer",
|
text = stringResource(Res.string.trailer_unable_to_play),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
@ -160,7 +162,7 @@ fun TrailerPlayerPopup(
|
||||||
)
|
)
|
||||||
if (onRetry != null) {
|
if (onRetry != null) {
|
||||||
TextButton(onClick = onRetry) {
|
TextButton(onClick = onRetry) {
|
||||||
Text("Retry")
|
Text(stringResource(Res.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
package com.nuvio.app.features.downloads
|
package com.nuvio.app.features.downloads
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.downloads_enqueue_missing_url
|
||||||
|
import nuvio.composeapp.generated.resources.downloads_enqueue_replaced
|
||||||
|
import nuvio.composeapp.generated.resources.downloads_enqueue_started
|
||||||
|
import nuvio.composeapp.generated.resources.downloads_enqueue_unsupported_format
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class DownloadStatus {
|
enum class DownloadStatus {
|
||||||
|
|
@ -48,22 +55,7 @@ data class DownloadItem(
|
||||||
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
|
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
|
||||||
|
|
||||||
val displaySubtitle: String
|
val displaySubtitle: String
|
||||||
get() = if (isEpisode) {
|
get() = episodeTitle.orEmpty()
|
||||||
buildString {
|
|
||||||
append("S")
|
|
||||||
append(seasonNumber)
|
|
||||||
append("E")
|
|
||||||
append(episodeNumber)
|
|
||||||
episodeTitle
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let {
|
|
||||||
append(" • ")
|
|
||||||
append(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"Movie"
|
|
||||||
}
|
|
||||||
|
|
||||||
val progressFraction: Float
|
val progressFraction: Float
|
||||||
get() {
|
get() {
|
||||||
|
|
@ -91,11 +83,18 @@ data class DownloadsUiState(
|
||||||
get() = items.filter { it.status == DownloadStatus.Completed }
|
get() = items.filter { it.status == DownloadStatus.Completed }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class DownloadEnqueueResult(
|
enum class DownloadEnqueueResult {
|
||||||
val toastMessage: String,
|
Started,
|
||||||
) {
|
Replaced,
|
||||||
Started("Download started"),
|
MissingUrl,
|
||||||
Replaced("Replaced previous download"),
|
UnsupportedFormat;
|
||||||
MissingUrl("No direct stream link available"),
|
|
||||||
UnsupportedFormat("Unsupported stream format for downloads"),
|
fun toastMessage(): String = runBlocking {
|
||||||
|
when (this@DownloadEnqueueResult) {
|
||||||
|
Started -> getString(Res.string.downloads_enqueue_started)
|
||||||
|
Replaced -> getString(Res.string.downloads_enqueue_replaced)
|
||||||
|
MissingUrl -> getString(Res.string.downloads_enqueue_missing_url)
|
||||||
|
UnsupportedFormat -> getString(Res.string.downloads_enqueue_unsupported_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.features.downloads
|
package com.nuvio.app.features.downloads
|
||||||
|
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -9,6 +10,8 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
object DownloadsRepository {
|
object DownloadsRepository {
|
||||||
private val _uiState = MutableStateFlow(DownloadsUiState())
|
private val _uiState = MutableStateFlow(DownloadsUiState())
|
||||||
|
|
@ -294,7 +297,7 @@ object DownloadsRepository {
|
||||||
} else {
|
} else {
|
||||||
current.copy(
|
current.copy(
|
||||||
status = DownloadStatus.Failed,
|
status = DownloadStatus.Failed,
|
||||||
errorMessage = message.ifBlank { "Download failed" },
|
errorMessage = message.ifBlank { runBlocking { getString(Res.string.download_failed) } },
|
||||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,11 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.nuvio.app.core.i18n.localizedByteUnit
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadsScreen(
|
fun DownloadsScreen(
|
||||||
|
|
@ -66,9 +69,9 @@ fun DownloadsScreen(
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = if (selectedShowId == null) {
|
title = if (selectedShowId == null) {
|
||||||
"Downloads"
|
stringResource(Res.string.compose_settings_root_downloads_title)
|
||||||
} else {
|
} else {
|
||||||
selectedShowTitle ?: "Show Downloads"
|
selectedShowTitle ?: stringResource(Res.string.downloads_show_downloads)
|
||||||
},
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
if (selectedShowId != null) {
|
if (selectedShowId != null) {
|
||||||
|
|
@ -115,7 +118,7 @@ private fun LazyListScope.downloadsRootContent(
|
||||||
|
|
||||||
if (activeItems.isNotEmpty()) {
|
if (activeItems.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
SectionTitle("ACTIVE")
|
SectionTitle(stringResource(Res.string.downloads_section_active))
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = activeItems,
|
items = activeItems,
|
||||||
|
|
@ -134,7 +137,7 @@ private fun LazyListScope.downloadsRootContent(
|
||||||
|
|
||||||
if (completedMovies.isNotEmpty()) {
|
if (completedMovies.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
SectionTitle("MOVIES")
|
SectionTitle(stringResource(Res.string.downloads_section_movies))
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = completedMovies,
|
items = completedMovies,
|
||||||
|
|
@ -153,7 +156,7 @@ private fun LazyListScope.downloadsRootContent(
|
||||||
|
|
||||||
if (completedShows.isNotEmpty()) {
|
if (completedShows.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
SectionTitle("SHOWS")
|
SectionTitle(stringResource(Res.string.downloads_section_shows))
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = completedShows,
|
items = completedShows,
|
||||||
|
|
@ -186,7 +189,7 @@ private fun LazyListScope.downloadsRootContent(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${episodes.size} downloaded episode${if (episodes.size == 1) "" else "s"}",
|
text = stringResource(Res.string.downloads_episode_count, episodes.size),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -210,7 +213,7 @@ private fun LazyListScope.downloadsRootContent(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No downloads yet",
|
text = stringResource(Res.string.downloads_empty_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -245,7 +248,7 @@ private fun LazyListScope.downloadsShowContent(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No completed episodes",
|
text = stringResource(Res.string.downloads_empty_episodes),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -255,13 +258,14 @@ private fun LazyListScope.downloadsShowContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
seasons.forEach { (seasonNumber, entries) ->
|
seasons.forEach { (seasonNumber, entries) ->
|
||||||
val seasonTitle = if (seasonNumber == 0) {
|
|
||||||
"Specials"
|
|
||||||
} else {
|
|
||||||
"Season $seasonNumber"
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
SectionTitle(seasonTitle)
|
SectionTitle(
|
||||||
|
if (seasonNumber == 0) {
|
||||||
|
stringResource(Res.string.episodes_specials)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.episodes_season, seasonNumber)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortedEpisodes = entries.sortedWith(
|
val sortedEpisodes = entries.sortedWith(
|
||||||
|
|
@ -345,7 +349,7 @@ private fun DownloadRow(
|
||||||
IconButton(onClick = onPause) {
|
IconButton(onClick = onPause) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Pause,
|
imageVector = Icons.Rounded.Pause,
|
||||||
contentDescription = "Pause",
|
contentDescription = stringResource(Res.string.compose_action_pause),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +357,7 @@ private fun DownloadRow(
|
||||||
IconButton(onClick = onResume) {
|
IconButton(onClick = onResume) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.PlayArrow,
|
imageVector = Icons.Rounded.PlayArrow,
|
||||||
contentDescription = "Resume",
|
contentDescription = stringResource(Res.string.action_resume),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -361,7 +365,7 @@ private fun DownloadRow(
|
||||||
IconButton(onClick = onRetry) {
|
IconButton(onClick = onRetry) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Refresh,
|
imageVector = Icons.Rounded.Refresh,
|
||||||
contentDescription = "Retry",
|
contentDescription = stringResource(Res.string.action_retry),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +373,7 @@ private fun DownloadRow(
|
||||||
IconButton(onClick = onOpen) {
|
IconButton(onClick = onOpen) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.PlayArrow,
|
imageVector = Icons.Rounded.PlayArrow,
|
||||||
contentDescription = "Play",
|
contentDescription = stringResource(Res.string.action_play),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -377,7 +381,7 @@ private fun DownloadRow(
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Delete,
|
imageVector = Icons.Rounded.Delete,
|
||||||
contentDescription = "Delete",
|
contentDescription = stringResource(Res.string.action_delete),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -410,6 +414,7 @@ private fun SectionTitle(title: String) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun statusText(item: DownloadItem): String {
|
private fun statusText(item: DownloadItem): String {
|
||||||
val size = if (item.totalBytes != null && item.totalBytes > 0L) {
|
val size = if (item.totalBytes != null && item.totalBytes > 0L) {
|
||||||
"${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}"
|
"${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}"
|
||||||
|
|
@ -418,23 +423,26 @@ private fun statusText(item: DownloadItem): String {
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (item.status) {
|
return when (item.status) {
|
||||||
DownloadStatus.Downloading -> "Downloading • $size"
|
DownloadStatus.Downloading -> stringResource(Res.string.downloads_status_downloading, size)
|
||||||
DownloadStatus.Paused -> "Paused • $size"
|
DownloadStatus.Paused -> stringResource(Res.string.downloads_status_paused, size)
|
||||||
DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}"
|
DownloadStatus.Completed -> stringResource(
|
||||||
DownloadStatus.Failed -> item.errorMessage ?: "Failed"
|
Res.string.downloads_status_completed,
|
||||||
|
formatBytes(item.totalBytes ?: item.downloadedBytes),
|
||||||
|
)
|
||||||
|
DownloadStatus.Failed -> item.errorMessage ?: stringResource(Res.string.downloads_status_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatBytes(bytes: Long): String {
|
private fun formatBytes(bytes: Long): String {
|
||||||
if (bytes <= 0L) return "0 B"
|
if (bytes <= 0L) return "0 ${localizedByteUnit("B")}"
|
||||||
val kib = 1024.0
|
val kib = 1024.0
|
||||||
val mib = kib * 1024.0
|
val mib = kib * 1024.0
|
||||||
val gib = mib * 1024.0
|
val gib = mib * 1024.0
|
||||||
val value = bytes.toDouble()
|
val value = bytes.toDouble()
|
||||||
return when {
|
return when {
|
||||||
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB"
|
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}"
|
||||||
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB"
|
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
|
||||||
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB"
|
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
|
||||||
else -> "$bytes B"
|
else -> "$bytes ${localizedByteUnit("B")}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
package com.nuvio.app.features.home
|
package com.nuvio.app.features.home
|
||||||
|
|
||||||
|
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
import com.nuvio.app.features.catalog.supportsPagination
|
import com.nuvio.app.features.catalog.supportsPagination
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.home_catalog_default_title
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
data class HomeCatalogDefinition(
|
data class HomeCatalogDefinition(
|
||||||
val key: String,
|
val key: String,
|
||||||
|
|
@ -23,7 +28,13 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
|
||||||
.map { catalog ->
|
.map { catalog ->
|
||||||
HomeCatalogDefinition(
|
HomeCatalogDefinition(
|
||||||
key = "${manifest.id}:${catalog.type}:${catalog.id}",
|
key = "${manifest.id}:${catalog.type}:${catalog.id}",
|
||||||
defaultTitle = "${catalog.name} - ${catalog.type.displayLabel()}",
|
defaultTitle = runBlocking {
|
||||||
|
getString(
|
||||||
|
Res.string.home_catalog_default_title,
|
||||||
|
catalog.name,
|
||||||
|
localizedMediaTypeLabel(catalog.type),
|
||||||
|
)
|
||||||
|
},
|
||||||
addonName = addon.displayTitle,
|
addonName = addon.displayTitle,
|
||||||
manifestUrl = addon.manifestUrl,
|
manifestUrl = addon.manifestUrl,
|
||||||
type = catalog.type,
|
type = catalog.type,
|
||||||
|
|
@ -33,7 +44,4 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
|
||||||
}
|
}
|
||||||
}.distinctBy(HomeCatalogDefinition::key)
|
}.distinctBy(HomeCatalogDefinition::key)
|
||||||
|
|
||||||
internal fun String.displayLabel(): String =
|
internal fun String.displayLabel(): String = localizedMediaTypeLabel(this)
|
||||||
replaceFirstChar { char ->
|
|
||||||
if (char.isLowerCase()) char.titlecase() else char.toString()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.home
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
import com.nuvio.app.features.collection.Collection
|
import com.nuvio.app.features.collection.Collection
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -10,6 +11,8 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
data class HomeCatalogSettingsItem(
|
data class HomeCatalogSettingsItem(
|
||||||
val key: String,
|
val key: String,
|
||||||
|
|
@ -480,7 +483,7 @@ internal fun buildCollectionDefinitions(collections: List<Collection>): List<Col
|
||||||
key = "collection_${collection.id}",
|
key = "collection_${collection.id}",
|
||||||
collectionId = collection.id,
|
collectionId = collection.id,
|
||||||
title = collection.title,
|
title = collection.title,
|
||||||
subtitle = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}",
|
subtitle = runBlocking { getString(Res.string.collections_folder_count, collection.folders.size) },
|
||||||
isPinnedToTop = collection.pinToTop,
|
isPinnedToTop = collection.pinToTop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ import kotlinx.coroutines.sync.withPermit
|
||||||
import com.nuvio.app.features.home.components.ContinueWatchingLayout
|
import com.nuvio.app.features.home.components.ContinueWatchingLayout
|
||||||
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
|
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
|
||||||
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
|
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
|
|
@ -417,8 +419,8 @@ fun HomeScreen(
|
||||||
item {
|
item {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
title = "No active addons",
|
title = stringResource(Res.string.compose_search_empty_no_active_addons_title),
|
||||||
message = "Install and validate at least one addon before loading catalog rows on Home.",
|
message = stringResource(Res.string.home_empty_no_active_addons_message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -457,9 +459,9 @@ fun HomeScreen(
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
title = "No home rows available",
|
title = stringResource(Res.string.home_empty_no_rows_title),
|
||||||
message = homeUiState.errorMessage
|
message = homeUiState.errorMessage
|
||||||
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
|
?: stringResource(Res.string.home_empty_no_rows_message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -610,25 +612,12 @@ private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
|
||||||
isNextUp || progressFraction < 0.995f
|
isNextUp || progressFraction < 0.995f
|
||||||
|
|
||||||
private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
||||||
val subtitle = buildString {
|
|
||||||
append("Up Next")
|
|
||||||
if (season != null && episode != null) {
|
|
||||||
append(" • S")
|
|
||||||
append(season)
|
|
||||||
append("E")
|
|
||||||
append(episode)
|
|
||||||
}
|
|
||||||
episodeTitle?.takeIf { it.isNotBlank() }?.let {
|
|
||||||
append(" • ")
|
|
||||||
append(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ContinueWatchingItem(
|
return ContinueWatchingItem(
|
||||||
parentMetaId = contentId,
|
parentMetaId = contentId,
|
||||||
parentMetaType = contentType,
|
parentMetaType = contentType,
|
||||||
videoId = videoId,
|
videoId = videoId,
|
||||||
title = name,
|
title = name,
|
||||||
subtitle = subtitle,
|
subtitle = episodeTitle.orEmpty(),
|
||||||
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
||||||
logo = logo,
|
logo = logo,
|
||||||
poster = poster,
|
poster = poster,
|
||||||
|
|
@ -649,20 +638,6 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem {
|
private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem {
|
||||||
val subtitle = if (season != null && episode != null) {
|
|
||||||
buildString {
|
|
||||||
append("S")
|
|
||||||
append(season)
|
|
||||||
append("E")
|
|
||||||
append(episode)
|
|
||||||
episodeTitle?.takeIf { it.isNotBlank() }?.let {
|
|
||||||
append(" • ")
|
|
||||||
append(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"Movie"
|
|
||||||
}
|
|
||||||
val explicitResumeProgressFraction = progressPercent
|
val explicitResumeProgressFraction = progressPercent
|
||||||
?.takeIf { duration <= 0L && it > 0f }
|
?.takeIf { duration <= 0L && it > 0f }
|
||||||
?.let { (it / 100f).coerceIn(0f, 1f) }
|
?.let { (it / 100f).coerceIn(0f, 1f) }
|
||||||
|
|
@ -679,7 +654,7 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
|
||||||
parentMetaType = contentType,
|
parentMetaType = contentType,
|
||||||
videoId = videoId,
|
videoId = videoId,
|
||||||
title = name,
|
title = name,
|
||||||
subtitle = subtitle,
|
subtitle = episodeTitle.orEmpty(),
|
||||||
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
||||||
logo = logo,
|
logo = logo,
|
||||||
poster = poster,
|
poster = poster,
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,15 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
||||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.posterCardClickable
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||||
|
|
@ -95,7 +98,7 @@ private fun HomeContinueWatchingSectionContent(
|
||||||
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
|
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
title = "Continue Watching",
|
title = stringResource(Res.string.compose_settings_page_continue_watching),
|
||||||
entries = items,
|
entries = items,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headerHorizontalPadding = sectionPadding,
|
headerHorizontalPadding = sectionPadding,
|
||||||
|
|
@ -305,11 +308,7 @@ private fun ContinueWatchingWideCard(
|
||||||
) {
|
) {
|
||||||
val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null
|
val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null
|
||||||
val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank()
|
val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank()
|
||||||
val wideMetaLine = when {
|
val wideMetaLine = localizedContinueWatchingSubtitle(item)
|
||||||
item.progressFraction <= 0f && isEpisodeCard -> "Up Next • S${item.seasonNumber}E${item.episodeNumber}"
|
|
||||||
isEpisodeCard -> "S${item.seasonNumber}E${item.episodeNumber}"
|
|
||||||
else -> item.subtitle
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -364,7 +363,10 @@ private fun ContinueWatchingWideCard(
|
||||||
trackColor = Color.White.copy(alpha = 0.10f),
|
trackColor = Color.White.copy(alpha = 0.10f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${continueWatchingProgressPercent(item.progressFraction)}% watched",
|
text = stringResource(
|
||||||
|
Res.string.home_continue_watching_watched,
|
||||||
|
continueWatchingProgressPercent(item.progressFraction),
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
fontSize = layout.progressLabelSize,
|
fontSize = layout.progressLabelSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -466,7 +468,11 @@ private fun ContinueWatchingPosterCard(
|
||||||
}
|
}
|
||||||
if (item.seasonNumber != null && item.episodeNumber != null) {
|
if (item.seasonNumber != null && item.episodeNumber != null) {
|
||||||
Text(
|
Text(
|
||||||
text = "S${item.seasonNumber} E${item.episodeNumber}",
|
text = stringResource(
|
||||||
|
Res.string.streams_episode_badge,
|
||||||
|
item.seasonNumber,
|
||||||
|
item.episodeNumber,
|
||||||
|
),
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
fontSize = layout.posterMetaSize,
|
fontSize = layout.posterMetaSize,
|
||||||
|
|
@ -519,7 +525,7 @@ private fun UpNextBadge(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Up next",
|
text = stringResource(Res.string.home_continue_watching_up_next),
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
fontSize = textSize,
|
fontSize = textSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
private const val HERO_BACKGROUND_PARALLAX = 0.055f
|
private const val HERO_BACKGROUND_PARALLAX = 0.055f
|
||||||
|
|
@ -256,7 +258,7 @@ fun HomeHeroSection(
|
||||||
shape = RoundedCornerShape(40.dp),
|
shape = RoundedCornerShape(40.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "View Details",
|
text = stringResource(Res.string.home_view_details),
|
||||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
|
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import io.github.jan.supabase.postgrest.rpc
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -24,6 +25,9 @@ import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.library_other
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class StoredLibraryPayload(
|
private data class StoredLibraryPayload(
|
||||||
|
|
@ -366,7 +370,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
|
||||||
|
|
||||||
internal fun String.toLibraryDisplayTitle(): String {
|
internal fun String.toLibraryDisplayTitle(): String {
|
||||||
val normalized = trim()
|
val normalized = trim()
|
||||||
if (normalized.isBlank()) return "Other"
|
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
.split('-', '_', ' ')
|
.split('-', '_', ' ')
|
||||||
|
|
@ -374,5 +378,5 @@ internal fun String.toLibraryDisplayTitle(): String {
|
||||||
.joinToString(" ") { token ->
|
.joinToString(" ") { token ->
|
||||||
token.lowercase().replaceFirstChar { char -> char.uppercase() }
|
token.lowercase().replaceFirstChar { char -> char.uppercase() }
|
||||||
}
|
}
|
||||||
.ifBlank { "Other" }
|
.ifBlank { runBlocking { getString(Res.string.library_other) } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import com.nuvio.app.features.home.components.HomePosterCard
|
||||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
|
|
@ -84,7 +86,11 @@ fun LibraryScreen(
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = if (isTraktSource) "Trakt Library" else "Library",
|
title = if (isTraktSource) {
|
||||||
|
stringResource(Res.string.library_trakt_title)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_title)
|
||||||
|
},
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
@ -116,7 +122,11 @@ fun LibraryScreen(
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
title = if (isTraktSource) "Couldn't load Trakt library" else "Couldn't load library",
|
title = if (isTraktSource) {
|
||||||
|
stringResource(Res.string.library_trakt_load_failed)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_load_failed)
|
||||||
|
},
|
||||||
message = uiState.errorMessage.orEmpty(),
|
message = uiState.errorMessage.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -139,11 +149,15 @@ fun LibraryScreen(
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty",
|
title = if (isTraktSource) {
|
||||||
message = if (isTraktSource) {
|
stringResource(Res.string.library_trakt_empty_title)
|
||||||
"Connect Trakt and save titles to your watchlist or personal lists."
|
|
||||||
} else {
|
} else {
|
||||||
"Saved titles will appear here after you tap Save on a details screen."
|
stringResource(Res.string.library_empty_title)
|
||||||
|
},
|
||||||
|
message = if (isTraktSource) {
|
||||||
|
stringResource(Res.string.library_trakt_empty_message)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_empty_message)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -166,11 +180,13 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = "Remove from Library?",
|
title = stringResource(Res.string.library_remove_title),
|
||||||
message = pendingRemovalItem?.let { "Remove ${it.name} from your library?" }.orEmpty(),
|
message = pendingRemovalItem?.let {
|
||||||
|
stringResource(Res.string.library_remove_message, it.name)
|
||||||
|
}.orEmpty(),
|
||||||
isVisible = pendingRemovalItem != null,
|
isVisible = pendingRemovalItem != null,
|
||||||
confirmText = "Remove",
|
confirmText = stringResource(Res.string.library_remove_confirm),
|
||||||
dismissText = "Cancel",
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
pendingRemovalItem?.id?.let(LibraryRepository::remove)
|
pendingRemovalItem?.id?.let(LibraryRepository::remove)
|
||||||
pendingRemovalItem = null
|
pendingRemovalItem = null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
package com.nuvio.app.features.notifications
|
package com.nuvio.app.features.notifications
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
|
||||||
|
import nuvio.composeapp.generated.resources.notifications_episode_release_body_code
|
||||||
|
import nuvio.composeapp.generated.resources.notifications_episode_release_body_code_title
|
||||||
|
import nuvio.composeapp.generated.resources.notifications_episode_release_body_generic
|
||||||
|
import nuvio.composeapp.generated.resources.notifications_episode_release_body_title
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
data class EpisodeReleaseNotificationsUiState(
|
data class EpisodeReleaseNotificationsUiState(
|
||||||
|
|
@ -76,16 +85,24 @@ internal fun buildEpisodeReleaseNotificationBody(
|
||||||
seasonNumber: Int?,
|
seasonNumber: Int?,
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
episodeTitle: String?,
|
episodeTitle: String?,
|
||||||
): String {
|
): String = runBlocking {
|
||||||
val seasonLabel = seasonNumber?.let { season -> "S${season.toString().padStart(2, '0')}" }
|
val code = when {
|
||||||
val episodeLabel = episodeNumber?.let { episode -> "E${episode.toString().padStart(2, '0')}" }
|
seasonNumber != null && episodeNumber != null ->
|
||||||
val code = listOfNotNull(seasonLabel, episodeLabel).joinToString(separator = "")
|
getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
|
||||||
|
episodeNumber != null ->
|
||||||
|
getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
val title = episodeTitle?.trim().takeUnless { it.isNullOrBlank() }
|
val title = episodeTitle?.trim().takeUnless { it.isNullOrBlank() }
|
||||||
|
|
||||||
return when {
|
when {
|
||||||
code.isNotBlank() && title != null -> "$code • $title is out now"
|
code.isNotBlank() && title != null ->
|
||||||
code.isNotBlank() -> "$code is out now"
|
getString(Res.string.notifications_episode_release_body_code_title, code, title)
|
||||||
title != null -> "$title is out now"
|
code.isNotBlank() ->
|
||||||
else -> "A new episode is out now"
|
getString(Res.string.notifications_episode_release_body_code, code)
|
||||||
|
title != null ->
|
||||||
|
getString(Res.string.notifications_episode_release_body_title, title)
|
||||||
|
else ->
|
||||||
|
getString(Res.string.notifications_episode_release_body_generic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +28,8 @@ import kotlin.concurrent.Volatile
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
object EpisodeReleaseNotificationsRepository {
|
object EpisodeReleaseNotificationsRepository {
|
||||||
|
|
@ -149,7 +151,7 @@ object EpisodeReleaseNotificationsRepository {
|
||||||
permissionGranted = false,
|
permissionGranted = false,
|
||||||
scheduledCount = 0,
|
scheduledCount = 0,
|
||||||
statusMessage = null,
|
statusMessage = null,
|
||||||
errorMessage = "Notifications permission is disabled for Nuvio.",
|
errorMessage = getString(Res.string.settings_notifications_permission_disabled),
|
||||||
)
|
)
|
||||||
persist()
|
persist()
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -175,7 +177,7 @@ object EpisodeReleaseNotificationsRepository {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isSendingTest = false,
|
isSendingTest = false,
|
||||||
statusMessage = null,
|
statusMessage = null,
|
||||||
errorMessage = "Save a show to your library first to test a deeplink notification.",
|
errorMessage = getString(Res.string.settings_notifications_test_requires_saved_show),
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +199,7 @@ object EpisodeReleaseNotificationsRepository {
|
||||||
isSendingTest = false,
|
isSendingTest = false,
|
||||||
permissionGranted = false,
|
permissionGranted = false,
|
||||||
statusMessage = null,
|
statusMessage = null,
|
||||||
errorMessage = "Notifications permission is disabled for Nuvio.",
|
errorMessage = getString(Res.string.settings_notifications_permission_disabled),
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +207,7 @@ object EpisodeReleaseNotificationsRepository {
|
||||||
val request = EpisodeReleaseNotificationRequest(
|
val request = EpisodeReleaseNotificationRequest(
|
||||||
requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}",
|
requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}",
|
||||||
notificationTitle = target.name,
|
notificationTitle = target.name,
|
||||||
notificationBody = "Preview episode release alert.",
|
notificationBody = getString(Res.string.notifications_test_preview_body),
|
||||||
releaseDateIso = CurrentDateProvider.todayIsoDate(),
|
releaseDateIso = CurrentDateProvider.todayIsoDate(),
|
||||||
deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id),
|
deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id),
|
||||||
backdropUrl = target.banner ?: target.poster,
|
backdropUrl = target.banner ?: target.poster,
|
||||||
|
|
@ -219,7 +221,7 @@ object EpisodeReleaseNotificationsRepository {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isSendingTest = false,
|
isSendingTest = false,
|
||||||
permissionGranted = true,
|
permissionGranted = true,
|
||||||
statusMessage = "Test notification sent for ${target.name}.",
|
statusMessage = getString(Res.string.notifications_test_sent_for, target.name),
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
|
|
@ -227,7 +229,7 @@ object EpisodeReleaseNotificationsRepository {
|
||||||
isSendingTest = false,
|
isSendingTest = false,
|
||||||
permissionGranted = true,
|
permissionGranted = true,
|
||||||
statusMessage = null,
|
statusMessage = null,
|
||||||
errorMessage = "Failed to send a test notification.",
|
errorMessage = getString(Res.string.notifications_test_send_failed),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_audio_tracks
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_no_audio_tracks_available
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioTrackModal(
|
fun AudioTrackModal(
|
||||||
|
|
@ -93,7 +97,7 @@ fun AudioTrackModal(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Audio Tracks",
|
text = stringResource(Res.string.compose_player_audio_tracks),
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
@ -148,7 +152,7 @@ private fun AudioTrackRow(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = getTrackDisplayName(track.label, track.language, track.index),
|
text = localizedTrackDisplayName(track.label, track.language, track.index),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = weight,
|
fontWeight = weight,
|
||||||
|
|
@ -184,7 +188,7 @@ private fun AudioEmptyState() {
|
||||||
.then(Modifier),
|
.then(Modifier),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "No audio tracks available",
|
text = stringResource(Res.string.compose_player_no_audio_tracks_available),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 10.dp),
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ import com.nuvio.app.core.ui.AppIconResource
|
||||||
import com.nuvio.app.core.ui.NuvioBackButton
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
import com.nuvio.app.core.ui.appIconPainter
|
import com.nuvio.app.core.ui.appIconPainter
|
||||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PlayerControlsShell(
|
internal fun PlayerControlsShell(
|
||||||
|
|
@ -212,7 +214,12 @@ private fun PlayerHeader(
|
||||||
)
|
)
|
||||||
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
|
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "S${seasonNumber}E${episodeNumber} • $episodeTitle",
|
text = stringResource(
|
||||||
|
Res.string.compose_player_episode_title_format,
|
||||||
|
seasonNumber,
|
||||||
|
episodeNumber,
|
||||||
|
episodeTitle,
|
||||||
|
),
|
||||||
style = typeScale.bodyMd.copy(
|
style = typeScale.bodyMd.copy(
|
||||||
fontSize = metrics.episodeInfoSize,
|
fontSize = metrics.episodeInfoSize,
|
||||||
lineHeight = metrics.episodeInfoSize * 1.3f,
|
lineHeight = metrics.episodeInfoSize * 1.3f,
|
||||||
|
|
@ -256,7 +263,11 @@ private fun PlayerHeader(
|
||||||
) {
|
) {
|
||||||
PlayerHeaderIconButton(
|
PlayerHeaderIconButton(
|
||||||
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
||||||
contentDescription = if (isLocked) "Unlock player controls" else "Lock player controls",
|
contentDescription = if (isLocked) {
|
||||||
|
stringResource(Res.string.compose_player_unlock_controls)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.compose_player_lock_controls)
|
||||||
|
},
|
||||||
buttonSize = metrics.headerIconSize + 16.dp,
|
buttonSize = metrics.headerIconSize + 16.dp,
|
||||||
iconSize = metrics.headerIconSize,
|
iconSize = metrics.headerIconSize,
|
||||||
onClick = onLockToggle,
|
onClick = onLockToggle,
|
||||||
|
|
@ -267,7 +278,7 @@ private fun PlayerHeader(
|
||||||
contentColor = Color.White,
|
contentColor = Color.White,
|
||||||
buttonSize = metrics.headerIconSize + 16.dp,
|
buttonSize = metrics.headerIconSize + 16.dp,
|
||||||
iconSize = metrics.headerIconSize,
|
iconSize = metrics.headerIconSize,
|
||||||
contentDescription = "Close player",
|
contentDescription = stringResource(Res.string.compose_player_close),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +326,7 @@ private fun CenterControls(
|
||||||
) {
|
) {
|
||||||
SideControlButton(
|
SideControlButton(
|
||||||
icon = Icons.Rounded.Replay10,
|
icon = Icons.Rounded.Replay10,
|
||||||
contentDescription = "Seek backward 10 seconds",
|
contentDescription = stringResource(Res.string.compose_player_seek_back_10),
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
onClick = onSeekBack,
|
onClick = onSeekBack,
|
||||||
)
|
)
|
||||||
|
|
@ -327,7 +338,7 @@ private fun CenterControls(
|
||||||
)
|
)
|
||||||
SideControlButton(
|
SideControlButton(
|
||||||
icon = Icons.Rounded.Forward10,
|
icon = Icons.Rounded.Forward10,
|
||||||
contentDescription = "Seek forward 10 seconds",
|
contentDescription = stringResource(Res.string.compose_player_seek_forward_10),
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
onClick = onSeekForward,
|
onClick = onSeekForward,
|
||||||
)
|
)
|
||||||
|
|
@ -384,7 +395,11 @@ private fun PlayPauseControlButton(
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
painter = playPausePainter,
|
painter = playPausePainter,
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
contentDescription = if (isPlaying) {
|
||||||
|
stringResource(Res.string.compose_action_pause)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.detail_btn_play)
|
||||||
|
},
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(metrics.playIconSize),
|
modifier = Modifier.size(metrics.playIconSize),
|
||||||
)
|
)
|
||||||
|
|
@ -454,7 +469,7 @@ private fun ProgressControls(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
PlayerActionPillButton(
|
PlayerActionPillButton(
|
||||||
label = resizeMode.label,
|
label = stringResource(resizeMode.labelRes),
|
||||||
painter = aspectRatioPainter,
|
painter = aspectRatioPainter,
|
||||||
onClick = onResizeModeClick,
|
onClick = onResizeModeClick,
|
||||||
)
|
)
|
||||||
|
|
@ -464,25 +479,25 @@ private fun ProgressControls(
|
||||||
onClick = onSpeedClick,
|
onClick = onSpeedClick,
|
||||||
)
|
)
|
||||||
PlayerActionPillButton(
|
PlayerActionPillButton(
|
||||||
label = "Subs",
|
label = stringResource(Res.string.compose_player_subs),
|
||||||
painter = subtitlesPainter,
|
painter = subtitlesPainter,
|
||||||
onClick = onSubtitleClick,
|
onClick = onSubtitleClick,
|
||||||
)
|
)
|
||||||
PlayerActionPillButton(
|
PlayerActionPillButton(
|
||||||
label = "Audio",
|
label = stringResource(Res.string.compose_player_audio),
|
||||||
painter = audioPainter,
|
painter = audioPainter,
|
||||||
onClick = onAudioClick,
|
onClick = onAudioClick,
|
||||||
)
|
)
|
||||||
if (onSourcesClick != null) {
|
if (onSourcesClick != null) {
|
||||||
PlayerActionPillButton(
|
PlayerActionPillButton(
|
||||||
label = "Sources",
|
label = stringResource(Res.string.compose_player_sources),
|
||||||
icon = Icons.Rounded.SwapHoriz,
|
icon = Icons.Rounded.SwapHoriz,
|
||||||
onClick = onSourcesClick,
|
onClick = onSourcesClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onEpisodesClick != null) {
|
if (onEpisodesClick != null) {
|
||||||
PlayerActionPillButton(
|
PlayerActionPillButton(
|
||||||
label = "Episodes",
|
label = stringResource(Res.string.compose_player_episodes),
|
||||||
icon = Icons.Rounded.VideoLibrary,
|
icon = Icons.Rounded.VideoLibrary,
|
||||||
onClick = onEpisodesClick,
|
onClick = onEpisodesClick,
|
||||||
)
|
)
|
||||||
|
|
@ -545,14 +560,14 @@ internal fun LockedPlayerOverlay(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Lock,
|
imageVector = Icons.Rounded.Lock,
|
||||||
contentDescription = "Unlock player controls",
|
contentDescription = stringResource(Res.string.compose_player_unlock_controls),
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(34.dp),
|
modifier = Modifier.size(34.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Tap to unlock",
|
text = stringResource(Res.string.compose_player_tap_to_unlock),
|
||||||
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = Color.White.copy(alpha = 0.92f),
|
color = Color.White.copy(alpha = 0.92f),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Episode selection panel shown inside the player.
|
* Episode selection panel shown inside the player.
|
||||||
|
|
@ -232,12 +234,12 @@ private fun EpisodesListSubView(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Episodes",
|
text = stringResource(Res.string.compose_player_panel_episodes),
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
PanelChipButton(label = "Close", onClick = onDismiss)
|
PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Season tabs
|
// Season tabs
|
||||||
|
|
@ -251,7 +253,11 @@ private fun EpisodesListSubView(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(availableSeasons, key = { season -> season }) { season ->
|
items(availableSeasons, key = { season -> season }) { season ->
|
||||||
val label = if (season == 0) "Specials" else "Season $season"
|
val label = if (season == 0) {
|
||||||
|
stringResource(Res.string.episodes_specials)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.episodes_season, season)
|
||||||
|
}
|
||||||
AddonFilterChip(
|
AddonFilterChip(
|
||||||
label = label,
|
label = label,
|
||||||
isSelected = selectedSeason == season,
|
isSelected = selectedSeason == season,
|
||||||
|
|
@ -273,7 +279,7 @@ private fun EpisodesListSubView(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No episodes available",
|
text = stringResource(Res.string.compose_player_no_episodes_available),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
|
|
@ -345,9 +351,15 @@ private fun EpisodeRow(
|
||||||
) {
|
) {
|
||||||
val episodeLabel = buildString {
|
val episodeLabel = buildString {
|
||||||
if (episode.season != null && episode.episode != null) {
|
if (episode.season != null && episode.episode != null) {
|
||||||
append("S${episode.season}E${episode.episode}")
|
append(
|
||||||
|
stringResource(
|
||||||
|
Res.string.compose_player_episode_code_full,
|
||||||
|
episode.season,
|
||||||
|
episode.episode,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else if (episode.episode != null) {
|
} else if (episode.episode != null) {
|
||||||
append("E${episode.episode}")
|
append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (episodeLabel.isNotBlank()) {
|
if (episodeLabel.isNotBlank()) {
|
||||||
|
|
@ -366,7 +378,7 @@ private fun EpisodeRow(
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Playing",
|
text = stringResource(Res.string.compose_player_playing),
|
||||||
color = colorScheme.onPrimaryContainer,
|
color = colorScheme.onPrimaryContainer,
|
||||||
fontSize = 9.sp,
|
fontSize = 9.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -421,12 +433,12 @@ private fun EpisodeStreamsSubView(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Streams",
|
text = stringResource(Res.string.compose_player_panel_streams),
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
PanelChipButton(label = "Close", onClick = onDismiss)
|
PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back + reload + episode info
|
// Back + reload + episode info
|
||||||
|
|
@ -439,19 +451,25 @@ private fun EpisodeStreamsSubView(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
PanelChipButton(
|
PanelChipButton(
|
||||||
label = "Back",
|
label = stringResource(Res.string.action_back),
|
||||||
icon = Icons.AutoMirrored.Rounded.ArrowBack,
|
icon = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
)
|
)
|
||||||
PanelChipButton(
|
PanelChipButton(
|
||||||
label = "Reload",
|
label = stringResource(Res.string.compose_action_reload),
|
||||||
icon = Icons.Rounded.Refresh,
|
icon = Icons.Rounded.Refresh,
|
||||||
onClick = onReload,
|
onClick = onReload,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
if (episode.season != null && episode.episode != null) {
|
if (episode.season != null && episode.episode != null) {
|
||||||
append("S${episode.season} E${episode.episode}")
|
append(
|
||||||
|
stringResource(
|
||||||
|
Res.string.compose_player_episode_code_full,
|
||||||
|
episode.season,
|
||||||
|
episode.episode,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (episode.title.isNotBlank()) {
|
if (episode.title.isNotBlank()) {
|
||||||
if (isNotEmpty()) append(" • ")
|
if (isNotEmpty()) append(" • ")
|
||||||
|
|
@ -480,7 +498,7 @@ private fun EpisodeStreamsSubView(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AddonFilterChip(
|
AddonFilterChip(
|
||||||
label = "All",
|
label = stringResource(Res.string.collections_tab_all),
|
||||||
isSelected = streamsUiState.selectedFilter == null,
|
isSelected = streamsUiState.selectedFilter == null,
|
||||||
onClick = { onFilterSelected(null) },
|
onClick = { onFilterSelected(null) },
|
||||||
)
|
)
|
||||||
|
|
@ -522,7 +540,7 @@ private fun EpisodeStreamsSubView(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No streams found",
|
text = stringResource(Res.string.compose_player_no_streams_found),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,97 @@
|
||||||
package com.nuvio.app.features.player
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.lang_afrikaans
|
||||||
|
import nuvio.composeapp.generated.resources.lang_albanian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_amharic
|
||||||
|
import nuvio.composeapp.generated.resources.lang_arabic
|
||||||
|
import nuvio.composeapp.generated.resources.lang_armenian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_azerbaijani
|
||||||
|
import nuvio.composeapp.generated.resources.lang_basque
|
||||||
|
import nuvio.composeapp.generated.resources.lang_belarusian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_bengali
|
||||||
|
import nuvio.composeapp.generated.resources.lang_bosnian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_bulgarian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_burmese
|
||||||
|
import nuvio.composeapp.generated.resources.lang_catalan
|
||||||
|
import nuvio.composeapp.generated.resources.lang_chinese
|
||||||
|
import nuvio.composeapp.generated.resources.lang_chinese_simplified
|
||||||
|
import nuvio.composeapp.generated.resources.lang_chinese_traditional
|
||||||
|
import nuvio.composeapp.generated.resources.lang_croatian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_czech
|
||||||
|
import nuvio.composeapp.generated.resources.lang_danish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_dutch
|
||||||
|
import nuvio.composeapp.generated.resources.lang_english
|
||||||
|
import nuvio.composeapp.generated.resources.lang_estonian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_filipino
|
||||||
|
import nuvio.composeapp.generated.resources.lang_finnish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_french
|
||||||
|
import nuvio.composeapp.generated.resources.lang_galician
|
||||||
|
import nuvio.composeapp.generated.resources.lang_georgian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_german
|
||||||
|
import nuvio.composeapp.generated.resources.lang_greek
|
||||||
|
import nuvio.composeapp.generated.resources.lang_gujarati
|
||||||
|
import nuvio.composeapp.generated.resources.lang_hebrew
|
||||||
|
import nuvio.composeapp.generated.resources.lang_hindi
|
||||||
|
import nuvio.composeapp.generated.resources.lang_hungarian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_icelandic
|
||||||
|
import nuvio.composeapp.generated.resources.lang_indonesian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_irish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_italian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_japanese
|
||||||
|
import nuvio.composeapp.generated.resources.lang_kannada
|
||||||
|
import nuvio.composeapp.generated.resources.lang_kazakh
|
||||||
|
import nuvio.composeapp.generated.resources.lang_khmer
|
||||||
|
import nuvio.composeapp.generated.resources.lang_korean
|
||||||
|
import nuvio.composeapp.generated.resources.lang_lao
|
||||||
|
import nuvio.composeapp.generated.resources.lang_latvian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_lithuanian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_macedonian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_malay
|
||||||
|
import nuvio.composeapp.generated.resources.lang_malayalam
|
||||||
|
import nuvio.composeapp.generated.resources.lang_maltese
|
||||||
|
import nuvio.composeapp.generated.resources.lang_marathi
|
||||||
|
import nuvio.composeapp.generated.resources.lang_mongolian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_nepali
|
||||||
|
import nuvio.composeapp.generated.resources.lang_norwegian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_persian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_polish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_portuguese_brazil
|
||||||
|
import nuvio.composeapp.generated.resources.lang_portuguese_portugal
|
||||||
|
import nuvio.composeapp.generated.resources.lang_punjabi
|
||||||
|
import nuvio.composeapp.generated.resources.lang_romanian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_russian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_serbian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_sinhala
|
||||||
|
import nuvio.composeapp.generated.resources.lang_slovak
|
||||||
|
import nuvio.composeapp.generated.resources.lang_slovenian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_spanish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_spanish_latin_america
|
||||||
|
import nuvio.composeapp.generated.resources.lang_swahili
|
||||||
|
import nuvio.composeapp.generated.resources.lang_swedish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_tamil
|
||||||
|
import nuvio.composeapp.generated.resources.lang_telugu
|
||||||
|
import nuvio.composeapp.generated.resources.lang_thai
|
||||||
|
import nuvio.composeapp.generated.resources.lang_turkish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_ukrainian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_urdu
|
||||||
|
import nuvio.composeapp.generated.resources.lang_uzbek
|
||||||
|
import nuvio.composeapp.generated.resources.lang_vietnamese
|
||||||
|
import nuvio.composeapp.generated.resources.lang_welsh
|
||||||
|
import nuvio.composeapp.generated.resources.lang_zulu
|
||||||
|
import nuvio.composeapp.generated.resources.settings_playback_option_default
|
||||||
|
import nuvio.composeapp.generated.resources.settings_playback_option_device_language
|
||||||
|
import nuvio.composeapp.generated.resources.settings_playback_option_forced
|
||||||
|
import nuvio.composeapp.generated.resources.settings_playback_option_none
|
||||||
|
import nuvio.composeapp.generated.resources.subtitle_language_unknown
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
data class LanguagePreferenceOption(
|
data class LanguagePreferenceOption(
|
||||||
val code: String,
|
val code: String,
|
||||||
val label: String,
|
val labelRes: StringResource,
|
||||||
)
|
)
|
||||||
|
|
||||||
object AudioLanguageOption {
|
object AudioLanguageOption {
|
||||||
|
|
@ -17,84 +106,84 @@ object SubtitleLanguageOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
val AvailableLanguageOptions: List<LanguagePreferenceOption> = listOf(
|
val AvailableLanguageOptions: List<LanguagePreferenceOption> = listOf(
|
||||||
LanguagePreferenceOption("af", "Afrikaans"),
|
LanguagePreferenceOption("af", Res.string.lang_afrikaans),
|
||||||
LanguagePreferenceOption("sq", "Albanian"),
|
LanguagePreferenceOption("sq", Res.string.lang_albanian),
|
||||||
LanguagePreferenceOption("am", "Amharic"),
|
LanguagePreferenceOption("am", Res.string.lang_amharic),
|
||||||
LanguagePreferenceOption("ar", "Arabic"),
|
LanguagePreferenceOption("ar", Res.string.lang_arabic),
|
||||||
LanguagePreferenceOption("hy", "Armenian"),
|
LanguagePreferenceOption("hy", Res.string.lang_armenian),
|
||||||
LanguagePreferenceOption("az", "Azerbaijani"),
|
LanguagePreferenceOption("az", Res.string.lang_azerbaijani),
|
||||||
LanguagePreferenceOption("eu", "Basque"),
|
LanguagePreferenceOption("eu", Res.string.lang_basque),
|
||||||
LanguagePreferenceOption("be", "Belarusian"),
|
LanguagePreferenceOption("be", Res.string.lang_belarusian),
|
||||||
LanguagePreferenceOption("bn", "Bengali"),
|
LanguagePreferenceOption("bn", Res.string.lang_bengali),
|
||||||
LanguagePreferenceOption("bs", "Bosnian"),
|
LanguagePreferenceOption("bs", Res.string.lang_bosnian),
|
||||||
LanguagePreferenceOption("bg", "Bulgarian"),
|
LanguagePreferenceOption("bg", Res.string.lang_bulgarian),
|
||||||
LanguagePreferenceOption("my", "Burmese"),
|
LanguagePreferenceOption("my", Res.string.lang_burmese),
|
||||||
LanguagePreferenceOption("ca", "Catalan"),
|
LanguagePreferenceOption("ca", Res.string.lang_catalan),
|
||||||
LanguagePreferenceOption("zh", "Chinese"),
|
LanguagePreferenceOption("zh", Res.string.lang_chinese),
|
||||||
LanguagePreferenceOption("zh-CN", "Chinese (Simplified)"),
|
LanguagePreferenceOption("zh-CN", Res.string.lang_chinese_simplified),
|
||||||
LanguagePreferenceOption("zh-TW", "Chinese (Traditional)"),
|
LanguagePreferenceOption("zh-TW", Res.string.lang_chinese_traditional),
|
||||||
LanguagePreferenceOption("hr", "Croatian"),
|
LanguagePreferenceOption("hr", Res.string.lang_croatian),
|
||||||
LanguagePreferenceOption("cs", "Czech"),
|
LanguagePreferenceOption("cs", Res.string.lang_czech),
|
||||||
LanguagePreferenceOption("da", "Danish"),
|
LanguagePreferenceOption("da", Res.string.lang_danish),
|
||||||
LanguagePreferenceOption("nl", "Dutch"),
|
LanguagePreferenceOption("nl", Res.string.lang_dutch),
|
||||||
LanguagePreferenceOption("en", "English"),
|
LanguagePreferenceOption("en", Res.string.lang_english),
|
||||||
LanguagePreferenceOption("et", "Estonian"),
|
LanguagePreferenceOption("et", Res.string.lang_estonian),
|
||||||
LanguagePreferenceOption("tl", "Filipino"),
|
LanguagePreferenceOption("tl", Res.string.lang_filipino),
|
||||||
LanguagePreferenceOption("fi", "Finnish"),
|
LanguagePreferenceOption("fi", Res.string.lang_finnish),
|
||||||
LanguagePreferenceOption("fr", "French"),
|
LanguagePreferenceOption("fr", Res.string.lang_french),
|
||||||
LanguagePreferenceOption("gl", "Galician"),
|
LanguagePreferenceOption("gl", Res.string.lang_galician),
|
||||||
LanguagePreferenceOption("ka", "Georgian"),
|
LanguagePreferenceOption("ka", Res.string.lang_georgian),
|
||||||
LanguagePreferenceOption("de", "German"),
|
LanguagePreferenceOption("de", Res.string.lang_german),
|
||||||
LanguagePreferenceOption("el", "Greek"),
|
LanguagePreferenceOption("el", Res.string.lang_greek),
|
||||||
LanguagePreferenceOption("gu", "Gujarati"),
|
LanguagePreferenceOption("gu", Res.string.lang_gujarati),
|
||||||
LanguagePreferenceOption("he", "Hebrew"),
|
LanguagePreferenceOption("he", Res.string.lang_hebrew),
|
||||||
LanguagePreferenceOption("hi", "Hindi"),
|
LanguagePreferenceOption("hi", Res.string.lang_hindi),
|
||||||
LanguagePreferenceOption("hu", "Hungarian"),
|
LanguagePreferenceOption("hu", Res.string.lang_hungarian),
|
||||||
LanguagePreferenceOption("is", "Icelandic"),
|
LanguagePreferenceOption("is", Res.string.lang_icelandic),
|
||||||
LanguagePreferenceOption("id", "Indonesian"),
|
LanguagePreferenceOption("id", Res.string.lang_indonesian),
|
||||||
LanguagePreferenceOption("ga", "Irish"),
|
LanguagePreferenceOption("ga", Res.string.lang_irish),
|
||||||
LanguagePreferenceOption("it", "Italian"),
|
LanguagePreferenceOption("it", Res.string.lang_italian),
|
||||||
LanguagePreferenceOption("ja", "Japanese"),
|
LanguagePreferenceOption("ja", Res.string.lang_japanese),
|
||||||
LanguagePreferenceOption("kn", "Kannada"),
|
LanguagePreferenceOption("kn", Res.string.lang_kannada),
|
||||||
LanguagePreferenceOption("kk", "Kazakh"),
|
LanguagePreferenceOption("kk", Res.string.lang_kazakh),
|
||||||
LanguagePreferenceOption("km", "Khmer"),
|
LanguagePreferenceOption("km", Res.string.lang_khmer),
|
||||||
LanguagePreferenceOption("ko", "Korean"),
|
LanguagePreferenceOption("ko", Res.string.lang_korean),
|
||||||
LanguagePreferenceOption("lo", "Lao"),
|
LanguagePreferenceOption("lo", Res.string.lang_lao),
|
||||||
LanguagePreferenceOption("lv", "Latvian"),
|
LanguagePreferenceOption("lv", Res.string.lang_latvian),
|
||||||
LanguagePreferenceOption("lt", "Lithuanian"),
|
LanguagePreferenceOption("lt", Res.string.lang_lithuanian),
|
||||||
LanguagePreferenceOption("mk", "Macedonian"),
|
LanguagePreferenceOption("mk", Res.string.lang_macedonian),
|
||||||
LanguagePreferenceOption("ms", "Malay"),
|
LanguagePreferenceOption("ms", Res.string.lang_malay),
|
||||||
LanguagePreferenceOption("ml", "Malayalam"),
|
LanguagePreferenceOption("ml", Res.string.lang_malayalam),
|
||||||
LanguagePreferenceOption("mt", "Maltese"),
|
LanguagePreferenceOption("mt", Res.string.lang_maltese),
|
||||||
LanguagePreferenceOption("mr", "Marathi"),
|
LanguagePreferenceOption("mr", Res.string.lang_marathi),
|
||||||
LanguagePreferenceOption("mn", "Mongolian"),
|
LanguagePreferenceOption("mn", Res.string.lang_mongolian),
|
||||||
LanguagePreferenceOption("ne", "Nepali"),
|
LanguagePreferenceOption("ne", Res.string.lang_nepali),
|
||||||
LanguagePreferenceOption("no", "Norwegian"),
|
LanguagePreferenceOption("no", Res.string.lang_norwegian),
|
||||||
LanguagePreferenceOption("pa", "Punjabi"),
|
LanguagePreferenceOption("pa", Res.string.lang_punjabi),
|
||||||
LanguagePreferenceOption("fa", "Persian"),
|
LanguagePreferenceOption("fa", Res.string.lang_persian),
|
||||||
LanguagePreferenceOption("pl", "Polish"),
|
LanguagePreferenceOption("pl", Res.string.lang_polish),
|
||||||
LanguagePreferenceOption("pt", "Portuguese (Portugal)"),
|
LanguagePreferenceOption("pt", Res.string.lang_portuguese_portugal),
|
||||||
LanguagePreferenceOption("pt-BR", "Portuguese (Brazil)"),
|
LanguagePreferenceOption("pt-BR", Res.string.lang_portuguese_brazil),
|
||||||
LanguagePreferenceOption("ro", "Romanian"),
|
LanguagePreferenceOption("ro", Res.string.lang_romanian),
|
||||||
LanguagePreferenceOption("ru", "Russian"),
|
LanguagePreferenceOption("ru", Res.string.lang_russian),
|
||||||
LanguagePreferenceOption("sr", "Serbian"),
|
LanguagePreferenceOption("sr", Res.string.lang_serbian),
|
||||||
LanguagePreferenceOption("si", "Sinhala"),
|
LanguagePreferenceOption("si", Res.string.lang_sinhala),
|
||||||
LanguagePreferenceOption("sk", "Slovak"),
|
LanguagePreferenceOption("sk", Res.string.lang_slovak),
|
||||||
LanguagePreferenceOption("sl", "Slovenian"),
|
LanguagePreferenceOption("sl", Res.string.lang_slovenian),
|
||||||
LanguagePreferenceOption("es", "Spanish"),
|
LanguagePreferenceOption("es", Res.string.lang_spanish),
|
||||||
LanguagePreferenceOption("es-419", "Spanish (Latin America)"),
|
LanguagePreferenceOption("es-419", Res.string.lang_spanish_latin_america),
|
||||||
LanguagePreferenceOption("sw", "Swahili"),
|
LanguagePreferenceOption("sw", Res.string.lang_swahili),
|
||||||
LanguagePreferenceOption("sv", "Swedish"),
|
LanguagePreferenceOption("sv", Res.string.lang_swedish),
|
||||||
LanguagePreferenceOption("ta", "Tamil"),
|
LanguagePreferenceOption("ta", Res.string.lang_tamil),
|
||||||
LanguagePreferenceOption("te", "Telugu"),
|
LanguagePreferenceOption("te", Res.string.lang_telugu),
|
||||||
LanguagePreferenceOption("th", "Thai"),
|
LanguagePreferenceOption("th", Res.string.lang_thai),
|
||||||
LanguagePreferenceOption("tr", "Turkish"),
|
LanguagePreferenceOption("tr", Res.string.lang_turkish),
|
||||||
LanguagePreferenceOption("uk", "Ukrainian"),
|
LanguagePreferenceOption("uk", Res.string.lang_ukrainian),
|
||||||
LanguagePreferenceOption("ur", "Urdu"),
|
LanguagePreferenceOption("ur", Res.string.lang_urdu),
|
||||||
LanguagePreferenceOption("uz", "Uzbek"),
|
LanguagePreferenceOption("uz", Res.string.lang_uzbek),
|
||||||
LanguagePreferenceOption("vi", "Vietnamese"),
|
LanguagePreferenceOption("vi", Res.string.lang_vietnamese),
|
||||||
LanguagePreferenceOption("cy", "Welsh"),
|
LanguagePreferenceOption("cy", Res.string.lang_welsh),
|
||||||
LanguagePreferenceOption("zu", "Zulu"),
|
LanguagePreferenceOption("zu", Res.string.lang_zulu),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val Iso639Aliases = mapOf(
|
private val Iso639Aliases = mapOf(
|
||||||
|
|
@ -149,12 +238,40 @@ fun languageMatchesPreference(trackLanguage: String?, targetLanguage: String): B
|
||||||
return trackPrimary == targetPrimary
|
return trackPrimary == targetPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
fun languageLabelForCode(code: String?): String {
|
private fun languageLabelResForCode(code: String?): StringResource? {
|
||||||
if (code.isNullOrBlank()) return "None"
|
val normalized = normalizeLanguageCode(code) ?: return null
|
||||||
if (code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true)) return "Forced"
|
|
||||||
return AvailableLanguageOptions.firstOrNull {
|
return AvailableLanguageOptions.firstOrNull {
|
||||||
it.code.equals(code, ignoreCase = true)
|
normalizeLanguageCode(it.code) == normalized
|
||||||
}?.label ?: formatLanguage(code)
|
}?.labelRes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun languageLabelForCode(code: String?): String = when {
|
||||||
|
code.isNullOrBlank() || code.equals(SubtitleLanguageOption.NONE, ignoreCase = true) ->
|
||||||
|
stringResource(Res.string.settings_playback_option_none)
|
||||||
|
code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true) ->
|
||||||
|
stringResource(Res.string.settings_playback_option_forced)
|
||||||
|
code.equals(AudioLanguageOption.DEFAULT, ignoreCase = true) ->
|
||||||
|
stringResource(Res.string.settings_playback_option_default)
|
||||||
|
code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) ||
|
||||||
|
code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) ->
|
||||||
|
stringResource(Res.string.settings_playback_option_device_language)
|
||||||
|
else -> languageLabelResForCode(code)?.let { stringResource(it) }
|
||||||
|
?: stringResource(Res.string.subtitle_language_unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLanguageLabelForCode(code: String?): String = when {
|
||||||
|
code.isNullOrBlank() || code.equals(SubtitleLanguageOption.NONE, ignoreCase = true) ->
|
||||||
|
getString(Res.string.settings_playback_option_none)
|
||||||
|
code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true) ->
|
||||||
|
getString(Res.string.settings_playback_option_forced)
|
||||||
|
code.equals(AudioLanguageOption.DEFAULT, ignoreCase = true) ->
|
||||||
|
getString(Res.string.settings_playback_option_default)
|
||||||
|
code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) ||
|
||||||
|
code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) ->
|
||||||
|
getString(Res.string.settings_playback_option_device_language)
|
||||||
|
else -> languageLabelResForCode(code)?.let { getString(it) }
|
||||||
|
?: getString(Res.string.subtitle_language_unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolvePreferredAudioLanguageTargets(
|
fun resolvePreferredAudioLanguageTargets(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_resize_fill
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_resize_fit
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_resize_zoom
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
internal data class PlayerLayoutMetrics(
|
internal data class PlayerLayoutMetrics(
|
||||||
|
|
@ -124,11 +129,11 @@ internal fun PlayerResizeMode.next(): PlayerResizeMode =
|
||||||
PlayerResizeMode.Zoom -> PlayerResizeMode.Fit
|
PlayerResizeMode.Zoom -> PlayerResizeMode.Fit
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val PlayerResizeMode.label: String
|
internal val PlayerResizeMode.labelRes: StringResource
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
PlayerResizeMode.Fit -> "Fit"
|
PlayerResizeMode.Fit -> Res.string.compose_player_resize_fit
|
||||||
PlayerResizeMode.Fill -> "Fill"
|
PlayerResizeMode.Fill -> Res.string.compose_player_resize_fill
|
||||||
PlayerResizeMode.Zoom -> "Zoom"
|
PlayerResizeMode.Zoom -> Res.string.compose_player_resize_zoom
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun formatPlaybackTime(positionMs: Long): String {
|
internal fun formatPlaybackTime(positionMs: Long): String {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.ui.NuvioBackButton
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_close
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_go_back
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_playback_error
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_youre_watching
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
internal enum class GestureFeedbackIcon {
|
internal enum class GestureFeedbackIcon {
|
||||||
|
|
@ -71,10 +79,14 @@ internal enum class GestureFeedbackIcon {
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class GestureFeedbackState(
|
internal data class GestureFeedbackState(
|
||||||
val message: String,
|
val message: String? = null,
|
||||||
|
val messageRes: StringResource? = null,
|
||||||
|
val messageArgs: List<Any> = emptyList(),
|
||||||
val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed,
|
val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed,
|
||||||
val isDanger: Boolean = false,
|
val isDanger: Boolean = false,
|
||||||
val secondaryMessage: String? = null,
|
val secondaryMessage: String? = null,
|
||||||
|
val secondaryMessageRes: StringResource? = null,
|
||||||
|
val secondaryMessageArgs: List<Any> = emptyList(),
|
||||||
val secondaryMessageColor: Color? = null,
|
val secondaryMessageColor: Color? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -141,7 +153,7 @@ internal fun OpeningOverlay(
|
||||||
contentColor = Color.White,
|
contentColor = Color.White,
|
||||||
buttonSize = 44.dp,
|
buttonSize = 44.dp,
|
||||||
iconSize = 24.dp,
|
iconSize = 24.dp,
|
||||||
contentDescription = "Close player",
|
contentDescription = stringResource(Res.string.compose_player_close),
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -218,6 +230,12 @@ internal fun GestureFeedbackPill(
|
||||||
GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind
|
GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind
|
||||||
}
|
}
|
||||||
val iconTint = if (feedback.isDanger) Color(0xFFFFC1C1) else Color.White
|
val iconTint = if (feedback.isDanger) Color(0xFFFFC1C1) else Color.White
|
||||||
|
val messageText = feedback.messageRes?.let { resource ->
|
||||||
|
stringResource(resource, *feedback.messageArgs.toTypedArray())
|
||||||
|
} ?: feedback.message.orEmpty()
|
||||||
|
val secondaryMessageText = feedback.secondaryMessageRes?.let { resource ->
|
||||||
|
stringResource(resource, *feedback.secondaryMessageArgs.toTypedArray())
|
||||||
|
} ?: feedback.secondaryMessage
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -242,11 +260,11 @@ internal fun GestureFeedbackPill(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = feedback.message,
|
text = messageText,
|
||||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
)
|
)
|
||||||
feedback.secondaryMessage?.let { secondaryMessage ->
|
secondaryMessageText?.let { secondaryMessage ->
|
||||||
Text(
|
Text(
|
||||||
text = secondaryMessage,
|
text = secondaryMessage,
|
||||||
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
|
@ -290,7 +308,7 @@ internal fun PauseMetadataOverlay(
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.Bottom,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "You're watching",
|
text = stringResource(Res.string.compose_player_youre_watching),
|
||||||
style = MaterialTheme.nuvioTypeScale.bodyLg,
|
style = MaterialTheme.nuvioTypeScale.bodyLg,
|
||||||
color = Color(0xFFB8B8B8),
|
color = Color(0xFFB8B8B8),
|
||||||
)
|
)
|
||||||
|
|
@ -318,7 +336,7 @@ internal fun PauseMetadataOverlay(
|
||||||
}
|
}
|
||||||
|
|
||||||
val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) {
|
val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) {
|
||||||
"S${seasonNumber}E${episodeNumber}"
|
stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
|
||||||
} else {
|
} else {
|
||||||
providerName
|
providerName
|
||||||
}
|
}
|
||||||
|
|
@ -377,7 +395,7 @@ internal fun ErrorModal(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Playback error",
|
text = stringResource(Res.string.compose_player_playback_error),
|
||||||
style = MaterialTheme.nuvioTypeScale.displaySm.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.nuvioTypeScale.displaySm.copy(fontWeight = FontWeight.Bold),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
@ -399,7 +417,7 @@ internal fun ErrorModal(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Go back",
|
text = stringResource(Res.string.compose_player_go_back),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 12.dp),
|
.padding(vertical = 12.dp),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
@ -153,6 +155,12 @@ fun PlayerScreen(
|
||||||
val overlayBottomPadding = sliderOverlayBottomPadding(metrics)
|
val overlayBottomPadding = sliderOverlayBottomPadding(metrics)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
val resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit)
|
||||||
|
val resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill)
|
||||||
|
val resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom)
|
||||||
|
val downloadedLabel = stringResource(Res.string.compose_player_downloaded)
|
||||||
|
val airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
|
||||||
|
val tbaLabel = stringResource(Res.string.compose_player_tba)
|
||||||
val gestureController = rememberPlayerGestureController()
|
val gestureController = rememberPlayerGestureController()
|
||||||
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
||||||
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
|
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -534,7 +542,12 @@ fun PlayerScreen(
|
||||||
if (seconds <= 0L) return
|
if (seconds <= 0L) return
|
||||||
showGestureFeedback(
|
showGestureFeedback(
|
||||||
GestureFeedbackState(
|
GestureFeedbackState(
|
||||||
message = if (direction == PlayerSeekDirection.Forward) "+${seconds}s" else "-${seconds}s",
|
messageRes = if (direction == PlayerSeekDirection.Forward) {
|
||||||
|
Res.string.compose_player_seek_feedback_forward
|
||||||
|
} else {
|
||||||
|
Res.string.compose_player_seek_feedback_backward
|
||||||
|
},
|
||||||
|
messageArgs = listOf(seconds),
|
||||||
icon = if (direction == PlayerSeekDirection.Forward) {
|
icon = if (direction == PlayerSeekDirection.Forward) {
|
||||||
GestureFeedbackIcon.SeekForward
|
GestureFeedbackIcon.SeekForward
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -554,11 +567,12 @@ fun PlayerScreen(
|
||||||
} else {
|
} else {
|
||||||
GestureFeedbackIcon.SeekBackward
|
GestureFeedbackIcon.SeekBackward
|
||||||
},
|
},
|
||||||
secondaryMessage = buildString {
|
secondaryMessageRes = if (deltaMs >= 0L) {
|
||||||
if (deltaMs >= 0L) append("+")
|
Res.string.compose_player_seek_delta_forward
|
||||||
append((abs(deltaMs) / 1000f).roundToInt())
|
} else {
|
||||||
append("s")
|
Res.string.compose_player_seek_delta_backward
|
||||||
},
|
},
|
||||||
|
secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()),
|
||||||
secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) {
|
secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) {
|
||||||
Color(0xFF6EE7A8)
|
Color(0xFF6EE7A8)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -571,7 +585,8 @@ fun PlayerScreen(
|
||||||
val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt()
|
val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt()
|
||||||
showGestureFeedback(
|
showGestureFeedback(
|
||||||
GestureFeedbackState(
|
GestureFeedbackState(
|
||||||
message = "Brightness $percentage%",
|
messageRes = Res.string.compose_player_brightness_level,
|
||||||
|
messageArgs = listOf(percentage),
|
||||||
icon = GestureFeedbackIcon.Brightness,
|
icon = GestureFeedbackIcon.Brightness,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -581,7 +596,12 @@ fun PlayerScreen(
|
||||||
val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
|
val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
|
||||||
showGestureFeedback(
|
showGestureFeedback(
|
||||||
GestureFeedbackState(
|
GestureFeedbackState(
|
||||||
message = if (level.isMuted) "Muted" else "Volume $percentage%",
|
messageRes = if (level.isMuted) {
|
||||||
|
Res.string.compose_player_muted
|
||||||
|
} else {
|
||||||
|
Res.string.compose_player_volume_level
|
||||||
|
},
|
||||||
|
messageArgs = if (level.isMuted) emptyList() else listOf(percentage),
|
||||||
icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
|
icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
|
||||||
isDanger = level.isMuted,
|
isDanger = level.isMuted,
|
||||||
),
|
),
|
||||||
|
|
@ -650,7 +670,13 @@ fun PlayerScreen(
|
||||||
val nextMode = resizeMode.next()
|
val nextMode = resizeMode.next()
|
||||||
resizeMode = nextMode
|
resizeMode = nextMode
|
||||||
PlayerSettingsRepository.setResizeMode(nextMode)
|
PlayerSettingsRepository.setResizeMode(nextMode)
|
||||||
showGestureMessage(nextMode.label)
|
showGestureMessage(
|
||||||
|
when (nextMode) {
|
||||||
|
PlayerResizeMode.Fit -> resizeModeFitLabel
|
||||||
|
PlayerResizeMode.Fill -> resizeModeFillLabel
|
||||||
|
PlayerResizeMode.Zoom -> resizeModeZoomLabel
|
||||||
|
},
|
||||||
|
)
|
||||||
controlsVisible = true
|
controlsVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -872,7 +898,7 @@ fun PlayerScreen(
|
||||||
episode.title.ifBlank { title }
|
episode.title.ifBlank { title }
|
||||||
}
|
}
|
||||||
activeStreamSubtitle = downloadItem.streamSubtitle
|
activeStreamSubtitle = downloadItem.streamSubtitle
|
||||||
activeProviderName = downloadItem.providerName.ifBlank { "Downloaded" }
|
activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel }
|
||||||
activeProviderAddonId = downloadItem.providerAddonId
|
activeProviderAddonId = downloadItem.providerAddonId
|
||||||
currentStreamBingeGroup = null
|
currentStreamBingeGroup = null
|
||||||
activeSeasonNumber = episode.season
|
activeSeasonNumber = episode.season
|
||||||
|
|
@ -1268,7 +1294,7 @@ fun PlayerScreen(
|
||||||
released = nextVideo.released,
|
released = nextVideo.released,
|
||||||
hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
|
hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
|
||||||
unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
|
unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
|
||||||
"Airs ${nextVideo.released ?: "TBA"}"
|
"$airsPrefix ${nextVideo.released ?: tbaLabel}"
|
||||||
} else null,
|
} else null,
|
||||||
)
|
)
|
||||||
} else null
|
} else null
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,14 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Check
|
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -40,14 +40,19 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.nuvio.app.core.i18n.localizedByteUnit
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
import kotlin.math.round
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerSourcesPanel(
|
fun PlayerSourcesPanel(
|
||||||
|
|
@ -108,19 +113,19 @@ fun PlayerSourcesPanel(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Sources",
|
text = stringResource(Res.string.compose_player_panel_sources),
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
PanelChipButton(
|
PanelChipButton(
|
||||||
label = "Reload",
|
label = stringResource(Res.string.compose_action_reload),
|
||||||
icon = Icons.Rounded.Refresh,
|
icon = Icons.Rounded.Refresh,
|
||||||
onClick = onReload,
|
onClick = onReload,
|
||||||
)
|
)
|
||||||
PanelChipButton(
|
PanelChipButton(
|
||||||
label = "Close",
|
label = stringResource(Res.string.action_close),
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +145,7 @@ fun PlayerSourcesPanel(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AddonFilterChip(
|
AddonFilterChip(
|
||||||
label = "All",
|
label = stringResource(Res.string.collections_tab_all),
|
||||||
isSelected = streamsUiState.selectedFilter == null,
|
isSelected = streamsUiState.selectedFilter == null,
|
||||||
onClick = { onFilterSelected(null) },
|
onClick = { onFilterSelected(null) },
|
||||||
)
|
)
|
||||||
|
|
@ -182,7 +187,7 @@ fun PlayerSourcesPanel(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No streams found",
|
text = stringResource(Res.string.compose_player_no_streams_found),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
|
|
@ -228,24 +233,32 @@ private fun SourceStreamRow(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val cardShape = RoundedCornerShape(12.dp)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.heightIn(min = 68.dp)
|
||||||
|
.shadow(
|
||||||
|
elevation = 2.dp,
|
||||||
|
shape = cardShape,
|
||||||
|
ambientColor = Color.Black.copy(alpha = 0.04f),
|
||||||
|
spotColor = Color.Black.copy(alpha = 0.04f),
|
||||||
|
)
|
||||||
|
.clip(cardShape)
|
||||||
.background(
|
.background(
|
||||||
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent,
|
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f),
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
|
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.Top,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
|
@ -256,11 +269,13 @@ private fun SourceStreamRow(
|
||||||
Text(
|
Text(
|
||||||
text = stream.streamLabel,
|
text = stream.streamLabel,
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
lineHeight = 20.sp,
|
||||||
overflow = TextOverflow.Ellipsis,
|
letterSpacing = 0.1.sp,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -270,7 +285,7 @@ private fun SourceStreamRow(
|
||||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Playing",
|
text = stringResource(Res.string.compose_player_playing),
|
||||||
color = colorScheme.onPrimaryContainer,
|
color = colorScheme.onPrimaryContainer,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -278,17 +293,26 @@ private fun SourceStreamRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stream.streamSubtitle?.let { subtitle ->
|
|
||||||
if (subtitle != stream.streamLabel) {
|
val subtitle = stream.streamSubtitle
|
||||||
|
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
color = colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 2,
|
lineHeight = 18.sp,
|
||||||
overflow = TextOverflow.Ellipsis,
|
),
|
||||||
|
color = colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
PlayerStreamFileSizeBadge(stream = stream)
|
||||||
Text(
|
Text(
|
||||||
text = stream.addonName,
|
text = stream.addonName,
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -298,17 +322,40 @@ private fun SourceStreamRow(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isCurrent) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Check,
|
|
||||||
contentDescription = "Currently playing",
|
|
||||||
tint = colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayerStreamFileSizeBadge(stream: StreamItem) {
|
||||||
|
val bytes = stream.behaviorHints.videoSize ?: return
|
||||||
|
val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0)
|
||||||
|
val sizeLabel = if (gib >= 1.0) {
|
||||||
|
val roundedGiB = round(gib * 10.0) / 10.0
|
||||||
|
"$roundedGiB ${localizedByteUnit("GB")}"
|
||||||
|
} else {
|
||||||
|
val mib = bytes.toDouble() / (1024.0 * 1024.0)
|
||||||
|
"${round(mib).toInt()} ${localizedByteUnit("MB")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFF0A0C0C))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.streams_size, sizeLabel),
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 0.2.sp,
|
||||||
|
),
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddonFilterChip(
|
internal fun AddonFilterChip(
|
||||||
label: String,
|
label: String,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package com.nuvio.app.features.player
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_track_number
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
data class AudioTrack(
|
data class AudioTrack(
|
||||||
|
|
@ -109,103 +113,9 @@ data class SubtitleAudioUiState(
|
||||||
val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn,
|
val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getTrackDisplayName(label: String?, language: String?, index: Int): String {
|
@Composable
|
||||||
|
fun localizedTrackDisplayName(label: String?, language: String?, index: Int): String {
|
||||||
if (!label.isNullOrBlank()) return label
|
if (!label.isNullOrBlank()) return label
|
||||||
if (!language.isNullOrBlank()) return formatLanguage(language)
|
if (!language.isNullOrBlank()) return languageLabelForCode(language)
|
||||||
return "Track ${index + 1}"
|
return stringResource(Res.string.compose_player_track_number, index + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatLanguage(code: String): String {
|
|
||||||
val lower = code.lowercase()
|
|
||||||
return LanguageNames[lower] ?: lower.replaceFirstChar { it.uppercase() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val LanguageNames = mapOf(
|
|
||||||
"en" to "English",
|
|
||||||
"eng" to "English",
|
|
||||||
"es" to "Spanish",
|
|
||||||
"spa" to "Spanish",
|
|
||||||
"fr" to "French",
|
|
||||||
"fre" to "French",
|
|
||||||
"fra" to "French",
|
|
||||||
"de" to "German",
|
|
||||||
"ger" to "German",
|
|
||||||
"deu" to "German",
|
|
||||||
"it" to "Italian",
|
|
||||||
"ita" to "Italian",
|
|
||||||
"pt" to "Portuguese",
|
|
||||||
"por" to "Portuguese",
|
|
||||||
"ru" to "Russian",
|
|
||||||
"rus" to "Russian",
|
|
||||||
"ja" to "Japanese",
|
|
||||||
"jpn" to "Japanese",
|
|
||||||
"ko" to "Korean",
|
|
||||||
"kor" to "Korean",
|
|
||||||
"zh" to "Chinese",
|
|
||||||
"chi" to "Chinese",
|
|
||||||
"zho" to "Chinese",
|
|
||||||
"ar" to "Arabic",
|
|
||||||
"ara" to "Arabic",
|
|
||||||
"hi" to "Hindi",
|
|
||||||
"hin" to "Hindi",
|
|
||||||
"nl" to "Dutch",
|
|
||||||
"nld" to "Dutch",
|
|
||||||
"dut" to "Dutch",
|
|
||||||
"pl" to "Polish",
|
|
||||||
"pol" to "Polish",
|
|
||||||
"sv" to "Swedish",
|
|
||||||
"swe" to "Swedish",
|
|
||||||
"tr" to "Turkish",
|
|
||||||
"tur" to "Turkish",
|
|
||||||
"he" to "Hebrew",
|
|
||||||
"heb" to "Hebrew",
|
|
||||||
"th" to "Thai",
|
|
||||||
"tha" to "Thai",
|
|
||||||
"vi" to "Vietnamese",
|
|
||||||
"vie" to "Vietnamese",
|
|
||||||
"cs" to "Czech",
|
|
||||||
"ces" to "Czech",
|
|
||||||
"cze" to "Czech",
|
|
||||||
"ro" to "Romanian",
|
|
||||||
"ron" to "Romanian",
|
|
||||||
"rum" to "Romanian",
|
|
||||||
"hu" to "Hungarian",
|
|
||||||
"hun" to "Hungarian",
|
|
||||||
"el" to "Greek",
|
|
||||||
"ell" to "Greek",
|
|
||||||
"gre" to "Greek",
|
|
||||||
"da" to "Danish",
|
|
||||||
"dan" to "Danish",
|
|
||||||
"fi" to "Finnish",
|
|
||||||
"fin" to "Finnish",
|
|
||||||
"no" to "Norwegian",
|
|
||||||
"nor" to "Norwegian",
|
|
||||||
"uk" to "Ukrainian",
|
|
||||||
"ukr" to "Ukrainian",
|
|
||||||
"bg" to "Bulgarian",
|
|
||||||
"bul" to "Bulgarian",
|
|
||||||
"hr" to "Croatian",
|
|
||||||
"hrv" to "Croatian",
|
|
||||||
"sr" to "Serbian",
|
|
||||||
"srp" to "Serbian",
|
|
||||||
"sk" to "Slovak",
|
|
||||||
"slk" to "Slovak",
|
|
||||||
"slo" to "Slovak",
|
|
||||||
"sl" to "Slovenian",
|
|
||||||
"slv" to "Slovenian",
|
|
||||||
"id" to "Indonesian",
|
|
||||||
"ind" to "Indonesian",
|
|
||||||
"ms" to "Malay",
|
|
||||||
"msa" to "Malay",
|
|
||||||
"may" to "Malay",
|
|
||||||
"ta" to "Tamil",
|
|
||||||
"tam" to "Tamil",
|
|
||||||
"te" to "Telugu",
|
|
||||||
"tel" to "Telugu",
|
|
||||||
"ml" to "Malayalam",
|
|
||||||
"mal" to "Malayalam",
|
|
||||||
"bn" to "Bengali",
|
|
||||||
"ben" to "Bengali",
|
|
||||||
"ur" to "Urdu",
|
|
||||||
"urd" to "Urdu",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.addon_title
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_built_in
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_fetch_subtitles
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_none
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_style
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_subtitles
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SubtitleModal(
|
fun SubtitleModal(
|
||||||
|
|
@ -110,7 +118,7 @@ fun SubtitleModal(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Subtitles",
|
text = stringResource(Res.string.compose_player_subtitles),
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
@ -191,9 +199,9 @@ private fun SubtitleTabBar(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = when (tab) {
|
text = when (tab) {
|
||||||
SubtitleTab.BuiltIn -> "Built-in"
|
SubtitleTab.BuiltIn -> stringResource(Res.string.compose_player_built_in)
|
||||||
SubtitleTab.Addons -> "Addons"
|
SubtitleTab.Addons -> stringResource(Res.string.addon_title)
|
||||||
SubtitleTab.Style -> "Style"
|
SubtitleTab.Style -> stringResource(Res.string.compose_player_style)
|
||||||
},
|
},
|
||||||
color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant,
|
color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
|
@ -230,7 +238,7 @@ private fun BuiltInSubtitleList(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "None",
|
text = stringResource(Res.string.compose_player_none),
|
||||||
color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface,
|
color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -258,7 +266,7 @@ private fun BuiltInSubtitleList(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = getTrackDisplayName(track.label, track.language, track.index),
|
text = localizedTrackDisplayName(track.label, track.language, track.index),
|
||||||
color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface,
|
color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
|
@ -324,7 +332,7 @@ private fun AddonSubtitleList(
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Tap to fetch subtitles",
|
text = stringResource(Res.string.compose_player_fetch_subtitles),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 10.dp),
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -360,7 +368,7 @@ private fun AddonSubtitleList(
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatLanguage(sub.language),
|
text = languageLabelForCode(sub.language),
|
||||||
color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant,
|
color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier.padding(bottom = 3.dp),
|
modifier = Modifier.padding(bottom = 3.dp),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
object SubtitleRepository {
|
object SubtitleRepository {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
@ -76,7 +79,7 @@ object SubtitleRepository {
|
||||||
id = id,
|
id = id,
|
||||||
url = url,
|
url = url,
|
||||||
language = lang,
|
language = lang,
|
||||||
display = "${formatLanguage(lang)} (${addon.displayTitle})",
|
display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +89,7 @@ object SubtitleRepository {
|
||||||
|
|
||||||
_addonSubtitles.value = allSubs
|
_addonSubtitles.value = allSubs
|
||||||
if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) {
|
if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) {
|
||||||
_error.value = "No subtitles found"
|
_error.value = getString(Res.string.compose_player_no_subtitles_found)
|
||||||
}
|
}
|
||||||
_isLoading.value = false
|
_isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SubtitleStylePanel(
|
fun SubtitleStylePanel(
|
||||||
|
|
@ -73,7 +75,7 @@ private fun StyleControlsCard(
|
||||||
) {
|
) {
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
icon = Icons.Rounded.Tune,
|
icon = Icons.Rounded.Tune,
|
||||||
label = "Style",
|
label = stringResource(Res.string.compose_player_style),
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -82,13 +84,13 @@ private fun StyleControlsCard(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Font Size",
|
text = stringResource(Res.string.compose_player_font_size),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
StepperControl(
|
StepperControl(
|
||||||
value = "${style.fontSizeSp}sp",
|
value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp),
|
||||||
onMinus = {
|
onMinus = {
|
||||||
onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12)))
|
onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12)))
|
||||||
},
|
},
|
||||||
|
|
@ -109,7 +111,7 @@ private fun StyleControlsCard(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Outline",
|
text = stringResource(Res.string.compose_player_outline),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -126,7 +128,8 @@ private fun StyleControlsCard(
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (style.outlineEnabled) "On" else "Off",
|
text = if (style.outlineEnabled) stringResource(Res.string.compose_action_on)
|
||||||
|
else stringResource(Res.string.compose_action_off),
|
||||||
color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface,
|
color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
|
@ -140,7 +143,7 @@ private fun StyleControlsCard(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Bottom Offset",
|
text = stringResource(Res.string.compose_player_bottom_offset),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -163,7 +166,7 @@ private fun StyleControlsCard(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Color",
|
text = stringResource(Res.string.compose_player_color),
|
||||||
color = colorScheme.onSurfaceVariant,
|
color = colorScheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -203,7 +206,7 @@ private fun StyleControlsCard(
|
||||||
.padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp),
|
.padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Reset Defaults",
|
text = stringResource(Res.string.compose_player_reset_defaults),
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = if (isCompact) 12.sp else 14.sp,
|
fontSize = if (isCompact) 12.sp else 14.sp,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_player_episode_title_format
|
||||||
|
import nuvio.composeapp.generated.resources.detail_btn_play
|
||||||
|
import nuvio.composeapp.generated.resources.player_next_episode
|
||||||
|
import nuvio.composeapp.generated.resources.player_next_episode_finding_source
|
||||||
|
import nuvio.composeapp.generated.resources.player_next_episode_playing_via_countdown
|
||||||
|
import nuvio.composeapp.generated.resources.player_next_episode_thumbnail
|
||||||
|
import nuvio.composeapp.generated.resources.player_next_episode_unaired
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NextEpisodeCard(
|
fun NextEpisodeCard(
|
||||||
|
|
@ -81,7 +90,7 @@ fun NextEpisodeCard(
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = nextEpisode.thumbnail,
|
model = nextEpisode.thumbnail,
|
||||||
contentDescription = "Next episode thumbnail",
|
contentDescription = stringResource(Res.string.player_next_episode_thumbnail),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|
@ -107,14 +116,19 @@ fun NextEpisodeCard(
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Next Episode",
|
text = stringResource(Res.string.player_next_episode),
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "S${nextEpisode.season}E${nextEpisode.episode} • ${nextEpisode.title}",
|
text = stringResource(
|
||||||
|
Res.string.compose_player_episode_title_format,
|
||||||
|
nextEpisode.season,
|
||||||
|
nextEpisode.episode,
|
||||||
|
nextEpisode.title,
|
||||||
|
),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -123,9 +137,13 @@ fun NextEpisodeCard(
|
||||||
)
|
)
|
||||||
val autoPlayStatus = when {
|
val autoPlayStatus = when {
|
||||||
!isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage
|
!isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage
|
||||||
isAutoPlaySearching -> "Finding source…"
|
isAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source)
|
||||||
!autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null ->
|
!autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null ->
|
||||||
"Playing via $autoPlaySourceName in $autoPlayCountdownSec…"
|
stringResource(
|
||||||
|
Res.string.player_next_episode_playing_via_countdown,
|
||||||
|
autoPlaySourceName,
|
||||||
|
autoPlayCountdownSec,
|
||||||
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (autoPlayStatus != null) {
|
if (autoPlayStatus != null) {
|
||||||
|
|
@ -156,7 +174,11 @@ fun NextEpisodeCard(
|
||||||
modifier = Modifier.size(13.dp),
|
modifier = Modifier.size(13.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (isPlayable) "Play" else "Unaired",
|
text = if (isPlayable) {
|
||||||
|
stringResource(Res.string.detail_btn_play)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.player_next_episode_unaired)
|
||||||
|
},
|
||||||
color = if (isPlayable) Color.White else Color.White.copy(alpha = 0.72f),
|
color = if (isPlayable) Color.White else Color.White.copy(alpha = 0.72f),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier.padding(start = 3.dp),
|
modifier = Modifier.padding(start = 3.dp),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.player_skip
|
||||||
|
import nuvio.composeapp.generated.resources.player_skip_intro
|
||||||
|
import nuvio.composeapp.generated.resources.player_skip_outro
|
||||||
|
import nuvio.composeapp.generated.resources.player_skip_recap
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SkipIntroButton(
|
fun SkipIntroButton(
|
||||||
|
|
@ -112,7 +118,7 @@ fun SkipIntroButton(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = getSkipLabel(lastType),
|
text = skipLabel(lastType),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
|
@ -140,11 +146,11 @@ fun SkipIntroButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSkipLabel(type: String?): String {
|
@Composable
|
||||||
return when (type?.lowercase()) {
|
private fun skipLabel(type: String?): String =
|
||||||
"intro", "op", "mixed-op" -> "Skip Intro"
|
when (type?.lowercase()) {
|
||||||
"outro", "ed", "mixed-ed", "credits" -> "Skip Outro"
|
"intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro)
|
||||||
"recap" -> "Skip Recap"
|
"outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro)
|
||||||
else -> "Skip"
|
"recap" -> stringResource(Res.string.player_skip_recap)
|
||||||
}
|
else -> stringResource(Res.string.player_skip)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -75,7 +78,7 @@ fun PinEntryDialog(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Enter PIN",
|
text = stringResource(Res.string.pin_enter),
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
@ -128,9 +131,12 @@ fun PinEntryDialog(
|
||||||
} else {
|
} else {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
error = result.message ?: if (result.retryAfterSeconds > 0) {
|
error = result.message ?: if (result.retryAfterSeconds > 0) {
|
||||||
"Locked. Try again in ${result.retryAfterSeconds}s"
|
getString(
|
||||||
|
Res.string.pin_locked_try_again,
|
||||||
|
result.retryAfterSeconds,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"Incorrect PIN"
|
getString(Res.string.pin_incorrect)
|
||||||
}
|
}
|
||||||
pin = ""
|
pin = ""
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +157,7 @@ fun PinEntryDialog(
|
||||||
if (onForgotPin != null) {
|
if (onForgotPin != null) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Forgot PIN?",
|
text = stringResource(Res.string.pin_forgot),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -104,7 +106,11 @@ fun ProfileEditScreen(
|
||||||
NuvioScreen(modifier = modifier) {
|
NuvioScreen(modifier = modifier) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = if (isNew) "Add Profile" else "Edit Profile",
|
title = if (isNew) {
|
||||||
|
stringResource(Res.string.profile_edit_add_title)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.profile_edit_edit_title)
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -127,13 +133,17 @@ fun ProfileEditScreen(
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Choose an avatar",
|
text = stringResource(Res.string.profile_choose_avatar),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = selectedAvatarItem?.displayName
|
text = selectedAvatarItem?.displayName
|
||||||
?: if (avatars.isEmpty()) "Loading avatars..." else "Select an avatar for this profile.",
|
?: if (avatars.isEmpty()) {
|
||||||
|
stringResource(Res.string.profile_loading_avatars)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.profile_select_avatar)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -171,27 +181,27 @@ fun ProfileEditScreen(
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Security",
|
text = stringResource(Res.string.profile_security),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (currentProfile?.pinEnabled == true) {
|
text = if (currentProfile?.pinEnabled == true) {
|
||||||
"This profile is protected with a PIN."
|
stringResource(Res.string.profile_security_pin_enabled)
|
||||||
} else {
|
} else {
|
||||||
"Add a PIN if you want this profile locked before switching into it."
|
stringResource(Res.string.profile_security_pin_disabled)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
if (currentProfile?.pinEnabled == true) {
|
if (currentProfile?.pinEnabled == true) {
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "Remove PIN Lock",
|
text = stringResource(Res.string.profile_remove_pin_lock),
|
||||||
onClick = { showPinClear = true },
|
onClick = { showPinClear = true },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "Set PIN Lock",
|
text = stringResource(Res.string.profile_set_pin_lock),
|
||||||
onClick = { showPinSetup = true },
|
onClick = { showPinSetup = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +213,13 @@ fun ProfileEditScreen(
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = if (isSaving) "Saving..." else if (isNew) "Create Profile" else "Save Changes",
|
text = if (isSaving) {
|
||||||
|
stringResource(Res.string.profile_saving)
|
||||||
|
} else if (isNew) {
|
||||||
|
stringResource(Res.string.profile_create_profile)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.collections_editor_save_changes)
|
||||||
|
},
|
||||||
enabled = name.isNotBlank() && !isSaving,
|
enabled = name.isNotBlank() && !isSaving,
|
||||||
onClick = {
|
onClick = {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
|
|
@ -247,7 +263,7 @@ fun ProfileEditScreen(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Delete Profile",
|
text = stringResource(Res.string.profile_delete_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
@ -257,11 +273,14 @@ fun ProfileEditScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = "Delete Profile?",
|
title = stringResource(Res.string.profile_delete_title),
|
||||||
message = "All data for \"${currentProfile?.name}\" will be permanently deleted.",
|
message = stringResource(
|
||||||
|
Res.string.profile_delete_confirm_message,
|
||||||
|
currentProfile?.name.orEmpty(),
|
||||||
|
),
|
||||||
isVisible = showDeleteConfirm,
|
isVisible = showDeleteConfirm,
|
||||||
confirmText = "Delete",
|
confirmText = stringResource(Res.string.action_delete),
|
||||||
dismissText = "Cancel",
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showDeleteConfirm = false
|
showDeleteConfirm = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -290,7 +309,7 @@ fun ProfileEditScreen(
|
||||||
|
|
||||||
if (showPinClear && currentProfile != null) {
|
if (showPinClear && currentProfile != null) {
|
||||||
PinEntryDialog(
|
PinEntryDialog(
|
||||||
profileName = "Remove PIN for ${currentProfile.name}",
|
profileName = stringResource(Res.string.profile_remove_pin_for, currentProfile.name),
|
||||||
onVerify = { pin -> ProfileRepository.clearPin(currentProfile.profileIndex, pin) },
|
onVerify = { pin -> ProfileRepository.clearPin(currentProfile.profileIndex, pin) },
|
||||||
onVerified = {
|
onVerified = {
|
||||||
showPinClear = false
|
showPinClear = false
|
||||||
|
|
@ -364,24 +383,39 @@ private fun ProfileIdentityCard(
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = name.ifBlank { if (isNew) "New profile" else "Unnamed profile" },
|
text = name.ifBlank {
|
||||||
|
if (isNew) stringResource(Res.string.profile_new)
|
||||||
|
else stringResource(Res.string.profile_unnamed)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = listOf(
|
text = listOf(
|
||||||
if (isNew) "New profile" else (profileIndex?.let { "Profile $it" } ?: "Profile"),
|
if (isNew) {
|
||||||
if (usesPrimaryAddons) "Primary addons on" else "Primary addons off",
|
stringResource(Res.string.profile_new)
|
||||||
|
} else {
|
||||||
|
profileIndex?.let { stringResource(Res.string.profile_label_number, it) }
|
||||||
|
?: stringResource(Res.string.profile_unnamed)
|
||||||
|
},
|
||||||
|
if (usesPrimaryAddons) {
|
||||||
|
stringResource(Res.string.profile_primary_addons_on)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.profile_primary_addons_off)
|
||||||
|
},
|
||||||
).joinToString(" | "),
|
).joinToString(" | "),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
selectedAvatar != null -> "Avatar: ${selectedAvatar.displayName}"
|
selectedAvatar != null -> stringResource(
|
||||||
hasAvatarChoices -> "Choose an avatar below."
|
Res.string.profile_avatar_selected,
|
||||||
else -> "Avatar options will appear here when the catalog loads."
|
selectedAvatar.displayName,
|
||||||
|
)
|
||||||
|
hasAvatarChoices -> stringResource(Res.string.profile_choose_avatar_below)
|
||||||
|
else -> stringResource(Res.string.profile_avatar_options_pending)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -392,12 +426,12 @@ private fun ProfileIdentityCard(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = onNameChange,
|
onValueChange = onNameChange,
|
||||||
placeholder = "Profile name",
|
placeholder = stringResource(Res.string.profile_name_placeholder),
|
||||||
)
|
)
|
||||||
|
|
||||||
ProfileOptionRow(
|
ProfileOptionRow(
|
||||||
title = "Use Primary Addons",
|
title = stringResource(Res.string.profile_use_primary_addons),
|
||||||
description = "Share the main profile's addon setup instead of managing a separate list.",
|
description = stringResource(Res.string.profile_use_primary_addons_description),
|
||||||
checked = usesPrimaryAddons,
|
checked = usesPrimaryAddons,
|
||||||
onCheckedChange = onUsesPrimaryAddonsChange,
|
onCheckedChange = onUsesPrimaryAddonsChange,
|
||||||
)
|
)
|
||||||
|
|
@ -510,7 +544,7 @@ fun PinSetupDialog(
|
||||||
|
|
||||||
when (step) {
|
when (step) {
|
||||||
"current" -> PinEntryDialog(
|
"current" -> PinEntryDialog(
|
||||||
profileName = "Enter current PIN",
|
profileName = stringResource(Res.string.profile_enter_current_pin),
|
||||||
onVerify = { pin -> ProfileRepository.verifyPin(profileIndex, pin) },
|
onVerify = { pin -> ProfileRepository.verifyPin(profileIndex, pin) },
|
||||||
onVerified = { pin ->
|
onVerified = { pin ->
|
||||||
currentPin = pin
|
currentPin = pin
|
||||||
|
|
@ -520,7 +554,7 @@ fun PinSetupDialog(
|
||||||
)
|
)
|
||||||
|
|
||||||
"new" -> PinEntryDialog(
|
"new" -> PinEntryDialog(
|
||||||
profileName = "Enter new PIN",
|
profileName = stringResource(Res.string.profile_enter_new_pin),
|
||||||
onVerify = { pin ->
|
onVerify = { pin ->
|
||||||
ProfileRepository.setPin(
|
ProfileRepository.setPin(
|
||||||
profileIndex = profileIndex,
|
profileIndex = profileIndex,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
@ -40,6 +41,9 @@ import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class StoredProfilePayload(
|
private data class StoredProfilePayload(
|
||||||
|
|
@ -52,6 +56,7 @@ object ProfileRepository {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val log = Logger.withTag("ProfileRepository")
|
private val log = Logger.withTag("ProfileRepository")
|
||||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ProfileState())
|
private val _state = MutableStateFlow(ProfileState())
|
||||||
val state: StateFlow<ProfileState> = _state.asStateFlow()
|
val state: StateFlow<ProfileState> = _state.asStateFlow()
|
||||||
|
|
@ -274,7 +279,7 @@ object ProfileRepository {
|
||||||
|
|
||||||
suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult {
|
suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult {
|
||||||
if (AuthRepository.state.value !is AuthState.Authenticated) {
|
if (AuthRepository.state.value !is AuthState.Authenticated) {
|
||||||
return PinVerifyResult(unlocked = false, message = "Connect to the internet to set a PIN.")
|
return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_requires_internet))
|
||||||
}
|
}
|
||||||
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|
@ -290,13 +295,13 @@ object ProfileRepository {
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to set pin" }
|
log.e(e) { "Failed to set pin" }
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
PinVerifyResult(unlocked = false, message = "Couldn't set PIN. Try again.")
|
PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_failed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult {
|
suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult {
|
||||||
if (AuthRepository.state.value !is AuthState.Authenticated) {
|
if (AuthRepository.state.value !is AuthState.Authenticated) {
|
||||||
return PinVerifyResult(unlocked = false, message = "Connect to the internet to remove the PIN lock.")
|
return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_requires_internet))
|
||||||
}
|
}
|
||||||
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|
@ -311,7 +316,7 @@ object ProfileRepository {
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to clear pin" }
|
log.e(e) { "Failed to clear pin" }
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
PinVerifyResult(unlocked = false, message = "Couldn't remove PIN lock. Try again.")
|
PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_failed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,7 +412,7 @@ object ProfileRepository {
|
||||||
if (payload.isEmpty()) {
|
if (payload.isEmpty()) {
|
||||||
return PinVerifyResult(
|
return PinVerifyResult(
|
||||||
unlocked = false,
|
unlocked = false,
|
||||||
message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.",
|
message = localizedString(Res.string.profile_pin_offline_verification_requires_online),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,7 +420,7 @@ object ProfileRepository {
|
||||||
json.decodeFromString<CachedProfilePinPayload>(payload)
|
json.decodeFromString<CachedProfilePinPayload>(payload)
|
||||||
}.getOrNull() ?: return PinVerifyResult(
|
}.getOrNull() ?: return PinVerifyResult(
|
||||||
unlocked = false,
|
unlocked = false,
|
||||||
message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.",
|
message = localizedString(Res.string.profile_pin_offline_verification_requires_online),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -426,7 +431,7 @@ object ProfileRepository {
|
||||||
ProfilePinCacheStorage.removePayload(profileIndex)
|
ProfilePinCacheStorage.removePayload(profileIndex)
|
||||||
return PinVerifyResult(
|
return PinVerifyResult(
|
||||||
unlocked = false,
|
unlocked = false,
|
||||||
message = "This profile PIN changed. Connect once to refresh the lock on this device.",
|
message = localizedString(Res.string.profile_pin_changed_requires_refresh),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,7 +439,7 @@ object ProfileRepository {
|
||||||
return if (digest == cached.digest) {
|
return if (digest == cached.digest) {
|
||||||
PinVerifyResult(unlocked = true)
|
PinVerifyResult(unlocked = true)
|
||||||
} else {
|
} else {
|
||||||
PinVerifyResult(unlocked = false, message = "Incorrect PIN")
|
PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ import com.nuvio.app.core.auth.AuthRepository
|
||||||
import com.nuvio.app.core.auth.AuthState
|
import com.nuvio.app.core.auth.AuthState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileSelectionScreen(
|
fun ProfileSelectionScreen(
|
||||||
|
|
@ -132,7 +134,7 @@ fun ProfileSelectionScreen(
|
||||||
Spacer(modifier = Modifier.height(if (isTabletLayout) 0.dp else 60.dp))
|
Spacer(modifier = Modifier.height(if (isTabletLayout) 0.dp else 60.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Who's watching?",
|
text = stringResource(Res.string.profile_who_is_watching),
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
fontSize = 30.sp,
|
fontSize = 30.sp,
|
||||||
letterSpacing = (-0.5).sp,
|
letterSpacing = (-0.5).sp,
|
||||||
|
|
@ -258,7 +260,11 @@ fun ProfileSelectionScreen(
|
||||||
.padding(horizontal = 24.dp, vertical = 10.dp),
|
.padding(horizontal = 24.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isEditMode) "Done" else "Manage Profiles",
|
text = if (isEditMode) {
|
||||||
|
stringResource(Res.string.action_done)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.profile_manage_profiles)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (isEditMode) MaterialTheme.colorScheme.primary
|
color = if (isEditMode) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -429,7 +435,9 @@ private fun ProfileAvatarCard(
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = profile.name.ifBlank { "Profile ${profile.profileIndex}" },
|
text = profile.name.ifBlank {
|
||||||
|
stringResource(Res.string.profile_label_number, profile.profileIndex)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
|
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -506,7 +514,7 @@ private fun AddProfileCard(
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Add Profile",
|
text = stringResource(Res.string.compose_profile_add_profile),
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
|
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileSwitcherTab(
|
fun ProfileSwitcherTab(
|
||||||
|
|
@ -305,7 +308,7 @@ private fun PopupAddProfileBubble(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Add,
|
imageVector = Icons.Rounded.Add,
|
||||||
contentDescription = "Add Profile",
|
contentDescription = stringResource(Res.string.compose_profile_add_profile),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -314,7 +317,7 @@ private fun PopupAddProfileBubble(
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Add",
|
text = stringResource(Res.string.compose_profile_add_profile),
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -466,7 +469,9 @@ private fun PopupProfileBubble(
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = profile.name.ifBlank { "Profile ${profile.profileIndex}" },
|
text = profile.name.ifBlank {
|
||||||
|
stringResource(Res.string.profile_label_number, profile.profileIndex)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -501,7 +506,7 @@ private fun InlinePinEntry(
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Enter PIN for $profileName",
|
text = stringResource(Res.string.pin_enter_for, profileName),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -579,9 +584,9 @@ private fun InlinePinEntry(
|
||||||
onVerified()
|
onVerified()
|
||||||
} else {
|
} else {
|
||||||
error = if (result.retryAfterSeconds > 0) {
|
error = if (result.retryAfterSeconds > 0) {
|
||||||
"Locked. Try again in ${result.retryAfterSeconds}s"
|
getString(Res.string.pin_locked_try_again, result.retryAfterSeconds)
|
||||||
} else {
|
} else {
|
||||||
"Wrong PIN"
|
getString(Res.string.pin_incorrect)
|
||||||
}
|
}
|
||||||
pin = ""
|
pin = ""
|
||||||
}
|
}
|
||||||
|
|
@ -601,7 +606,7 @@ private fun InlinePinEntry(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Cancel",
|
text = stringResource(Res.string.pin_cancel),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -645,7 +650,7 @@ private fun CompactPinKeypad(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.Backspace,
|
imageVector = Icons.AutoMirrored.Rounded.Backspace,
|
||||||
contentDescription = "Backspace",
|
contentDescription = stringResource(Res.string.pin_backspace),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -685,7 +690,7 @@ fun ActiveProfileMiniAvatar(
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Person,
|
imageVector = Icons.Rounded.Person,
|
||||||
contentDescription = "Profile",
|
contentDescription = stringResource(Res.string.compose_nav_profile),
|
||||||
modifier = Modifier.size(size.dp),
|
modifier = Modifier.size(size.dp),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ import com.nuvio.app.features.home.PosterShape
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.discoverContent(
|
internal fun LazyListScope.discoverContent(
|
||||||
state: DiscoverUiState,
|
state: DiscoverUiState,
|
||||||
|
|
@ -91,7 +93,11 @@ internal fun LazyListScope.discoverContent(
|
||||||
state.selectedCatalog?.let { selectedCatalog ->
|
state.selectedCatalog?.let { selectedCatalog ->
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
text = "${selectedCatalog.addonName} • ${selectedCatalog.type.displayTypeLabel()}",
|
text = stringResource(
|
||||||
|
Res.string.discover_catalog_context,
|
||||||
|
selectedCatalog.addonName,
|
||||||
|
selectedCatalog.type.displayTypeLabel(),
|
||||||
|
),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
|
|
@ -149,7 +155,7 @@ internal fun LazyListScope.discoverContent(
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
|
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = "Discover",
|
text = stringResource(Res.string.compose_search_discover_title),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
|
@ -169,16 +175,16 @@ private fun DiscoverFilterRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
DiscoverDropdownChip(
|
DiscoverDropdownChip(
|
||||||
title = "Select Type",
|
title = stringResource(Res.string.discover_select_type),
|
||||||
label = state.selectedType?.displayTypeLabel() ?: "Type",
|
label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type),
|
||||||
selectedKey = state.selectedType,
|
selectedKey = state.selectedType,
|
||||||
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
|
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
|
||||||
enabled = state.typeOptions.isNotEmpty(),
|
enabled = state.typeOptions.isNotEmpty(),
|
||||||
onSelected = { onTypeSelected(it.key) },
|
onSelected = { onTypeSelected(it.key) },
|
||||||
)
|
)
|
||||||
DiscoverDropdownChip(
|
DiscoverDropdownChip(
|
||||||
title = "Select Catalog",
|
title = stringResource(Res.string.discover_select_catalog),
|
||||||
label = state.selectedCatalog?.catalogName ?: "Catalog",
|
label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog),
|
||||||
selectedKey = state.selectedCatalogKey,
|
selectedKey = state.selectedCatalogKey,
|
||||||
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
|
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
|
||||||
enabled = state.catalogOptions.isNotEmpty(),
|
enabled = state.catalogOptions.isNotEmpty(),
|
||||||
|
|
@ -188,13 +194,13 @@ private fun DiscoverFilterRow(
|
||||||
val selectedCatalog = state.selectedCatalog
|
val selectedCatalog = state.selectedCatalog
|
||||||
val genreOptions = buildList {
|
val genreOptions = buildList {
|
||||||
if (selectedCatalog?.genreRequired != true) {
|
if (selectedCatalog?.genreRequired != true) {
|
||||||
add(DiscoverOptionItem(key = "", label = "All Genres"))
|
add(DiscoverOptionItem(key = "", label = stringResource(Res.string.discover_all_genres)))
|
||||||
}
|
}
|
||||||
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
|
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
|
||||||
}
|
}
|
||||||
DiscoverDropdownChip(
|
DiscoverDropdownChip(
|
||||||
title = "Select Genre",
|
title = stringResource(Res.string.discover_select_genre),
|
||||||
label = state.selectedGenre ?: "All Genres",
|
label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres),
|
||||||
selectedKey = state.selectedGenre ?: "",
|
selectedKey = state.selectedGenre ?: "",
|
||||||
options = genreOptions,
|
options = genreOptions,
|
||||||
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
|
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
|
||||||
|
|
@ -490,23 +496,23 @@ private fun DiscoverEmptyStateCard(
|
||||||
|
|
||||||
when (reason) {
|
when (reason) {
|
||||||
DiscoverEmptyStateReason.NoActiveAddons -> {
|
DiscoverEmptyStateReason.NoActiveAddons -> {
|
||||||
title = "No active addons"
|
title = stringResource(Res.string.compose_search_empty_no_active_addons_title)
|
||||||
message = "Install and validate at least one addon before browsing discover catalogs."
|
message = stringResource(Res.string.discover_empty_no_active_addons_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
|
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
|
||||||
title = "No discover catalogs"
|
title = stringResource(Res.string.discover_empty_no_catalogs_title)
|
||||||
message = "Installed addons do not expose board-compatible catalogs for discover."
|
message = stringResource(Res.string.discover_empty_no_catalogs_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscoverEmptyStateReason.RequestFailed -> {
|
DiscoverEmptyStateReason.RequestFailed -> {
|
||||||
title = "Could not load discover"
|
title = stringResource(Res.string.discover_empty_load_failed_title)
|
||||||
message = errorMessage ?: "The selected catalog failed to return discover items."
|
message = errorMessage ?: stringResource(Res.string.discover_empty_load_failed_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscoverEmptyStateReason.NoResults, null -> {
|
DiscoverEmptyStateReason.NoResults, null -> {
|
||||||
title = "No titles found"
|
title = stringResource(Res.string.discover_empty_no_results_title)
|
||||||
message = "The selected catalog and filters did not return any items."
|
message = stringResource(Res.string.discover_empty_no_results_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,13 +528,14 @@ private data class DiscoverOptionItem(
|
||||||
val label: String,
|
val label: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun String.displayTypeLabel(): String =
|
private fun String.displayTypeLabel(): String =
|
||||||
when (lowercase()) {
|
when (lowercase()) {
|
||||||
"movie" -> "Movies"
|
"movie" -> stringResource(Res.string.media_movies)
|
||||||
"series" -> "Series"
|
"series" -> stringResource(Res.string.media_series)
|
||||||
"anime" -> "Anime"
|
"anime" -> stringResource(Res.string.media_anime)
|
||||||
"channel" -> "Channels"
|
"channel" -> stringResource(Res.string.media_channels)
|
||||||
"tv" -> "TV"
|
"tv" -> stringResource(Res.string.media_tv)
|
||||||
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.features.search
|
package com.nuvio.app.features.search
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||||
import com.nuvio.app.features.addons.AddonCatalog
|
import com.nuvio.app.features.addons.AddonCatalog
|
||||||
import com.nuvio.app.features.addons.AddonExtraProperty
|
import com.nuvio.app.features.addons.AddonExtraProperty
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
|
|
@ -21,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
object SearchRepository {
|
object SearchRepository {
|
||||||
private val log = Logger.withTag("SearchRepository")
|
private val log = Logger.withTag("SearchRepository")
|
||||||
|
|
@ -313,7 +316,7 @@ object SearchRepository {
|
||||||
|
|
||||||
return HomeCatalogSection(
|
return HomeCatalogSection(
|
||||||
key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}",
|
key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}",
|
||||||
title = "$catalogName - ${type.displayLabel()}",
|
title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()),
|
||||||
subtitle = addon.displayTitle,
|
subtitle = addon.displayTitle,
|
||||||
addonName = addon.displayTitle,
|
addonName = addon.displayTitle,
|
||||||
type = type,
|
type = type,
|
||||||
|
|
@ -410,7 +413,7 @@ object SearchRepository {
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
nextSkip = null,
|
nextSkip = null,
|
||||||
emptyStateReason = DiscoverEmptyStateReason.RequestFailed,
|
emptyStateReason = DiscoverEmptyStateReason.RequestFailed,
|
||||||
errorMessage = error.message ?: "Unable to load discover items.",
|
errorMessage = error.message ?: getString(Res.string.discover_empty_load_failed_message),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -486,9 +489,7 @@ private fun List<MetaPreview>.previewNames(limit: Int = 5): String {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.displayLabel(): String =
|
private fun String.displayLabel(): String =
|
||||||
replaceFirstChar { char ->
|
localizedMediaTypeLabel(this)
|
||||||
if (char.isLowerCase()) char.titlecase() else char.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.typeSortKey(): String =
|
private fun String.typeSortKey(): String =
|
||||||
when (lowercase()) {
|
when (lowercase()) {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,22 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_nav_search
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_clear
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_discover_title
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_failed_message
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_failed_title
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_no_active_addons_message
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_no_active_addons_title
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_no_results_message
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_no_results_title
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_no_search_catalogs_message
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_empty_no_search_catalogs_title
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_placeholder
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_recent_searches
|
||||||
|
import nuvio.composeapp.generated.resources.compose_search_remove_recent_search
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
|
|
@ -78,14 +94,9 @@ fun SearchScreen(
|
||||||
var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val headerTitle by remember(query, listState) {
|
val discoverInFocus by remember(query, listState) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
if (query.isNotBlank()) {
|
query.isBlank() && listState.firstVisibleItemIndex > 0
|
||||||
"Search"
|
|
||||||
} else {
|
|
||||||
val discoverInFocus = listState.firstVisibleItemIndex > 0
|
|
||||||
if (discoverInFocus) "Discover" else "Search"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +202,11 @@ fun SearchScreen(
|
||||||
val homeSectionPadding = remember(maxWidth) {
|
val homeSectionPadding = remember(maxWidth) {
|
||||||
homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||||
}
|
}
|
||||||
|
val headerTitle = when {
|
||||||
|
query.isNotBlank() -> stringResource(Res.string.compose_nav_search)
|
||||||
|
discoverInFocus -> stringResource(Res.string.compose_search_discover_title)
|
||||||
|
else -> stringResource(Res.string.compose_nav_search)
|
||||||
|
}
|
||||||
|
|
||||||
NuvioScreen(
|
NuvioScreen(
|
||||||
horizontalPadding = 0.dp,
|
horizontalPadding = 0.dp,
|
||||||
|
|
@ -212,13 +228,13 @@ fun SearchScreen(
|
||||||
NuvioInputField(
|
NuvioInputField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = { query = it },
|
onValueChange = { query = it },
|
||||||
placeholder = "Search movies, shows...",
|
placeholder = stringResource(Res.string.compose_search_placeholder),
|
||||||
trailingContent = if (query.isNotBlank()) {
|
trailingContent = if (query.isNotBlank()) {
|
||||||
{
|
{
|
||||||
IconButton(onClick = { query = "" }) {
|
IconButton(onClick = { query = "" }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Close,
|
imageVector = Icons.Rounded.Close,
|
||||||
contentDescription = "Clear search",
|
contentDescription = stringResource(Res.string.compose_search_clear),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -336,23 +352,23 @@ private fun SearchEmptyStateCard(
|
||||||
|
|
||||||
when (reason) {
|
when (reason) {
|
||||||
SearchEmptyStateReason.NoActiveAddons -> {
|
SearchEmptyStateReason.NoActiveAddons -> {
|
||||||
title = "No active addons"
|
title = stringResource(Res.string.compose_search_empty_no_active_addons_title)
|
||||||
message = "Install and validate at least one addon before searching."
|
message = stringResource(Res.string.compose_search_empty_no_active_addons_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchEmptyStateReason.NoSearchCatalogs -> {
|
SearchEmptyStateReason.NoSearchCatalogs -> {
|
||||||
title = "No searchable catalogs"
|
title = stringResource(Res.string.compose_search_empty_no_search_catalogs_title)
|
||||||
message = "Your installed addons do not expose catalog search."
|
message = stringResource(Res.string.compose_search_empty_no_search_catalogs_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchEmptyStateReason.RequestFailed -> {
|
SearchEmptyStateReason.RequestFailed -> {
|
||||||
title = "Search failed"
|
title = stringResource(Res.string.compose_search_empty_failed_title)
|
||||||
message = errorMessage ?: "Installed addons failed to return valid search results."
|
message = errorMessage ?: stringResource(Res.string.compose_search_empty_failed_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchEmptyStateReason.NoResults, null -> {
|
SearchEmptyStateReason.NoResults, null -> {
|
||||||
title = "No results found"
|
title = stringResource(Res.string.compose_search_empty_no_results_title)
|
||||||
message = "Installed searchable catalogs did not return any matches for this query."
|
message = stringResource(Res.string.compose_search_empty_no_results_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,7 +393,7 @@ private fun SearchRecentSection(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Recent Searches",
|
text = stringResource(Res.string.compose_search_recent_searches),
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
|
@ -439,7 +455,7 @@ private fun SearchRecentRow(
|
||||||
IconButton(onClick = onRemovePress) {
|
IconButton(onClick = onRemovePress) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Close,
|
imageVector = Icons.Rounded.Close,
|
||||||
contentDescription = "Remove recent search",
|
contentDescription = stringResource(Res.string.compose_search_remove_recent_search),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,23 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.action_cancel
|
||||||
|
import nuvio.composeapp.generated.resources.action_delete
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_delete_account
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_delete_account_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_email
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_not_signed_in
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_sign_out
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_message
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_status
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_status_anonymous
|
||||||
|
import nuvio.composeapp.generated.resources.settings_account_status_signed_in
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.accountSettingsContent(
|
internal fun LazyListScope.accountSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
|
|
@ -51,7 +68,7 @@ private fun AccountSettingsBody(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Text(
|
Text(
|
||||||
text = "Account",
|
text = stringResource(Res.string.compose_settings_page_account),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -65,12 +82,16 @@ private fun AccountSettingsBody(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Status",
|
text = stringResource(Res.string.settings_account_status),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (state.isAnonymous) "Anonymous" else "Signed In",
|
text = if (state.isAnonymous) {
|
||||||
|
stringResource(Res.string.settings_account_status_anonymous)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.settings_account_status_signed_in)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -83,7 +104,7 @@ private fun AccountSettingsBody(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Email",
|
text = stringResource(Res.string.settings_account_email),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -98,7 +119,7 @@ private fun AccountSettingsBody(
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Text(
|
Text(
|
||||||
text = "Not signed in",
|
text = stringResource(Res.string.settings_account_not_signed_in),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -107,7 +128,7 @@ private fun AccountSettingsBody(
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioPrimaryButton(
|
NuvioPrimaryButton(
|
||||||
text = "Sign Out",
|
text = stringResource(Res.string.settings_account_sign_out),
|
||||||
onClick = { showSignOutConfirm = true },
|
onClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -126,13 +147,13 @@ private fun AccountSettingsBody(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Delete Account",
|
text = stringResource(Res.string.settings_account_delete_account),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "This will permanently delete your account and all associated data.",
|
text = stringResource(Res.string.settings_account_delete_account_description),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -142,11 +163,11 @@ private fun AccountSettingsBody(
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = "Sign Out?",
|
title = stringResource(Res.string.settings_account_sign_out_confirm_title),
|
||||||
message = "You will be returned to the login screen.",
|
message = stringResource(Res.string.settings_account_sign_out_confirm_message),
|
||||||
isVisible = showSignOutConfirm,
|
isVisible = showSignOutConfirm,
|
||||||
confirmText = "Sign Out",
|
confirmText = stringResource(Res.string.settings_account_sign_out),
|
||||||
dismissText = "Cancel",
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showSignOutConfirm = false
|
showSignOutConfirm = false
|
||||||
scope.launch { AuthRepository.signOut() }
|
scope.launch { AuthRepository.signOut() }
|
||||||
|
|
@ -155,11 +176,11 @@ private fun AccountSettingsBody(
|
||||||
)
|
)
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = "Delete Account?",
|
title = stringResource(Res.string.settings_account_delete_confirm_title),
|
||||||
message = "This action cannot be undone. All your data, profiles, and sync history will be permanently removed.",
|
message = stringResource(Res.string.settings_account_delete_confirm_message),
|
||||||
isVisible = showDeleteConfirm,
|
isVisible = showDeleteConfirm,
|
||||||
confirmText = "Delete",
|
confirmText = stringResource(Res.string.action_delete),
|
||||||
dismissText = "Cancel",
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showDeleteConfirm = false
|
showDeleteConfirm = false
|
||||||
scope.launch { AuthRepository.deleteAccount() }
|
scope.launch { AuthRepository.deleteAccount() }
|
||||||
|
|
|
||||||
|
|
@ -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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.rounded.Language
|
||||||
import androidx.compose.material.icons.rounded.Style
|
import androidx.compose.material.icons.rounded.Style
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -32,7 +38,30 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.core.ui.AppTheme
|
import com.nuvio.app.core.ui.AppTheme
|
||||||
|
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
|
||||||
|
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
||||||
|
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||||
|
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
||||||
|
import com.nuvio.app.core.ui.labelRes
|
||||||
import com.nuvio.app.core.ui.ThemeColors
|
import com.nuvio.app.core.ui.ThemeColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.cd_selected
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_app_language
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_section_display
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_section_home
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_section_theme
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
internal fun LazyListScope.appearanceSettingsContent(
|
internal fun LazyListScope.appearanceSettingsContent(
|
||||||
|
|
@ -41,12 +70,14 @@ internal fun LazyListScope.appearanceSettingsContent(
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
onAmoledToggle: (Boolean) -> Unit,
|
onAmoledToggle: (Boolean) -> Unit,
|
||||||
|
selectedAppLanguage: AppLanguage,
|
||||||
|
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||||
onContinueWatchingClick: () -> Unit,
|
onContinueWatchingClick: () -> Unit,
|
||||||
onPosterCustomizationClick: () -> Unit,
|
onPosterCustomizationClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "THEME",
|
title = stringResource(Res.string.settings_appearance_section_theme),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
|
@ -74,39 +105,59 @@ internal fun LazyListScope.appearanceSettingsContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
|
var showLanguageSheet by remember { mutableStateOf(false) }
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "DISPLAY",
|
title = stringResource(Res.string.settings_appearance_section_display),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsSwitchRow(
|
SettingsSwitchRow(
|
||||||
title = "AMOLED Black",
|
title = stringResource(Res.string.settings_appearance_amoled_black),
|
||||||
description = "Use pure black backgrounds for OLED screens.",
|
description = stringResource(Res.string.settings_appearance_amoled_description),
|
||||||
checked = amoledEnabled,
|
checked = amoledEnabled,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = onAmoledToggle,
|
onCheckedChange = onAmoledToggle,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = stringResource(Res.string.settings_appearance_app_language),
|
||||||
|
description = stringResource(selectedAppLanguage.labelRes),
|
||||||
|
icon = Icons.Rounded.Language,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = { showLanguageSheet = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showLanguageSheet) {
|
||||||
|
AppearanceLanguageBottomSheet(
|
||||||
|
selectedLanguage = selectedAppLanguage,
|
||||||
|
onLanguageSelected = {
|
||||||
|
onAppLanguageSelected(it)
|
||||||
|
showLanguageSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showLanguageSheet = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "HOME",
|
title = stringResource(Res.string.settings_appearance_section_home),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Continue Watching",
|
title = stringResource(Res.string.compose_settings_page_continue_watching),
|
||||||
description = "Show, hide, and style the Continue Watching shelf.",
|
description = stringResource(Res.string.settings_appearance_continue_watching_description),
|
||||||
icon = Icons.Rounded.Style,
|
icon = Icons.Rounded.Style,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onContinueWatchingClick,
|
onClick = onContinueWatchingClick,
|
||||||
)
|
)
|
||||||
SettingsGroupDivider(isTablet = isTablet)
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Poster Customization",
|
title = stringResource(Res.string.compose_settings_page_poster_customization),
|
||||||
description = "Adjust shared poster card width and corner radius presets.",
|
description = stringResource(Res.string.settings_appearance_poster_customization_description),
|
||||||
icon = Icons.Rounded.Tune,
|
icon = Icons.Rounded.Tune,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onPosterCustomizationClick,
|
onClick = onPosterCustomizationClick,
|
||||||
|
|
@ -116,6 +167,78 @@ internal fun LazyListScope.appearanceSettingsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class AppLanguageSheetOption(
|
||||||
|
val language: AppLanguage,
|
||||||
|
val labelRes: StringResource,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AppearanceLanguageBottomSheet(
|
||||||
|
selectedLanguage: AppLanguage,
|
||||||
|
onLanguageSelected: (AppLanguage) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val options = remember {
|
||||||
|
AppLanguage.entries.map { language ->
|
||||||
|
AppLanguageSheetOption(
|
||||||
|
language = language,
|
||||||
|
labelRes = language.labelRes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NuvioModalBottomSheet(
|
||||||
|
onDismissRequest = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_appearance_app_language_sheet_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
if (index > 0) {
|
||||||
|
NuvioBottomSheetDivider()
|
||||||
|
}
|
||||||
|
NuvioBottomSheetActionRow(
|
||||||
|
title = stringResource(option.labelRes),
|
||||||
|
onClick = {
|
||||||
|
onLanguageSelected(option.language)
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (option.language == selectedLanguage) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(Res.string.cd_selected),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThemeChip(
|
private fun ThemeChip(
|
||||||
theme: AppTheme,
|
theme: AppTheme,
|
||||||
|
|
@ -152,7 +275,7 @@ private fun ThemeChip(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Check,
|
imageVector = Icons.Default.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = stringResource(Res.string.cd_selected),
|
||||||
tint = palette.onSecondary,
|
tint = palette.onSecondary,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -162,7 +285,7 @@ private fun ThemeChip(
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = theme.displayName,
|
text = stringResource(theme.labelRes),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (isSelected) {
|
color = if (isSelected) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,20 @@ import androidx.compose.material.icons.rounded.Extension
|
||||||
import androidx.compose.material.icons.rounded.Home
|
import androidx.compose.material.icons.rounded.Home
|
||||||
import androidx.compose.material.icons.rounded.Hub
|
import androidx.compose.material.icons.rounded.Hub
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_addons
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_plugins
|
||||||
|
import nuvio.composeapp.generated.resources.collections_header
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_meta_screen_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_plugins_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_section_home
|
||||||
|
import nuvio.composeapp.generated.resources.settings_content_discovery_section_sources
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.contentDiscoveryContent(
|
internal fun LazyListScope.contentDiscoveryContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
|
|
@ -19,21 +33,21 @@ internal fun LazyListScope.contentDiscoveryContent(
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "SOURCES",
|
title = stringResource(Res.string.settings_content_discovery_section_sources),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Addons",
|
title = stringResource(Res.string.compose_settings_page_addons),
|
||||||
description = "Install, remove, refresh, and sort your content sources.",
|
description = stringResource(Res.string.settings_content_discovery_addons_description),
|
||||||
icon = Icons.Rounded.Extension,
|
icon = Icons.Rounded.Extension,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onAddonsClick,
|
onClick = onAddonsClick,
|
||||||
)
|
)
|
||||||
if (showPluginsEntry) {
|
if (showPluginsEntry) {
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Plugins",
|
title = stringResource(Res.string.compose_settings_page_plugins),
|
||||||
description = "Install JavaScript scraper repositories and test providers internally.",
|
description = stringResource(Res.string.settings_content_discovery_plugins_description),
|
||||||
icon = Icons.Rounded.Hub,
|
icon = Icons.Rounded.Hub,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onPluginsClick,
|
onClick = onPluginsClick,
|
||||||
|
|
@ -44,27 +58,27 @@ internal fun LazyListScope.contentDiscoveryContent(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "HOME",
|
title = stringResource(Res.string.settings_content_discovery_section_home),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Homescreen",
|
title = stringResource(Res.string.compose_settings_page_homescreen),
|
||||||
description = "Control which catalogs appear on Home and in what order.",
|
description = stringResource(Res.string.settings_content_discovery_homescreen_description),
|
||||||
icon = Icons.Rounded.Home,
|
icon = Icons.Rounded.Home,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onHomescreenClick,
|
onClick = onHomescreenClick,
|
||||||
)
|
)
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Meta Screen",
|
title = stringResource(Res.string.compose_settings_page_meta_screen),
|
||||||
description = "Disable detail sections and reorder everything below Hero.",
|
description = stringResource(Res.string.settings_content_discovery_meta_screen_description),
|
||||||
icon = Icons.Rounded.Tune,
|
icon = Icons.Rounded.Tune,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onMetaScreenClick,
|
onClick = onMetaScreenClick,
|
||||||
)
|
)
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = "Collections",
|
title = stringResource(Res.string.collections_header),
|
||||||
description = "Create custom catalog groupings with folders shown on Home.",
|
description = stringResource(Res.string.settings_content_discovery_collections_description),
|
||||||
icon = Icons.Rounded.CollectionsBookmark,
|
icon = Icons.Rounded.CollectionsBookmark,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onCollectionsClick,
|
onClick = onCollectionsClick,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,23 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
|
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.continueWatchingSettingsContent(
|
internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
|
|
@ -35,13 +52,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "VISIBILITY",
|
title = stringResource(Res.string.settings_continue_watching_section_visibility),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsSwitchRow(
|
SettingsSwitchRow(
|
||||||
title = "Show Continue Watching",
|
title = stringResource(Res.string.settings_continue_watching_show_title),
|
||||||
description = "Display the Continue Watching shelf on the Home screen.",
|
description = stringResource(Res.string.settings_continue_watching_show_description),
|
||||||
checked = isVisible,
|
checked = isVisible,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = ContinueWatchingPreferencesRepository::setVisible,
|
onCheckedChange = ContinueWatchingPreferencesRepository::setVisible,
|
||||||
|
|
@ -51,7 +68,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "CARD STYLE",
|
title = stringResource(Res.string.settings_continue_watching_section_card_style),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
ContinueWatchingStyleSelector(
|
ContinueWatchingStyleSelector(
|
||||||
|
|
@ -63,13 +80,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "UP NEXT BEHAVIOR",
|
title = stringResource(Res.string.settings_continue_watching_section_up_next_behavior),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsSwitchRow(
|
SettingsSwitchRow(
|
||||||
title = "Up Next from furthest episode",
|
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
||||||
description = "When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode. useful if you rewatch earlier episodes.",
|
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
||||||
checked = upNextFromFurthestEpisode,
|
checked = upNextFromFurthestEpisode,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
||||||
|
|
@ -79,13 +96,13 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "ON LAUNCH",
|
title = stringResource(Res.string.settings_continue_watching_section_on_launch),
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsSwitchRow(
|
SettingsSwitchRow(
|
||||||
title = "Resume prompt on launch",
|
title = stringResource(Res.string.settings_continue_watching_resume_prompt_title),
|
||||||
description = "Show a popup to continue where you left off when opening the app after leaving from the player.",
|
description = stringResource(Res.string.settings_continue_watching_resume_prompt_description),
|
||||||
checked = showResumePromptOnLaunch,
|
checked = showResumePromptOnLaunch,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch,
|
onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch,
|
||||||
|
|
@ -173,20 +190,28 @@ private fun ContinueWatchingStyleOption(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = style.name.lowercase().replaceFirstChar(Char::uppercase),
|
text = stringResource(style.labelRes),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (style == ContinueWatchingSectionStyle.Wide) {
|
text = stringResource(style.descriptionRes),
|
||||||
"Info-dense horizontal card"
|
|
||||||
} else {
|
|
||||||
"Artwork-first poster card"
|
|
||||||
},
|
|
||||||
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
|
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val ContinueWatchingSectionStyle.labelRes: StringResource
|
||||||
|
get() = when (this) {
|
||||||
|
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide
|
||||||
|
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ContinueWatchingSectionStyle.descriptionRes: StringResource
|
||||||
|
get() = when (this) {
|
||||||
|
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
|
||||||
|
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
|
||||||
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue