Merge branch 'library/layout' into cmp-rewrite

This commit is contained in:
tapframe 2026-07-19 02:56:23 +05:30
commit a88e142575
19 changed files with 1080 additions and 297 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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()
}
}

View file

@ -1611,8 +1611,19 @@
<string name="home_catalog_default_title">%1$s - %2$s</string>
<string name="library_empty_message">Saved titles will appear here after you tap Save on a details screen.</string>
<string name="library_empty_title">Your library is empty</string>
<string name="library_filter_all_types">All types</string>
<string name="library_filter_list">Select list</string>
<string name="library_filter_sort">Sort library</string>
<string name="library_filter_type">Select type</string>
<string name="library_layout_show_horizontal">Show horizontal shelves</string>
<string name="library_layout_show_vertical">Show vertical grid</string>
<string name="library_load_failed">Couldn't load library</string>
<string name="library_other">Other</string>
<string name="library_sort_added_asc">Oldest added</string>
<string name="library_sort_added_desc">Recently added</string>
<string name="library_sort_title_asc">Title AZ</string>
<string name="library_sort_title_desc">Title ZA</string>
<string name="library_sort_trakt_order">Trakt order</string>
<string name="library_source_cloud">Cloud</string>
<string name="library_source_saved">Saved</string>
<string name="library_title">Library</string>

View file

@ -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,

View file

@ -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()

View file

@ -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(

View file

@ -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
}

View file

@ -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<MetaPreview>,
columns: Int,
modifier: Modifier = Modifier,
watchedKeys: Set<String> = emptySet(),
fullyWatchedSeriesKeys: Set<String> = 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
}

View file

@ -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<LibraryDisplaySettingsUiState> = _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<LibrarySection>,
val selectedSectionKey: String?,
val availableTypes: List<String>,
val selectedType: String?,
val entries: List<LibraryVerticalEntry>,
)
internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List<LibrarySortOption> =
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<LibraryItem>,
selected: LibrarySortOption,
sourceMode: LibrarySourceMode,
): List<LibraryItem> =
when (effectiveLibrarySortOption(selected, sourceMode)) {
LibrarySortOption.DEFAULT -> items.sortedWith(
compareBy<LibraryItem> { it.traktRank ?: Int.MAX_VALUE }
.thenByDescending { it.savedAtEpochMs }
.thenBy { libraryTitleTieBreakKey(it) }
.thenBy { it.id },
)
LibrarySortOption.ADDED_DESC -> items.sortedWith(
compareByDescending<LibraryItem> { it.savedAtEpochMs }
.thenBy { libraryTitleTieBreakKey(it) }
.thenBy { it.id },
)
LibrarySortOption.ADDED_ASC -> items.sortedWith(
compareBy<LibraryItem> { it.savedAtEpochMs }
.thenBy { libraryTitleTieBreakKey(it) }
.thenBy { it.id },
)
LibrarySortOption.TITLE_ASC -> items.sortedWith(
compareBy<LibraryItem> { libraryTitleSortKey(it) }
.thenBy { it.id },
)
LibrarySortOption.TITLE_DESC -> items.sortedWith(
compareByDescending<LibraryItem> { libraryTitleSortKey(it) }
.thenBy { it.id },
)
}
internal fun sortLibrarySections(
sections: List<LibrarySection>,
selected: LibrarySortOption,
sourceMode: LibrarySourceMode,
): List<LibrarySection> =
sections.map { section ->
section.copy(items = sortLibraryItems(section.items, selected, sourceMode))
}
internal fun buildLibraryVerticalProjection(
sections: List<LibrarySection>,
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<String, LibraryVerticalEntry>()
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<StoredLibraryDisplaySettings>(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,
)

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.library
internal expect object LibraryDisplaySettingsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -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<String>,
fullyWatchedSeriesKeys: Set<String>,
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<LibraryVerticalEntry>.findEntry(preview: MetaPreview): LibraryVerticalEntry? =
firstOrNull { entry -> entry.item.id == preview.id && entry.item.type == preview.type }

View file

@ -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<Unit> = 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<String?>(null) }
var selectedLibrarySectionKey by rememberSaveable { mutableStateOf<String?>(null) }
var selectedLibraryType by rememberSaveable { mutableStateOf<String?>(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<LibraryDisplaySection>,
watchedKeys: Set<String>,
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,

View file

@ -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) {

View file

@ -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<MetaPreview>,
columns: Int,
modifier: Modifier = Modifier,
watchedKeys: Set<String> = emptySet(),
fullyWatchedSeriesKeys: Set<String> = 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
}

View file

@ -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?,

View file

@ -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,
)
}

View file

@ -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",

View file

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