mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-28 15:23:01 +00:00
Merge branch 'library/layout' into cmp-rewrite
This commit is contained in:
commit
a88e142575
19 changed files with 1080 additions and 297 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 A–Z</string>
|
||||
<string name="library_sort_title_desc">Title Z–A</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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
internal expect object LibraryDisplaySettingsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue