mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: Implement theme settings management; add ThemeSettingsStorage and ThemeSettingsRepository; update appearance settings UI
This commit is contained in:
parent
f3c854751e
commit
d3fbfb1716
19 changed files with 543 additions and 12 deletions
|
|
@ -11,6 +11,7 @@ import com.nuvio.app.features.library.LibraryStorage
|
|||
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
|
||||
import com.nuvio.app.features.player.PlayerSettingsStorage
|
||||
import com.nuvio.app.features.profiles.ProfileStorage
|
||||
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
||||
import com.nuvio.app.features.watched.WatchedStorage
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressStorage
|
||||
|
|
@ -33,6 +34,7 @@ class MainActivity : ComponentActivity() {
|
|||
HomeCatalogSettingsStorage.initialize(applicationContext)
|
||||
PlayerSettingsStorage.initialize(applicationContext)
|
||||
ProfileStorage.initialize(applicationContext)
|
||||
ThemeSettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
WatchProgressStorage.initialize(applicationContext)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
actual object ThemeSettingsStorage {
|
||||
private const val preferencesName = "nuvio_theme_settings"
|
||||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadSelectedTheme(): String? =
|
||||
preferences?.getString(selectedThemeKey, null)
|
||||
|
||||
actual fun saveSelectedTheme(themeName: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(selectedThemeKey, themeName)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadAmoledEnabled(): Boolean? =
|
||||
preferences?.let { prefs ->
|
||||
if (prefs.contains(amoledEnabledKey)) prefs.getBoolean(amoledEnabledKey, false) else null
|
||||
}
|
||||
|
||||
actual fun saveAmoledEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(amoledEnabledKey, enabled)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
|||
import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
|
||||
import com.nuvio.app.features.settings.AddonsSettingsScreen
|
||||
import com.nuvio.app.features.settings.AccountSettingsScreen
|
||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamsRepository
|
||||
import com.nuvio.app.features.streams.StreamsScreen
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
|
|
@ -162,7 +163,13 @@ fun App() {
|
|||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build()
|
||||
}
|
||||
NuvioTheme {
|
||||
val selectedTheme by remember {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
ThemeSettingsRepository.selectedTheme
|
||||
}.collectAsStateWithLifecycle()
|
||||
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
||||
|
||||
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
|
||||
LaunchedEffect(Unit) {
|
||||
AuthRepository.initialize()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
enum class AppTheme(val displayName: String) {
|
||||
CRIMSON("Crimson"),
|
||||
OCEAN("Ocean"),
|
||||
VIOLET("Violet"),
|
||||
EMERALD("Emerald"),
|
||||
AMBER("Amber"),
|
||||
ROSE("Rose"),
|
||||
WHITE("White"),
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.RippleConfiguration
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -15,6 +17,31 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val LocalAppTheme = staticCompositionLocalOf { AppTheme.WHITE }
|
||||
|
||||
val MaterialTheme.appTheme: AppTheme
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalAppTheme.current
|
||||
|
||||
private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false) = darkColorScheme(
|
||||
primary = palette.secondary,
|
||||
onPrimary = palette.onSecondary,
|
||||
primaryContainer = palette.focusBackground,
|
||||
onPrimaryContainer = palette.onSecondary,
|
||||
secondary = palette.secondaryVariant,
|
||||
onSecondary = palette.onSecondaryVariant,
|
||||
background = if (amoled) Color.Black else palette.background,
|
||||
onBackground = Color(0xFFF5F7F8),
|
||||
surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated,
|
||||
onSurface = Color(0xFFF5F7F8),
|
||||
surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard,
|
||||
onSurfaceVariant = Color(0xFF969CA3),
|
||||
outline = Color(0xFF252A2A),
|
||||
error = Color(0xFFE36A8A),
|
||||
onError = Color(0xFFFCE5EC),
|
||||
)
|
||||
|
||||
private val NuvioDarkColors = darkColorScheme(
|
||||
primary = Color(0xFF2E86B8),
|
||||
onPrimary = Color(0xFFD2E8F7),
|
||||
|
|
@ -139,8 +166,12 @@ private val NuvioRippleConfiguration = RippleConfiguration(
|
|||
@Composable
|
||||
fun NuvioTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
appTheme: AppTheme = AppTheme.WHITE,
|
||||
amoled: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = buildColorScheme(ThemeColors.getColorPalette(appTheme), amoled = amoled)
|
||||
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(
|
||||
|
|
@ -149,9 +180,10 @@ fun NuvioTheme(
|
|||
),
|
||||
LocalNuvioTypeScale provides NuvioTypeTokens,
|
||||
LocalRippleConfiguration provides NuvioRippleConfiguration,
|
||||
LocalAppTheme provides appTheme,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) NuvioDarkColors else NuvioDarkColors,
|
||||
colorScheme = colorScheme,
|
||||
typography = NuvioTypography,
|
||||
content = content,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
data class ThemeColorPalette(
|
||||
val secondary: Color,
|
||||
val secondaryVariant: Color,
|
||||
val onSecondary: Color = Color.White,
|
||||
val onSecondaryVariant: Color = Color.White,
|
||||
val focusRing: Color,
|
||||
val focusBackground: Color,
|
||||
val background: Color = Color(0xFF0D0D0D),
|
||||
val backgroundElevated: Color = Color(0xFF1A1A1A),
|
||||
val backgroundCard: Color = Color(0xFF242424),
|
||||
)
|
||||
|
||||
object ThemeColors {
|
||||
|
||||
val Crimson = ThemeColorPalette(
|
||||
secondary = Color(0xFFE53935),
|
||||
secondaryVariant = Color(0xFFC62828),
|
||||
focusRing = Color(0xFFFF5252),
|
||||
focusBackground = Color(0xFF3D1A1A),
|
||||
background = Color(0xFF0D0D0D),
|
||||
backgroundElevated = Color(0xFF1A1A1A),
|
||||
backgroundCard = Color(0xFF241A1A),
|
||||
)
|
||||
|
||||
val Ocean = ThemeColorPalette(
|
||||
secondary = Color(0xFF1E88E5),
|
||||
secondaryVariant = Color(0xFF1565C0),
|
||||
focusRing = Color(0xFF42A5F5),
|
||||
focusBackground = Color(0xFF1A2D3D),
|
||||
background = Color(0xFF0D0D0F),
|
||||
backgroundElevated = Color(0xFF1A1A1E),
|
||||
backgroundCard = Color(0xFF1A1F24),
|
||||
)
|
||||
|
||||
val Violet = ThemeColorPalette(
|
||||
secondary = Color(0xFF8E24AA),
|
||||
secondaryVariant = Color(0xFF6A1B9A),
|
||||
focusRing = Color(0xFFAB47BC),
|
||||
focusBackground = Color(0xFF2D1A3D),
|
||||
background = Color(0xFF0D0D0F),
|
||||
backgroundElevated = Color(0xFF1A1A1E),
|
||||
backgroundCard = Color(0xFF1F1A24),
|
||||
)
|
||||
|
||||
val Emerald = ThemeColorPalette(
|
||||
secondary = Color(0xFF43A047),
|
||||
secondaryVariant = Color(0xFF2E7D32),
|
||||
focusRing = Color(0xFF66BB6A),
|
||||
focusBackground = Color(0xFF1A3D1E),
|
||||
background = Color(0xFF0D0D0D),
|
||||
backgroundElevated = Color(0xFF1A1A1A),
|
||||
backgroundCard = Color(0xFF1A241A),
|
||||
)
|
||||
|
||||
val Amber = ThemeColorPalette(
|
||||
secondary = Color(0xFFFB8C00),
|
||||
secondaryVariant = Color(0xFFEF6C00),
|
||||
focusRing = Color(0xFFFFA726),
|
||||
focusBackground = Color(0xFF3D2D1A),
|
||||
background = Color(0xFF0F0D0D),
|
||||
backgroundElevated = Color(0xFF1E1A1A),
|
||||
backgroundCard = Color(0xFF24201A),
|
||||
)
|
||||
|
||||
val Rose = ThemeColorPalette(
|
||||
secondary = Color(0xFFD81B60),
|
||||
secondaryVariant = Color(0xFFC2185B),
|
||||
focusRing = Color(0xFFEC407A),
|
||||
focusBackground = Color(0xFF3D1A2D),
|
||||
background = Color(0xFF0D0D0D),
|
||||
backgroundElevated = Color(0xFF1A1A1A),
|
||||
backgroundCard = Color(0xFF241A1F),
|
||||
)
|
||||
|
||||
val White = ThemeColorPalette(
|
||||
secondary = Color(0xFFF5F5F5),
|
||||
secondaryVariant = Color(0xFFE0E0E0),
|
||||
onSecondary = Color(0xFF111111),
|
||||
onSecondaryVariant = Color(0xFF111111),
|
||||
focusRing = Color(0xFFFFFFFF),
|
||||
focusBackground = Color(0xFF303030),
|
||||
background = Color(0xFF0D0D0D),
|
||||
backgroundElevated = Color(0xFF1A1A1A),
|
||||
backgroundCard = Color(0xFF222222),
|
||||
)
|
||||
|
||||
fun getColorPalette(theme: AppTheme): ThemeColorPalette = when (theme) {
|
||||
AppTheme.CRIMSON -> Crimson
|
||||
AppTheme.OCEAN -> Ocean
|
||||
AppTheme.VIOLET -> Violet
|
||||
AppTheme.EMERALD -> Emerald
|
||||
AppTheme.AMBER -> Amber
|
||||
AppTheme.ROSE -> Rose
|
||||
AppTheme.WHITE -> White
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.nuvio.app.features.addons.AddonCatalog
|
|||
import com.nuvio.app.features.addons.httpGetText
|
||||
import com.nuvio.app.features.home.HomeCatalogParser
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
|
||||
const val CATALOG_PAGE_SIZE = 100
|
||||
|
||||
|
|
@ -30,14 +31,14 @@ suspend fun fetchCatalogPage(
|
|||
skip = skip,
|
||||
),
|
||||
)
|
||||
val items = HomeCatalogParser.parseCatalog(payload)
|
||||
val nextSkip = if (items.size >= CATALOG_PAGE_SIZE) {
|
||||
val parsed = HomeCatalogParser.parseCatalogResponse(payload)
|
||||
val nextSkip = if (parsed.rawItemCount >= CATALOG_PAGE_SIZE) {
|
||||
(skip ?: 0) + CATALOG_PAGE_SIZE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return CatalogPage(
|
||||
items = items,
|
||||
items = parsed.items,
|
||||
nextSkip = nextSkip,
|
||||
)
|
||||
}
|
||||
|
|
@ -50,11 +51,11 @@ fun mergeCatalogItems(
|
|||
incoming: List<MetaPreview>,
|
||||
): List<MetaPreview> {
|
||||
if (incoming.isEmpty()) return existing
|
||||
val seen = existing.mapTo(mutableSetOf()) { item -> "${item.type}:${item.id}" }
|
||||
val seen = existing.mapTo(mutableSetOf()) { item -> item.stableKey() }
|
||||
return buildList(existing.size + incoming.size) {
|
||||
addAll(existing)
|
||||
incoming.forEach { item ->
|
||||
val key = "${item.type}:${item.id}"
|
||||
val key = item.stableKey()
|
||||
if (seen.add(key)) {
|
||||
add(item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import com.nuvio.app.core.ui.posterCardClickable
|
|||
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -116,7 +117,7 @@ fun CatalogScreen(
|
|||
} else {
|
||||
items(
|
||||
items = uiState.items,
|
||||
key = { item -> "${item.type}:${item.id}" },
|
||||
key = { item -> item.stableKey() },
|
||||
) { item ->
|
||||
CatalogPosterTile(
|
||||
item = item,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ internal object MetaDetailsParser {
|
|||
fun parse(payload: String): MetaDetails {
|
||||
val root = json.parseToJsonElement(payload).asJsonObjectOrNull()
|
||||
?: error("Expected top-level JSON object in response")
|
||||
val meta = root["meta"].asJsonObjectOrNull()
|
||||
?: error("Missing or invalid 'meta' object in response")
|
||||
val meta = root.extractMetaObject()
|
||||
?: error("Response did not contain a valid meta object")
|
||||
val links = meta.links()
|
||||
|
||||
return MetaDetails(
|
||||
|
|
@ -88,6 +88,20 @@ internal object MetaDetailsParser {
|
|||
private fun JsonObject.behaviorHints(): JsonObject =
|
||||
this["behaviorHints"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
|
||||
private fun JsonObject.extractMetaObject(): JsonObject? {
|
||||
val data = this["data"].asJsonObjectOrNull()
|
||||
val candidates = listOfNotNull(
|
||||
this["meta"].asJsonObjectOrNull(),
|
||||
data?.get("meta").asJsonObjectOrNull(),
|
||||
data?.takeIf { it.looksLikeMetaObject() },
|
||||
this.takeIf { it.looksLikeMetaObject() },
|
||||
)
|
||||
return candidates.firstOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.looksLikeMetaObject(): Boolean =
|
||||
string("id") != null && string("type") != null && string("name") != null
|
||||
|
||||
private fun JsonObject.directors(links: List<MetaLink>): List<String> {
|
||||
val appExtras = this["app_extras"] as? JsonObject
|
||||
val topLevel = stringListOrCsv("director")
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ internal object HomeCatalogParser {
|
|||
}
|
||||
|
||||
fun parseCatalog(payload: String): List<MetaPreview> {
|
||||
return parseCatalogResponse(payload).items
|
||||
}
|
||||
|
||||
fun parseCatalogResponse(payload: String): ParsedCatalogResponse {
|
||||
val root = json.parseToJsonElement(payload).jsonObject
|
||||
return root.array("metas")
|
||||
val parsedItems = root.array("metas")
|
||||
.mapNotNull { element ->
|
||||
val meta = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = meta.string("id")
|
||||
|
|
@ -41,6 +45,10 @@ internal object HomeCatalogParser {
|
|||
},
|
||||
)
|
||||
}
|
||||
return ParsedCatalogResponse(
|
||||
items = parsedItems.distinctBy { it.stableKey() },
|
||||
rawItemCount = root.array("metas").size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? =
|
||||
|
|
@ -56,3 +64,8 @@ internal object HomeCatalogParser {
|
|||
else -> PosterShape.Poster
|
||||
}
|
||||
}
|
||||
|
||||
data class ParsedCatalogResponse(
|
||||
val items: List<MetaPreview>,
|
||||
val rawItemCount: Int,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ data class MetaPreview(
|
|||
val genres: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
fun MetaPreview.stableKey(): String = "$type:$id"
|
||||
|
||||
enum class PosterShape {
|
||||
Poster,
|
||||
Square,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.nuvio.app.core.ui.NuvioShelfSection
|
|||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
|
||||
@Composable
|
||||
fun HomeCatalogRowSection(
|
||||
|
|
@ -26,7 +27,7 @@ fun HomeCatalogRowSection(
|
|||
rowContentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
|
||||
onViewAllClick = onViewAllClick,
|
||||
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
||||
key = { item -> item.id },
|
||||
key = { item -> item.stableKey() },
|
||||
) { item ->
|
||||
HomePosterCard(
|
||||
item = item,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,93 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.rounded.Style
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import com.nuvio.app.core.ui.ThemeColors
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
internal fun LazyListScope.appearanceSettingsContent(
|
||||
isTablet: Boolean,
|
||||
selectedTheme: AppTheme,
|
||||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
onContinueWatchingClick: () -> Unit,
|
||||
) {
|
||||
item {
|
||||
SettingsSection(
|
||||
title = "THEME",
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
val themes = listOf(AppTheme.WHITE) + AppTheme.entries.filterNot { it == AppTheme.WHITE }
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = if (isTablet) 20.dp else 16.dp,
|
||||
vertical = if (isTablet) 18.dp else 14.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp),
|
||||
) {
|
||||
themes.forEach { theme ->
|
||||
ThemeChip(
|
||||
theme = theme,
|
||||
isSelected = theme == selectedTheme,
|
||||
onClick = { onThemeSelected(theme) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsSection(
|
||||
title = "DISPLAY",
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = "AMOLED Black",
|
||||
description = "Use pure black backgrounds for OLED screens.",
|
||||
checked = amoledEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = onAmoledToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsSection(
|
||||
title = "HOME",
|
||||
|
|
@ -25,3 +105,71 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeChip(
|
||||
theme: AppTheme,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val palette = ThemeColors.getColorPalette(theme)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 1.5.dp,
|
||||
color = palette.focusRing,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.secondary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = palette.onSecondary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = theme.displayName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 36.dp, height = 3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(palette.focusRing),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
|
|
@ -56,6 +57,12 @@ fun SettingsScreen(
|
|||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
val selectedTheme by remember {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
ThemeSettingsRepository.selectedTheme
|
||||
}.collectAsStateWithLifecycle()
|
||||
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
||||
|
||||
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
|
||||
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
||||
val previousPage = page.previousPage()
|
||||
|
|
@ -70,6 +77,10 @@ fun SettingsScreen(
|
|||
page = page,
|
||||
onPageChange = { currentPage = it.name },
|
||||
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
||||
selectedTheme = selectedTheme,
|
||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenClick = onHomescreenClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
|
|
@ -81,6 +92,10 @@ fun SettingsScreen(
|
|||
page = page,
|
||||
onPageChange = { currentPage = it.name },
|
||||
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
||||
selectedTheme = selectedTheme,
|
||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenClick = onHomescreenClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
|
|
@ -96,6 +111,10 @@ private fun MobileSettingsScreen(
|
|||
page: SettingsPage,
|
||||
onPageChange: (SettingsPage) -> Unit,
|
||||
showLoadingOverlay: Boolean,
|
||||
selectedTheme: AppTheme,
|
||||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onHomescreenClick: () -> Unit = {},
|
||||
onContinueWatchingClick: () -> Unit = {},
|
||||
|
|
@ -126,6 +145,10 @@ private fun MobileSettingsScreen(
|
|||
)
|
||||
SettingsPage.Appearance -> appearanceSettingsContent(
|
||||
isTablet = false,
|
||||
selectedTheme = selectedTheme,
|
||||
onThemeSelected = onThemeSelected,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = onAmoledToggle,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
)
|
||||
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
|
||||
|
|
@ -142,6 +165,10 @@ private fun TabletSettingsScreen(
|
|||
page: SettingsPage,
|
||||
onPageChange: (SettingsPage) -> Unit,
|
||||
showLoadingOverlay: Boolean,
|
||||
selectedTheme: AppTheme,
|
||||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onHomescreenClick: () -> Unit = {},
|
||||
onContinueWatchingClick: () -> Unit = {},
|
||||
|
|
@ -221,6 +248,10 @@ private fun TabletSettingsScreen(
|
|||
)
|
||||
SettingsPage.Appearance -> appearanceSettingsContent(
|
||||
isTablet = true,
|
||||
selectedTheme = selectedTheme,
|
||||
onThemeSelected = onThemeSelected,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = onAmoledToggle,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
)
|
||||
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
object ThemeSettingsRepository {
|
||||
private val _selectedTheme = MutableStateFlow(AppTheme.WHITE)
|
||||
val selectedTheme: StateFlow<AppTheme> = _selectedTheme.asStateFlow()
|
||||
|
||||
private val _amoledEnabled = MutableStateFlow(false)
|
||||
val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow()
|
||||
|
||||
private var hasLoaded = false
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
hasLoaded = true
|
||||
val stored = ThemeSettingsStorage.loadSelectedTheme()
|
||||
val theme = if (stored != null) {
|
||||
try {
|
||||
AppTheme.valueOf(stored)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
AppTheme.WHITE
|
||||
}
|
||||
} else {
|
||||
AppTheme.WHITE
|
||||
}
|
||||
_selectedTheme.value = theme
|
||||
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
|
||||
}
|
||||
|
||||
fun setTheme(theme: AppTheme) {
|
||||
ensureLoaded()
|
||||
if (_selectedTheme.value == theme) return
|
||||
_selectedTheme.value = theme
|
||||
ThemeSettingsStorage.saveSelectedTheme(theme.name)
|
||||
}
|
||||
|
||||
fun setAmoled(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (_amoledEnabled.value == enabled) return
|
||||
_amoledEnabled.value = enabled
|
||||
ThemeSettingsStorage.saveAmoledEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
internal expect object ThemeSettingsStorage {
|
||||
fun loadSelectedTheme(): String?
|
||||
fun saveSelectedTheme(themeName: String)
|
||||
fun loadAmoledEnabled(): Boolean?
|
||||
fun saveAmoledEnabled(enabled: Boolean)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class MetaDetailsParserTest {
|
||||
|
|
@ -11,4 +12,21 @@ class MetaDetailsParserTest {
|
|||
MetaDetailsParser.parse("""{"meta":null}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse accepts bare meta object response`() {
|
||||
val result = MetaDetailsParser.parse(
|
||||
"""
|
||||
{
|
||||
"id": "mal:62516",
|
||||
"type": "series",
|
||||
"name": "The Fragrant Flower Blooms with Dignity"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("mal:62516", result.id)
|
||||
assertEquals("series", result.type)
|
||||
assertEquals("The Fragrant Flower Blooms with Dignity", result.name)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class HomeCatalogParserTest {
|
||||
|
||||
@Test
|
||||
fun `parse catalog response de-duplicates repeated metas but preserves raw count`() {
|
||||
val result = HomeCatalogParser.parseCatalogResponse(
|
||||
"""
|
||||
{
|
||||
"metas": [
|
||||
{ "id": "mal:62516", "type": "series", "name": "A" },
|
||||
{ "id": "mal:62516", "type": "series", "name": "A duplicate" },
|
||||
{ "id": "mal:1", "type": "movie", "name": "B" }
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(3, result.rawItemCount)
|
||||
assertEquals(
|
||||
listOf("series:mal:62516", "movie:mal:1"),
|
||||
result.items.map { it.stableKey() },
|
||||
)
|
||||
assertEquals("A", result.items.first().name)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
actual object ThemeSettingsStorage {
|
||||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
|
||||
actual fun loadSelectedTheme(): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey(selectedThemeKey)
|
||||
|
||||
actual fun saveSelectedTheme(themeName: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(themeName, forKey = selectedThemeKey)
|
||||
}
|
||||
|
||||
actual fun loadAmoledEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
return if (defaults.objectForKey(amoledEnabledKey) != null) {
|
||||
defaults.boolForKey(amoledEnabledKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveAmoledEnabled(enabled: Boolean) {
|
||||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = amoledEnabledKey)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue