From b55be263d9ee6ecc3c0484689b27b5d4487923ea Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 18 Jul 2026 20:11:42 +0530
Subject: [PATCH] feat(library): add sortable shelf and grid layouts
---
.../kotlin/com/nuvio/app/MainActivity.kt | 2 +
...PlatformLocalAccountDataCleaner.android.kt | 1 +
.../LibraryDisplaySettingsStorage.android.kt | 26 ++
.../composeResources/values/strings.xml | 11 +
.../commonMain/kotlin/com/nuvio/app/App.kt | 6 +-
.../core/storage/LocalAccountDataCleaner.kt | 2 +
.../app/features/catalog/CatalogRepository.kt | 9 +-
.../app/features/catalog/CatalogTarget.kt | 2 +
.../features/home/components/PosterGrid.kt | 172 ++++++++
.../library/LibraryDisplaySettings.kt | 251 ++++++++++++
.../library/LibraryDisplaySettingsStorage.kt | 6 +
.../features/library/LibrarySavedContent.kt | 169 ++++++++
.../app/features/library/LibraryScreen.kt | 382 ++++++++++++------
.../features/profiles/ProfileRepository.kt | 2 +
.../features/search/SearchDiscoverContent.kt | 154 +------
.../nuvio/app/features/search/SearchScreen.kt | 13 +-
.../library/LibraryDisplaySettingsTest.kt | 153 +++++++
.../PlatformLocalAccountDataCleaner.ios.kt | 1 +
.../LibraryDisplaySettingsStorage.ios.kt | 15 +
19 files changed, 1080 insertions(+), 297 deletions(-)
create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt
create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index da28ad7e..d13a2453 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -21,6 +21,7 @@ import com.nuvio.app.features.debrid.DebridSettingsStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
import com.nuvio.app.features.downloads.DownloadsStorage
+import com.nuvio.app.features.library.LibraryDisplaySettingsStorage
import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
@@ -103,6 +104,7 @@ class MainActivity : AppCompatActivity() {
TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
+ LibraryDisplaySettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
index b6d09cbd..4817bce8 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
@@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner {
private val preferenceNames = listOf(
"nuvio_addons",
"nuvio_library",
+ "nuvio_library_display_settings",
"nuvio_home_catalog_settings",
"nuvio_player_settings",
"torrent_settings",
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt
new file mode 100644
index 00000000..75b02683
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt
@@ -0,0 +1,26 @@
+package com.nuvio.app.features.library
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.nuvio.app.core.storage.ProfileScopedKey
+
+actual object LibraryDisplaySettingsStorage {
+ private const val preferencesName = "nuvio_library_display_settings"
+ private const val payloadKey = "library_display_settings_payload"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadPayload(): String? =
+ preferences?.getString(ProfileScopedKey.of(payloadKey), null)
+
+ actual fun savePayload(payload: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(payloadKey), payload)
+ ?.apply()
+ }
+}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 1aef63cd..34744803 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1611,8 +1611,19 @@
%1$s - %2$s
Saved titles will appear here after you tap Save on a details screen.
Your library is empty
+ All types
+ Select list
+ Sort library
+ Select type
+ Show horizontal shelves
+ Show vertical grid
Couldn't load library
Other
+ Oldest added
+ Recently added
+ Title A–Z
+ Title Z–A
+ Trakt order
Cloud
Saved
Library
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index ffd470e7..aca9874a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -158,6 +158,7 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibrarySection
+import com.nuvio.app.features.library.LibrarySortOption
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
@@ -1676,7 +1677,7 @@ private fun MainAppContent(
stringResource(Res.string.compose_catalog_subtitle_library)
}
- val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
+ val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption ->
val launchId = CatalogLaunchStore.put(
CatalogLaunch(
title = section.displayTitle,
@@ -1684,6 +1685,7 @@ private fun MainAppContent(
target = CatalogTarget.Library(
contentType = section.items.firstOrNull()?.type ?: "movie",
sectionType = section.type,
+ sortOption = sortOption,
),
),
)
@@ -3605,7 +3607,7 @@ private fun AppTabHost(
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
- onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
+ onLibrarySectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
onConnectCloudClick: (() -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
index 68fa9f0a..f00ca59a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
@@ -12,6 +12,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeRepository
import com.nuvio.app.features.library.LibraryRepository
+import com.nuvio.app.features.library.LibraryDisplaySettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerSettingsRepository
@@ -56,6 +57,7 @@ internal object LocalAccountDataCleaner {
HomeCatalogSettingsRepository.clearLocalState()
MetaScreenSettingsRepository.clearLocalState()
LibraryRepository.clearLocalState()
+ LibraryDisplaySettingsRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState()
CollectionMobileSettingsRepository.clearLocalState()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt
index 2d2c4909..c69a3ea8 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt
@@ -4,6 +4,7 @@ import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
import com.nuvio.app.features.collection.catalogRouteKey
import com.nuvio.app.features.library.LibraryRepository
+import com.nuvio.app.features.library.sortLibraryItems
import com.nuvio.app.features.library.toMetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
@@ -88,10 +89,16 @@ object CatalogRepository {
runCatching {
val target = request.target as CatalogTarget.Library
LibraryRepository.ensureLoaded()
- LibraryRepository.uiState.value.sections
+ val libraryState = LibraryRepository.uiState.value
+ val items = libraryState.sections
.firstOrNull { it.type == target.sectionType }
?.items
.orEmpty()
+ sortLibraryItems(
+ items = items,
+ selected = target.sortOption,
+ sourceMode = libraryState.sourceMode,
+ )
.map { it.toMetaPreview() }
.let(::dedupeCatalogItems)
}.fold(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt
index e3ae9303..ae9abb29 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt
@@ -1,5 +1,6 @@
package com.nuvio.app.features.catalog
+import com.nuvio.app.features.library.LibrarySortOption
import kotlinx.serialization.Serializable
sealed interface CatalogTarget {
@@ -17,6 +18,7 @@ sealed interface CatalogTarget {
data class Library(
override val contentType: String,
val sectionType: String,
+ val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT,
) : CatalogTarget {
override val supportsPagination: Boolean = false
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt
new file mode 100644
index 00000000..41c3484f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt
@@ -0,0 +1,172 @@
+package com.nuvio.app.features.home.components
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.nuvio.app.core.format.formatReleaseDateForDisplay
+import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
+import com.nuvio.app.core.ui.posterCardClickable
+import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
+import com.nuvio.app.features.home.MetaPreview
+import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.watching.application.WatchingState
+
+internal fun posterGridColumnCountForWidth(screenWidth: Dp): Int =
+ when {
+ screenWidth >= 1400.dp -> 7
+ screenWidth >= 1200.dp -> 6
+ screenWidth >= 1000.dp -> 5
+ screenWidth >= 840.dp -> 4
+ else -> 3
+ }
+
+@Composable
+internal fun PosterGridRow(
+ items: List,
+ columns: Int,
+ modifier: Modifier = Modifier,
+ watchedKeys: Set = emptySet(),
+ fullyWatchedSeriesKeys: Set = emptySet(),
+ onPosterClick: ((MetaPreview) -> Unit)? = null,
+ onPosterLongClick: ((MetaPreview) -> Unit)? = null,
+) {
+ val posterCardStyle = rememberPosterCardStyleUiState()
+
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ items.forEach { item ->
+ PosterGridTile(
+ item = item,
+ cornerRadiusDp = posterCardStyle.cornerRadiusDp,
+ hideLabels = posterCardStyle.hideLabelsEnabled,
+ modifier = Modifier.weight(1f),
+ isWatched = WatchingState.isPosterWatched(
+ watchedKeys = watchedKeys,
+ item = item,
+ fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
+ ),
+ onClick = onPosterClick?.let { { it(item) } },
+ onLongClick = onPosterLongClick?.let { { it(item) } },
+ )
+ }
+ repeat(columns - items.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+internal fun PosterGridSkeletonRow(
+ columns: Int,
+ modifier: Modifier = Modifier,
+) {
+ val posterCardStyle = rememberPosterCardStyleUiState()
+
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ repeat(columns) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(0.68f)
+ .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
+ .background(MaterialTheme.colorScheme.surface),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun PosterGridTile(
+ item: MetaPreview,
+ cornerRadiusDp: Int,
+ hideLabels: Boolean,
+ modifier: Modifier = Modifier,
+ isWatched: Boolean = false,
+ onClick: (() -> Unit)? = null,
+ onLongClick: (() -> Unit)? = null,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(item.posterShape.posterGridAspectRatio())
+ .clip(RoundedCornerShape(cornerRadiusDp.dp))
+ .background(MaterialTheme.colorScheme.surface)
+ .posterCardClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ zoomImageUrl = item.poster,
+ zoomCornerRadius = cornerRadiusDp.dp,
+ ),
+ ) {
+ if (item.poster != null) {
+ AsyncImage(
+ model = item.poster,
+ contentDescription = item.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ NuvioPosterWatchedOverlay(isWatched = isWatched)
+ }
+ if (!hideLabels) {
+ Text(
+ text = item.name,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }
+ if (detail != null) {
+ Text(
+ text = detail,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+}
+
+private fun PosterShape.posterGridAspectRatio(): Float =
+ when (this) {
+ PosterShape.Poster -> 0.68f
+ PosterShape.Square -> 1f
+ PosterShape.Landscape -> 1.2f
+ }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt
new file mode 100644
index 00000000..d7f2b775
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt
@@ -0,0 +1,251 @@
+package com.nuvio.app.features.library
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+enum class LibraryLayoutMode {
+ HORIZONTAL,
+ VERTICAL,
+}
+
+enum class LibrarySortOption {
+ DEFAULT,
+ ADDED_DESC,
+ ADDED_ASC,
+ TITLE_ASC,
+ TITLE_DESC,
+}
+
+data class LibraryDisplaySettingsUiState(
+ val layoutMode: LibraryLayoutMode = LibraryLayoutMode.HORIZONTAL,
+ val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT,
+)
+
+object LibraryDisplaySettingsRepository {
+ private val _uiState = MutableStateFlow(LibraryDisplaySettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var hasLoaded = false
+
+ fun ensureLoaded() {
+ if (hasLoaded) return
+ loadFromDisk()
+ }
+
+ fun onProfileChanged() {
+ loadFromDisk()
+ }
+
+ fun clearLocalState() {
+ hasLoaded = false
+ _uiState.value = LibraryDisplaySettingsUiState()
+ }
+
+ fun setLayoutMode(layoutMode: LibraryLayoutMode) {
+ ensureLoaded()
+ if (_uiState.value.layoutMode == layoutMode) return
+ _uiState.value = _uiState.value.copy(layoutMode = layoutMode)
+ persist()
+ }
+
+ fun setSortOption(sortOption: LibrarySortOption) {
+ ensureLoaded()
+ if (_uiState.value.sortOption == sortOption) return
+ _uiState.value = _uiState.value.copy(sortOption = sortOption)
+ persist()
+ }
+
+ private fun loadFromDisk() {
+ hasLoaded = true
+ _uiState.value = decodeLibraryDisplaySettings(LibraryDisplaySettingsStorage.loadPayload())
+ }
+
+ private fun persist() {
+ LibraryDisplaySettingsStorage.savePayload(encodeLibraryDisplaySettings(_uiState.value))
+ }
+}
+
+internal data class LibraryVerticalEntry(
+ val item: LibraryItem,
+ val section: LibrarySection,
+)
+
+internal data class LibraryVerticalProjection(
+ val availableSections: List,
+ val selectedSectionKey: String?,
+ val availableTypes: List,
+ val selectedType: String?,
+ val entries: List,
+)
+
+internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List =
+ if (sourceMode == LibrarySourceMode.TRAKT) {
+ LibrarySortOption.entries
+ } else {
+ LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT }
+ }
+
+internal fun effectiveLibrarySortOption(
+ selected: LibrarySortOption,
+ sourceMode: LibrarySourceMode,
+): LibrarySortOption =
+ if (selected == LibrarySortOption.DEFAULT && sourceMode == LibrarySourceMode.LOCAL) {
+ LibrarySortOption.ADDED_DESC
+ } else {
+ selected
+ }
+
+internal fun sortLibraryItems(
+ items: List,
+ selected: LibrarySortOption,
+ sourceMode: LibrarySourceMode,
+): List =
+ when (effectiveLibrarySortOption(selected, sourceMode)) {
+ LibrarySortOption.DEFAULT -> items.sortedWith(
+ compareBy { it.traktRank ?: Int.MAX_VALUE }
+ .thenByDescending { it.savedAtEpochMs }
+ .thenBy { libraryTitleTieBreakKey(it) }
+ .thenBy { it.id },
+ )
+ LibrarySortOption.ADDED_DESC -> items.sortedWith(
+ compareByDescending { it.savedAtEpochMs }
+ .thenBy { libraryTitleTieBreakKey(it) }
+ .thenBy { it.id },
+ )
+ LibrarySortOption.ADDED_ASC -> items.sortedWith(
+ compareBy { it.savedAtEpochMs }
+ .thenBy { libraryTitleTieBreakKey(it) }
+ .thenBy { it.id },
+ )
+ LibrarySortOption.TITLE_ASC -> items.sortedWith(
+ compareBy { libraryTitleSortKey(it) }
+ .thenBy { it.id },
+ )
+ LibrarySortOption.TITLE_DESC -> items.sortedWith(
+ compareByDescending { libraryTitleSortKey(it) }
+ .thenBy { it.id },
+ )
+ }
+
+internal fun sortLibrarySections(
+ sections: List,
+ selected: LibrarySortOption,
+ sourceMode: LibrarySourceMode,
+): List =
+ sections.map { section ->
+ section.copy(items = sortLibraryItems(section.items, selected, sourceMode))
+ }
+
+internal fun buildLibraryVerticalProjection(
+ sections: List,
+ sourceMode: LibrarySourceMode,
+ selectedSectionKey: String?,
+ selectedType: String?,
+ sortOption: LibrarySortOption,
+): LibraryVerticalProjection {
+ val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList()
+ val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) {
+ sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull()
+ } else {
+ null
+ }
+ val baseEntries = if (selectedSection != null) {
+ selectedSection.items.map { item -> LibraryVerticalEntry(item, selectedSection) }
+ } else {
+ sections.flatMap { section ->
+ section.items.map { item -> LibraryVerticalEntry(item, section) }
+ }
+ }
+ val deduplicatedEntries = LinkedHashMap()
+ baseEntries.forEach { entry ->
+ val key = libraryDisplayItemKey(entry.item)
+ if (key !in deduplicatedEntries) {
+ deduplicatedEntries[key] = entry
+ }
+ }
+ val availableTypes = deduplicatedEntries.values
+ .map { entry -> entry.item.type.normalizedLibraryType() }
+ .filter { it.isNotBlank() }
+ .distinct()
+ .sorted()
+ val effectiveType = selectedType
+ ?.normalizedLibraryType()
+ ?.takeIf { it in availableTypes }
+ val filteredEntries = deduplicatedEntries.values.filter { entry ->
+ effectiveType == null || entry.item.type.normalizedLibraryType() == effectiveType
+ }
+ val entryByKey = filteredEntries.associateBy { entry -> libraryDisplayItemKey(entry.item) }
+ val sortedEntries = sortLibraryItems(
+ items = filteredEntries.map { entry -> entry.item },
+ selected = sortOption,
+ sourceMode = sourceMode,
+ ).mapNotNull { item -> entryByKey[libraryDisplayItemKey(item)] }
+
+ return LibraryVerticalProjection(
+ availableSections = availableSections,
+ selectedSectionKey = selectedSection?.type,
+ availableTypes = availableTypes,
+ selectedType = effectiveType,
+ entries = sortedEntries,
+ )
+}
+
+internal fun encodeLibraryDisplaySettings(state: LibraryDisplaySettingsUiState): String =
+ LibraryDisplaySettingsJson.encodeToString(
+ StoredLibraryDisplaySettings(
+ layoutMode = state.layoutMode.name,
+ sortOption = state.sortOption.name,
+ ),
+ )
+
+internal fun decodeLibraryDisplaySettings(payload: String?): LibraryDisplaySettingsUiState {
+ val stored = payload
+ ?.takeIf { it.isNotBlank() }
+ ?.let { value ->
+ runCatching {
+ LibraryDisplaySettingsJson.decodeFromString(value)
+ }.getOrNull()
+ }
+ return LibraryDisplaySettingsUiState(
+ layoutMode = stored?.layoutMode
+ ?.let { value -> LibraryLayoutMode.entries.firstOrNull { it.name == value } }
+ ?: LibraryLayoutMode.HORIZONTAL,
+ sortOption = stored?.sortOption
+ ?.let { value -> LibrarySortOption.entries.firstOrNull { it.name == value } }
+ ?: LibrarySortOption.DEFAULT,
+ )
+}
+
+private val LibraryDisplaySettingsJson = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+}
+
+private val LeadingLibraryTitleArticle = Regex("^(the|an|a)\\s+", RegexOption.IGNORE_CASE)
+
+private fun libraryTitleSortKey(item: LibraryItem): String =
+ libraryTitleTieBreakKey(item)
+ .trim()
+ .replace(LeadingLibraryTitleArticle, "")
+
+private fun libraryTitleTieBreakKey(item: LibraryItem): String =
+ item.name
+ .ifBlank { item.id }
+ .lowercase()
+
+private fun libraryDisplayItemKey(item: LibraryItem): String =
+ "${item.type.normalizedLibraryType()}:${item.id.trim()}"
+
+private fun String.normalizedLibraryType(): String = trim().lowercase()
+
+@Serializable
+private data class StoredLibraryDisplaySettings(
+ @SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name,
+ @SerialName("sort_option") val sortOption: String = LibrarySortOption.DEFAULT.name,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt
new file mode 100644
index 00000000..a1cfceaa
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt
@@ -0,0 +1,6 @@
+package com.nuvio.app.features.library
+
+internal expect object LibraryDisplaySettingsStorage {
+ fun loadPayload(): String?
+ fun savePayload(payload: String)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt
new file mode 100644
index 00000000..b5fc5c46
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt
@@ -0,0 +1,169 @@
+package com.nuvio.app.features.library
+
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.core.i18n.localizedMediaTypeLabel
+import com.nuvio.app.core.ui.NuvioDropdownChip
+import com.nuvio.app.core.ui.NuvioDropdownOption
+import com.nuvio.app.features.home.MetaPreview
+import com.nuvio.app.features.home.components.PosterGridRow
+import com.nuvio.app.features.home.components.PosterGridSkeletonRow
+import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.library_filter_all_types
+import nuvio.composeapp.generated.resources.library_filter_list
+import nuvio.composeapp.generated.resources.library_filter_sort
+import nuvio.composeapp.generated.resources.library_filter_type
+import nuvio.composeapp.generated.resources.library_sort_added_asc
+import nuvio.composeapp.generated.resources.library_sort_added_desc
+import nuvio.composeapp.generated.resources.library_sort_title_asc
+import nuvio.composeapp.generated.resources.library_sort_title_desc
+import nuvio.composeapp.generated.resources.library_sort_trakt_order
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+internal fun LibrarySavedControls(
+ layoutMode: LibraryLayoutMode,
+ sourceMode: LibrarySourceMode,
+ sortOption: LibrarySortOption,
+ verticalProjection: LibraryVerticalProjection,
+ onSectionSelected: (String) -> Unit,
+ onTypeSelected: (String?) -> Unit,
+ onSortSelected: (LibrarySortOption) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sortOptions = availableLibrarySortOptions(sourceMode)
+ val allTypesLabel = stringResource(Res.string.library_filter_all_types)
+
+ Row(
+ modifier = modifier.horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) {
+ val selectedSection = verticalProjection.availableSections
+ .firstOrNull { section -> section.type == verticalProjection.selectedSectionKey }
+ NuvioDropdownChip(
+ title = stringResource(Res.string.library_filter_list),
+ label = selectedSection?.displayTitle.orEmpty(),
+ selectedKey = verticalProjection.selectedSectionKey,
+ options = verticalProjection.availableSections.map { section ->
+ NuvioDropdownOption(key = section.type, label = section.displayTitle)
+ },
+ enabled = verticalProjection.availableSections.size > 1,
+ onSelected = { option -> onSectionSelected(option.key) },
+ )
+ }
+
+ if (layoutMode == LibraryLayoutMode.VERTICAL) {
+ val typeOptions = buildList {
+ add(NuvioDropdownOption(key = "", label = allTypesLabel))
+ addAll(
+ verticalProjection.availableTypes.map { type ->
+ NuvioDropdownOption(key = type, label = localizedMediaTypeLabel(type))
+ },
+ )
+ }
+ NuvioDropdownChip(
+ title = stringResource(Res.string.library_filter_type),
+ label = verticalProjection.selectedType
+ ?.let(::localizedMediaTypeLabel)
+ ?: allTypesLabel,
+ selectedKey = verticalProjection.selectedType.orEmpty(),
+ options = typeOptions,
+ enabled = typeOptions.size > 1,
+ onSelected = { option -> onTypeSelected(option.key.ifBlank { null }) },
+ )
+ }
+
+ NuvioDropdownChip(
+ title = stringResource(Res.string.library_filter_sort),
+ label = librarySortOptionLabel(sortOption),
+ selectedKey = sortOption.name,
+ options = sortOptions.map { option ->
+ NuvioDropdownOption(key = option.name, label = librarySortOptionLabel(option))
+ },
+ enabled = sortOptions.size > 1,
+ onSelected = { option ->
+ LibrarySortOption.entries
+ .firstOrNull { it.name == option.key }
+ ?.let(onSortSelected)
+ },
+ )
+ }
+}
+
+internal fun LazyListScope.libraryVerticalContent(
+ projection: LibraryVerticalProjection,
+ columns: Int,
+ watchedKeys: Set,
+ fullyWatchedSeriesKeys: Set,
+ onPosterClick: ((LibraryItem) -> Unit)?,
+ onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
+) {
+ items(
+ items = projection.entries.chunked(columns),
+ key = { rowEntries ->
+ val firstEntry = rowEntries.first()
+ "library-vertical:${firstEntry.item.type}:${firstEntry.item.id}"
+ },
+ ) { rowEntries ->
+ PosterGridRow(
+ items = rowEntries.map { entry -> entry.item.toMetaPreview() },
+ columns = columns,
+ modifier = libraryContentTransitionModifier()
+ .padding(horizontal = 16.dp),
+ watchedKeys = watchedKeys,
+ fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
+ onPosterClick = onPosterClick?.let { callback ->
+ { preview -> rowEntries.findEntry(preview)?.item?.let(callback) }
+ },
+ onPosterLongClick = onPosterLongClick?.let { callback ->
+ { preview ->
+ rowEntries.findEntry(preview)?.let { entry -> callback(entry.item, entry.section) }
+ }
+ },
+ )
+ }
+}
+
+internal fun LazyListScope.libraryVerticalSkeletonItems(columns: Int) {
+ items(
+ count = 2,
+ key = { index -> "library-vertical-skeleton:$index" },
+ ) {
+ PosterGridSkeletonRow(
+ columns = columns,
+ modifier = libraryContentTransitionModifier()
+ .padding(horizontal = 16.dp),
+ )
+ }
+}
+
+internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier =
+ Modifier.animateItem(
+ fadeInSpec = tween(durationMillis = 160),
+ placementSpec = tween(durationMillis = 180),
+ fadeOutSpec = tween(durationMillis = 90),
+ )
+
+@Composable
+private fun librarySortOptionLabel(option: LibrarySortOption): String =
+ when (option) {
+ LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order)
+ LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc)
+ LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc)
+ LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc)
+ LibrarySortOption.TITLE_DESC -> stringResource(Res.string.library_sort_title_desc)
+ }
+
+private fun List.findEntry(preview: MetaPreview): LibraryVerticalEntry? =
+ firstOrNull { entry -> entry.item.id == preview.id && entry.item.type == preview.type }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index 9e29c577..ae561f6c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -1,5 +1,6 @@
package com.nuvio.app.features.library
+import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -12,10 +13,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -28,8 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.GridView
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.ViewAgenda
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -77,6 +82,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
+import com.nuvio.app.features.home.components.posterGridColumnCountForWidth
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watching.application.WatchingState
@@ -92,7 +98,7 @@ fun LibraryScreen(
scrollToTopRequests: Flow = emptyFlow(),
onPosterClick: ((LibraryItem) -> Unit)? = null,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
- onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
+ onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
onConnectCloudClick: (() -> Unit)? = null,
) {
@@ -109,6 +115,11 @@ fun LibraryScreen(
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
+ val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
+ val displaySettings by remember {
+ LibraryDisplaySettingsRepository.ensureLoaded()
+ LibraryDisplaySettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
@@ -125,9 +136,37 @@ fun LibraryScreen(
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
}
var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) }
+ var selectedLibrarySectionKey by rememberSaveable { mutableStateOf(null) }
+ var selectedLibraryType by rememberSaveable { mutableStateOf(null) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
+ val effectiveSortOption = effectiveLibrarySortOption(
+ selected = displaySettings.sortOption,
+ sourceMode = uiState.sourceMode,
+ )
+ val sortedSections = remember(uiState.sections, displaySettings.sortOption, uiState.sourceMode) {
+ sortLibrarySections(
+ sections = uiState.sections,
+ selected = displaySettings.sortOption,
+ sourceMode = uiState.sourceMode,
+ )
+ }
+ val verticalProjection = remember(
+ uiState.sections,
+ uiState.sourceMode,
+ selectedLibrarySectionKey,
+ selectedLibraryType,
+ displaySettings.sortOption,
+ ) {
+ buildLibraryVerticalProjection(
+ sections = uiState.sections,
+ sourceMode = uiState.sourceMode,
+ selectedSectionKey = selectedLibrarySectionKey,
+ selectedType = selectedLibraryType,
+ sortOption = displaySettings.sortOption,
+ )
+ }
val retryLibraryLoad: () -> Unit = {
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
@@ -174,151 +213,226 @@ fun LibraryScreen(
val disintegration = remember { LibraryDisintegrationHolder() }
val librarySectionsDisplay = if (
- sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty()
+ sourceMode != LibraryViewMode.Cloud &&
+ displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL &&
+ uiState.isLoaded &&
+ sortedSections.isNotEmpty()
) {
- disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT)
+ disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT)
} else {
disintegration.reset()
emptyList()
}
- NuvioScreen(
- modifier = modifier,
- horizontalPadding = 0.dp,
- listState = listState,
- ) {
- stickyHeader {
- Box(modifier = Modifier.fillMaxWidth()) {
- Box(
- modifier = Modifier
- .matchParentSize()
- .background(MaterialTheme.colorScheme.background)
- .nuvioConsumePointerEvents(),
- )
- androidx.compose.foundation.layout.Column(
- modifier = Modifier.fillMaxWidth(),
- ) {
- NuvioScreenHeader(
- title = if (sourceMode == LibraryViewMode.Cloud) {
- stringResource(Res.string.library_title)
- } else if (isTraktSource) {
- stringResource(Res.string.library_trakt_title)
- } else {
- stringResource(Res.string.library_title)
- },
- modifier = Modifier.padding(horizontal = 16.dp),
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val gridColumns = remember(maxWidth) { posterGridColumnCountForWidth(maxWidth) }
+
+ NuvioScreen(
+ modifier = Modifier.fillMaxSize(),
+ horizontalPadding = 0.dp,
+ listState = listState,
+ ) {
+ stickyHeader {
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(MaterialTheme.colorScheme.background)
+ .nuvioConsumePointerEvents(),
)
- LibrarySourceSwitch(
- selectedMode = sourceMode,
- onModeSelected = { mode ->
- sourceModeName = mode.name
- },
- modifier = Modifier.padding(horizontal = 16.dp),
- )
- Spacer(modifier = Modifier.height(6.dp))
+ androidx.compose.foundation.layout.Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ NuvioScreenHeader(
+ title = if (sourceMode == LibraryViewMode.Cloud) {
+ stringResource(Res.string.library_title)
+ } else if (isTraktSource) {
+ stringResource(Res.string.library_trakt_title)
+ } else {
+ stringResource(Res.string.library_title)
+ },
+ modifier = Modifier.padding(horizontal = 16.dp),
+ actions = {
+ if (sourceMode == LibraryViewMode.Saved) {
+ val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) {
+ LibraryLayoutMode.VERTICAL
+ } else {
+ LibraryLayoutMode.HORIZONTAL
+ }
+ IconButton(
+ onClick = {
+ LibraryDisplaySettingsRepository.setLayoutMode(targetLayout)
+ },
+ ) {
+ Crossfade(
+ targetState = targetLayout,
+ animationSpec = tween(durationMillis = 140),
+ label = "libraryLayoutAction",
+ ) { animatedTargetLayout ->
+ Icon(
+ imageVector = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) {
+ Icons.Rounded.GridView
+ } else {
+ Icons.Rounded.ViewAgenda
+ },
+ contentDescription = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) {
+ stringResource(Res.string.library_layout_show_vertical)
+ } else {
+ stringResource(Res.string.library_layout_show_horizontal)
+ },
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ },
+ )
+ LibrarySourceSwitch(
+ selectedMode = sourceMode,
+ onModeSelected = { mode ->
+ sourceModeName = mode.name
+ },
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ }
}
}
- }
- if (sourceMode == LibraryViewMode.Cloud) {
- cloudLibraryContent(
- uiState = cloudUiState,
- selectedProviderId = selectedProviderId,
- selectedType = selectedType,
- selectedCloudItemKey = selectedCloudItemKey,
- onProviderSelected = {
- selectedProviderId = it
- selectedTypeName = null
- selectedCloudItemKey = null
- },
- onTypeSelected = {
- selectedTypeName = it?.name
- selectedCloudItemKey = null
- },
- onItemSelected = { item ->
- val playableFiles = item.playableFiles
- when {
- playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
- playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
- }
- },
- onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
- onBackToItems = { selectedCloudItemKey = null },
- onRefresh = { CloudLibraryRepository.refresh() },
- onConnectCloudClick = onConnectCloudClick,
- )
- } else {
- when {
- !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
- items(3) {
- HomeSkeletonRow(
- modifier = Modifier.padding(horizontal = 16.dp),
- showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
- )
- }
- }
-
- !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
- item {
- if (networkStatusUiState.isOfflineLike) {
- NuvioNetworkOfflineCard(
- condition = networkStatusUiState.condition,
- modifier = Modifier.padding(horizontal = 16.dp),
- onRetry = retryLibraryLoad,
- )
+ if (sourceMode == LibraryViewMode.Cloud) {
+ cloudLibraryContent(
+ uiState = cloudUiState,
+ selectedProviderId = selectedProviderId,
+ selectedType = selectedType,
+ selectedCloudItemKey = selectedCloudItemKey,
+ onProviderSelected = {
+ selectedProviderId = it
+ selectedTypeName = null
+ selectedCloudItemKey = null
+ },
+ onTypeSelected = {
+ selectedTypeName = it?.name
+ selectedCloudItemKey = null
+ },
+ onItemSelected = { item ->
+ val playableFiles = item.playableFiles
+ when {
+ playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
+ playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
+ }
+ },
+ onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
+ onBackToItems = { selectedCloudItemKey = null },
+ onRefresh = { CloudLibraryRepository.refresh() },
+ onConnectCloudClick = onConnectCloudClick,
+ )
+ } else {
+ when {
+ !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
+ if (displaySettings.layoutMode == LibraryLayoutMode.VERTICAL) {
+ libraryVerticalSkeletonItems(gridColumns)
} else {
- HomeEmptyStateCard(
- modifier = Modifier.padding(horizontal = 16.dp),
- title = if (isTraktSource) {
- stringResource(Res.string.library_trakt_load_failed)
- } else {
- stringResource(Res.string.library_load_failed)
+ items(3) {
+ HomeSkeletonRow(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
+ )
+ }
+ }
+ }
+
+ !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
+ item {
+ if (networkStatusUiState.isOfflineLike) {
+ NuvioNetworkOfflineCard(
+ condition = networkStatusUiState.condition,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ onRetry = retryLibraryLoad,
+ )
+ } else {
+ HomeEmptyStateCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ title = if (isTraktSource) {
+ stringResource(Res.string.library_trakt_load_failed)
+ } else {
+ stringResource(Res.string.library_load_failed)
+ },
+ message = uiState.errorMessage.orEmpty(),
+ actionLabel = stringResource(Res.string.action_retry),
+ onActionClick = retryLibraryLoad,
+ )
+ }
+ }
+ }
+
+ uiState.sections.isEmpty() -> {
+ item {
+ if (networkStatusUiState.isOfflineLike && isTraktSource) {
+ NuvioNetworkOfflineCard(
+ condition = networkStatusUiState.condition,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ onRetry = retryLibraryLoad,
+ )
+ } else {
+ HomeEmptyStateCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ title = if (isTraktSource) {
+ stringResource(Res.string.library_trakt_empty_title)
+ } else {
+ stringResource(Res.string.library_empty_title)
+ },
+ message = if (isTraktSource) {
+ stringResource(Res.string.library_trakt_empty_message)
+ } else {
+ stringResource(Res.string.library_empty_message)
+ },
+ )
+ }
+ }
+ }
+
+ else -> {
+ item(
+ key = "library-saved-controls:${uiState.sourceMode}:" +
+ "${displaySettings.layoutMode}:$effectiveSortOption",
+ ) {
+ LibrarySavedControls(
+ layoutMode = displaySettings.layoutMode,
+ sourceMode = uiState.sourceMode,
+ sortOption = effectiveSortOption,
+ verticalProjection = verticalProjection,
+ onSectionSelected = { sectionKey ->
+ selectedLibrarySectionKey = sectionKey
+ selectedLibraryType = null
},
- message = uiState.errorMessage.orEmpty(),
- actionLabel = stringResource(Res.string.action_retry),
- onActionClick = retryLibraryLoad,
+ onTypeSelected = { type -> selectedLibraryType = type },
+ onSortSelected = LibraryDisplaySettingsRepository::setSortOption,
+ modifier = libraryContentTransitionModifier()
+ .padding(horizontal = 16.dp),
+ )
+ }
+ when (displaySettings.layoutMode) {
+ LibraryLayoutMode.HORIZONTAL -> librarySections(
+ displaySections = librarySectionsDisplay,
+ watchedKeys = watchedUiState.watchedKeys,
+ showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
+ sortOption = effectiveSortOption,
+ onPosterClick = onPosterClick,
+ onSectionViewAllClick = onSectionViewAllClick,
+ onPosterLongClick = onPosterLongClick,
+ onDisintegrated = disintegration::onExited,
+ )
+ LibraryLayoutMode.VERTICAL -> libraryVerticalContent(
+ projection = verticalProjection,
+ columns = gridColumns,
+ watchedKeys = watchedUiState.watchedKeys,
+ fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
+ onPosterClick = onPosterClick,
+ onPosterLongClick = onPosterLongClick,
)
}
}
}
-
- uiState.sections.isEmpty() -> {
- item {
- if (networkStatusUiState.isOfflineLike && isTraktSource) {
- NuvioNetworkOfflineCard(
- condition = networkStatusUiState.condition,
- modifier = Modifier.padding(horizontal = 16.dp),
- onRetry = retryLibraryLoad,
- )
- } else {
- HomeEmptyStateCard(
- modifier = Modifier.padding(horizontal = 16.dp),
- title = if (isTraktSource) {
- stringResource(Res.string.library_trakt_empty_title)
- } else {
- stringResource(Res.string.library_empty_title)
- },
- message = if (isTraktSource) {
- stringResource(Res.string.library_trakt_empty_message)
- } else {
- stringResource(Res.string.library_empty_message)
- },
- )
- }
- }
- }
-
- else -> {
- librarySections(
- displaySections = librarySectionsDisplay,
- watchedKeys = watchedUiState.watchedKeys,
- showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
- onPosterClick = onPosterClick,
- onSectionViewAllClick = onSectionViewAllClick,
- onPosterLongClick = onPosterLongClick,
- onDisintegrated = disintegration::onExited,
- )
- }
}
}
}
@@ -1000,24 +1114,26 @@ private fun LazyListScope.librarySections(
displaySections: List,
watchedKeys: Set,
showHeaderAccent: Boolean,
+ sortOption: LibrarySortOption,
onPosterClick: ((LibraryItem) -> Unit)?,
- onSectionViewAllClick: ((LibrarySection) -> Unit)?,
+ onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)?,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
onDisintegrated: (String) -> Unit,
) {
items(
items = displaySections,
- key = { section -> section.type },
+ key = { section -> "library-horizontal:${section.type}" },
) { section ->
NuvioShelfSection(
title = section.displayTitle,
entries = section.previewEntries,
+ modifier = libraryContentTransitionModifier(),
headerHorizontalPadding = 16.dp,
rowContentPadding = PaddingValues(horizontal = 16.dp),
showHeaderAccent = showHeaderAccent,
onViewAllClick = section.source
?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT }
- ?.let { source -> onSectionViewAllClick?.let { { it(source) } } },
+ ?.let { source -> onSectionViewAllClick?.let { { it(source, sortOption) } } },
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { entry -> entry.globalKey },
animatePlacement = true,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index fd76b3fe..c1c7ca36 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -16,6 +16,7 @@ import com.nuvio.app.features.home.HomeRepository
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.library.LibraryRepository
+import com.nuvio.app.features.library.LibraryDisplaySettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.p2p.P2pSettingsRepository
@@ -156,6 +157,7 @@ object ProfileRepository {
TraktSettingsRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged()
LibraryRepository.onProfileChanged(profileIndex)
+ LibraryDisplaySettingsRepository.onProfileChanged()
WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex)
if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt
index ed58b7f2..ed433129 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt
@@ -1,48 +1,32 @@
package com.nuvio.app.features.search
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
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.lazy.items
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
import com.nuvio.app.core.ui.NuvioLoadingIndicator
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import coil3.compose.AsyncImage
import com.nuvio.app.core.network.NetworkCondition
-import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioDropdownChip
import com.nuvio.app.core.ui.NuvioDropdownOption
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
-import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
-import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
-import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.features.home.MetaPreview
-import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.home.components.PosterGridRow
+import com.nuvio.app.features.home.components.PosterGridSkeletonRow
import com.nuvio.app.features.home.components.HomeEmptyStateCard
-import com.nuvio.app.features.watching.application.WatchingState
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -92,7 +76,7 @@ internal fun LazyListScope.discoverContent(
when {
state.isLoading && state.items.isEmpty() -> {
items(2) {
- DiscoverSkeletonRow(
+ PosterGridSkeletonRow(
columns = columns,
modifier = Modifier.padding(horizontal = 16.dp),
)
@@ -113,7 +97,7 @@ internal fun LazyListScope.discoverContent(
else -> {
items(state.items.chunked(columns)) { rowItems ->
- DiscoverGridRow(
+ PosterGridRow(
items = rowItems,
columns = columns,
modifier = Modifier.padding(horizontal = 16.dp),
@@ -193,129 +177,6 @@ private fun DiscoverFilterRow(
}
}
-@Composable
-private fun DiscoverGridRow(
- items: List,
- columns: Int,
- modifier: Modifier = Modifier,
- watchedKeys: Set = emptySet(),
- fullyWatchedSeriesKeys: Set = emptySet(),
- onPosterClick: ((MetaPreview) -> Unit)? = null,
- onPosterLongClick: ((MetaPreview) -> Unit)? = null,
-) {
- val posterCardStyle = rememberPosterCardStyleUiState()
-
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.Top,
- ) {
- items.forEach { item ->
- DiscoverPosterTile(
- item = item,
- cornerRadiusDp = posterCardStyle.cornerRadiusDp,
- hideLabels = posterCardStyle.hideLabelsEnabled,
- modifier = Modifier.weight(1f),
- isWatched = WatchingState.isPosterWatched(
- watchedKeys = watchedKeys,
- item = item,
- fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
- ),
- onClick = onPosterClick?.let { { it(item) } },
- onLongClick = onPosterLongClick?.let { { it(item) } },
- )
- }
- repeat(columns - items.size) {
- Spacer(modifier = Modifier.weight(1f))
- }
- }
-}
-
-@OptIn(ExperimentalFoundationApi::class)
-@Composable
-private fun DiscoverPosterTile(
- item: MetaPreview,
- cornerRadiusDp: Int,
- hideLabels: Boolean,
- modifier: Modifier = Modifier,
- isWatched: Boolean = false,
- onClick: (() -> Unit)? = null,
- onLongClick: (() -> Unit)? = null,
-) {
- Column(
- modifier = modifier,
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .aspectRatio(item.posterShape.discoverAspectRatio())
- .clip(RoundedCornerShape(cornerRadiusDp.dp))
- .background(MaterialTheme.colorScheme.surface)
- .posterCardClickable(
- onClick = onClick,
- onLongClick = onLongClick,
- zoomImageUrl = item.poster,
- zoomCornerRadius = cornerRadiusDp.dp,
- ),
- ) {
- if (item.poster != null) {
- AsyncImage(
- model = item.poster,
- contentDescription = item.name,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
- }
- NuvioPosterWatchedOverlay(isWatched = isWatched)
- }
- if (!hideLabels) {
- Text(
- text = item.name,
- style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
- color = MaterialTheme.colorScheme.onBackground,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }
- if (detail != null) {
- Text(
- text = detail,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- } else {
- Spacer(modifier = Modifier.height(8.dp))
- }
- }
- }
-}
-
-@Composable
-private fun DiscoverSkeletonRow(
- columns: Int,
- modifier: Modifier = Modifier,
-) {
- val posterCardStyle = rememberPosterCardStyleUiState()
-
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- repeat(columns) {
- Box(
- modifier = Modifier
- .weight(1f)
- .aspectRatio(0.68f)
- .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
- .background(MaterialTheme.colorScheme.surface),
- )
- }
- }
-}
-
@Composable
private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
Box(
@@ -390,10 +251,3 @@ private fun String.displayTypeLabel(): String =
"tv" -> stringResource(Res.string.media_tv)
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
-
-private fun PosterShape.discoverAspectRatio(): Float =
- when (this) {
- PosterShape.Poster -> 0.68f
- PosterShape.Square -> 1f
- PosterShape.Landscape -> 1.2f
- }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
index 4520befe..104e63e3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
@@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -56,6 +55,7 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.HomeSkeletonRow
+import com.nuvio.app.features.home.components.posterGridColumnCountForWidth
import com.nuvio.app.features.watched.WatchedRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -226,7 +226,7 @@ fun SearchScreen(
modifier = modifier.fillMaxSize(),
) {
val discoverColumns = remember(maxWidth) {
- discoverColumnCountForWidth(maxWidth)
+ posterGridColumnCountForWidth(maxWidth)
}
val homeSectionPadding = remember(maxWidth) {
homeSectionHorizontalPaddingForWidth(maxWidth.value)
@@ -382,15 +382,6 @@ fun SearchScreen(
}
}
-private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
- when {
- screenWidth >= 1400.dp -> 7
- screenWidth >= 1200.dp -> 6
- screenWidth >= 1000.dp -> 5
- screenWidth >= 840.dp -> 4
- else -> 3
- }
-
@Composable
private fun SearchEmptyStateCard(
reason: SearchEmptyStateReason?,
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt
new file mode 100644
index 00000000..bbfdf90b
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt
@@ -0,0 +1,153 @@
+package com.nuvio.app.features.library
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class LibraryDisplaySettingsTest {
+
+ @Test
+ fun `local default resolves to recently added while Trakt uses rank order`() {
+ assertEquals(
+ LibrarySortOption.ADDED_DESC,
+ effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL),
+ )
+ assertEquals(
+ LibrarySortOption.DEFAULT,
+ effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT),
+ )
+
+ val input = listOf(
+ item("ranked-second", savedAt = 3L, traktRank = 2),
+ item("unranked", savedAt = 4L),
+ item("ranked-first", savedAt = 1L, traktRank = 1),
+ item("ranked-first-newer", savedAt = 2L, traktRank = 1),
+ )
+ assertEquals(
+ listOf("ranked-first-newer", "ranked-first", "ranked-second", "unranked"),
+ sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT).map { it.id },
+ )
+ assertEquals(
+ listOf("unranked", "ranked-second", "ranked-first-newer", "ranked-first"),
+ sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL).map { it.id },
+ )
+ }
+
+ @Test
+ fun `added sorting works in both directions`() {
+ val input = listOf(
+ item("middle", savedAt = 2L),
+ item("oldest", savedAt = 1L),
+ item("newest", savedAt = 3L),
+ )
+
+ assertEquals(
+ listOf("newest", "middle", "oldest"),
+ sortLibraryItems(input, LibrarySortOption.ADDED_DESC, LibrarySourceMode.LOCAL).map { it.id },
+ )
+ assertEquals(
+ listOf("oldest", "middle", "newest"),
+ sortLibraryItems(input, LibrarySortOption.ADDED_ASC, LibrarySourceMode.TRAKT).map { it.id },
+ )
+ }
+
+ @Test
+ fun `title sorting ignores leading English articles`() {
+ val input = listOf(
+ item("batman", name = "The Batman"),
+ item("arrival", name = "Arrival"),
+ item("quiet", name = "A Quiet Place"),
+ )
+
+ assertEquals(
+ listOf("arrival", "batman", "quiet"),
+ sortLibraryItems(input, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL).map { it.id },
+ )
+ assertEquals(
+ listOf("quiet", "batman", "arrival"),
+ sortLibraryItems(input, LibrarySortOption.TITLE_DESC, LibrarySourceMode.LOCAL).map { it.id },
+ )
+ }
+
+ @Test
+ fun `horizontal sections sort independently without changing section order`() {
+ val sections = listOf(
+ LibrarySection(
+ type = "movie",
+ displayTitle = "Movies",
+ items = listOf(item("z", name = "Zulu"), item("a", name = "Alpha")),
+ ),
+ LibrarySection(
+ type = "series",
+ displayTitle = "Series",
+ items = listOf(item("y", type = "series", name = "Yellow"), item("b", type = "series", name = "Beta")),
+ ),
+ )
+
+ val sorted = sortLibrarySections(sections, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL)
+
+ assertEquals(listOf("movie", "series"), sorted.map { it.type })
+ assertEquals(listOf("a", "z"), sorted[0].items.map { it.id })
+ assertEquals(listOf("b", "y"), sorted[1].items.map { it.id })
+ }
+
+ @Test
+ fun `vertical Trakt projection selects one list then filters and sorts its items`() {
+ val watchlist = LibrarySection(
+ type = "watchlist",
+ displayTitle = "Watchlist",
+ items = listOf(
+ item("z", name = "Zulu"),
+ item("series", type = "series", name = "Series"),
+ item("a", name = "Alpha"),
+ ),
+ )
+ val personal = LibrarySection(
+ type = "personal:1",
+ displayTitle = "Favorites",
+ items = listOf(item("favorite", name = "Favorite")),
+ )
+
+ val projection = buildLibraryVerticalProjection(
+ sections = listOf(watchlist, personal),
+ sourceMode = LibrarySourceMode.TRAKT,
+ selectedSectionKey = "missing",
+ selectedType = "movie",
+ sortOption = LibrarySortOption.TITLE_ASC,
+ )
+
+ assertEquals("watchlist", projection.selectedSectionKey)
+ assertEquals(listOf("movie", "series"), projection.availableTypes)
+ assertEquals("movie", projection.selectedType)
+ assertEquals(listOf("a", "z"), projection.entries.map { it.item.id })
+ assertEquals(listOf("watchlist", "watchlist"), projection.entries.map { it.section.type })
+ }
+
+ @Test
+ fun `display settings payload round trips and invalid values fall back safely`() {
+ val state = LibraryDisplaySettingsUiState(
+ layoutMode = LibraryLayoutMode.VERTICAL,
+ sortOption = LibrarySortOption.TITLE_DESC,
+ )
+
+ assertEquals(state, decodeLibraryDisplaySettings(encodeLibraryDisplaySettings(state)))
+ assertEquals(
+ LibraryDisplaySettingsUiState(),
+ decodeLibraryDisplaySettings("""{"layout_mode":"unknown","sort_option":"unknown"}"""),
+ )
+ }
+
+ private fun item(
+ id: String,
+ type: String = "movie",
+ name: String = id,
+ savedAt: Long = 0L,
+ traktRank: Int? = null,
+ ): LibraryItem =
+ LibraryItem(
+ id = id,
+ type = type,
+ name = name,
+ savedAtEpochMs = savedAt,
+ traktRank = traktRank,
+ )
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
index c9a43966..af708049 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
@@ -54,6 +54,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"trakt_auth_payload",
"trakt_library_payload",
"trakt_settings_payload",
+ "library_display_settings_payload",
"pending_watch_progress_source",
"collection_mobile_settings_payload",
"collections_payload",
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt
new file mode 100644
index 00000000..aeeef6f3
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt
@@ -0,0 +1,15 @@
+package com.nuvio.app.features.library
+
+import com.nuvio.app.core.storage.ProfileScopedKey
+import platform.Foundation.NSUserDefaults
+
+actual object LibraryDisplaySettingsStorage {
+ private const val payloadKey = "library_display_settings_payload"
+
+ actual fun loadPayload(): String? =
+ NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
+
+ actual fun savePayload(payload: String) {
+ NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
+ }
+}