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)) + } +}