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