From b5b5554f473af7dec3350e3ab24dbf6be22ed5b7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:22:02 +0530 Subject: [PATCH] feat: tmdb as source for collections --- .../nuvio/app/core/i18n/LocalizedUiText.kt | 127 ++- .../collection/CollectionEditorRepository.kt | 337 +++++- .../collection/CollectionEditorScreen.kt | 1015 +++++++++++++++-- .../collection/CollectionJsonPreserver.kt | 50 +- .../features/collection/CollectionModels.kt | 108 ++ .../collection/CollectionRepository.kt | 8 +- .../collection/FolderDetailRepository.kt | 111 +- .../TmdbCollectionSourceResolver.kt | 526 +++++++++ .../com/nuvio/app/features/home/HomeScreen.kt | 13 +- .../home/components/HomeHeroSection.kt | 2 +- .../app/features/library/LibraryRepository.kt | 8 +- .../watchprogress/WatchProgressModels.kt | 26 +- .../details/SeriesPlaybackResolverTest.kt | 4 +- .../watching/domain/SeriesContinuityTest.kt | 4 +- 14 files changed, 2086 insertions(+), 253 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt index fe9a7297..ca955abb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt @@ -46,96 +46,105 @@ import nuvio.composeapp.generated.resources.unit_bytes_kb import nuvio.composeapp.generated.resources.unit_bytes_mb import org.jetbrains.compose.resources.getString -fun localizedMediaTypeLabel(type: String): String = runBlocking { - when (type.trim().lowercase()) { - "movie" -> getString(Res.string.media_movies) - "series" -> getString(Res.string.media_series) - "anime" -> getString(Res.string.media_anime) - "channel" -> getString(Res.string.media_channels) - "tv" -> getString(Res.string.media_tv) - else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } +fun localizedMediaTypeLabel(type: String): String { + val fallback = type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + return when (type.trim().lowercase()) { + "movie" -> resourceString("Movies") { getString(Res.string.media_movies) } + "series" -> resourceString("Series") { getString(Res.string.media_series) } + "anime" -> resourceString("Anime") { getString(Res.string.media_anime) } + "channel" -> resourceString("Channels") { getString(Res.string.media_channels) } + "tv" -> resourceString("TV") { getString(Res.string.media_tv) } + else -> fallback } } -fun localizedMovieTypeLabel(): String = runBlocking { getString(Res.string.media_movie) } +fun localizedMovieTypeLabel(): String = resourceString("Movie") { getString(Res.string.media_movie) } -fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? = runBlocking { +fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? = when { seasonNumber != null && episodeNumber != null -> - getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + resourceString("S${seasonNumber}E${episodeNumber}") { + getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + } episodeNumber != null -> - getString(Res.string.compose_player_episode_code_episode_only, episodeNumber) + resourceString("E${episodeNumber}") { + getString(Res.string.compose_player_episode_code_episode_only, episodeNumber) + } else -> null } -} -fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { +fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String { val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber) - if (episodeCode != null) { - getString(Res.string.action_play_episode, episodeCode) + return if (episodeCode != null) { + resourceString("Play $episodeCode") { getString(Res.string.action_play_episode, episodeCode) } } else { - getString(Res.string.action_play) + resourceString("Play") { getString(Res.string.action_play) } } } -fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { +fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String { val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber) - if (episodeCode != null) { - getString(Res.string.action_resume_episode, episodeCode) + return if (episodeCode != null) { + resourceString("Resume $episodeCode") { getString(Res.string.action_resume_episode, episodeCode) } } else { - getString(Res.string.action_resume) + resourceString("Resume") { getString(Res.string.action_resume) } } } -fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking { +fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = if (seasonNumber != null && episodeNumber != null) { - getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) + resourceString("Up Next • S${seasonNumber}E${episodeNumber}") { + getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) + } } else { - getString(Res.string.continue_watching_up_next) + resourceString("Up Next") { getString(Res.string.continue_watching_up_next) } } -} -fun localizedMonthName(month: Int): String = runBlocking { +fun localizedMonthName(month: Int): String = when (month) { - 1 -> getString(Res.string.date_month_january) - 2 -> getString(Res.string.date_month_february) - 3 -> getString(Res.string.date_month_march) - 4 -> getString(Res.string.date_month_april) - 5 -> getString(Res.string.date_month_may) - 6 -> getString(Res.string.date_month_june) - 7 -> getString(Res.string.date_month_july) - 8 -> getString(Res.string.date_month_august) - 9 -> getString(Res.string.date_month_september) - 10 -> getString(Res.string.date_month_october) - 11 -> getString(Res.string.date_month_november) - 12 -> getString(Res.string.date_month_december) + 1 -> resourceString("January") { getString(Res.string.date_month_january) } + 2 -> resourceString("February") { getString(Res.string.date_month_february) } + 3 -> resourceString("March") { getString(Res.string.date_month_march) } + 4 -> resourceString("April") { getString(Res.string.date_month_april) } + 5 -> resourceString("May") { getString(Res.string.date_month_may) } + 6 -> resourceString("June") { getString(Res.string.date_month_june) } + 7 -> resourceString("July") { getString(Res.string.date_month_july) } + 8 -> resourceString("August") { getString(Res.string.date_month_august) } + 9 -> resourceString("September") { getString(Res.string.date_month_september) } + 10 -> resourceString("October") { getString(Res.string.date_month_october) } + 11 -> resourceString("November") { getString(Res.string.date_month_november) } + 12 -> resourceString("December") { getString(Res.string.date_month_december) } else -> month.toString() } -} -fun localizedShortMonthName(month: Int): String = runBlocking { +fun localizedShortMonthName(month: Int): String = when (month) { - 1 -> getString(Res.string.date_month_short_jan) - 2 -> getString(Res.string.date_month_short_feb) - 3 -> getString(Res.string.date_month_short_mar) - 4 -> getString(Res.string.date_month_short_apr) - 5 -> getString(Res.string.date_month_short_may) - 6 -> getString(Res.string.date_month_short_jun) - 7 -> getString(Res.string.date_month_short_jul) - 8 -> getString(Res.string.date_month_short_aug) - 9 -> getString(Res.string.date_month_short_sep) - 10 -> getString(Res.string.date_month_short_oct) - 11 -> getString(Res.string.date_month_short_nov) - 12 -> getString(Res.string.date_month_short_dec) + 1 -> resourceString("Jan") { getString(Res.string.date_month_short_jan) } + 2 -> resourceString("Feb") { getString(Res.string.date_month_short_feb) } + 3 -> resourceString("Mar") { getString(Res.string.date_month_short_mar) } + 4 -> resourceString("Apr") { getString(Res.string.date_month_short_apr) } + 5 -> resourceString("May") { getString(Res.string.date_month_short_may) } + 6 -> resourceString("Jun") { getString(Res.string.date_month_short_jun) } + 7 -> resourceString("Jul") { getString(Res.string.date_month_short_jul) } + 8 -> resourceString("Aug") { getString(Res.string.date_month_short_aug) } + 9 -> resourceString("Sep") { getString(Res.string.date_month_short_sep) } + 10 -> resourceString("Oct") { getString(Res.string.date_month_short_oct) } + 11 -> resourceString("Nov") { getString(Res.string.date_month_short_nov) } + 12 -> resourceString("Dec") { getString(Res.string.date_month_short_dec) } else -> month.toString() } -} -fun localizedByteUnit(unit: String): String = runBlocking { +fun localizedByteUnit(unit: String): String = when (unit) { - "GB" -> getString(Res.string.unit_bytes_gb) - "MB" -> getString(Res.string.unit_bytes_mb) - "KB" -> getString(Res.string.unit_bytes_kb) - else -> getString(Res.string.unit_bytes_b) + "GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) } + "MB" -> resourceString("MB") { getString(Res.string.unit_bytes_mb) } + "KB" -> resourceString("KB") { getString(Res.string.unit_bytes_kb) } + else -> resourceString("B") { getString(Res.string.unit_bytes_b) } } -} + +private fun resourceString( + fallback: String, + provider: suspend () -> String, +): String = runCatching { + runBlocking { provider() } +}.getOrDefault(fallback) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index e5c09ab7..cbb476c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -2,9 +2,13 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger import com.nuvio.app.features.home.PosterShape +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -22,11 +26,32 @@ data class CollectionEditorUiState( val editingFolder: CollectionFolder? = null, val showFolderEditor: Boolean = false, val showCatalogPicker: Boolean = false, + val showTmdbSourcePicker: Boolean = false, val genrePickerSourceIndex: Int? = null, + val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS, + val tmdbInput: String = "", + val tmdbTitleInput: String = "", + val tmdbMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE, + val tmdbMediaBoth: Boolean = false, + val tmdbSortBy: String = TmdbCollectionSort.POPULAR_DESC.value, + val tmdbFilters: TmdbCollectionFilters = TmdbCollectionFilters(), + val tmdbCompanyResults: List = emptyList(), + val tmdbCollectionResults: List = emptyList(), + val tmdbSearchError: String? = null, ) +enum class TmdbBuilderMode { + PRESETS, + LIST, + PRODUCTION, + NETWORK, + COLLECTION, + DISCOVER, +} + object CollectionEditorRepository { private val log = Logger.withTag("CollectionEditorRepository") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _uiState = MutableStateFlow(CollectionEditorUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -198,39 +223,40 @@ object CollectionEditorRepository { catalogId = catalog.catalogId, genre = defaultGenre, ) - if (folder.catalogSources.any { + if (folder.resolvedCatalogSources.any { it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId }) return _uiState.value = _uiState.value.copy( - editingFolder = folder.copy(catalogSources = folder.catalogSources + source), + editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()), ) } fun removeCatalogSource(index: Int) { val folder = _uiState.value.editingFolder ?: return - if (index !in folder.catalogSources.indices) return + val sources = folder.resolvedSources + if (index !in sources.indices) return _uiState.value = _uiState.value.copy( - editingFolder = folder.copy( - catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) }, - ), + editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }), genrePickerSourceIndex = null, ) } fun updateCatalogSourceGenre(index: Int, genre: String?) { val folder = _uiState.value.editingFolder ?: return - if (index !in folder.catalogSources.indices) return - val updated = folder.catalogSources.toMutableList() + val sources = folder.resolvedSources + if (index !in sources.indices || sources[index].isTmdb) return + val updated = sources.toMutableList() updated[index] = updated[index].copy(genre = genre) _uiState.value = _uiState.value.copy( - editingFolder = folder.copy(catalogSources = updated), + editingFolder = folder.withSources(updated), ) } fun toggleCatalogSource(catalog: AvailableCatalog) { val folder = _uiState.value.editingFolder ?: return - val existingIndex = folder.catalogSources.indexOfFirst { - it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId + val sources = folder.resolvedSources + val existingIndex = sources.indexOfFirst { + !it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId } if (existingIndex >= 0) { removeCatalogSource(existingIndex) @@ -242,6 +268,7 @@ object CollectionEditorRepository { fun showCatalogPicker() { _uiState.value = _uiState.value.copy( showCatalogPicker = true, + showTmdbSourcePicker = false, genrePickerSourceIndex = null, ) } @@ -250,12 +277,27 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy(showCatalogPicker = false) } + fun showTmdbSourcePicker() { + _uiState.value = _uiState.value.copy( + showTmdbSourcePicker = true, + showCatalogPicker = false, + genrePickerSourceIndex = null, + tmdbSearchError = null, + ) + } + + fun hideTmdbSourcePicker() { + _uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null) + } + fun showGenrePicker(index: Int) { val folder = _uiState.value.editingFolder ?: return - if (index !in folder.catalogSources.indices) return + val sources = folder.resolvedSources + if (index !in sources.indices || sources[index].isTmdb) return _uiState.value = _uiState.value.copy( genrePickerSourceIndex = index, showCatalogPicker = false, + showTmdbSourcePicker = false, ) } @@ -265,17 +307,19 @@ object CollectionEditorRepository { fun saveFolderEdit() { val folder = _uiState.value.editingFolder ?: return + val normalizedFolder = folder.withSources(folder.resolvedSources) val existing = _uiState.value.folders - val updated = if (existing.any { it.id == folder.id }) { - existing.map { if (it.id == folder.id) folder else it } + val updated = if (existing.any { it.id == normalizedFolder.id }) { + existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it } } else { - existing + folder + existing + normalizedFolder } _uiState.value = _uiState.value.copy( folders = updated, editingFolder = null, showFolderEditor = false, showCatalogPicker = false, + showTmdbSourcePicker = false, genrePickerSourceIndex = null, ) } @@ -285,10 +329,211 @@ object CollectionEditorRepository { editingFolder = null, showFolderEditor = false, showCatalogPicker = false, + showTmdbSourcePicker = false, genrePickerSourceIndex = null, ) } + fun setTmdbBuilderMode(mode: TmdbBuilderMode) { + val mediaType = if (mode == TmdbBuilderMode.NETWORK) { + TmdbCollectionMediaType.TV + } else { + _uiState.value.tmdbMediaType + } + _uiState.value = _uiState.value.copy( + tmdbBuilderMode = mode, + tmdbMediaType = mediaType, + tmdbMediaBoth = if ( + mode == TmdbBuilderMode.NETWORK || + mode == TmdbBuilderMode.LIST || + mode == TmdbBuilderMode.COLLECTION + ) { + false + } else { + _uiState.value.tmdbMediaBoth + }, + tmdbCompanyResults = emptyList(), + tmdbCollectionResults = emptyList(), + tmdbSearchError = null, + ) + } + + fun setTmdbInput(value: String) { + _uiState.value = _uiState.value.copy(tmdbInput = value, tmdbSearchError = null) + } + + fun setTmdbTitleInput(value: String) { + _uiState.value = _uiState.value.copy(tmdbTitleInput = value) + } + + fun setTmdbMediaType(value: TmdbCollectionMediaType) { + _uiState.value = _uiState.value.copy(tmdbMediaType = value, tmdbMediaBoth = false) + } + + fun setTmdbMediaBoth(value: Boolean) { + _uiState.value = _uiState.value.copy( + tmdbMediaBoth = value, + tmdbMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.tmdbMediaType, + ) + } + + fun setTmdbSortBy(value: String) { + _uiState.value = _uiState.value.copy(tmdbSortBy = value) + } + + fun updateTmdbFilters(transform: (TmdbCollectionFilters) -> TmdbCollectionFilters) { + _uiState.value = _uiState.value.copy(tmdbFilters = transform(_uiState.value.tmdbFilters)) + } + + fun addTmdbPreset(source: CollectionSource) { + addTmdbSource(source) + } + + fun searchTmdbCompanies() { + val query = _uiState.value.tmdbInput.trim() + if (query.isBlank()) return + scope.launch { + val results = runCatching { TmdbCollectionSourceResolver.searchCompanies(query) } + _uiState.value = _uiState.value.copy( + tmdbCompanyResults = results.getOrDefault(emptyList()), + tmdbSearchError = results.exceptionOrNull()?.message, + ) + } + } + + fun searchTmdbCollections() { + val query = _uiState.value.tmdbInput.trim() + if (query.isBlank()) return + scope.launch { + val results = runCatching { TmdbCollectionSourceResolver.searchCollections(query) } + _uiState.value = _uiState.value.copy( + tmdbCollectionResults = results.getOrDefault(emptyList()), + tmdbSearchError = results.exceptionOrNull()?.message, + ) + } + } + + fun addTmdbSource(source: CollectionSource) { + val sourceType = source.tmdbType() + if (source.tmdbId != null && sourceType in coverMetadataSourceTypes) { + scope.launch { + val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, source.tmdbId) } + val resolved = metadata.getOrNull() + addTmdbSources( + sources = listOf( + if (source.title.isNullOrBlank()) { + source.copy(title = resolved?.title) + } else { + source + }, + ), + coverImageUrl = resolved?.coverImageUrl, + ) + } + return + } + addTmdbSources(listOf(source)) + } + + fun addTmdbSourcesFromPicker(sources: List) { + val metadataSource = sources.firstOrNull { + it.tmdbId != null && it.tmdbType() in coverMetadataSourceTypes + } + if (metadataSource != null) { + scope.launch { + val sourceType = metadataSource.tmdbType() + val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, metadataSource.tmdbId!!) } + addTmdbSources(sources, metadata.getOrNull()?.coverImageUrl) + } + return + } + addTmdbSources(sources) + } + + fun addTmdbSourceFromInput() { + val state = _uiState.value + val mode = state.tmdbBuilderMode + val sourceType = when (mode) { + TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER + TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST + TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION + TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY + TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK + TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER + } + val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput) + if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) { + _uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.") + return + } + val mediaTypes = selectedMediaTypes(state, sourceType) + val baseTitle = state.tmdbTitleInput.ifBlank { + when (sourceType) { + TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim() + TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim() + TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim() + TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim() + TmdbCollectionSourceType.DISCOVER -> "TMDB Discover" + } + } + val sources = mediaTypes.map { mediaType -> + CollectionSource( + provider = "tmdb", + tmdbSourceType = sourceType.name, + title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1), + tmdbId = id, + mediaType = mediaType.name, + sortBy = state.tmdbSortBy, + filters = state.tmdbFilters, + ) + } + if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) { + scope.launch { + val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) } + val resolved = metadata.getOrNull() + if (metadata.isFailure) { + _uiState.value = _uiState.value.copy( + tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source", + ) + return@launch + } + addTmdbSources( + sources.map { source -> + source.copy(title = state.tmdbTitleInput.ifBlank { resolved?.title ?: baseTitle }) + }, + coverImageUrl = resolved?.coverImageUrl, + ) + } + return + } + addTmdbSourcesFromPicker(sources) + } + + private fun addTmdbSources(sources: List, coverImageUrl: String? = null) { + val folder = _uiState.value.editingFolder ?: return + val existingKeys = folder.resolvedSources.mapTo(mutableSetOf(), ::collectionSourceKey) + val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) } + if (newSources.isEmpty()) return + val shouldApplyCover = newSources.any { it.tmdbType() in coverMetadataSourceTypes } && + !coverImageUrl.isNullOrBlank() && + folder.coverImageUrl.isNullOrBlank() + val updatedFolder = if (shouldApplyCover) { + folder.withSources(folder.resolvedSources + newSources) + .copy(coverImageUrl = coverImageUrl, coverEmoji = null) + } else { + folder.withSources(folder.resolvedSources + newSources) + } + _uiState.value = _uiState.value.copy( + editingFolder = updatedFolder, + showTmdbSourcePicker = false, + tmdbInput = "", + tmdbTitleInput = "", + tmdbCompanyResults = emptyList(), + tmdbCollectionResults = emptyList(), + tmdbSearchError = null, + ) + } + fun save(): Boolean { val state = _uiState.value if (state.title.isBlank()) return false @@ -311,3 +556,65 @@ object CollectionEditorRepository { return true } } + +private val coverMetadataSourceTypes = setOf( + TmdbCollectionSourceType.COLLECTION, + TmdbCollectionSourceType.COMPANY, + TmdbCollectionSourceType.NETWORK, +) + +private fun CollectionCatalogSource.toCollectionSource(): CollectionSource = + CollectionSource( + provider = "addon", + addonId = addonId, + type = type, + catalogId = catalogId, + genre = genre, + ) + +private fun CollectionFolder.withSources(nextSources: List): CollectionFolder = + copy( + sources = nextSources, + catalogSources = nextSources.mapNotNull { it.addonCatalogSource() }, + ) + +private fun collectionSourceKey(source: CollectionSource): String = + if (source.isTmdb) { + "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" + } else { + "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" + } + +private fun selectedMediaTypes( + state: CollectionEditorUiState, + sourceType: TmdbCollectionSourceType, +): List = + when (sourceType) { + TmdbCollectionSourceType.COMPANY, + TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) { + listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) + } else { + listOf(state.tmdbMediaType) + } + TmdbCollectionSourceType.NETWORK -> listOf(TmdbCollectionMediaType.TV) + TmdbCollectionSourceType.COLLECTION, + TmdbCollectionSourceType.LIST -> listOf(TmdbCollectionMediaType.MOVIE) + } + +private fun titleForMedia( + title: String, + mediaType: TmdbCollectionMediaType, + addSuffix: Boolean, +): String { + if (!addSuffix) return title + val suffix = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> "Movies" + TmdbCollectionMediaType.TV -> "Series" + } + return "$title $suffix" +} + +private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = + tmdbSourceType + ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } + ?: TmdbCollectionSourceType.DISCOVER diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 3fb927c3..15b2a28a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize @@ -29,9 +31,11 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -85,9 +89,28 @@ fun CollectionEditorScreen( val editingFolder = state.editingFolder if (state.showFolderEditor && editingFolder != null) { + if (state.showCatalogPicker) { + CatalogPickerScreen( + availableCatalogs = state.availableCatalogs, + selectedSources = editingFolder.resolvedCatalogSources, + onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, + onBack = { CollectionEditorRepository.hideCatalogPicker() }, + ) + return + } + + if (state.showTmdbSourcePicker) { + TmdbSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, + ) + return + } + val genrePickerIndex = state.genrePickerSourceIndex - val genrePickerSource = genrePickerIndex?.let { editingFolder.catalogSources.getOrNull(it) } - val genrePickerCatalog = genrePickerSource?.let { source -> + val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } + val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() + val genrePickerCatalog = genrePickerCatalogSource?.let { source -> state.availableCatalogs.find { it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId } @@ -98,24 +121,15 @@ fun CollectionEditorScreen( onBack = { CollectionEditorRepository.cancelFolderEdit() }, ) - if (state.showCatalogPicker) { - CatalogPickerSheet( - availableCatalogs = state.availableCatalogs, - selectedSources = editingFolder.catalogSources, - onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onDismiss = { CollectionEditorRepository.hideCatalogPicker() }, - ) - } - if ( genrePickerIndex != null && - genrePickerSource != null && + genrePickerCatalogSource != null && genrePickerCatalog != null && genrePickerCatalog.genreOptions.isNotEmpty() ) { GenrePickerSheet( title = genrePickerCatalog.catalogName, - selectedGenre = genrePickerSource.genre, + selectedGenre = genrePickerCatalogSource.genre, genreOptions = genrePickerCatalog.genreOptions, allowAll = !genrePickerCatalog.genreRequired, onSelect = { @@ -129,12 +143,21 @@ fun CollectionEditorScreen( } if (state.showCatalogPicker) { - CatalogPickerSheet( + CatalogPickerScreen( availableCatalogs = state.availableCatalogs, - selectedSources = state.editingFolder?.catalogSources.orEmpty(), + selectedSources = state.editingFolder?.resolvedCatalogSources.orEmpty(), onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onDismiss = { CollectionEditorRepository.hideCatalogPicker() }, + onBack = { CollectionEditorRepository.hideCatalogPicker() }, ) + return + } + + if (state.showTmdbSourcePicker) { + TmdbSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, + ) + return } Box(modifier = Modifier.fillMaxSize()) { @@ -451,7 +474,7 @@ private fun FolderListItem( Column(modifier = Modifier.weight(1f)) { val summary = stringResource( Res.string.collections_editor_source_count, - folder.catalogSources.size, + folder.resolvedSources.size, posterShapeLabel(folder.posterShape), ) Text( @@ -683,18 +706,30 @@ private fun FolderEditorPage( FolderEditorSection( title = stringResource(Res.string.collections_editor_section_catalog_sources), actions = { - TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(Res.string.collections_editor_add_catalog)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("TMDB") + } + TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_add_catalog)) + } } }, ) { - if (folder.catalogSources.isEmpty()) { + val sources = folder.resolvedSources + if (sources.isEmpty()) { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -712,15 +747,25 @@ private fun FolderEditorPage( } } else { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - folder.catalogSources.forEachIndexed { index, source -> - FolderCatalogSourceCard( - source = source, - matchingCatalog = state.availableCatalogs.find { - it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId - }, - onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, - onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) }, - ) + sources.forEachIndexed { index, source -> + val addonSource = source.addonCatalogSource() + if (source.isTmdb) { + FolderTmdbSourceCard( + source = source, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + ) + } else if (addonSource != null) { + FolderCatalogSourceCard( + source = addonSource, + matchingCatalog = state.availableCatalogs.find { + it.addonId == addonSource.addonId && + it.type == addonSource.type && + it.catalogId == addonSource.catalogId + }, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) }, + ) + } } } } @@ -756,120 +801,755 @@ private fun FolderEditorPage( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CatalogPickerSheet( +private fun CatalogPickerScreen( availableCatalogs: List, selectedSources: List, onToggle: (AvailableCatalog) -> Unit, - onDismiss: () -> Unit, + onBack: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + PlatformBackHandler(enabled = true) { + onBack() + } - NuvioModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - ) { - LazyColumn( - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = stringResource(Res.string.collections_editor_select_catalogs), + onBack = onBack, + ) + } + + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - text = stringResource(Res.string.collections_editor_select_catalogs), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, + text = stringResource(Res.string.collections_editor_select_catalogs_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.collections_editor_done)) + Text( + text = "${selectedSources.size} selected", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + val grouped = availableCatalogs.groupBy { it.addonName } + grouped.forEach { (addonName, catalogs) -> + item { + val selectedCount = catalogs.count { catalog -> + selectedSources.any { + it.addonId == catalog.addonId && + it.type == catalog.type && + it.catalogId == catalog.catalogId } } - Text( - text = stringResource(Res.string.collections_editor_select_catalogs_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), + PickerPanel( + title = addonName, + subtitle = if (selectedCount > 0) "$selectedCount selected" else "${catalogs.size} catalogs", + ) { + catalogs.forEachIndexed { index, catalog -> + val isSelected = selectedSources.any { + it.addonId == catalog.addonId && + it.type == catalog.type && + it.catalogId == catalog.catalogId + } + PickerOptionRow( + title = catalog.catalogName, + subtitle = catalog.type.replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() + }, + selected = isSelected, + onClick = { onToggle(catalog) }, + ) + if (index != catalogs.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + } + } + } + } + } + + item { + Spacer(modifier = Modifier.height(24.dp + nuvioSafeBottomPadding())) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TmdbSourcePickerScreen( + state: CollectionEditorUiState, + onBack: () -> Unit, +) { + val bottomInset = nuvioSafeBottomPadding() + val sourceType = when (state.tmdbBuilderMode) { + TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER + TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST + TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION + TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY + TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK + TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER + } + val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER + + PlatformBackHandler(enabled = true) { + onBack() + } + + Box(modifier = Modifier.fillMaxSize()) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = "TMDB Sources", + onBack = onBack, ) } - val grouped = availableCatalogs.groupBy { it.addonName } - grouped.forEach { (addonName, catalogs) -> - item { - NuvioSectionLabel( - text = addonName.uppercase(), - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), - ) - } - catalogs.forEach { catalog -> - val isSelected = selectedSources.any { - it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId - } - item(key = "${catalog.addonId}:${catalog.type}:${catalog.catalogId}") { - val bgColor = if (isSelected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - val borderColor = if (isSelected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) - } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(bgColor) - .border(1.dp, borderColor, RoundedCornerShape(10.dp)) - .clickable { onToggle(catalog) } - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = catalog.catalogName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = catalog.type.replaceFirstChar { - if (it.isLowerCase()) it.titlecase() else it.toString() - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = stringResource(Res.string.cd_selected), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - } + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TmdbBuilderMode.entries.forEach { mode -> + FilterChip( + selected = state.tmdbBuilderMode == mode, + onClick = { CollectionEditorRepository.setTmdbBuilderMode(mode) }, + label = { Text(tmdbBuilderModeLabel(mode)) }, + leadingIcon = if (state.tmdbBuilderMode == mode) { + { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } else null, + ) } } } item { - Spacer(modifier = Modifier.height(24.dp)) + NuvioSurfaceCard { + Text( + text = tmdbModeHelpText(state.tmdbBuilderMode), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (state.tmdbBuilderMode != TmdbBuilderMode.PRESETS) item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (requiresId) { + TmdbLabeledField( + label = tmdbInputLabel(state.tmdbBuilderMode), + value = state.tmdbInput, + onValueChange = { CollectionEditorRepository.setTmdbInput(it) }, + placeholder = tmdbInputPlaceholder(state.tmdbBuilderMode), + helper = tmdbInputHelper(state.tmdbBuilderMode), + ) + } + TmdbLabeledField( + label = "Display title", + value = state.tmdbTitleInput, + onValueChange = { CollectionEditorRepository.setTmdbTitleInput(it) }, + placeholder = tmdbTitlePlaceholder(state.tmdbBuilderMode), + helper = "Shown as the row/tab name. If blank, Nuvio creates one from the source.", + ) + if (state.tmdbSearchError != null) { + Text( + text = state.tmdbSearchError, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + if (state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION && state.tmdbCompanyResults.isNotEmpty()) { + item { + PickerSectionLabel("Search Results") + } + itemsIndexed(state.tmdbCompanyResults) { _, result -> + val title = result.name ?: "TMDB Company ${result.id}" + PickerOptionRow( + title = title, + subtitle = listOfNotNull("Production", result.originCountry).joinToString(" • "), + selected = false, + onClick = { + val sources = tmdbSelectedMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "tmdb", + tmdbSourceType = TmdbCollectionSourceType.COMPANY.name, + title = tmdbTitleForMedia(title, mediaType, state.tmdbMediaBoth), + tmdbId = result.id, + mediaType = mediaType.name, + sortBy = state.tmdbSortBy, + filters = state.tmdbFilters, + ) + } + CollectionEditorRepository.addTmdbSourcesFromPicker(sources) + }, + ) + } + } + + if (state.tmdbBuilderMode == TmdbBuilderMode.COLLECTION && state.tmdbCollectionResults.isNotEmpty()) { + item { + PickerSectionLabel("Search Results") + } + itemsIndexed(state.tmdbCollectionResults) { _, result -> + val title = result.name ?: "TMDB Collection ${result.id}" + PickerOptionRow( + title = title, + subtitle = "TMDB Movie Collection", + selected = false, + onClick = { + CollectionEditorRepository.addTmdbSource( + CollectionSource( + provider = "tmdb", + tmdbSourceType = TmdbCollectionSourceType.COLLECTION.name, + title = title, + tmdbId = result.id, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = state.tmdbSortBy, + ), + ) + }, + ) + } + } + + if (sourceType == TmdbCollectionSourceType.COMPANY || sourceType == TmdbCollectionSourceType.DISCOVER) { + item { + PickerPanel( + title = "Media", + subtitle = "Create one source or split it into movie and series feeds.", + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.tmdbMediaType == TmdbCollectionMediaType.MOVIE && !state.tmdbMediaBoth, + onClick = { + CollectionEditorRepository.setTmdbMediaBoth(false) + CollectionEditorRepository.setTmdbMediaType(TmdbCollectionMediaType.MOVIE) + }, + label = { Text("Movies") }, + ) + FilterChip( + selected = state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth, + onClick = { + CollectionEditorRepository.setTmdbMediaBoth(false) + CollectionEditorRepository.setTmdbMediaType(TmdbCollectionMediaType.TV) + }, + label = { Text("Series") }, + ) + FilterChip( + selected = state.tmdbMediaBoth, + onClick = { CollectionEditorRepository.setTmdbMediaBoth(true) }, + label = { Text("Both") }, + ) + } + } + } + } + + if (sourceType == TmdbCollectionSourceType.COMPANY || + sourceType == TmdbCollectionSourceType.NETWORK || + sourceType == TmdbCollectionSourceType.DISCOVER + ) { + item { + PickerPanel( + title = "Sort", + subtitle = "Controls the default order TMDB returns.", + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val sorts = listOf( + TmdbCollectionSort.POPULAR_DESC, + TmdbCollectionSort.VOTE_AVERAGE_DESC, + if (state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth) { + TmdbCollectionSort.FIRST_AIR_DATE_DESC + } else { + TmdbCollectionSort.RELEASE_DATE_DESC + }, + ) + sorts.forEach { sort -> + FilterChip( + selected = state.tmdbSortBy == sort.value, + onClick = { CollectionEditorRepository.setTmdbSortBy(sort.value) }, + label = { Text(tmdbSortLabel(sort)) }, + ) + } + } + } + } + + item { + PickerPanel( + title = "Filters", + subtitle = "Combine TMDB IDs and date/rating constraints for exact feeds.", + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TmdbQuickChips( + label = "Quick genres", + chips = tmdbGenreQuickChips(state.tmdbMediaType), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withGenres = value) } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.withGenres.orEmpty(), + placeholder = "Genre IDs, comma-separated", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withGenres = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.year?.toString().orEmpty(), + placeholder = "Year", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(year = value.toIntOrNull()) + } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.releaseDateGte.orEmpty(), + placeholder = "Release from YYYY-MM-DD", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(releaseDateGte = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.releaseDateLte.orEmpty(), + placeholder = "Release until YYYY-MM-DD", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(releaseDateLte = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(), + placeholder = "Minimum rating", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(voteAverageGte = value.toDoubleOrNull()) + } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.voteCountGte?.toString().orEmpty(), + placeholder = "Minimum vote count", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(voteCountGte = value.toIntOrNull()) + } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(), + placeholder = "Maximum rating", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(voteAverageLte = value.toDoubleOrNull()) + } + }, + ) + TmdbQuickChips( + label = "Quick languages", + chips = listOf("English" to "en", "Korean" to "ko", "Japanese" to "ja", "Hindi" to "hi", "Spanish" to "es"), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginalLanguage = value) } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.withOriginalLanguage.orEmpty(), + placeholder = "Original language, e.g. en", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withOriginalLanguage = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = "Quick countries", + chips = listOf("United States" to "US", "Korea" to "KR", "Japan" to "JP", "India" to "IN", "United Kingdom" to "GB"), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginCountry = value) } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.withOriginCountry.orEmpty(), + placeholder = "Origin country, e.g. US", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withOriginCountry = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = "Quick keywords", + chips = listOf("Superhero" to "9715", "Based on Novel" to "818", "Time Travel" to "4379", "Space" to "9882"), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withKeywords = value) } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.withKeywords.orEmpty(), + placeholder = "Keyword IDs", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withKeywords = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = "Quick companies", + chips = listOf("Marvel" to "420", "Disney" to "2", "Pixar" to "3", "Lucasfilm" to "1", "Warner Bros." to "174"), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withCompanies = value) } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.withCompanies.orEmpty(), + placeholder = "Company IDs, e.g. 420", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withCompanies = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = "Quick networks", + chips = listOf("Netflix" to "213", "HBO" to "49", "Disney+" to "2739", "Prime Video" to "1024", "Hulu" to "453"), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withNetworks = value) } + }, + ) + TmdbFilterField( + value = state.tmdbFilters.withNetworks.orEmpty(), + placeholder = "Network IDs, e.g. 213", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withNetworks = value.ifBlank { null }) + } + }, + ) + } + } + } + } + + if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) item { + PickerSectionLabel("Presets") + } + if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) { + itemsIndexed(TmdbCollectionSourceResolver.presets()) { _, preset -> + PickerOptionRow( + title = preset.label, + subtitle = tmdbSourceSubtitle(preset.source), + selected = false, + onClick = { CollectionEditorRepository.addTmdbPreset(preset.source) }, + ) + } + } + + item { + val spacerHeight = if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) { + 24.dp + bottomInset + } else { + 96.dp + bottomInset + } + Spacer(modifier = Modifier.height(spacerHeight)) + } + } + + if (state.tmdbBuilderMode != TmdbBuilderMode.PRESETS) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f), + tonalElevation = 6.dp, + shadowElevation = 10.dp, + ) { + PickerActionBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = bottomInset), + ) { + if (sourceType == TmdbCollectionSourceType.COMPANY || sourceType == TmdbCollectionSourceType.COLLECTION) { + TextButton( + onClick = { + if (sourceType == TmdbCollectionSourceType.COMPANY) { + CollectionEditorRepository.searchTmdbCompanies() + } else { + CollectionEditorRepository.searchTmdbCollections() + } + }, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Search") + } + } + NuvioPrimaryButton( + text = "Add TMDB source", + modifier = Modifier.weight(1f), + enabled = !requiresId || state.tmdbInput.isNotBlank(), + onClick = { CollectionEditorRepository.addTmdbSourceFromInput() }, + ) + } } } } } +@Composable +private fun PickerPanel( + title: String, + subtitle: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + NuvioSurfaceCard { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + content() + } + } +} + +@Composable +private fun PickerOptionRow( + title: String, + subtitle: String? = null, + selected: Boolean, + onClick: () -> Unit, +) { + val rowShape = RoundedCornerShape(12.dp) + val bgColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(rowShape) + .background(bgColor) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun PickerSectionLabel(text: String) { + NuvioSectionLabel( + text = text.uppercase(), + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) +} + +@Composable +private fun PickerActionBar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +@Composable +private fun TmdbLabeledField( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + helper: String, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + NuvioInputField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + ) + if (helper.isNotBlank()) { + Text( + text = helper, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TmdbFilterField( + label: String, + helper: String, + value: String, + placeholder: String, + onValueChange: (String) -> Unit, +) { + TmdbLabeledField( + label = label, + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + helper = helper, + ) +} + +@Composable +private fun TmdbQuickChips( + label: String, + chips: List>, + onSelect: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + chips.forEach { (chipLabel, value) -> + FilterChip( + selected = false, + onClick = { onSelect(value) }, + label = { Text(chipLabel) }, + ) + } + } + } +} + +private fun tmdbGenreQuickChips(mediaType: TmdbCollectionMediaType): List> = + when (mediaType) { + TmdbCollectionMediaType.MOVIE -> listOf( + "Action" to "28", + "Adventure" to "12", + "Animation" to "16", + "Comedy" to "35", + "Horror" to "27", + "Sci-Fi" to "878", + ) + TmdbCollectionMediaType.TV -> listOf( + "Drama" to "18", + "Comedy" to "35", + "Animation" to "16", + "Crime" to "80", + "Sci-Fi" to "10765", + "Reality" to "10764", + ) + } + +private fun tmdbSelectedMediaTypes(state: CollectionEditorUiState): List = + if (state.tmdbMediaBoth) { + listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) + } else { + listOf(state.tmdbMediaType) + } + +private fun tmdbTitleForMedia( + title: String, + mediaType: TmdbCollectionMediaType, + addSuffix: Boolean, +): String { + if (!addSuffix) return title + val suffix = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> "Movies" + TmdbCollectionMediaType.TV -> "Series" + } + return "$title $suffix" +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GenrePickerSheet( @@ -997,6 +1677,51 @@ private fun FolderEditorToggleRow( } } +@Composable +private fun FolderTmdbSourceCard( + source: CollectionSource, + onRemove: () -> Unit, +) { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = source.title?.takeIf { it.isNotBlank() } ?: "TMDB", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "TMDB", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_remove), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + Text( + text = tmdbSourceSubtitle(source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun FolderCatalogSourceCard( @@ -1091,6 +1816,44 @@ private fun FolderCatalogSourceCard( } } +private fun tmdbBuilderModeLabel(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.PRESETS -> "Presets" + TmdbBuilderMode.LIST -> "List" + TmdbBuilderMode.COLLECTION -> "Collection" + TmdbBuilderMode.PRODUCTION -> "Production" + TmdbBuilderMode.NETWORK -> "Network" + TmdbBuilderMode.DISCOVER -> "Discover" + } + +private fun tmdbSortLabel(sort: TmdbCollectionSort): String = + when (sort) { + TmdbCollectionSort.POPULAR_DESC -> "Popular" + TmdbCollectionSort.VOTE_AVERAGE_DESC -> "Top rated" + TmdbCollectionSort.RELEASE_DATE_DESC -> "Newest movies" + TmdbCollectionSort.FIRST_AIR_DATE_DESC -> "Newest series" + } + +private fun tmdbSourceSubtitle(source: CollectionSource): String { + val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { + TmdbCollectionMediaType.MOVIE -> "Movies" + TmdbCollectionMediaType.TV -> "Series" + } + val sort = source.sortBy?.let { value -> + TmdbCollectionSort.entries.firstOrNull { it.value == value }?.let(::tmdbSortLabel) + } ?: "Popular" + val sourceType = runCatching { + TmdbCollectionSourceType.valueOf(source.tmdbSourceType.orEmpty()) + }.getOrDefault(TmdbCollectionSourceType.DISCOVER) + return when (sourceType) { + TmdbCollectionSourceType.LIST -> "TMDB List" + TmdbCollectionSourceType.COLLECTION -> "TMDB Movie Collection" + TmdbCollectionSourceType.COMPANY -> listOf("Production", media, sort).joinToString(" • ") + TmdbCollectionSourceType.NETWORK -> listOf("Network", "Series", sort).joinToString(" • ") + TmdbCollectionSourceType.DISCOVER -> listOf("TMDB Discover", media, sort).joinToString(" • ") + } +} + @Composable private fun posterShapeLabel(shape: PosterShape): String = when (shape) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt index f7da122a..660e8a45 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -57,9 +57,22 @@ internal object CollectionJsonPreserver { folder: CollectionFolder, ): JsonObject { val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject + val rawUnifiedSourcesByKey = raw?.get("sources").asObjectArrayByKey(::unifiedSourceKey) + val mergedUnifiedSources = buildJsonArray { + folder.resolvedSources.forEach { source -> + val sourceElement = json.encodeToJsonElement(CollectionSource.serializer(), source) + add( + mergeUnifiedSource( + json = json, + raw = rawUnifiedSourcesByKey[unifiedSourceKey(sourceElement)], + source = source, + ), + ) + } + } val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey) val mergedSources = buildJsonArray { - folder.catalogSources.forEach { source -> + folder.resolvedCatalogSources.forEach { source -> val sourceElement = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source) add( @@ -71,7 +84,23 @@ internal object CollectionJsonPreserver { ) } } - return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources)) + return mergeObjects( + raw, + encoded, + mapOf( + "sources" to mergedUnifiedSources, + "catalogSources" to mergedSources, + ), + ) + } + + private fun mergeUnifiedSource( + json: Json, + raw: JsonObject?, + source: CollectionSource, + ): JsonObject { + val encoded = json.encodeToJsonElement(CollectionSource.serializer(), source).jsonObject + return mergeObjects(raw, encoded) } private fun mergeSource( @@ -111,4 +140,21 @@ internal object CollectionJsonPreserver { val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null return "$addonId|$type|$catalogId" } + + private fun unifiedSourceKey(element: JsonElement): String? { + val obj = element as? JsonObject ?: return null + val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon" + return if (provider.equals("tmdb", ignoreCase = true)) { + val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null + val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() + val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() + "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" + } else { + val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null + val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null + val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null + "$provider|$addonId|$type|$catalogId" + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index fc1aacef..5d17161b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -30,6 +30,95 @@ data class CollectionCatalogSource( val genre: String? = null, ) +@Immutable +@Serializable +data class CollectionSource( + val provider: String = "addon", + val addonId: String? = null, + val type: String? = null, + val catalogId: String? = null, + val genre: String? = null, + val tmdbSourceType: String? = null, + val title: String? = null, + val tmdbId: Int? = null, + val mediaType: String? = null, + val sortBy: String? = null, + val filters: TmdbCollectionFilters? = null, +) { + val isTmdb: Boolean + get() = provider.equals("tmdb", ignoreCase = true) + + fun addonCatalogSource(): CollectionCatalogSource? { + if (isTmdb) return null + val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null + val sourceType = type?.takeIf { it.isNotBlank() } ?: return null + val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null + return CollectionCatalogSource( + addonId = sourceAddonId, + type = sourceType, + catalogId = sourceCatalogId, + genre = genre, + ) + } +} + +@Serializable +enum class TmdbCollectionSourceType { + LIST, + COLLECTION, + COMPANY, + NETWORK, + DISCOVER, +} + +@Serializable +enum class TmdbCollectionMediaType(val value: String) { + MOVIE("movie"), + TV("tv"); + + companion object { + fun fromString(value: String?): TmdbCollectionMediaType = + when (value?.trim()?.lowercase()) { + "tv", "series" -> TV + else -> MOVIE + } + } +} + +enum class TmdbCollectionSort(val value: String) { + POPULAR_DESC("popularity.desc"), + VOTE_AVERAGE_DESC("vote_average.desc"), + RELEASE_DATE_DESC("primary_release_date.desc"), + FIRST_AIR_DATE_DESC("first_air_date.desc"), +} + +@Immutable +@Serializable +data class TmdbCollectionFilters( + val withGenres: String? = null, + val releaseDateGte: String? = null, + val releaseDateLte: String? = null, + val voteAverageGte: Double? = null, + val voteAverageLte: Double? = null, + val voteCountGte: Int? = null, + val withOriginalLanguage: String? = null, + val withOriginCountry: String? = null, + val withKeywords: String? = null, + val withCompanies: String? = null, + val withNetworks: String? = null, + val year: Int? = null, +) + +data class TmdbSourceImportMetadata( + val title: String? = null, + val coverImageUrl: String? = null, +) + +data class TmdbPresetSource( + val label: String, + val source: CollectionSource, +) + @Immutable @Serializable data class CollectionFolder( @@ -41,7 +130,10 @@ data class CollectionFolder( val coverEmoji: String? = null, val tileShape: String = "poster", val hideTitle: Boolean = false, + val sources: List = emptyList(), val catalogSources: List = emptyList(), + val heroBackdropUrl: String? = null, + val titleLogoUrl: String? = null, ) { val posterShape: PosterShape get() = when (tileShape.lowercase()) { @@ -50,6 +142,22 @@ data class CollectionFolder( "square" -> PosterShape.Square else -> PosterShape.Poster } + + val resolvedSources: List + get() = sources.ifEmpty { + catalogSources.map { source -> + CollectionSource( + provider = "addon", + addonId = source.addonId, + type = source.type, + catalogId = source.catalogId, + genre = source.genre, + ) + } + } + + val resolvedCatalogSources: List + get() = resolvedSources.mapNotNull { it.addonCatalogSource() } } @Immutable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 860f97d5..9d57d011 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -179,8 +179,12 @@ object CollectionRepository { }, ) } - f.catalogSources.forEachIndexed { si, s -> - if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) { + f.resolvedSources.forEachIndexed { si, s -> + val invalidAddon = !s.isTmdb && + (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) + val invalidTmdb = s.isTmdb && + s.tmdbSourceType.isNullOrBlank() + if (invalidAddon || invalidTmdb) { return ValidationResult( valid = false, error = runBlocking { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index b7a4b096..e853eeba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -27,6 +27,7 @@ import org.jetbrains.compose.resources.getString data class FolderTab( val label: String, val typeLabel: String = "", + val source: CollectionSource? = null, val manifestUrl: String? = null, val type: String = "", val catalogId: String = "", @@ -114,7 +115,8 @@ object FolderDetailRepository { return } - val showAll = collection.showAllTab && folder.catalogSources.size > 1 + val sources = folder.resolvedSources + val showAll = collection.showAllTab && sources.size > 1 val addons = AddonRepository.uiState.value.addons val tabs = buildList { @@ -127,26 +129,44 @@ object FolderDetailRepository { ), ) } - folder.catalogSources.forEach { source -> - val addon = addons.find { it.manifest?.id == source.addonId } - val catalog = addon?.manifest?.catalogs?.find { - it.id == source.catalogId && it.type == source.type + sources.forEach { source -> + if (source.isTmdb) { + val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) + val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie" + add( + FolderTab( + label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB", + typeLabel = "TMDB", + source = source, + type = type, + catalogId = tmdbCatalogId(source), + supportsPagination = source.tmdbSourceType != TmdbCollectionSourceType.COLLECTION.name, + isLoading = true, + ), + ) + } else { + val catalogSource = source.addonCatalogSource() ?: return@forEach + val addon = addons.find { it.manifest?.id == catalogSource.addonId } + val catalog = addon?.manifest?.catalogs?.find { + it.id == catalogSource.catalogId && it.type == catalogSource.type + } + val label = catalog?.name ?: catalogSource.catalogId + val typeLabel = localizedMediaTypeLabel(catalogSource.type) + val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else "" + add( + FolderTab( + label = "$label ($typeLabel)$genreSuffix", + typeLabel = typeLabel, + source = source, + manifestUrl = addon?.manifestUrl, + type = catalogSource.type, + catalogId = catalogSource.catalogId, + genre = catalogSource.genre, + supportsPagination = catalog?.supportsPagination() == true, + isLoading = true, + ), + ) } - val label = catalog?.name ?: source.catalogId - val typeLabel = localizedMediaTypeLabel(source.type) - val genreSuffix = if (source.genre != null) " · ${source.genre}" else "" - add( - FolderTab( - label = "$label ($typeLabel)$genreSuffix", - typeLabel = typeLabel, - manifestUrl = addon?.manifestUrl, - type = source.type, - catalogId = source.catalogId, - genre = source.genre, - supportsPagination = catalog?.supportsPagination() == true, - isLoading = true, - ), - ) } } @@ -161,15 +181,16 @@ object FolderDetailRepository { ) // Load catalog data for each source - folder.catalogSources.forEachIndexed { sourceIndex, source -> + sources.forEachIndexed { sourceIndex, source -> val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex - val addon = addons.find { it.manifest?.id == source.addonId } - if (addon == null) { + val catalogSource = source.addonCatalogSource() + val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } } + if (!source.isTmdb && addon == null) { updateTab(tabIndex) { it.copy( isLoading = false, error = runBlocking { - getString(Res.string.collections_folder_addon_not_found, source.addonId) + getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty()) }, ) } @@ -180,7 +201,7 @@ object FolderDetailRepository { } // If no sources, mark as done - if (folder.catalogSources.isEmpty()) { + if (sources.isEmpty()) { _uiState.value = _uiState.value.copy(isLoading = false) } } @@ -229,8 +250,8 @@ object FolderDetailRepository { private fun loadTabPage(index: Int, reset: Boolean) { val currentTab = _uiState.value.tabs.getOrNull(index) ?: return - val manifestUrl = currentTab.manifestUrl ?: return val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return + if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return updateTab(index) { tab -> if (reset) { @@ -252,13 +273,21 @@ object FolderDetailRepository { loadJobs.remove(index)?.cancel() val job = scope.launch { runCatching { - fetchCatalogPage( - manifestUrl = manifestUrl, - type = currentTab.type, - catalogId = currentTab.catalogId, - genre = currentTab.genre, - skip = requestedSkip.takeIf { it > 0 }, - ) + val source = currentTab.source + if (source?.isTmdb == true) { + TmdbCollectionSourceResolver.resolve( + source = source, + page = if (reset) 1 else requestedSkip, + ) + } else { + fetchCatalogPage( + manifestUrl = requireNotNull(currentTab.manifestUrl), + type = currentTab.type, + catalogId = currentTab.catalogId, + genre = currentTab.genre, + skip = requestedSkip.takeIf { it > 0 }, + ) + } }.onSuccess { page -> updateTab(index) { tab -> val mergedItems = if (reset) { @@ -279,7 +308,7 @@ object FolderDetailRepository { } rebuildAllTab() }.onFailure { error -> - log.e(error) { "Failed to load catalog ${currentTab.catalogId} from $manifestUrl" } + log.e(error) { "Failed to load source ${currentTab.catalogId}" } updateTab(index) { tab -> tab.copy( isLoading = false, @@ -353,3 +382,17 @@ object FolderDetailRepository { } } } + +private fun Boolean?.orFalse(): Boolean = this == true + +private fun tmdbCatalogId(source: CollectionSource): String = + buildString { + append("tmdb_") + append(source.tmdbSourceType?.lowercase().orEmpty()) + source.tmdbId?.let { + append("_") + append(it) + } + append("_") + append(source.mediaType?.lowercase().orEmpty()) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt new file mode 100644 index 00000000..ee25fa48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt @@ -0,0 +1,526 @@ +package com.nuvio.app.features.collection + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.catalog.CatalogPage +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.tmdb.buildTmdbUrl +import com.nuvio.app.features.tmdb.normalizeTmdbLanguage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.math.roundToInt + +object TmdbCollectionSourceResolver { + private val log = Logger.withTag("TmdbCollectionSource") + private val json = Json { ignoreUnknownKeys = true } + + suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { + val settings = TmdbSettingsRepository.snapshot() + val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } + ?: error("Add a TMDB API key in Settings to use TMDB sources.") + val language = normalizeTmdbLanguage(settings.language) + val sourceType = source.tmdbType() + + when (sourceType) { + TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page) + TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language) + TmdbCollectionSourceType.COMPANY, + TmdbCollectionSourceType.NETWORK, + TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page) + } + } + + suspend fun importMetadata(sourceType: TmdbCollectionSourceType, id: Int): TmdbSourceImportMetadata = + withContext(Dispatchers.Default) { + val settings = TmdbSettingsRepository.snapshot() + val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } + ?: error("Add a TMDB API key in Settings to use TMDB sources.") + val language = normalizeTmdbLanguage(settings.language) + when (sourceType) { + TmdbCollectionSourceType.LIST -> { + val body = fetch( + endpoint = "list/$id", + apiKey = apiKey, + query = mapOf("language" to language, "page" to "1"), + ) ?: error("TMDB list not found") + TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() }) + } + + TmdbCollectionSourceType.COLLECTION -> { + val body = fetch( + endpoint = "collection/$id", + apiKey = apiKey, + query = mapOf("language" to language), + ) ?: error("TMDB collection not found") + TmdbSourceImportMetadata( + title = body.name?.takeIf { it.isNotBlank() }, + coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"), + ) + } + + TmdbCollectionSourceType.COMPANY -> { + val body = fetch( + endpoint = "company/$id", + apiKey = apiKey, + ) ?: error("TMDB company not found") + TmdbSourceImportMetadata( + title = body.name?.takeIf { it.isNotBlank() }, + coverImageUrl = imageUrl(body.logoPath, "w500"), + ) + } + + TmdbCollectionSourceType.NETWORK -> { + val body = fetch( + endpoint = "network/$id", + apiKey = apiKey, + ) ?: error("TMDB network not found") + TmdbSourceImportMetadata( + title = body.name?.takeIf { it.isNotBlank() }, + coverImageUrl = imageUrl(body.logoPath, "w500"), + ) + } + + TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover") + } + } + + suspend fun searchCompanies(query: String): List = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyList() + val settings = TmdbSettingsRepository.snapshot() + val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } + ?: error("Add a TMDB API key in Settings to use TMDB sources.") + fetch( + endpoint = "search/company", + apiKey = apiKey, + query = mapOf("query" to trimmed), + )?.results.orEmpty() + } + + suspend fun searchCollections(query: String): List = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyList() + val settings = TmdbSettingsRepository.snapshot() + val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } + ?: error("Add a TMDB API key in Settings to use TMDB sources.") + val language = normalizeTmdbLanguage(settings.language) + fetch( + endpoint = "search/collection", + apiKey = apiKey, + query = mapOf("query" to trimmed, "language" to language), + )?.results.orEmpty() + } + + suspend fun searchKeywords(query: String): Map = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyMap() + val settings = TmdbSettingsRepository.snapshot() + val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } + ?: error("Add a TMDB API key in Settings to use TMDB sources.") + fetch( + endpoint = "search/keyword", + apiKey = apiKey, + query = mapOf("query" to trimmed), + )?.results.orEmpty() + .mapNotNull { result -> + val name = result.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + result.id to name + } + .toMap() + } + + suspend fun genres(mediaType: TmdbCollectionMediaType): Map = withContext(Dispatchers.Default) { + val settings = TmdbSettingsRepository.snapshot() + val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } + ?: error("Add a TMDB API key in Settings to use TMDB sources.") + val language = normalizeTmdbLanguage(settings.language) + val endpoint = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> "genre/movie/list" + TmdbCollectionMediaType.TV -> "genre/tv/list" + } + fetch( + endpoint = endpoint, + apiKey = apiKey, + query = mapOf("language" to language), + )?.genres.orEmpty().associate { it.id to it.name } + } + + fun parseTmdbId(input: String): Int? { + val trimmed = input.trim() + trimmed.toIntOrNull()?.let { return it } + return Regex("""(?:list|collection|company|network)/(\d+)""") + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?: Regex("""[?&]id=(\d+)""") + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + } + + fun presets(): List = listOf( + TmdbPresetSource("Marvel Studios", company("Marvel Studios", 420)), + TmdbPresetSource("Walt Disney Pictures", company("Walt Disney Pictures", 2)), + TmdbPresetSource("Pixar", company("Pixar", 3)), + TmdbPresetSource("Lucasfilm", company("Lucasfilm", 1)), + TmdbPresetSource("Warner Bros.", company("Warner Bros.", 174)), + TmdbPresetSource("Netflix", network("Netflix", 213)), + TmdbPresetSource("HBO", network("HBO", 49)), + TmdbPresetSource("Disney+", network("Disney+", 2739)), + TmdbPresetSource("Prime Video", network("Prime Video", 1024)), + TmdbPresetSource("Hulu", network("Hulu", 453)), + TmdbPresetSource("Apple TV+", network("Apple TV+", 2552)), + ) + + private suspend fun resolveList( + source: CollectionSource, + apiKey: String, + language: String, + page: Int, + ): CatalogPage { + val id = source.tmdbId ?: error("Missing TMDB list ID") + val body = fetch( + endpoint = "list/$id", + apiKey = apiKey, + query = mapOf("language" to language, "page" to page.toString()), + ) ?: error("TMDB list not found") + val items = body.items.orEmpty() + .mapNotNull { it.toPreview() } + .distinctBy { "${it.type}:${it.id}" } + return CatalogPage( + items = items, + rawItemCount = items.size, + nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null, + ) + } + + private suspend fun resolveCollection( + source: CollectionSource, + apiKey: String, + language: String, + ): CatalogPage { + val id = source.tmdbId ?: error("Missing TMDB collection ID") + val body = fetch( + endpoint = "collection/$id", + apiKey = apiKey, + query = mapOf("language" to language), + ) ?: error("TMDB collection not found") + val items = body.parts.orEmpty() + .sortedBy { it.releaseDate ?: "9999" } + .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } + .distinctBy { it.id } + return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null) + } + + private suspend fun resolveDiscover( + source: CollectionSource, + apiKey: String, + language: String, + page: Int, + ): CatalogPage { + val sourceType = source.tmdbType() + val mediaType = if (sourceType == TmdbCollectionSourceType.NETWORK) { + TmdbCollectionMediaType.TV + } else { + source.tmdbMediaType() + } + val filters = source.filters ?: TmdbCollectionFilters() + val query = buildDiscoverQuery( + source = source, + sourceType = sourceType, + mediaType = mediaType, + language = language, + page = page, + filters = filters, + ) + val endpoint = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> "discover/movie" + TmdbCollectionMediaType.TV -> "discover/tv" + } + val body = fetch( + endpoint = endpoint, + apiKey = apiKey, + query = query, + ) ?: error("TMDB discover returned no data") + val items = body.results.orEmpty() + .mapNotNull { it.toPreview(mediaType) } + .distinctBy { it.id } + return CatalogPage( + items = items, + rawItemCount = items.size, + nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null, + ) + } + + private fun buildDiscoverQuery( + source: CollectionSource, + sourceType: TmdbCollectionSourceType, + mediaType: TmdbCollectionMediaType, + language: String, + page: Int, + filters: TmdbCollectionFilters, + ): Map { + val sortBy = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> movieSort(source.sortBy) + TmdbCollectionMediaType.TV -> tvSort(source.sortBy) + } + return buildMap { + put("language", language) + put("page", page.toString()) + put("sort_by", sortBy) + val companyId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.COMPANY } + val networkId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.NETWORK } + putIfNotBlank("with_companies", companyId ?: filters.withCompanies) + putIfNotBlank("with_networks", networkId ?: filters.withNetworks) + putIfNotBlank("with_genres", filters.withGenres) + putIfNotBlank("vote_count.gte", filters.voteCountGte?.toString()) + putIfNotBlank("vote_average.gte", filters.voteAverageGte?.toString()) + putIfNotBlank("vote_average.lte", filters.voteAverageLte?.toString()) + putIfNotBlank("with_original_language", filters.withOriginalLanguage) + putIfNotBlank("with_origin_country", filters.withOriginCountry) + putIfNotBlank("with_keywords", filters.withKeywords) + putIfNotBlank("year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.MOVIE }?.toString()) + putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString()) + putIfNotBlank( + if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.gte" else "first_air_date.gte", + filters.releaseDateGte, + ) + putIfNotBlank( + if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.lte" else "first_air_date.lte", + filters.releaseDateLte, + ) + } + } + + private suspend inline fun fetch( + endpoint: String, + apiKey: String, + query: Map = emptyMap(), + ): T? { + val url = buildTmdbUrl(endpoint = endpoint, apiKey = apiKey, query = query) + return runCatching { + json.decodeFromString(httpGetText(url)) + }.onFailure { error -> + log.w(error) { "TMDB source request failed for $endpoint" } + }.getOrNull() + } + + private fun TmdbListItem.toPreview(): MetaPreview? { + val media = mediaType?.lowercase() + val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE + return toPreview(contentType) + } + + private fun TmdbListItem.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } + ?: name?.takeIf { it.isNotBlank() } + ?: originalTitle?.takeIf { it.isNotBlank() } + ?: originalName?.takeIf { it.isNotBlank() } + ?: return null + return MetaPreview( + id = "tmdb:$id", + type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie", + name = title, + poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"), + banner = imageUrl(backdropPath, "w1280"), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4) + TmdbCollectionMediaType.TV -> firstAirDate?.take(4) + }, + rawReleaseDate = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> releaseDate + TmdbCollectionMediaType.TV -> firstAirDate + }, + popularity = popularity, + imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() }, + ) + } + + private fun TmdbCollectionPart.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + return MetaPreview( + id = "tmdb:$id", + type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie", + name = title, + poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"), + banner = imageUrl(backdropPath, "w1280"), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = releaseDate?.take(4), + rawReleaseDate = releaseDate, + popularity = popularity, + imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() }, + ) + } + + private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = + tmdbSourceType + ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } + ?: TmdbCollectionSourceType.DISCOVER + + private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType = + TmdbCollectionMediaType.fromString(mediaType) + + private fun company(title: String, id: Int) = CollectionSource( + provider = "tmdb", + tmdbSourceType = TmdbCollectionSourceType.COMPANY.name, + title = title, + tmdbId = id, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TmdbCollectionSort.POPULAR_DESC.value, + ) + + private fun network(title: String, id: Int) = CollectionSource( + provider = "tmdb", + tmdbSourceType = TmdbCollectionSourceType.NETWORK.name, + title = title, + tmdbId = id, + mediaType = TmdbCollectionMediaType.TV.name, + sortBy = TmdbCollectionSort.POPULAR_DESC.value, + ) + + private fun movieSort(sortBy: String?): String = + when (sortBy) { + TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value + null, "" -> TmdbCollectionSort.POPULAR_DESC.value + else -> sortBy + } + + private fun tvSort(sortBy: String?): String = + when (sortBy) { + TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value + null, "" -> TmdbCollectionSort.POPULAR_DESC.value + else -> sortBy + } +} + +private fun MutableMap.putIfNotBlank(key: String, value: String?) { + if (!value.isNullOrBlank()) { + put(key, value) + } +} + +private fun imageUrl(path: String?, size: String): String? { + val clean = path?.takeIf { it.isNotBlank() } ?: return null + return "https://image.tmdb.org/t/p/$size$clean" +} + +@Serializable +private data class TmdbListResponse( + val name: String? = null, + val page: Int? = null, + @SerialName("total_pages") val totalPages: Int? = null, + val items: List? = null, +) + +@Serializable +private data class TmdbCollectionResponse( + val name: String? = null, + @SerialName("poster_path") val posterPath: String? = null, + @SerialName("backdrop_path") val backdropPath: String? = null, + val parts: List? = null, +) + +@Serializable +private data class TmdbDiscoverResponse( + val page: Int? = null, + @SerialName("total_pages") val totalPages: Int? = null, + val results: List? = null, +) + +@Serializable +private data class TmdbCompanyResponse( + val name: String? = null, + @SerialName("logo_path") val logoPath: String? = null, +) + +@Serializable +private data class TmdbNetworkResponse( + val name: String? = null, + @SerialName("logo_path") val logoPath: String? = null, +) + +@Serializable +data class TmdbCompanySearchResult( + val id: Int, + val name: String? = null, + @SerialName("origin_country") val originCountry: String? = null, +) + +@Serializable +private data class TmdbCompanySearchResponse( + val results: List? = null, +) + +@Serializable +data class TmdbCollectionSearchResult( + val id: Int, + val name: String? = null, + @SerialName("poster_path") val posterPath: String? = null, + @SerialName("backdrop_path") val backdropPath: String? = null, +) + +@Serializable +private data class TmdbCollectionSearchResponse( + val results: List? = null, +) + +@Serializable +private data class TmdbKeywordSearchResponse( + val results: List? = null, +) + +@Serializable +private data class TmdbKeywordSearchResult( + val id: Int, + val name: String? = null, +) + +@Serializable +private data class TmdbGenreResponse( + val genres: List? = null, +) + +@Serializable +private data class TmdbGenreItem( + val id: Int, + val name: String, +) + +@Serializable +private data class TmdbListItem( + val id: Int, + @SerialName("media_type") val mediaType: String? = null, + val title: String? = null, + val name: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + @SerialName("original_name") val originalName: String? = null, + val overview: String? = null, + @SerialName("poster_path") val posterPath: String? = null, + @SerialName("backdrop_path") val backdropPath: String? = null, + @SerialName("release_date") val releaseDate: String? = null, + @SerialName("first_air_date") val firstAirDate: String? = null, + @SerialName("vote_average") val voteAverage: Double? = null, + val popularity: Double? = null, +) + +@Serializable +private data class TmdbCollectionPart( + val id: Int, + val title: String? = null, + val overview: String? = null, + @SerialName("poster_path") val posterPath: String? = null, + @SerialName("backdrop_path") val backdropPath: String? = null, + @SerialName("release_date") val releaseDate: String? = null, + @SerialName("vote_average") val voteAverage: Double? = null, + val popularity: Double? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index b2ddbb8c..82659478 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -40,6 +40,7 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingState @@ -617,7 +618,11 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { parentMetaType = contentType, videoId = videoId, title = name, - subtitle = episodeTitle.orEmpty(), + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = season, + episodeNumber = episode, + episodeTitle = episodeTitle, + ), imageUrl = episodeThumbnail ?: backdrop ?: poster, logo = logo, poster = poster, @@ -654,7 +659,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem parentMetaType = contentType, videoId = videoId, title = name, - subtitle = episodeTitle.orEmpty(), + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = season, + episodeNumber = episode, + episodeTitle = episodeTitle, + ), imageUrl = episodeThumbnail ?: backdrop ?: poster, logo = logo, poster = poster, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 32ab8059..b76fdf7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -65,7 +65,7 @@ private const val HERO_SCROLL_UP_SCALE_MULTIPLIER = 0.002f private const val HERO_SCROLL_MAX_SCALE = 1.3f private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f -private const val MOBILE_HERO_VIEWPORT_RATIO = 0.78f +private const val MOBILE_HERO_VIEWPORT_RATIO = 0.82f private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 7aae75f8..a3983cbf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -11,7 +11,6 @@ import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -25,9 +24,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.library_other -import org.jetbrains.compose.resources.getString @Serializable private data class StoredLibraryPayload( @@ -370,7 +366,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem( internal fun String.toLibraryDisplayTitle(): String { val normalized = trim() - if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) } + if (normalized.isBlank()) return "Other" return normalized .split('-', '_', ' ') @@ -378,5 +374,5 @@ internal fun String.toLibraryDisplayTitle(): String { .joinToString(" ") { token -> token.lowercase().replaceFirstChar { char -> char.uppercase() } } - .ifBlank { runBlocking { getString(Res.string.library_other) } } + .ifBlank { "Other" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 5f4157b0..12efbd73 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -190,7 +190,11 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { parentMetaType = normalizedEntry.parentMetaType, videoId = normalizedEntry.videoId, title = normalizedEntry.title, - subtitle = normalizedEntry.episodeTitle.orEmpty(), + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = normalizedEntry.seasonNumber, + episodeNumber = normalizedEntry.episodeNumber, + episodeTitle = normalizedEntry.episodeTitle, + ), imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, logo = normalizedEntry.logo, poster = normalizedEntry.poster, @@ -223,7 +227,11 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( fallbackVideoId = nextEpisode.id, ), title = title, - subtitle = nextEpisode.title, + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = nextEpisode.season, + episodeNumber = nextEpisode.episode, + episodeTitle = nextEpisode.title, + ), imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, logo = logo, poster = poster, @@ -243,6 +251,20 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( ) } +internal fun buildContinueWatchingEpisodeSubtitle( + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, +): String { + val episodeCode = when { + seasonNumber != null && episodeNumber != null -> "S${seasonNumber}E${episodeNumber}" + episodeNumber != null -> "E${episodeNumber}" + else -> null + } + val title = episodeTitle.orEmpty() + return listOfNotNull(episodeCode, title.takeIf { it.isNotBlank() }).joinToString(" • ") +} + fun buildPlaybackVideoId( parentMetaId: String, seasonNumber: Int?, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index a7b47fdc..1713004f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -36,7 +36,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(1, action.seasonNumber) assertEquals(3, action.episodeNumber) @@ -85,7 +85,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index 51727f2e..cb3f6ba7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -39,7 +39,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(3, action.episodeNumber) } @@ -142,7 +142,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next S2E2", action.label) + assertEquals("Up Next • S2E2", action.label) assertEquals("show:2:2", action.videoId) } }