mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite
This commit is contained in:
commit
bb6ead8cba
30 changed files with 959 additions and 173 deletions
|
|
@ -272,7 +272,7 @@ kotlin {
|
||||||
|
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
dependencies {
|
dependencies {
|
||||||
add("fullImplementation", libs.quickjs.kt)
|
add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
|
||||||
add("fullImplementation", libs.ksoup)
|
add("fullImplementation", libs.ksoup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
BIN
composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
Normal file
BIN
composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
Normal file
Binary file not shown.
|
|
@ -110,29 +110,38 @@
|
||||||
<string name="collections_editor_tmdb_production_mode">Production</string>
|
<string name="collections_editor_tmdb_production_mode">Production</string>
|
||||||
<string name="collections_editor_tmdb_network_mode">Network</string>
|
<string name="collections_editor_tmdb_network_mode">Network</string>
|
||||||
<string name="collections_editor_tmdb_collection_mode">Collection</string>
|
<string name="collections_editor_tmdb_collection_mode">Collection</string>
|
||||||
|
<string name="collections_editor_tmdb_person_mode">Person</string>
|
||||||
|
<string name="collections_editor_tmdb_director_mode">Director</string>
|
||||||
<string name="collections_editor_tmdb_custom_mode">Custom</string>
|
<string name="collections_editor_tmdb_custom_mode">Custom</string>
|
||||||
<string name="collections_editor_tmdb_help_presets">Pick a ready-made source. You can edit or remove it after adding.</string>
|
<string name="collections_editor_tmdb_help_presets">Pick a ready-made source. You can edit or remove it after adding.</string>
|
||||||
<string name="collections_editor_tmdb_help_list">Paste a public TMDB list URL or only the number from the URL.</string>
|
<string name="collections_editor_tmdb_help_list">Paste a public TMDB list URL or only the number from the URL.</string>
|
||||||
<string name="collections_editor_tmdb_help_production">Search by studio name, or paste a TMDB company ID/URL and add it directly.</string>
|
<string name="collections_editor_tmdb_help_production">Search by studio name, or paste a TMDB company ID/URL and add it directly.</string>
|
||||||
<string name="collections_editor_tmdb_help_network">Enter a network ID. Common networks are available in Presets and quick filters.</string>
|
<string name="collections_editor_tmdb_help_network">Enter a network ID. Common networks are available in Presets and quick filters.</string>
|
||||||
<string name="collections_editor_tmdb_help_collection">Search a movie collection name or paste the collection ID from TMDB.</string>
|
<string name="collections_editor_tmdb_help_collection">Search a movie collection name or paste the collection ID from TMDB.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_person">Enter a TMDB person ID or URL to build a row from cast credits.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_director">Enter a TMDB person ID or URL to build a row from director credits.</string>
|
||||||
<string name="collections_editor_tmdb_help_discover">Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter.</string>
|
<string name="collections_editor_tmdb_help_discover">Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter.</string>
|
||||||
<string name="collections_editor_tmdb_public_list">Public TMDB list</string>
|
<string name="collections_editor_tmdb_public_list">Public TMDB list</string>
|
||||||
<string name="collections_editor_tmdb_network_id">Network ID</string>
|
<string name="collections_editor_tmdb_network_id">Network ID</string>
|
||||||
<string name="collections_editor_tmdb_collection_id">Collection ID</string>
|
<string name="collections_editor_tmdb_collection_id">Collection ID</string>
|
||||||
|
<string name="collections_editor_tmdb_person_id">Person ID</string>
|
||||||
<string name="collections_editor_tmdb_company_search">Production company name, ID, or URL</string>
|
<string name="collections_editor_tmdb_company_search">Production company name, ID, or URL</string>
|
||||||
<string name="collections_editor_tmdb_id_or_url">TMDB ID or URL</string>
|
<string name="collections_editor_tmdb_id_or_url">TMDB ID or URL</string>
|
||||||
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 or 8504994</string>
|
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 or 8504994</string>
|
||||||
<string name="collections_editor_tmdb_network_placeholder">213 for Netflix, 49 for HBO, 2739 for Disney+</string>
|
<string name="collections_editor_tmdb_network_placeholder">213 for Netflix, 49 for HBO, 2739 for Disney+</string>
|
||||||
<string name="collections_editor_tmdb_collection_placeholder">10 for Star Wars Collection</string>
|
<string name="collections_editor_tmdb_collection_placeholder">10 for Star Wars Collection</string>
|
||||||
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, or company URL</string>
|
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, or company URL</string>
|
||||||
|
<string name="collections_editor_tmdb_person_placeholder">31 for Tom Hanks, or person URL</string>
|
||||||
<string name="collections_editor_tmdb_search_helper">Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420.</string>
|
<string name="collections_editor_tmdb_search_helper">Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420.</string>
|
||||||
<string name="collections_editor_tmdb_collection_helper">Example: Star Wars Collection, Harry Potter Collection, or a collection URL.</string>
|
<string name="collections_editor_tmdb_collection_helper">Example: Star Wars Collection, Harry Potter Collection, or a collection URL.</string>
|
||||||
<string name="collections_editor_tmdb_network_helper">Example IDs: Netflix 213, HBO 49, Disney+ 2739.</string>
|
<string name="collections_editor_tmdb_network_helper">Example IDs: Netflix 213, HBO 49, Disney+ 2739.</string>
|
||||||
<string name="collections_editor_tmdb_list_helper">Example: https://www.themoviedb.org/list/8504994 or 8504994.</string>
|
<string name="collections_editor_tmdb_list_helper">Example: https://www.themoviedb.org/list/8504994 or 8504994.</string>
|
||||||
|
<string name="collections_editor_tmdb_person_helper">Example: https://www.themoviedb.org/person/31-tom-hanks or 31.</string>
|
||||||
<string name="collections_editor_tmdb_display_title">Display title</string>
|
<string name="collections_editor_tmdb_display_title">Display title</string>
|
||||||
<string name="collections_editor_tmdb_title_helper">Shown as the row/tab name. If blank, Nuvio creates one from the source.</string>
|
<string name="collections_editor_tmdb_title_helper">Shown as the row/tab name. If blank, Nuvio creates one from the source.</string>
|
||||||
<string name="collections_editor_tmdb_title_placeholder">Marvel Movies, Netflix Originals, Pixar</string>
|
<string name="collections_editor_tmdb_title_placeholder">Marvel Movies, Netflix Originals, Pixar</string>
|
||||||
|
<string name="collections_editor_tmdb_person_title_placeholder">Tom Hanks Movies, Favorite Actors</string>
|
||||||
|
<string name="collections_editor_tmdb_director_title_placeholder">Christopher Nolan Movies, Favorite Directors</string>
|
||||||
<string name="collections_editor_tmdb_discover_title_placeholder">Best Action Movies, Korean Dramas, 2024 Animation</string>
|
<string name="collections_editor_tmdb_discover_title_placeholder">Best Action Movies, Korean Dramas, 2024 Animation</string>
|
||||||
<string name="collections_editor_tmdb_search_results">Search Results</string>
|
<string name="collections_editor_tmdb_search_results">Search Results</string>
|
||||||
<string name="collections_editor_tmdb_collection">TMDB Collection</string>
|
<string name="collections_editor_tmdb_collection">TMDB Collection</string>
|
||||||
|
|
@ -212,6 +221,7 @@
|
||||||
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
|
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
|
||||||
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
|
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
|
||||||
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
|
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
|
||||||
|
<string name="collections_editor_tmdb_sort_original">Original</string>
|
||||||
<string name="collections_editor_tmdb_sort_popular">Popular</string>
|
<string name="collections_editor_tmdb_sort_popular">Popular</string>
|
||||||
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</string>
|
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</string>
|
||||||
<string name="collections_editor_tmdb_sort_recent">Recent</string>
|
<string name="collections_editor_tmdb_sort_recent">Recent</string>
|
||||||
|
|
@ -219,6 +229,8 @@
|
||||||
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
|
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
|
||||||
<string name="collections_editor_tmdb_subtitle_production">Production</string>
|
<string name="collections_editor_tmdb_subtitle_production">Production</string>
|
||||||
<string name="collections_editor_tmdb_subtitle_network">Network</string>
|
<string name="collections_editor_tmdb_subtitle_network">Network</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_person">Person</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_director">Director</string>
|
||||||
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
|
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
|
||||||
<string name="collections_empty_subtitle">Create one to organize your catalogs.</string>
|
<string name="collections_empty_subtitle">Create one to organize your catalogs.</string>
|
||||||
<string name="collections_empty_title">No collections yet</string>
|
<string name="collections_empty_title">No collections yet</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.nuvio.app.core.ui
|
||||||
|
|
||||||
|
internal data class DuplicateSafeLazyEntry<T>(
|
||||||
|
val value: T,
|
||||||
|
val lazyKey: Any,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun <T> List<T>.withDuplicateSafeLazyKeys(key: (T) -> Any): List<DuplicateSafeLazyEntry<T>> {
|
||||||
|
val keyCounts = groupingBy(key).eachCount()
|
||||||
|
val occurrences = mutableMapOf<Any, Int>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,10 +82,10 @@ fun <T> NuvioShelfSection(
|
||||||
) {
|
) {
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
items(
|
items(
|
||||||
items = entries,
|
items = entries.withDuplicateSafeLazyKeys(key),
|
||||||
key = key,
|
key = { entry -> entry.lazyKey },
|
||||||
) { entry ->
|
) { keyedEntry ->
|
||||||
itemContent(entry)
|
itemContent(keyedEntry.value)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(entries) { entry ->
|
items(entries) { entry ->
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
import com.nuvio.app.core.ui.posterCardClickable
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
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.MetaPreview
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
import com.nuvio.app.features.home.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
|
|
@ -175,9 +176,10 @@ fun CatalogScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(
|
items(
|
||||||
items = uiState.items,
|
items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||||
key = { item -> item.stableKey() },
|
key = { item -> item.lazyKey },
|
||||||
) { item ->
|
) { keyedItem ->
|
||||||
|
val item = keyedItem.value
|
||||||
CatalogPosterTile(
|
CatalogPosterTile(
|
||||||
item = item,
|
item = item,
|
||||||
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
||||||
|
|
|
||||||
|
|
@ -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<ManagedAddon>.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<AvailableCatalog>.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<AddonCatalog>.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<AvailableCatalog>.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? =
|
||||||
|
find { it.catalogId == source.catalogId && it.type == source.type }
|
||||||
|
?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type }
|
||||||
|
|
||||||
|
|
@ -46,6 +46,8 @@ enum class TmdbBuilderMode {
|
||||||
PRODUCTION,
|
PRODUCTION,
|
||||||
NETWORK,
|
NETWORK,
|
||||||
COLLECTION,
|
COLLECTION,
|
||||||
|
PERSON,
|
||||||
|
DIRECTOR,
|
||||||
DISCOVER,
|
DISCOVER,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,9 +342,15 @@ object CollectionEditorRepository {
|
||||||
} else {
|
} else {
|
||||||
_uiState.value.tmdbMediaType
|
_uiState.value.tmdbMediaType
|
||||||
}
|
}
|
||||||
|
val sortBy = when (mode) {
|
||||||
|
TmdbBuilderMode.LIST,
|
||||||
|
TmdbBuilderMode.COLLECTION -> TmdbCollectionSort.ORIGINAL.value
|
||||||
|
else -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
|
}
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
tmdbBuilderMode = mode,
|
tmdbBuilderMode = mode,
|
||||||
tmdbMediaType = mediaType,
|
tmdbMediaType = mediaType,
|
||||||
|
tmdbSortBy = sortBy,
|
||||||
tmdbMediaBoth = if (
|
tmdbMediaBoth = if (
|
||||||
mode == TmdbBuilderMode.NETWORK ||
|
mode == TmdbBuilderMode.NETWORK ||
|
||||||
mode == TmdbBuilderMode.LIST ||
|
mode == TmdbBuilderMode.LIST ||
|
||||||
|
|
@ -459,6 +467,8 @@ object CollectionEditorRepository {
|
||||||
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
|
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
|
||||||
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
|
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
|
||||||
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
|
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
|
||||||
|
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
|
||||||
|
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
|
||||||
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
|
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
|
||||||
}
|
}
|
||||||
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
|
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
|
||||||
|
|
@ -473,6 +483,8 @@ object CollectionEditorRepository {
|
||||||
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
|
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
|
||||||
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
|
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
|
||||||
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
|
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
|
||||||
|
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
|
||||||
|
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
|
||||||
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
|
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -561,6 +573,8 @@ private val coverMetadataSourceTypes = setOf(
|
||||||
TmdbCollectionSourceType.COLLECTION,
|
TmdbCollectionSourceType.COLLECTION,
|
||||||
TmdbCollectionSourceType.COMPANY,
|
TmdbCollectionSourceType.COMPANY,
|
||||||
TmdbCollectionSourceType.NETWORK,
|
TmdbCollectionSourceType.NETWORK,
|
||||||
|
TmdbCollectionSourceType.PERSON,
|
||||||
|
TmdbCollectionSourceType.DIRECTOR,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
|
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
|
||||||
|
|
@ -591,6 +605,8 @@ private fun selectedMediaTypes(
|
||||||
): List<TmdbCollectionMediaType> =
|
): List<TmdbCollectionMediaType> =
|
||||||
when (sourceType) {
|
when (sourceType) {
|
||||||
TmdbCollectionSourceType.COMPANY,
|
TmdbCollectionSourceType.COMPANY,
|
||||||
|
TmdbCollectionSourceType.PERSON,
|
||||||
|
TmdbCollectionSourceType.DIRECTOR,
|
||||||
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
|
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
|
||||||
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
|
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -111,9 +111,7 @@ fun CollectionEditorScreen(
|
||||||
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
|
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
|
||||||
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
|
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
|
||||||
val genrePickerCatalog = genrePickerCatalogSource?.let { source ->
|
val genrePickerCatalog = genrePickerCatalogSource?.let { source ->
|
||||||
state.availableCatalogs.find {
|
state.availableCatalogs.findAvailableCatalog(source)
|
||||||
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderEditorPage(
|
FolderEditorPage(
|
||||||
|
|
@ -757,11 +755,7 @@ private fun FolderEditorPage(
|
||||||
} else if (addonSource != null) {
|
} else if (addonSource != null) {
|
||||||
FolderCatalogSourceCard(
|
FolderCatalogSourceCard(
|
||||||
source = addonSource,
|
source = addonSource,
|
||||||
matchingCatalog = state.availableCatalogs.find {
|
matchingCatalog = state.availableCatalogs.findAvailableCatalog(addonSource),
|
||||||
it.addonId == addonSource.addonId &&
|
|
||||||
it.type == addonSource.type &&
|
|
||||||
it.catalogId == addonSource.catalogId
|
|
||||||
},
|
|
||||||
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
|
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
|
||||||
onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
|
onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
|
||||||
)
|
)
|
||||||
|
|
@ -897,13 +891,19 @@ private fun TmdbSourcePickerScreen(
|
||||||
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
|
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
|
||||||
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
|
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
|
||||||
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
|
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
|
||||||
|
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
|
||||||
|
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
|
||||||
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
|
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
|
||||||
}
|
}
|
||||||
val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER
|
val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER
|
||||||
val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
|
val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
|
||||||
|
state.tmdbBuilderMode == TmdbBuilderMode.PERSON ||
|
||||||
|
state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR ||
|
||||||
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
|
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
|
||||||
val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
|
val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
|
||||||
state.tmdbBuilderMode == TmdbBuilderMode.NETWORK ||
|
state.tmdbBuilderMode == TmdbBuilderMode.NETWORK ||
|
||||||
|
state.tmdbBuilderMode == TmdbBuilderMode.PERSON ||
|
||||||
|
state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR ||
|
||||||
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
|
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
|
||||||
val showFilterControls = 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.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_production_mode)
|
||||||
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode)
|
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode)
|
||||||
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_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)
|
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.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_help_production)
|
||||||
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network)
|
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network)
|
||||||
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_help_collection)
|
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)
|
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.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_id)
|
||||||
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id)
|
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id)
|
||||||
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_search)
|
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)
|
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.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_placeholder)
|
||||||
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder)
|
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder)
|
||||||
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_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)
|
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.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_helper)
|
||||||
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper)
|
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper)
|
||||||
TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper)
|
TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper)
|
||||||
|
TmdbBuilderMode.PERSON,
|
||||||
|
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_helper)
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1940,12 +1950,15 @@ private fun tmdbInputHelper(mode: TmdbBuilderMode): String =
|
||||||
private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String =
|
private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String =
|
||||||
when (mode) {
|
when (mode) {
|
||||||
TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_discover_title_placeholder)
|
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)
|
else -> stringResource(Res.string.collections_editor_tmdb_title_placeholder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
|
private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
|
TmdbCollectionSort.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original)
|
||||||
TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular)
|
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.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated)
|
||||||
TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
|
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),
|
stringResource(Res.string.collections_editor_tmdb_series),
|
||||||
sort,
|
sort,
|
||||||
).joinToString(" • ")
|
).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(
|
TmdbCollectionSourceType.DISCOVER -> listOf(
|
||||||
stringResource(Res.string.collections_editor_tmdb_subtitle_discover),
|
stringResource(Res.string.collections_editor_tmdb_subtitle_discover),
|
||||||
media,
|
media,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ enum class TmdbCollectionSourceType {
|
||||||
COMPANY,
|
COMPANY,
|
||||||
NETWORK,
|
NETWORK,
|
||||||
DISCOVER,
|
DISCOVER,
|
||||||
|
PERSON,
|
||||||
|
DIRECTOR,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -86,6 +88,7 @@ enum class TmdbCollectionMediaType(val value: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TmdbCollectionSort(val value: String) {
|
enum class TmdbCollectionSort(val value: String) {
|
||||||
|
ORIGINAL("original"),
|
||||||
POPULAR_DESC("popularity.desc"),
|
POPULAR_DESC("popularity.desc"),
|
||||||
VOTE_AVERAGE_DESC("vote_average.desc"),
|
VOTE_AVERAGE_DESC("vote_average.desc"),
|
||||||
RELEASE_DATE_DESC("primary_release_date.desc"),
|
RELEASE_DATE_DESC("primary_release_date.desc"),
|
||||||
|
|
|
||||||
|
|
@ -140,16 +140,19 @@ object FolderDetailRepository {
|
||||||
source = source,
|
source = source,
|
||||||
type = type,
|
type = type,
|
||||||
catalogId = tmdbCatalogId(source),
|
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,
|
isLoading = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
||||||
val addon = addons.find { it.manifest?.id == catalogSource.addonId }
|
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
|
||||||
val catalog = addon?.manifest?.catalogs?.find {
|
val addon = resolvedCatalog?.addon
|
||||||
it.id == catalogSource.catalogId && it.type == catalogSource.type
|
val catalog = resolvedCatalog?.catalog
|
||||||
}
|
|
||||||
val label = catalog?.name ?: catalogSource.catalogId
|
val label = catalog?.name ?: catalogSource.catalogId
|
||||||
val typeLabel = localizedMediaTypeLabel(catalogSource.type)
|
val typeLabel = localizedMediaTypeLabel(catalogSource.type)
|
||||||
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
|
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
|
||||||
|
|
@ -184,8 +187,8 @@ object FolderDetailRepository {
|
||||||
sources.forEachIndexed { sourceIndex, source ->
|
sources.forEachIndexed { sourceIndex, source ->
|
||||||
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
||||||
val catalogSource = source.addonCatalogSource()
|
val catalogSource = source.addonCatalogSource()
|
||||||
val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } }
|
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
|
||||||
if (!source.isTmdb && addon == null) {
|
if (!source.isTmdb && resolvedCatalog == null) {
|
||||||
updateTab(tabIndex) {
|
updateTab(tabIndex) {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import com.nuvio.app.core.ui.NuvioPosterCard
|
||||||
import com.nuvio.app.core.ui.NuvioPosterShape
|
import com.nuvio.app.core.ui.NuvioPosterShape
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
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.HomeCatalogSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
|
@ -275,9 +276,10 @@ private fun TabbedGridContent(
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = selectedTab.items,
|
items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||||
key = { item -> item.stableKey() },
|
key = { item -> item.lazyKey },
|
||||||
) { item ->
|
) { keyedItem ->
|
||||||
|
val item = keyedItem.value
|
||||||
NuvioPosterCard(
|
NuvioPosterCard(
|
||||||
title = item.name,
|
title = item.name,
|
||||||
imageUrl = item.poster,
|
imageUrl = item.poster,
|
||||||
|
|
@ -326,9 +328,10 @@ private fun RowsContent(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = sections,
|
items = sections.withDuplicateSafeLazyKeys { it.key },
|
||||||
key = { it.key },
|
key = { it.lazyKey },
|
||||||
) { section ->
|
) { keyedSection ->
|
||||||
|
val section = keyedSection.value
|
||||||
HomeCatalogRowSection(
|
HomeCatalogRowSection(
|
||||||
section = section,
|
section = section,
|
||||||
entries = section.items.take(18),
|
entries = section.items.take(18),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ object TmdbCollectionSourceResolver {
|
||||||
when (sourceType) {
|
when (sourceType) {
|
||||||
TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
|
TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
|
||||||
TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
|
TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
|
||||||
|
TmdbCollectionSourceType.PERSON,
|
||||||
|
TmdbCollectionSourceType.DIRECTOR -> resolvePersonCredits(source, apiKey, language)
|
||||||
TmdbCollectionSourceType.COMPANY,
|
TmdbCollectionSourceType.COMPANY,
|
||||||
TmdbCollectionSourceType.NETWORK,
|
TmdbCollectionSourceType.NETWORK,
|
||||||
TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page)
|
TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page)
|
||||||
|
|
@ -85,6 +87,19 @@ object TmdbCollectionSourceResolver {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TmdbCollectionSourceType.PERSON,
|
||||||
|
TmdbCollectionSourceType.DIRECTOR -> {
|
||||||
|
val body = fetch<TmdbPersonResponse>(
|
||||||
|
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")
|
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +168,7 @@ object TmdbCollectionSourceResolver {
|
||||||
fun parseTmdbId(input: String): Int? {
|
fun parseTmdbId(input: String): Int? {
|
||||||
val trimmed = input.trim()
|
val trimmed = input.trim()
|
||||||
trimmed.toIntOrNull()?.let { return it }
|
trimmed.toIntOrNull()?.let { return it }
|
||||||
return Regex("""(?:list|collection|company|network)/(\d+)""")
|
return Regex("""(?:list|collection|company|network|person)/(\d+)""")
|
||||||
.find(trimmed)
|
.find(trimmed)
|
||||||
?.groupValues
|
?.groupValues
|
||||||
?.getOrNull(1)
|
?.getOrNull(1)
|
||||||
|
|
@ -193,6 +208,7 @@ object TmdbCollectionSourceResolver {
|
||||||
) ?: error("TMDB list not found")
|
) ?: error("TMDB list not found")
|
||||||
val items = body.items.orEmpty()
|
val items = body.items.orEmpty()
|
||||||
.mapNotNull { it.toPreview() }
|
.mapNotNull { it.toPreview() }
|
||||||
|
.sortedFor(source.sortBy)
|
||||||
.distinctBy { "${it.type}:${it.id}" }
|
.distinctBy { "${it.type}:${it.id}" }
|
||||||
return CatalogPage(
|
return CatalogPage(
|
||||||
items = items,
|
items = items,
|
||||||
|
|
@ -213,12 +229,35 @@ object TmdbCollectionSourceResolver {
|
||||||
query = mapOf("language" to language),
|
query = mapOf("language" to language),
|
||||||
) ?: error("TMDB collection not found")
|
) ?: error("TMDB collection not found")
|
||||||
val items = body.parts.orEmpty()
|
val items = body.parts.orEmpty()
|
||||||
.sortedBy { it.releaseDate ?: "9999" }
|
|
||||||
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
|
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
|
||||||
|
.sortedFor(source.sortBy)
|
||||||
.distinctBy { it.id }
|
.distinctBy { it.id }
|
||||||
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
|
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<TmdbPersonCreditsResponse>(
|
||||||
|
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(
|
private suspend fun resolveDiscover(
|
||||||
source: CollectionSource,
|
source: CollectionSource,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
|
@ -312,6 +351,21 @@ object TmdbCollectionSourceResolver {
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<MetaPreview>.sortedFor(sortBy: String?): List<MetaPreview> =
|
||||||
|
when (sortBy) {
|
||||||
|
TmdbCollectionSort.ORIGINAL.value -> this
|
||||||
|
TmdbCollectionSort.VOTE_AVERAGE_DESC.value -> sortedWith(
|
||||||
|
compareByDescending<MetaPreview> { 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? {
|
private fun TmdbListItem.toPreview(): MetaPreview? {
|
||||||
val media = mediaType?.lowercase()
|
val media = mediaType?.lowercase()
|
||||||
val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE
|
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 =
|
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
|
||||||
tmdbSourceType
|
tmdbSourceType
|
||||||
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
|
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
|
||||||
|
|
@ -370,6 +480,12 @@ object TmdbCollectionSourceResolver {
|
||||||
private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
|
private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
|
||||||
TmdbCollectionMediaType.fromString(mediaType)
|
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(
|
private fun company(title: String, id: Int) = CollectionSource(
|
||||||
provider = "tmdb",
|
provider = "tmdb",
|
||||||
tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
|
tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
|
||||||
|
|
@ -391,6 +507,7 @@ object TmdbCollectionSourceResolver {
|
||||||
private fun movieSort(sortBy: String?): String =
|
private fun movieSort(sortBy: String?): String =
|
||||||
when (sortBy) {
|
when (sortBy) {
|
||||||
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
|
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
|
||||||
|
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
else -> sortBy
|
else -> sortBy
|
||||||
}
|
}
|
||||||
|
|
@ -398,6 +515,7 @@ object TmdbCollectionSourceResolver {
|
||||||
private fun tvSort(sortBy: String?): String =
|
private fun tvSort(sortBy: String?): String =
|
||||||
when (sortBy) {
|
when (sortBy) {
|
||||||
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
|
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
|
||||||
|
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
else -> sortBy
|
else -> sortBy
|
||||||
}
|
}
|
||||||
|
|
@ -449,6 +567,12 @@ private data class TmdbNetworkResponse(
|
||||||
@SerialName("logo_path") val logoPath: String? = null,
|
@SerialName("logo_path") val logoPath: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbPersonResponse(
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("profile_path") val profilePath: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TmdbCompanySearchResult(
|
data class TmdbCompanySearchResult(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
|
@ -496,6 +620,47 @@ private data class TmdbGenreItem(
|
||||||
val name: String,
|
val name: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbPersonCreditsResponse(
|
||||||
|
val cast: List<TmdbPersonCreditCast>? = null,
|
||||||
|
val crew: List<TmdbPersonCreditCrew>? = 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
|
@Serializable
|
||||||
private data class TmdbListItem(
|
private data class TmdbListItem(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.httpGetText
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
||||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -101,17 +102,22 @@ object MetaDetailsRepository {
|
||||||
_uiState.value = MetaDetailsUiState(isLoading = true)
|
_uiState.value = MetaDetailsUiState(isLoading = true)
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val manifests = AddonRepository.uiState.value.addons
|
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
|
||||||
.mapNotNull { it.manifest }
|
val manifests = findMetaManifests(type = type, id = metaLookupId)
|
||||||
.filter { manifest ->
|
|
||||||
manifest.resources.any { resource ->
|
|
||||||
resource.name == "meta" &&
|
|
||||||
resource.types.contains(type) &&
|
|
||||||
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifests.isEmpty()) {
|
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" }
|
log.w { "No addon provides meta for type=$type id=$id" }
|
||||||
_uiState.value = MetaDetailsUiState(
|
_uiState.value = MetaDetailsUiState(
|
||||||
errorMessage = getString(Res.string.details_no_addon_meta),
|
errorMessage = getString(Res.string.details_no_addon_meta),
|
||||||
|
|
@ -122,42 +128,32 @@ object MetaDetailsRepository {
|
||||||
|
|
||||||
for (manifest in manifests) {
|
for (manifest in manifests) {
|
||||||
val result = withContext(Dispatchers.Default) {
|
val result = withContext(Dispatchers.Default) {
|
||||||
tryFetchMeta(manifest, type, id, includeMdbList = false)
|
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
|
||||||
}
|
}
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
var cachedEntry = CachedMetaEntry(baseMeta = result)
|
publishLoadedMeta(
|
||||||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
requestKey = requestKey,
|
||||||
|
|
||||||
if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) {
|
|
||||||
_uiState.value = MetaDetailsUiState(meta = result)
|
|
||||||
activeRequestKey = requestKey
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.value = MetaDetailsUiState(
|
|
||||||
isLoading = true,
|
|
||||||
meta = result,
|
meta = result,
|
||||||
)
|
fallbackItemId = metaLookupId,
|
||||||
val enrichedMeta = withContext(Dispatchers.Default) {
|
mdbListSettings = mdbListSettings,
|
||||||
enrichForMetaScreen(
|
|
||||||
requestKey = requestKey,
|
|
||||||
meta = result,
|
|
||||||
fallbackItemId = id,
|
|
||||||
settings = mdbListSettings,
|
|
||||||
settingsFingerprint = metaScreenSettingsFingerprint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
cachedEntry = cachedEntry.copy(
|
|
||||||
metaScreenMeta = enrichedMeta,
|
|
||||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||||
)
|
)
|
||||||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
|
||||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
|
||||||
activeRequestKey = requestKey
|
|
||||||
return@launch
|
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(
|
_uiState.value = MetaDetailsUiState(
|
||||||
errorMessage = getString(Res.string.details_load_failed_all_addons),
|
errorMessage = getString(Res.string.details_load_failed_all_addons),
|
||||||
)
|
)
|
||||||
|
|
@ -187,19 +183,12 @@ object MetaDetailsRepository {
|
||||||
val requestKey = "$type:$id"
|
val requestKey = "$type:$id"
|
||||||
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
|
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
|
||||||
|
|
||||||
val manifests = AddonRepository.uiState.value.addons
|
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
|
||||||
.mapNotNull { it.manifest }
|
val manifests = findMetaManifests(type = type, id = metaLookupId)
|
||||||
.filter { manifest ->
|
|
||||||
manifest.resources.any { resource ->
|
|
||||||
resource.name == "meta" &&
|
|
||||||
resource.types.contains(type) &&
|
|
||||||
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (manifest in manifests) {
|
for (manifest in manifests) {
|
||||||
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
|
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
|
||||||
tryFetchMeta(manifest, type, id, includeMdbList = false)
|
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
|
||||||
}
|
}
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
|
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
|
private const val FETCH_TIMEOUT_MS = 5_000L
|
||||||
|
|
@ -265,6 +256,78 @@ object MetaDetailsRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findMetaManifests(type: String, id: String): List<AddonManifest> =
|
||||||
|
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(
|
private suspend fun enrichForMetaScreen(
|
||||||
requestKey: String,
|
requestKey: String,
|
||||||
meta: MetaDetails,
|
meta: MetaDetails,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
|
@ -122,7 +123,11 @@ fun DetailCommentsSection(
|
||||||
state = listState,
|
state = listState,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
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(
|
CommentCard(
|
||||||
review = review,
|
review = review,
|
||||||
onClick = { onCommentClick(review) },
|
onClick = { onCommentClick(review) },
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -582,7 +583,10 @@ private fun EpisodeHorizontalRow(
|
||||||
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
|
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
|
||||||
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing),
|
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(
|
val episodeVideoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = episode.season,
|
seasonNumber = episode.season,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
|
@ -158,10 +158,10 @@ fun DetailTrailersSection(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
|
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
|
||||||
) {
|
) {
|
||||||
items(
|
itemsIndexed(
|
||||||
items = selectedTrailers,
|
items = selectedTrailers,
|
||||||
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" },
|
key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" },
|
||||||
) { trailer ->
|
) { _, trailer ->
|
||||||
TrailerCard(
|
TrailerCard(
|
||||||
trailer = trailer,
|
trailer = trailer,
|
||||||
cardWidth = sizing.cardWidth,
|
cardWidth = sizing.cardWidth,
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,10 @@ private fun EpisodesListSubView(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.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
|
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
|
||||||
EpisodeRow(
|
EpisodeRow(
|
||||||
episode = episode,
|
episode = episode,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import com.nuvio.app.core.ui.NuvioInputField
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
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.addons.AddonRepository
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||||
|
|
@ -303,9 +304,10 @@ fun SearchScreen(
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
items(
|
items(
|
||||||
items = uiState.sections,
|
items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
|
||||||
key = { section -> section.key },
|
key = { section -> section.lazyKey },
|
||||||
) { section ->
|
) { keyedSection ->
|
||||||
|
val section = keyedSection.value
|
||||||
HomeCatalogRowSection(
|
HomeCatalogRowSection(
|
||||||
section = section,
|
section = section,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
internal fun applyEnrichment(
|
||||||
meta: MetaDetails,
|
meta: MetaDetails,
|
||||||
enrichment: TmdbEnrichment?,
|
enrichment: TmdbEnrichment?,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,47 @@ import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class TmdbMetadataServiceTest {
|
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
|
@Test
|
||||||
fun `applyEnrichment replaces enabled metadata groups`() {
|
fun `applyEnrichment replaces enabled metadata groups`() {
|
||||||
val base = MetaDetails(
|
val base = MetaDetails(
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,52 @@
|
||||||
package com.nuvio.app.features.downloads
|
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.ExperimentalForeignApi
|
||||||
import kotlinx.cinterop.addressOf
|
import kotlinx.cinterop.addressOf
|
||||||
import kotlinx.cinterop.convert
|
import kotlinx.cinterop.convert
|
||||||
import kotlinx.cinterop.usePinned
|
import kotlinx.cinterop.usePinned
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.ensureActive
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import platform.Foundation.NSError
|
||||||
|
import platform.Foundation.NSDate
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSHTTPURLResponse
|
||||||
import platform.Foundation.NSHomeDirectory
|
import platform.Foundation.NSHomeDirectory
|
||||||
|
import platform.Foundation.NSMutableURLRequest
|
||||||
|
import platform.Foundation.NSOperationQueue
|
||||||
import platform.Foundation.NSURL
|
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.fopen
|
||||||
|
import platform.posix.fclose
|
||||||
|
import platform.posix.fread
|
||||||
import platform.posix.fwrite
|
import platform.posix.fwrite
|
||||||
|
|
||||||
private val downloadHttpClient = HttpClient(Darwin) {
|
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
|
||||||
install(HttpTimeout) {
|
private const val DOWNLOAD_RESOURCE_TIMEOUT_SECONDS = 24.0 * 60.0 * 60.0
|
||||||
requestTimeoutMillis = 60_000
|
private const val PROGRESS_MIN_INTERVAL_SECONDS = 0.5
|
||||||
connectTimeoutMillis = 60_000
|
private const val PROGRESS_MIN_BYTE_DELTA = 512L * 1024L
|
||||||
socketTimeoutMillis = 60_000
|
|
||||||
}
|
private val backgroundSessionCompletionHandlers = mutableMapOf<String, () -> Unit>()
|
||||||
expectSuccess = false
|
|
||||||
|
fun handleDownloadsBackgroundEvents(
|
||||||
|
identifier: String,
|
||||||
|
completionHandler: () -> Unit,
|
||||||
|
) {
|
||||||
|
backgroundSessionCompletionHandlers[identifier] = completionHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
@ -46,6 +59,7 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
): DownloadsTaskHandle {
|
): DownloadsTaskHandle {
|
||||||
val job = SupervisorJob()
|
val job = SupervisorJob()
|
||||||
val scope = CoroutineScope(job + Dispatchers.Default)
|
val scope = CoroutineScope(job + Dispatchers.Default)
|
||||||
|
val handle = IosDownloadsTaskHandle(job)
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val downloadsDirectory = downloadsDirectoryPath()
|
val downloadsDirectory = downloadsDirectoryPath()
|
||||||
|
|
@ -55,55 +69,42 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
try {
|
try {
|
||||||
var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L
|
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 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)
|
removePathIfExists(tempPath)
|
||||||
resumeFromBytes = 0L
|
resumeFromBytes = 0L
|
||||||
attemptedRangeRequest = false
|
attemptedRangeRequest = false
|
||||||
response = performRequest(null)
|
result = performDownloadRequest(
|
||||||
|
request = request,
|
||||||
|
rangeStart = null,
|
||||||
|
resumeFromBytes = 0L,
|
||||||
|
tempPath = tempPath,
|
||||||
|
handle = handle,
|
||||||
|
onProgress = onProgress,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.status.isSuccess()) {
|
if (result.statusCode !in 200..299) {
|
||||||
error("Request failed with HTTP ${response.status.value}")
|
error("Request failed with HTTP ${result.statusCode}")
|
||||||
}
|
|
||||||
|
|
||||||
val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L
|
|
||||||
val appendToTemp = isPartialResume
|
|
||||||
val startingBytes = if (appendToTemp) resumeFromBytes else 0L
|
|
||||||
|
|
||||||
if (!appendToTemp) {
|
|
||||||
removePathIfExists(tempPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L
|
||||||
|
val startingBytes = if (isPartialResume) resumeFromBytes else 0L
|
||||||
val totalBytes = resolveTotalBytes(
|
val totalBytes = resolveTotalBytes(
|
||||||
startingBytes = startingBytes,
|
startingBytes = startingBytes,
|
||||||
isPartialResume = isPartialResume,
|
isPartialResume = isPartialResume,
|
||||||
contentRangeHeader = response.headers["Content-Range"],
|
contentRangeHeader = result.contentRange,
|
||||||
contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L },
|
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)
|
removePathIfExists(destinationPath)
|
||||||
val moved = NSFileManager.defaultManager.moveItemAtPath(
|
val moved = NSFileManager.defaultManager.moveItemAtPath(
|
||||||
|
|
@ -118,12 +119,14 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
|
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
|
||||||
val finalSize = fileSizeOrNull(destinationPath)
|
val finalSize = fileSizeOrNull(destinationPath)
|
||||||
onSuccess(localFileUri, totalBytes ?: finalSize)
|
onSuccess(localFileUri, totalBytes ?: finalSize)
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
handle.cancelNativeTask()
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
onFailure(error.message ?: "Download failed")
|
onFailure(error.message ?: "Download failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return IosDownloadsTaskHandle(job)
|
return handle
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun removeFile(localFileUri: String?): Boolean {
|
actual fun removeFile(localFileUri: String?): Boolean {
|
||||||
|
|
@ -141,9 +144,172 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
private class IosDownloadsTaskHandle(
|
private class IosDownloadsTaskHandle(
|
||||||
private val job: Job,
|
private val job: Job,
|
||||||
) : DownloadsTaskHandle {
|
) : 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() {
|
override fun cancel() {
|
||||||
|
cancelNativeTask()
|
||||||
job.cancel()
|
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<IosDownloadResult>()
|
||||||
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
@ -166,45 +332,98 @@ private fun removePathIfExists(path: String): Boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private suspend fun writeChannelToFile(
|
private suspend fun performDownloadRequest(
|
||||||
channel: ByteReadChannel,
|
request: DownloadPlatformRequest,
|
||||||
path: String,
|
rangeStart: Long?,
|
||||||
append: Boolean,
|
resumeFromBytes: Long,
|
||||||
initialDownloadedBytes: Long,
|
tempPath: String,
|
||||||
totalBytes: Long?,
|
handle: IosDownloadsTaskHandle,
|
||||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||||
): Boolean {
|
): IosDownloadResult {
|
||||||
val file = fopen(path, if (append) "ab" else "wb") ?: return false
|
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)
|
val buffer = ByteArray(16 * 1024)
|
||||||
var downloadedBytes = initialDownloadedBytes
|
|
||||||
onProgress(downloadedBytes, totalBytes)
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
while (true) {
|
while (true) {
|
||||||
kotlinx.coroutines.currentCoroutineContext().ensureActive()
|
val read = buffer.usePinned { pinned ->
|
||||||
val read = channel.readAvailable(buffer, 0, buffer.size)
|
fread(
|
||||||
if (read < 0) break
|
pinned.addressOf(0),
|
||||||
if (read == 0) continue
|
1.convert(),
|
||||||
|
buffer.size.convert(),
|
||||||
|
source,
|
||||||
|
).toInt()
|
||||||
|
}
|
||||||
|
if (read <= 0) break
|
||||||
|
|
||||||
val wroteChunk = buffer.usePinned { pinned ->
|
val wrote = buffer.usePinned { pinned ->
|
||||||
val written = fwrite(
|
fwrite(
|
||||||
pinned.addressOf(0),
|
pinned.addressOf(0),
|
||||||
1.convert(),
|
1.convert(),
|
||||||
read.convert(),
|
read.convert(),
|
||||||
file,
|
destination,
|
||||||
)
|
).toInt()
|
||||||
written.toInt() == read
|
|
||||||
}
|
}
|
||||||
if (!wroteChunk) {
|
if (wrote != read) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadedBytes += read.toLong()
|
|
||||||
onProgress(downloadedBytes, totalBytes)
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
} finally {
|
} finally {
|
||||||
fclose(file)
|
fclose(source)
|
||||||
|
fclose(destination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=42
|
CURRENT_PROJECT_VERSION=48
|
||||||
MARKETING_VERSION=0.1.10
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E1B229BC363ABB711AF255E3"
|
||||||
|
BuildableName = "Nuvio.app"
|
||||||
|
BlueprintName = "iosApp"
|
||||||
|
ReferencedContainer = "container:iosApp.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E1B229BC363ABB711AF255E3"
|
||||||
|
BuildableName = "Nuvio.app"
|
||||||
|
BlueprintName = "iosApp"
|
||||||
|
ReferencedContainer = "container:iosApp.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E1B229BC363ABB711AF255E3"
|
||||||
|
BuildableName = "Nuvio.app"
|
||||||
|
BlueprintName = "iosApp"
|
||||||
|
ReferencedContainer = "container:iosApp.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
|
|
@ -17,6 +17,11 @@
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,17 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
|
||||||
OrientationLockCoordinator.shared.supportedOrientations
|
OrientationLockCoordinator.shared.supportedOrientations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
DownloadsPlatformDownloader_iosKt.handleDownloadsBackgroundEvents(
|
||||||
|
identifier: identifier,
|
||||||
|
completionHandler: completionHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
|
|
|
||||||
1
mediamp
1
mediamp
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit df33966d7fbc6eb14e43fb1892e062417d76e7f5
|
|
||||||
1
vendor/mpv-kt-upstream
vendored
1
vendor/mpv-kt-upstream
vendored
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 8a8ddddf430555878273da13006fc57e182b0c0c
|
|
||||||
1
vendor/quickjs-kt
vendored
Submodule
1
vendor/quickjs-kt
vendored
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 57ce096200ac36bceb4e1ee5b6ec411b12357eb8
|
||||||
Loading…
Reference in a new issue