diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5136bd9c..73a97208 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -272,7 +272,7 @@ kotlin { afterEvaluate { dependencies { - add("fullImplementation", libs.quickjs.kt) + add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) add("fullImplementation", libs.ksoup) } } diff --git a/composeApp/libs/lib-decoder-iamf-release.aar b/composeApp/libs/lib-decoder-iamf-release.aar deleted file mode 100644 index 741d9e3f..00000000 Binary files a/composeApp/libs/lib-decoder-iamf-release.aar and /dev/null differ diff --git a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar new file mode 100644 index 00000000..ef0ce528 Binary files /dev/null and b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar differ diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e4f9ff23..4a014ff0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -110,29 +110,38 @@ Production Network Collection + Person + Director Custom Pick a ready-made source. You can edit or remove it after adding. Paste a public TMDB list URL or only the number from the URL. Search by studio name, or paste a TMDB company ID/URL and add it directly. Enter a network ID. Common networks are available in Presets and quick filters. Search a movie collection name or paste the collection ID from TMDB. + Enter a TMDB person ID or URL to build a row from cast credits. + Enter a TMDB person ID or URL to build a row from director credits. Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter. Public TMDB list Network ID Collection ID + Person ID Production company name, ID, or URL TMDB ID or URL https://www.themoviedb.org/list/8504994 or 8504994 213 for Netflix, 49 for HBO, 2739 for Disney+ 10 for Star Wars Collection Marvel Studios, 420, or company URL + 31 for Tom Hanks, or person URL Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420. Example: Star Wars Collection, Harry Potter Collection, or a collection URL. Example IDs: Netflix 213, HBO 49, Disney+ 2739. Example: https://www.themoviedb.org/list/8504994 or 8504994. + Example: https://www.themoviedb.org/person/31-tom-hanks or 31. Display title Shown as the row/tab name. If blank, Nuvio creates one from the source. Marvel Movies, Netflix Originals, Pixar + Tom Hanks Movies, Favorite Actors + Christopher Nolan Movies, Favorite Directors Best Action Movies, Korean Dramas, 2024 Animation Search Results TMDB Collection @@ -212,6 +221,7 @@ Disney+ Prime Video Hulu + Original Popular Top Rated Recent @@ -219,6 +229,8 @@ TMDB Movie Collection Production Network + Person + Director TMDB Discover Create one to organize your catalogs. No collections yet diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeys.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeys.kt new file mode 100644 index 00000000..cc3755eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeys.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.ui + +internal data class DuplicateSafeLazyEntry( + val value: T, + val lazyKey: Any, +) + +internal fun List.withDuplicateSafeLazyKeys(key: (T) -> Any): List> { + val keyCounts = groupingBy(key).eachCount() + val occurrences = mutableMapOf() + + return map { entry -> + val baseKey = key(entry) + val lazyKey = if (keyCounts[baseKey] == 1) { + baseKey + } else { + val occurrence = occurrences.getOrElse(baseKey) { 0 } + occurrences[baseKey] = occurrence + 1 + "$baseKey#$occurrence" + } + DuplicateSafeLazyEntry(value = entry, lazyKey = lazyKey) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index b1b99312..ace10d77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -82,10 +82,10 @@ fun NuvioShelfSection( ) { if (key != null) { items( - items = entries, - key = key, - ) { entry -> - itemContent(entry) + items = entries.withDuplicateSafeLazyKeys(key), + key = { entry -> entry.lazyKey }, + ) { keyedEntry -> + itemContent(keyedEntry.value) } } else { items(entries) { entry -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index 9e53063e..fdff2ecd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -50,6 +50,7 @@ import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey @@ -175,9 +176,10 @@ fun CatalogScreen( } } else { items( - items = uiState.items, - key = { item -> item.stableKey() }, - ) { item -> + items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() }, + key = { item -> item.lazyKey }, + ) { keyedItem -> + val item = keyedItem.value CatalogPosterTile( item = item, cornerRadiusDp = posterCardStyle.cornerRadiusDp, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt new file mode 100644 index 00000000..cad93b34 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.features.collection + +import com.nuvio.app.features.addons.AddonCatalog +import com.nuvio.app.features.addons.ManagedAddon + +internal data class ResolvedCollectionCatalog( + val addon: ManagedAddon, + val catalog: AddonCatalog, +) + +internal fun List.findCollectionCatalog( + source: CollectionCatalogSource, +): ResolvedCollectionCatalog? { + val declaredAddon = firstOrNull { it.manifest?.id == source.addonId } + val declaredCatalog = declaredAddon?.manifest?.catalogs?.findSourceCatalog(source) + if (declaredAddon != null && declaredCatalog != null) { + return ResolvedCollectionCatalog(addon = declaredAddon, catalog = declaredCatalog) + } + + return firstNotNullOfOrNull { addon -> + val catalog = addon.manifest?.catalogs?.find { + it.id == source.catalogId && it.type == source.type + } ?: return@firstNotNullOfOrNull null + ResolvedCollectionCatalog(addon = addon, catalog = catalog) + } +} + +internal fun List.findAvailableCatalog( + source: CollectionCatalogSource, +): AvailableCatalog? { + val declaredCatalogs = filter { it.addonId == source.addonId } + return declaredCatalogs.findSourceCatalog(source) + ?: firstOrNull { it.catalogId == source.catalogId && it.type == source.type } +} + +private fun List.findSourceCatalog(source: CollectionCatalogSource): AddonCatalog? = + find { it.id == source.catalogId && it.type == source.type } + ?: find { it.id == source.catalogId.substringBefore(",") && it.type == source.type } + +private fun List.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? = + find { it.catalogId == source.catalogId && it.type == source.type } + ?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type } + 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 cbb476c8..f7597072 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 @@ -46,6 +46,8 @@ enum class TmdbBuilderMode { PRODUCTION, NETWORK, COLLECTION, + PERSON, + DIRECTOR, DISCOVER, } @@ -340,9 +342,15 @@ object CollectionEditorRepository { } else { _uiState.value.tmdbMediaType } + val sortBy = when (mode) { + TmdbBuilderMode.LIST, + TmdbBuilderMode.COLLECTION -> TmdbCollectionSort.ORIGINAL.value + else -> TmdbCollectionSort.POPULAR_DESC.value + } _uiState.value = _uiState.value.copy( tmdbBuilderMode = mode, tmdbMediaType = mediaType, + tmdbSortBy = sortBy, tmdbMediaBoth = if ( mode == TmdbBuilderMode.NETWORK || mode == TmdbBuilderMode.LIST || @@ -459,6 +467,8 @@ object CollectionEditorRepository { TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK + TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON + TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER } val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput) @@ -473,6 +483,8 @@ object CollectionEditorRepository { TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim() TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim() TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim() + TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim() + TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim() TmdbCollectionSourceType.DISCOVER -> "TMDB Discover" } } @@ -561,6 +573,8 @@ private val coverMetadataSourceTypes = setOf( TmdbCollectionSourceType.COLLECTION, TmdbCollectionSourceType.COMPANY, TmdbCollectionSourceType.NETWORK, + TmdbCollectionSourceType.PERSON, + TmdbCollectionSourceType.DIRECTOR, ) private fun CollectionCatalogSource.toCollectionSource(): CollectionSource = @@ -591,6 +605,8 @@ private fun selectedMediaTypes( ): List = when (sourceType) { TmdbCollectionSourceType.COMPANY, + TmdbCollectionSourceType.PERSON, + TmdbCollectionSourceType.DIRECTOR, TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) { listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) } else { 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 41ee6532..a47e36ab 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 @@ -111,9 +111,7 @@ fun CollectionEditorScreen( 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 - } + state.availableCatalogs.findAvailableCatalog(source) } FolderEditorPage( @@ -757,11 +755,7 @@ private fun FolderEditorPage( } else if (addonSource != null) { FolderCatalogSourceCard( source = addonSource, - matchingCatalog = state.availableCatalogs.find { - it.addonId == addonSource.addonId && - it.type == addonSource.type && - it.catalogId == addonSource.catalogId - }, + matchingCatalog = state.availableCatalogs.findAvailableCatalog(addonSource), onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) }, ) @@ -897,13 +891,19 @@ private fun TmdbSourcePickerScreen( TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK + TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON + TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER } val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION || + state.tmdbBuilderMode == TmdbBuilderMode.PERSON || + state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR || state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION || state.tmdbBuilderMode == TmdbBuilderMode.NETWORK || + state.tmdbBuilderMode == TmdbBuilderMode.PERSON || + state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR || state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER val showFilterControls = state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER @@ -1892,6 +1892,8 @@ private fun tmdbBuilderModeLabel(mode: TmdbBuilderMode): String = TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_production_mode) TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode) TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_mode) + TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_person_mode) + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_director_mode) TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_custom_mode) } @@ -1903,6 +1905,8 @@ private fun tmdbModeHelpText(mode: TmdbBuilderMode): String = TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_help_production) TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network) TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_help_collection) + TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_help_person) + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_help_director) TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_help_discover) } @@ -1913,6 +1917,8 @@ private fun tmdbInputLabel(mode: TmdbBuilderMode): String = TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_id) TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id) TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_search) + TmdbBuilderMode.PERSON, + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_id) else -> stringResource(Res.string.collections_editor_tmdb_id_or_url) } @@ -1923,6 +1929,8 @@ private fun tmdbInputPlaceholder(mode: TmdbBuilderMode): String = TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_placeholder) TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder) TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_placeholder) + TmdbBuilderMode.PERSON, + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_placeholder) else -> stringResource(Res.string.collections_editor_tmdb_id_or_url) } @@ -1933,6 +1941,8 @@ private fun tmdbInputHelper(mode: TmdbBuilderMode): String = TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_helper) TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper) TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper) + TmdbBuilderMode.PERSON, + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_helper) else -> "" } @@ -1940,12 +1950,15 @@ private fun tmdbInputHelper(mode: TmdbBuilderMode): String = private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String = when (mode) { TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_discover_title_placeholder) + TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_person_title_placeholder) + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_director_title_placeholder) else -> stringResource(Res.string.collections_editor_tmdb_title_placeholder) } @Composable private fun tmdbSortLabel(sort: TmdbCollectionSort): String = when (sort) { + TmdbCollectionSort.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original) TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular) TmdbCollectionSort.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated) TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) @@ -1979,6 +1992,16 @@ private fun tmdbSourceSubtitle(source: CollectionSource): String { stringResource(Res.string.collections_editor_tmdb_series), sort, ).joinToString(" • ") + TmdbCollectionSourceType.PERSON -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_person), + media, + sort, + ).joinToString(" • ") + TmdbCollectionSourceType.DIRECTOR -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_director), + media, + sort, + ).joinToString(" • ") TmdbCollectionSourceType.DISCOVER -> listOf( stringResource(Res.string.collections_editor_tmdb_subtitle_discover), media, 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 208aa03d..f0780ad2 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 @@ -69,6 +69,8 @@ enum class TmdbCollectionSourceType { COMPANY, NETWORK, DISCOVER, + PERSON, + DIRECTOR, } @Serializable @@ -86,6 +88,7 @@ enum class TmdbCollectionMediaType(val value: String) { } enum class TmdbCollectionSort(val value: String) { + ORIGINAL("original"), POPULAR_DESC("popularity.desc"), VOTE_AVERAGE_DESC("vote_average.desc"), RELEASE_DATE_DESC("primary_release_date.desc"), 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 e853eeba..36698b25 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 @@ -140,16 +140,19 @@ object FolderDetailRepository { source = source, type = type, catalogId = tmdbCatalogId(source), - supportsPagination = source.tmdbSourceType != TmdbCollectionSourceType.COLLECTION.name, + supportsPagination = source.tmdbSourceType !in setOf( + TmdbCollectionSourceType.COLLECTION.name, + TmdbCollectionSourceType.PERSON.name, + TmdbCollectionSourceType.DIRECTOR.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 resolvedCatalog = addons.findCollectionCatalog(catalogSource) + val addon = resolvedCatalog?.addon + val catalog = resolvedCatalog?.catalog val label = catalog?.name ?: catalogSource.catalogId val typeLabel = localizedMediaTypeLabel(catalogSource.type) val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else "" @@ -184,8 +187,8 @@ object FolderDetailRepository { sources.forEachIndexed { sourceIndex, source -> val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val catalogSource = source.addonCatalogSource() - val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } } - if (!source.isTmdb && addon == null) { + val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } + if (!source.isTmdb && resolvedCatalog == null) { updateTab(tabIndex) { it.copy( isLoading = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 07c3cb73..6101d18a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -54,6 +54,7 @@ import com.nuvio.app.core.ui.NuvioPosterCard import com.nuvio.app.core.ui.NuvioPosterShape import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape @@ -275,9 +276,10 @@ private fun TabbedGridContent( verticalArrangement = Arrangement.spacedBy(14.dp), ) { items( - items = selectedTab.items, - key = { item -> item.stableKey() }, - ) { item -> + items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() }, + key = { item -> item.lazyKey }, + ) { keyedItem -> + val item = keyedItem.value NuvioPosterCard( title = item.name, imageUrl = item.poster, @@ -326,9 +328,10 @@ private fun RowsContent( verticalArrangement = Arrangement.spacedBy(16.dp), ) { items( - items = sections, - key = { it.key }, - ) { section -> + items = sections.withDuplicateSafeLazyKeys { it.key }, + key = { it.lazyKey }, + ) { keyedSection -> + val section = keyedSection.value HomeCatalogRowSection( section = section, entries = section.items.take(18), 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 index ee25fa48..3f37d3d8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt @@ -29,6 +29,8 @@ object TmdbCollectionSourceResolver { when (sourceType) { TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page) TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language) + TmdbCollectionSourceType.PERSON, + TmdbCollectionSourceType.DIRECTOR -> resolvePersonCredits(source, apiKey, language) TmdbCollectionSourceType.COMPANY, TmdbCollectionSourceType.NETWORK, TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page) @@ -85,6 +87,19 @@ object TmdbCollectionSourceResolver { ) } + TmdbCollectionSourceType.PERSON, + TmdbCollectionSourceType.DIRECTOR -> { + val body = fetch( + endpoint = "person/$id", + apiKey = apiKey, + query = mapOf("language" to language), + ) ?: error("TMDB person not found") + TmdbSourceImportMetadata( + title = body.name?.takeIf { it.isNotBlank() }, + coverImageUrl = imageUrl(body.profilePath, "w500"), + ) + } + TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover") } } @@ -153,7 +168,7 @@ object TmdbCollectionSourceResolver { fun parseTmdbId(input: String): Int? { val trimmed = input.trim() trimmed.toIntOrNull()?.let { return it } - return Regex("""(?:list|collection|company|network)/(\d+)""") + return Regex("""(?:list|collection|company|network|person)/(\d+)""") .find(trimmed) ?.groupValues ?.getOrNull(1) @@ -193,6 +208,7 @@ object TmdbCollectionSourceResolver { ) ?: error("TMDB list not found") val items = body.items.orEmpty() .mapNotNull { it.toPreview() } + .sortedFor(source.sortBy) .distinctBy { "${it.type}:${it.id}" } return CatalogPage( items = items, @@ -213,12 +229,35 @@ object TmdbCollectionSourceResolver { query = mapOf("language" to language), ) ?: error("TMDB collection not found") val items = body.parts.orEmpty() - .sortedBy { it.releaseDate ?: "9999" } .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } + .sortedFor(source.sortBy) .distinctBy { it.id } return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null) } + private suspend fun resolvePersonCredits( + source: CollectionSource, + apiKey: String, + language: String, + ): CatalogPage { + val id = source.tmdbId ?: error("Missing TMDB person ID") + val mediaType = source.tmdbMediaType() + val body = fetch( + endpoint = "person/$id/combined_credits", + apiKey = apiKey, + query = mapOf("language" to language), + ) ?: error("TMDB person credits not found") + val items = when (source.tmdbType()) { + TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty() + .filter { it.job.equals("Director", ignoreCase = true) } + .mapNotNull { it.toPreview(mediaType) } + else -> body.cast.orEmpty().mapNotNull { it.toPreview(mediaType) } + } + .distinctBy { "${it.type}:${it.id}" } + .sortedFor(source.sortBy) + return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null) + } + private suspend fun resolveDiscover( source: CollectionSource, apiKey: String, @@ -312,6 +351,21 @@ object TmdbCollectionSourceResolver { }.getOrNull() } + private fun List.sortedFor(sortBy: String?): List = + when (sortBy) { + TmdbCollectionSort.ORIGINAL.value -> this + TmdbCollectionSort.VOTE_AVERAGE_DESC.value -> sortedWith( + compareByDescending { it.imdbRating?.toDoubleOrNull() ?: -1.0 } + .thenByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() }, + ) + TmdbCollectionSort.RELEASE_DATE_DESC.value, + TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> sortedByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() } + TmdbCollectionSort.POPULAR_DESC.value, + null, + "" -> this + else -> this + } + private fun TmdbListItem.toPreview(): MetaPreview? { val media = mediaType?.lowercase() val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE @@ -362,6 +416,62 @@ object TmdbCollectionSourceResolver { ) } + private fun TmdbPersonCreditCast.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + if (!matchesMediaType(mediaType, this.mediaType)) return null + 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 TmdbPersonCreditCrew.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + if (!matchesMediaType(mediaType, this.mediaType)) return null + 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 CollectionSource.tmdbType(): TmdbCollectionSourceType = tmdbSourceType ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } @@ -370,6 +480,12 @@ object TmdbCollectionSourceResolver { private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType = TmdbCollectionMediaType.fromString(mediaType) + private fun matchesMediaType(expected: TmdbCollectionMediaType, actual: String?): Boolean = + when (expected) { + TmdbCollectionMediaType.MOVIE -> actual == "movie" + TmdbCollectionMediaType.TV -> actual == "tv" + } + private fun company(title: String, id: Int) = CollectionSource( provider = "tmdb", tmdbSourceType = TmdbCollectionSourceType.COMPANY.name, @@ -391,6 +507,7 @@ object TmdbCollectionSourceResolver { private fun movieSort(sortBy: String?): String = when (sortBy) { TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value + TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value null, "" -> TmdbCollectionSort.POPULAR_DESC.value else -> sortBy } @@ -398,6 +515,7 @@ object TmdbCollectionSourceResolver { private fun tvSort(sortBy: String?): String = when (sortBy) { TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value + TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value null, "" -> TmdbCollectionSort.POPULAR_DESC.value else -> sortBy } @@ -449,6 +567,12 @@ private data class TmdbNetworkResponse( @SerialName("logo_path") val logoPath: String? = null, ) +@Serializable +private data class TmdbPersonResponse( + val name: String? = null, + @SerialName("profile_path") val profilePath: String? = null, +) + @Serializable data class TmdbCompanySearchResult( val id: Int, @@ -496,6 +620,47 @@ private data class TmdbGenreItem( val name: String, ) +@Serializable +private data class TmdbPersonCreditsResponse( + val cast: List? = null, + val crew: List? = null, +) + +@Serializable +private data class TmdbPersonCreditCast( + 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 TmdbPersonCreditCrew( + 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, + val job: String? = null, + @SerialName("vote_average") val voteAverage: Double? = null, + val popularity: Double? = null, +) + @Serializable private data class TmdbListItem( val id: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 61f4ba86..12e42ded 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbSettingsRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -101,17 +102,22 @@ object MetaDetailsRepository { _uiState.value = MetaDetailsUiState(isLoading = true) scope.launch { - val manifests = AddonRepository.uiState.value.addons - .mapNotNull { it.manifest } - .filter { manifest -> - manifest.resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) }) - } - } + val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) + val manifests = findMetaManifests(type = type, id = metaLookupId) if (manifests.isEmpty()) { + val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id) + if (tmdbMeta != null) { + publishLoadedMeta( + requestKey = requestKey, + meta = tmdbMeta, + fallbackItemId = id, + mdbListSettings = mdbListSettings, + metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, + ) + return@launch + } + log.w { "No addon provides meta for type=$type id=$id" } _uiState.value = MetaDetailsUiState( errorMessage = getString(Res.string.details_no_addon_meta), @@ -122,42 +128,32 @@ object MetaDetailsRepository { for (manifest in manifests) { val result = withContext(Dispatchers.Default) { - tryFetchMeta(manifest, type, id, includeMdbList = false) + tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false) } if (result != null) { - var cachedEntry = CachedMetaEntry(baseMeta = result) - cachedMetaByRequestKey[requestKey] = cachedEntry - - if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) { - _uiState.value = MetaDetailsUiState(meta = result) - activeRequestKey = requestKey - return@launch - } - - _uiState.value = MetaDetailsUiState( - isLoading = true, + publishLoadedMeta( + requestKey = requestKey, meta = result, - ) - val enrichedMeta = withContext(Dispatchers.Default) { - enrichForMetaScreen( - requestKey = requestKey, - meta = result, - fallbackItemId = id, - settings = mdbListSettings, - settingsFingerprint = metaScreenSettingsFingerprint, - ) - } - cachedEntry = cachedEntry.copy( - metaScreenMeta = enrichedMeta, + fallbackItemId = metaLookupId, + mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) - cachedMetaByRequestKey[requestKey] = cachedEntry - _uiState.value = MetaDetailsUiState(meta = enrichedMeta) - activeRequestKey = requestKey return@launch } } + val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id) + if (tmdbMeta != null) { + publishLoadedMeta( + requestKey = requestKey, + meta = tmdbMeta, + fallbackItemId = id, + mdbListSettings = mdbListSettings, + metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, + ) + return@launch + } + _uiState.value = MetaDetailsUiState( errorMessage = getString(Res.string.details_load_failed_all_addons), ) @@ -187,19 +183,12 @@ object MetaDetailsRepository { val requestKey = "$type:$id" cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta } - val manifests = AddonRepository.uiState.value.addons - .mapNotNull { it.manifest } - .filter { manifest -> - manifest.resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) }) - } - } + val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) + val manifests = findMetaManifests(type = type, id = metaLookupId) for (manifest in manifests) { val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) { - tryFetchMeta(manifest, type, id, includeMdbList = false) + tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false) } if (result != null) { cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result) @@ -207,7 +196,9 @@ object MetaDetailsRepository { } } - return null + return tryFetchTmdbFallbackMeta(type = type, id = id)?.also { result -> + cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result) + } } private const val FETCH_TIMEOUT_MS = 5_000L @@ -265,6 +256,78 @@ object MetaDetailsRepository { } } + private fun findMetaManifests(type: String, id: String): List = + AddonRepository.uiState.value.addons + .mapNotNull { it.manifest } + .filter { manifest -> + manifest.resources.any { resource -> + resource.name == "meta" && + resource.types.contains(type) && + (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) }) + } + } + + private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String { + val tmdbId = itemId + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.toIntOrNull() + ?: return itemId + + return withTimeoutOrNull(FETCH_TIMEOUT_MS) { + TmdbService.tmdbToImdb(tmdbId = tmdbId, mediaType = itemType) + } + ?.takeIf { it.isNotBlank() } + ?: itemId + } + + private suspend fun tryFetchTmdbFallbackMeta(type: String, id: String): MetaDetails? = + withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) { + TmdbMetadataService.fetchStandaloneMeta( + type = type, + id = id, + settings = TmdbSettingsRepository.snapshot(), + ) + } + + private suspend fun publishLoadedMeta( + requestKey: String, + meta: MetaDetails, + fallbackItemId: String, + mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings, + metaScreenSettingsFingerprint: String, + ) { + val cachedEntry = CachedMetaEntry(baseMeta = meta) + cachedMetaByRequestKey[requestKey] = cachedEntry + + if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { + _uiState.value = MetaDetailsUiState(meta = meta) + activeRequestKey = requestKey + return + } + + _uiState.value = MetaDetailsUiState( + isLoading = true, + meta = meta, + ) + val enrichedMeta = withContext(Dispatchers.Default) { + enrichForMetaScreen( + requestKey = requestKey, + meta = meta, + fallbackItemId = fallbackItemId, + settings = mdbListSettings, + settingsFingerprint = metaScreenSettingsFingerprint, + ) + } + cachedMetaByRequestKey[requestKey] = cachedEntry.copy( + metaScreenMeta = enrichedMeta, + metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, + ) + _uiState.value = MetaDetailsUiState(meta = enrichedMeta) + activeRequestKey = requestKey + } + private suspend fun enrichForMetaScreen( requestKey: String, meta: MetaDetails, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt index 43d740d1..5bc7112a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.trakt.TraktCommentReview import kotlinx.coroutines.flow.distinctUntilChanged import nuvio.composeapp.generated.resources.* @@ -122,7 +123,11 @@ fun DetailCommentsSection( state = listState, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - items(comments, key = { it.id }) { review -> + items( + items = comments.withDuplicateSafeLazyKeys { it.id }, + key = { it.lazyKey }, + ) { keyedReview -> + val review = keyedReview.value CommentCard( review = review, onClick = { onCommentClick(review) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index a463e5a2..485c729a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -582,7 +583,10 @@ private fun EpisodeHorizontalRow( contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding), horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing), ) { - items(episodes, key = { it.id }) { episode -> + itemsIndexed( + items = episodes, + key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, + ) { _, episode -> val episodeVideoId = buildPlaybackVideoId( parentMetaId = parentMetaId, seasonNumber = episode.season, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index 0edc1a3c..e9ef5fa8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore @@ -158,10 +158,10 @@ fun DetailTrailersSection( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing), ) { - items( + itemsIndexed( items = selectedTrailers, - key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" }, - ) { trailer -> + key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" }, + ) { _, trailer -> TrailerCard( trailer = trailer, cardWidth = sizing.cardWidth, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index fc675a39..032fc605 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -291,7 +291,10 @@ private fun EpisodesListSubView( verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp), ) { - items(seasonEpisodes, key = { "${it.season}:${it.episode}:${it.id}" }) { episode -> + itemsIndexed( + items = seasonEpisodes, + key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, + ) { _, episode -> val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode EpisodeRow( episode = episode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index c127cf3c..45e335eb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -44,6 +44,7 @@ import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomeCatalogRowSection @@ -303,9 +304,10 @@ fun SearchScreen( else -> { items( - items = uiState.sections, - key = { section -> section.key }, - ) { section -> + items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key }, + key = { section -> section.lazyKey }, + ) { keyedSection -> + val section = keyedSection.value HomeCatalogRowSection( section = section, modifier = Modifier.padding(bottom = 12.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index f398257f..823125a6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -638,6 +638,69 @@ object TmdbMetadataService { ) } + suspend fun fetchStandaloneMeta( + type: String, + id: String, + settings: TmdbSettings, + ): MetaDetails? { + if (!settings.hasApiKey) return null + + val tmdbId = id + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.toIntOrNull() + ?: return null + val tmdbType = normalizeMetaType(type) + val enrichment = fetchEnrichment( + tmdbId = tmdbId.toString(), + mediaType = tmdbType, + language = settings.language, + settings = settings, + ) ?: return null + + return buildStandaloneMeta( + type = type, + id = id, + tmdbId = tmdbId, + enrichment = enrichment, + ) + } + + internal fun buildStandaloneMeta( + type: String, + id: String, + tmdbId: Int, + enrichment: TmdbEnrichment, + ): MetaDetails = + MetaDetails( + id = id, + type = type, + name = enrichment.localizedTitle ?: "TMDB $tmdbId", + poster = enrichment.poster, + background = enrichment.backdrop, + logo = enrichment.logo, + description = enrichment.description, + releaseInfo = enrichment.releaseInfo, + lastAirDate = enrichment.lastAirDate, + status = enrichment.status, + imdbRating = enrichment.rating?.formatRating(), + ageRating = enrichment.ageRating, + runtime = enrichment.runtimeMinutes?.formatRuntime(), + genres = enrichment.genres, + director = enrichment.director, + writer = enrichment.writer, + cast = enrichment.people, + productionCompanies = enrichment.productionCompanies, + networks = enrichment.networks, + country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "), + language = enrichment.language, + moreLikeThis = enrichment.moreLikeThis, + collectionName = enrichment.collectionName, + collectionItems = enrichment.collectionItems, + trailers = enrichment.trailers, + ) + internal fun applyEnrichment( meta: MetaDetails, enrichment: TmdbEnrichment?, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt index d4145c30..22dd5a59 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt @@ -8,6 +8,47 @@ import kotlin.test.Test import kotlin.test.assertEquals class TmdbMetadataServiceTest { + @Test + fun `buildStandaloneMeta maps tmdb enrichment without addon meta`() { + val enrichment = TmdbEnrichment( + localizedTitle = "TMDB Movie", + description = "TMDB description", + genres = listOf("Adventure"), + backdrop = "backdrop", + logo = "logo", + poster = "poster", + people = listOf(MetaPerson(name = "Cast Member", role = "Hero")), + director = listOf("Director"), + writer = listOf("Writer"), + releaseInfo = "2026-01-01", + rating = 8.4, + runtimeMinutes = 105, + ageRating = "PG-13", + status = "Released", + countries = listOf("US", "GB"), + language = "en", + productionCompanies = listOf(MetaCompany(name = "Studio")), + networks = emptyList(), + ) + + val result = TmdbMetadataService.buildStandaloneMeta( + type = "movie", + id = "tmdb:123", + tmdbId = 123, + enrichment = enrichment, + ) + + assertEquals("tmdb:123", result.id) + assertEquals("movie", result.type) + assertEquals("TMDB Movie", result.name) + assertEquals("TMDB description", result.description) + assertEquals("8.4", result.imdbRating) + assertEquals("105m", result.runtime) + assertEquals("US, GB", result.country) + assertEquals(listOf("Cast Member"), result.cast.map { it.name }) + assertEquals(listOf("Studio"), result.productionCompanies.map { it.name }) + } + @Test fun `applyEnrichment replaces enabled metadata groups`() { val base = MetaDetails( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 50cf133b..2ce2b26a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -1,39 +1,52 @@ package com.nuvio.app.features.downloads -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.isSuccess -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readAvailable import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.convert import kotlinx.cinterop.usePinned import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import platform.Foundation.NSError +import platform.Foundation.NSDate import platform.Foundation.NSFileManager +import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHomeDirectory +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSOperationQueue import platform.Foundation.NSURL -import platform.posix.fclose +import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData +import platform.Foundation.NSURLResponse +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionDownloadDelegateProtocol +import platform.Foundation.NSURLSessionDownloadTask +import platform.Foundation.NSURLSessionTask +import platform.Foundation.setHTTPMethod +import platform.Foundation.setValue +import platform.Foundation.timeIntervalSince1970 +import platform.darwin.NSObject import platform.posix.fopen +import platform.posix.fclose +import platform.posix.fread import platform.posix.fwrite -private val downloadHttpClient = HttpClient(Darwin) { - install(HttpTimeout) { - requestTimeoutMillis = 60_000 - connectTimeoutMillis = 60_000 - socketTimeoutMillis = 60_000 - } - expectSuccess = false +private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 +private const val DOWNLOAD_RESOURCE_TIMEOUT_SECONDS = 24.0 * 60.0 * 60.0 +private const val PROGRESS_MIN_INTERVAL_SECONDS = 0.5 +private const val PROGRESS_MIN_BYTE_DELTA = 512L * 1024L + +private val backgroundSessionCompletionHandlers = mutableMapOf Unit>() + +fun handleDownloadsBackgroundEvents( + identifier: String, + completionHandler: () -> Unit, +) { + backgroundSessionCompletionHandlers[identifier] = completionHandler } @OptIn(ExperimentalForeignApi::class) @@ -46,6 +59,7 @@ internal actual object DownloadsPlatformDownloader { ): DownloadsTaskHandle { val job = SupervisorJob() val scope = CoroutineScope(job + Dispatchers.Default) + val handle = IosDownloadsTaskHandle(job) scope.launch { val downloadsDirectory = downloadsDirectoryPath() @@ -55,55 +69,42 @@ internal actual object DownloadsPlatformDownloader { try { var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L - suspend fun performRequest(rangeStart: Long?) = downloadHttpClient.get(request.sourceUrl) { - request.sourceHeaders.forEach { (key, value) -> - header(key, value) - } - if (rangeStart != null && rangeStart > 0L) { - header("Range", "bytes=$rangeStart-") - } - } - var attemptedRangeRequest = resumeFromBytes > 0L - var response = performRequest(if (attemptedRangeRequest) resumeFromBytes else null) + var result = performDownloadRequest( + request = request, + rangeStart = if (attemptedRangeRequest) resumeFromBytes else null, + resumeFromBytes = resumeFromBytes, + tempPath = tempPath, + handle = handle, + onProgress = onProgress, + ) - if (attemptedRangeRequest && response.status.value == 416) { + if (attemptedRangeRequest && result.statusCode == 416) { removePathIfExists(tempPath) resumeFromBytes = 0L attemptedRangeRequest = false - response = performRequest(null) + result = performDownloadRequest( + request = request, + rangeStart = null, + resumeFromBytes = 0L, + tempPath = tempPath, + handle = handle, + onProgress = onProgress, + ) } - if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") - } - - val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L - val appendToTemp = isPartialResume - val startingBytes = if (appendToTemp) resumeFromBytes else 0L - - if (!appendToTemp) { - removePathIfExists(tempPath) + if (result.statusCode !in 200..299) { + error("Request failed with HTTP ${result.statusCode}") } + val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L + val startingBytes = if (isPartialResume) resumeFromBytes else 0L val totalBytes = resolveTotalBytes( startingBytes = startingBytes, isPartialResume = isPartialResume, - contentRangeHeader = response.headers["Content-Range"], - contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L }, + contentRangeHeader = result.contentRange, + contentLength = result.contentLength, ) - val channel = response.bodyAsChannel() - val wrote = writeChannelToFile( - channel = channel, - path = tempPath, - append = appendToTemp, - initialDownloadedBytes = startingBytes, - totalBytes = totalBytes, - onProgress = onProgress, - ) - if (!wrote) { - error("Failed to write download file") - } removePathIfExists(destinationPath) val moved = NSFileManager.defaultManager.moveItemAtPath( @@ -118,12 +119,14 @@ internal actual object DownloadsPlatformDownloader { val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" val finalSize = fileSizeOrNull(destinationPath) onSuccess(localFileUri, totalBytes ?: finalSize) + } catch (_: CancellationException) { + handle.cancelNativeTask() } catch (error: Throwable) { onFailure(error.message ?: "Download failed") } } - return IosDownloadsTaskHandle(job) + return handle } actual fun removeFile(localFileUri: String?): Boolean { @@ -141,9 +144,172 @@ internal actual object DownloadsPlatformDownloader { private class IosDownloadsTaskHandle( private val job: Job, ) : DownloadsTaskHandle { + private var task: NSURLSessionDownloadTask? = null + private var session: NSURLSession? = null + + fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) { + this.task = task + this.session = session + } + override fun cancel() { + cancelNativeTask() job.cancel() } + + fun cancelNativeTask() { + task?.cancel() + session?.invalidateAndCancel() + task = null + session = null + } +} + +private data class IosDownloadResult( + val statusCode: Int, + val contentRange: String?, + val contentLength: Long?, +) + +@OptIn(ExperimentalForeignApi::class) +private class IosDownloadDelegate( + private val attemptedRangeRequest: Boolean, + private val resumeFromBytes: Long, + private val tempPath: String, + private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, +) : NSObject(), NSURLSessionDownloadDelegateProtocol { + private val completion = CompletableDeferred() + private var result: IosDownloadResult? = null + private var fileError: Throwable? = null + private var lastProgressBytes = -1L + private var lastProgressTimestampSeconds = 0.0 + + suspend fun awaitCompletion(): IosDownloadResult = completion.await() + + override fun URLSession( + session: NSURLSession, + downloadTask: NSURLSessionDownloadTask, + didFinishDownloadingToURL: NSURL, + ) { + val httpResponse = downloadTask.response as? NSHTTPURLResponse + val statusCode = httpResponse?.statusCode?.toInt() ?: 200 + result = IosDownloadResult( + statusCode = statusCode, + contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), + contentLength = httpResponse + ?.valueForHTTPHeaderField("Content-Length") + ?.toLongOrNull() + ?.takeIf { it > 0L }, + ) + + if (statusCode !in 200..299) return + + val sourcePath = didFinishDownloadingToURL.path + if (sourcePath.isNullOrBlank()) { + fileError = IllegalStateException("Downloaded file was not available") + return + } + + val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L + val stored = if (isPartialResume) { + appendFile(sourcePath, tempPath) + } else { + removePathIfExists(tempPath) && + NSFileManager.defaultManager.moveItemAtPath( + srcPath = sourcePath, + toPath = tempPath, + error = null, + ) + } + + if (!stored) { + fileError = IllegalStateException("Failed to store download file") + } + } + + override fun URLSession( + session: NSURLSession, + downloadTask: NSURLSessionDownloadTask, + didWriteData: Long, + totalBytesWritten: Long, + totalBytesExpectedToWrite: Long, + ) { + val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt() + val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) { + resumeFromBytes + } else { + 0L + } + val expectedTotal = totalBytesExpectedToWrite + .takeIf { it > 0L } + ?.let { startingBytes + it } + reportProgress( + downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L), + totalBytes = expectedTotal, + ) + } + + override fun URLSession( + session: NSURLSession, + task: NSURLSessionTask, + didCompleteWithError: NSError?, + ) { + if (didCompleteWithError != null) { + completion.completeExceptionally( + IllegalStateException(didCompleteWithError.localizedDescription), + ) + return + } + + val error = fileError + if (error != null) { + completion.completeExceptionally(error) + return + } + + completion.complete(result ?: task.response.toDownloadResult()) + } + + override fun URLSessionDidFinishEventsForBackgroundURLSession(session: NSURLSession) { + val identifier = session.configuration.identifier ?: return + backgroundSessionCompletionHandlers.remove(identifier)?.invoke() + } + + private fun reportProgress( + downloadedBytes: Long, + totalBytes: Long?, + ) { + val normalizedDownloadedBytes = downloadedBytes.coerceAtLeast(0L) + val now = NSDate().timeIntervalSince1970 + val byteDelta = normalizedDownloadedBytes - lastProgressBytes + val timeDelta = now - lastProgressTimestampSeconds + val reachedEnd = totalBytes != null && normalizedDownloadedBytes >= totalBytes + + if ( + lastProgressBytes >= 0L && + !reachedEnd && + byteDelta < PROGRESS_MIN_BYTE_DELTA && + timeDelta < PROGRESS_MIN_INTERVAL_SECONDS + ) { + return + } + + lastProgressBytes = normalizedDownloadedBytes + lastProgressTimestampSeconds = now + onProgress(normalizedDownloadedBytes, totalBytes) + } +} + +private fun NSURLResponse?.toDownloadResult(): IosDownloadResult { + val httpResponse = this as? NSHTTPURLResponse + return IosDownloadResult( + statusCode = httpResponse?.statusCode?.toInt() ?: 200, + contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), + contentLength = httpResponse + ?.valueForHTTPHeaderField("Content-Length") + ?.toLongOrNull() + ?.takeIf { it > 0L }, + ) } @OptIn(ExperimentalForeignApi::class) @@ -166,45 +332,98 @@ private fun removePathIfExists(path: String): Boolean { } @OptIn(ExperimentalForeignApi::class) -private suspend fun writeChannelToFile( - channel: ByteReadChannel, - path: String, - append: Boolean, - initialDownloadedBytes: Long, - totalBytes: Long?, +private suspend fun performDownloadRequest( + request: DownloadPlatformRequest, + rangeStart: Long?, + resumeFromBytes: Long, + tempPath: String, + handle: IosDownloadsTaskHandle, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, -): Boolean { - val file = fopen(path, if (append) "ab" else "wb") ?: return false +): IosDownloadResult { + val url = NSURL(string = request.sourceUrl) + val nativeRequest = NSMutableURLRequest( + uRL = url, + cachePolicy = NSURLRequestReloadIgnoringLocalCacheData, + timeoutInterval = DOWNLOAD_REQUEST_TIMEOUT_SECONDS, + ) + nativeRequest.setHTTPMethod("GET") + nativeRequest.setAllowsCellularAccess(true) + nativeRequest.setAllowsExpensiveNetworkAccess(true) + nativeRequest.setAllowsConstrainedNetworkAccess(true) + request.sourceHeaders.forEach { (key, value) -> + nativeRequest.setValue(value, forHTTPHeaderField = key) + } + if (rangeStart != null && rangeStart > 0L) { + nativeRequest.setValue("bytes=$rangeStart-", forHTTPHeaderField = "Range") + } + + val delegate = IosDownloadDelegate( + attemptedRangeRequest = rangeStart != null && rangeStart > 0L, + resumeFromBytes = resumeFromBytes, + tempPath = tempPath, + onProgress = onProgress, + ) + val configuration = NSURLSessionConfiguration.defaultSessionConfiguration().apply { + timeoutIntervalForRequest = DOWNLOAD_REQUEST_TIMEOUT_SECONDS + timeoutIntervalForResource = DOWNLOAD_RESOURCE_TIMEOUT_SECONDS + waitsForConnectivity = true + allowsCellularAccess = true + allowsExpensiveNetworkAccess = true + allowsConstrainedNetworkAccess = true + } + val session = NSURLSession.sessionWithConfiguration( + configuration = configuration, + delegate = delegate, + delegateQueue = NSOperationQueue(), + ) + val task = session.downloadTaskWithRequest(nativeRequest) + + handle.attach(task, session) + onProgress(resumeFromBytes.coerceAtLeast(0L), null) + task.resume() + + return try { + delegate.awaitCompletion() + } finally { + session.finishTasksAndInvalidate() + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun appendFile(sourcePath: String, destinationPath: String): Boolean { + val source = fopen(sourcePath, "rb") ?: return false + val destination = fopen(destinationPath, "ab") ?: run { + fclose(source) + return false + } val buffer = ByteArray(16 * 1024) - var downloadedBytes = initialDownloadedBytes - onProgress(downloadedBytes, totalBytes) return try { while (true) { - kotlinx.coroutines.currentCoroutineContext().ensureActive() - val read = channel.readAvailable(buffer, 0, buffer.size) - if (read < 0) break - if (read == 0) continue + val read = buffer.usePinned { pinned -> + fread( + pinned.addressOf(0), + 1.convert(), + buffer.size.convert(), + source, + ).toInt() + } + if (read <= 0) break - val wroteChunk = buffer.usePinned { pinned -> - val written = fwrite( + val wrote = buffer.usePinned { pinned -> + fwrite( pinned.addressOf(0), 1.convert(), read.convert(), - file, - ) - written.toInt() == read + destination, + ).toInt() } - if (!wroteChunk) { - return false - } - - downloadedBytes += read.toLong() - onProgress(downloadedBytes, totalBytes) + if (wrote != read) return false } true } finally { - fclose(file) + fclose(source) + fclose(destination) } } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index d2dd1f20..e702a219 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=42 -MARKETING_VERSION=0.1.10 +CURRENT_PROJECT_VERSION=48 +MARKETING_VERSION=0.1.0 diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 00000000..9401d693 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 4f941103..7ecac2c5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -17,6 +17,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSSupportsLiveActivities diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index 5d514e02..cf78e051 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -23,6 +23,17 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN OrientationLockCoordinator.shared.supportedOrientations } + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + DownloadsPlatformDownloader_iosKt.handleDownloadsBackgroundEvents( + identifier: identifier, + completionHandler: completionHandler + ) + } + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/mediamp b/mediamp deleted file mode 160000 index df33966d..00000000 --- a/mediamp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit df33966d7fbc6eb14e43fb1892e062417d76e7f5 diff --git a/vendor/mpv-kt-upstream b/vendor/mpv-kt-upstream deleted file mode 160000 index 8a8ddddf..00000000 --- a/vendor/mpv-kt-upstream +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8a8ddddf430555878273da13006fc57e182b0c0c diff --git a/vendor/quickjs-kt b/vendor/quickjs-kt new file mode 160000 index 00000000..57ce0962 --- /dev/null +++ b/vendor/quickjs-kt @@ -0,0 +1 @@ +Subproject commit 57ce096200ac36bceb4e1ee5b6ec411b12357eb8