diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml
index 421b1531..725a89a0 100644
--- a/composeApp/src/commonMain/composeResources/values-es/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml
@@ -90,6 +90,9 @@
Elige los catálogos del complemento que debe agrupar esta carpeta.
Seleccionar catálogos
Seleccionar género
+ %1$d seleccionados
+ %1$d catálogos
+ %1$d seleccionados
Póster
Cuadrado
Panorámico
@@ -102,6 +105,121 @@
Filas
Pestañas
Modo de vista
+ Fuentes de TMDB
+ Lista pública
+ Producción
+ Cadena
+ Colección
+ Personalizado
+ Elige una fuente preparada. Puedes editarla o quitarla después de añadirla.
+ Pega una URL de lista pública de TMDB o solo el número de la URL.
+ Busca por nombre de estudio, o pega un ID/URL de compañía de TMDB y añádelo directamente.
+ Introduce un ID de cadena. Las cadenas comunes están disponibles en ajustes predefinidos y filtros rápidos.
+ Busca el nombre de una colección de películas o pega el ID de colección de TMDB.
+ Crea una fila dinámica de TMDB con filtros opcionales. Deja los campos vacíos cuando no necesites ese filtro.
+ Lista pública de TMDB
+ ID de cadena
+ ID de colección
+ Nombre, ID o URL de compañía de producción
+ ID o URL de TMDB
+ https://www.themoviedb.org/list/8504994 o 8504994
+ 213 para Netflix, 49 para HBO, 2739 para Disney+
+ 10 para Star Wars Collection
+ Marvel Studios, 420 o URL de compañía
+ Ejemplos: Marvel Studios, 420 o https://www.themoviedb.org/company/420.
+ Ejemplo: Star Wars Collection, Harry Potter Collection o una URL de colección.
+ IDs de ejemplo: Netflix 213, HBO 49, Disney+ 2739.
+ Ejemplo: https://www.themoviedb.org/list/8504994 o 8504994.
+ Título visible
+ Se muestra como nombre de fila/pestaña. Si queda vacío, Nuvio crea uno desde la fuente.
+ Películas de Marvel, Originales de Netflix, Pixar
+ Mejores películas de acción, dramas coreanos, animación 2024
+ Resultados de búsqueda
+ Colección de TMDB
+ Compañía de TMDB %1$d
+ Colección de TMDB %1$d
+ Tipo
+ Películas
+ Series
+ Ambos
+ Orden
+ Filtros
+ Deja los campos vacíos cuando no necesites ese filtro.
+ Géneros rápidos
+ Idiomas rápidos
+ Países rápidos
+ Palabras clave rápidas
+ Estudios rápidos
+ Cadenas rápidas
+ IDs de género
+ Usa números de género de TMDB. Separa varios con comas para AND, o barras verticales para OR.
+ Fecha de estreno o emisión desde
+ Fecha de estreno o emisión hasta
+ Usa YYYY-MM-DD, por ejemplo 2024-01-01.
+ Calificación mínima
+ Calificación máxima
+ Calificación de TMDB de 0 a 10. Ejemplo: 7.0.
+ Votos mínimos
+ Úsalo para evitar títulos poco conocidos con pocos votos. Ejemplo: 100.
+ Idioma original
+ Usa códigos de idioma de dos letras, por ejemplo en, ko, ja, hi.
+ País de origen
+ Usa códigos de país de dos letras, por ejemplo US, KR, JP, IN.
+ IDs de palabra clave
+ Usa números de palabra clave de TMDB. Los chips rápidos rellenan ejemplos comunes.
+ 9715 para superhéroes
+ IDs de compañía
+ Usa IDs de estudio/compañía. Los chips rápidos rellenan ejemplos comunes.
+ 420 para Marvel Studios
+ IDs de cadena
+ Solo para series. Usa IDs de cadena como Netflix 213 o HBO 49.
+ 213 para Netflix
+ Año
+ Usa un año de cuatro dígitos, por ejemplo 2024.
+ Predefinidos
+ Buscar
+ Añadir fuente
+ Acción
+ Aventura
+ Animación
+ Comedia
+ Terror
+ Ciencia ficción
+ Drama
+ Crimen
+ Reality
+ Inglés
+ Coreano
+ Japonés
+ Hindi
+ Español
+ Estados Unidos
+ Corea
+ Japón
+ India
+ Reino Unido
+ Superhéroes
+ Basado en novela
+ Viaje en el tiempo
+ Espacio
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Popular
+ Mejor valoradas
+ Reciente
+ Lista de TMDB
+ Colección de películas de TMDB
+ Producción
+ Cadena
+ Discover de TMDB
Crea una para organizar tus catálogos.
Aún no hay colecciones
%1$d carpeta(s)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 842bef80..7ddc8923 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -90,6 +90,9 @@
Choose the addon catalogs this folder should aggregate.
Select Catalogs
Select genre
+ %1$d selected
+ %1$d catalogs
+ %1$d selected
Poster
Square
Wide
@@ -102,6 +105,121 @@
Rows
Tabs
View Mode
+ TMDB Sources
+ Public List
+ Production
+ Network
+ Collection
+ Custom
+ Pick a ready-made source. You can edit or remove it after adding.
+ Paste a public TMDB list URL or only the number from the URL.
+ Search by studio name, or paste a TMDB company ID/URL and add it directly.
+ Enter a network ID. Common networks are available in Presets and quick filters.
+ Search a movie collection name or paste the collection ID from TMDB.
+ Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter.
+ Public TMDB list
+ Network ID
+ Collection ID
+ Production company name, ID, or URL
+ TMDB ID or URL
+ https://www.themoviedb.org/list/8504994 or 8504994
+ 213 for Netflix, 49 for HBO, 2739 for Disney+
+ 10 for Star Wars Collection
+ Marvel Studios, 420, or company URL
+ Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420.
+ Example: Star Wars Collection, Harry Potter Collection, or a collection URL.
+ Example IDs: Netflix 213, HBO 49, Disney+ 2739.
+ Example: https://www.themoviedb.org/list/8504994 or 8504994.
+ Display title
+ Shown as the row/tab name. If blank, Nuvio creates one from the source.
+ Marvel Movies, Netflix Originals, Pixar
+ Best Action Movies, Korean Dramas, 2024 Animation
+ Search Results
+ TMDB Collection
+ TMDB Company %1$d
+ TMDB Collection %1$d
+ Type
+ Movies
+ Series
+ Both
+ Sort
+ Filters
+ Leave fields empty when you do not need that filter.
+ Quick genres
+ Quick languages
+ Quick countries
+ Quick keywords
+ Quick studios
+ Quick networks
+ Genre IDs
+ Use TMDB genre numbers. Separate multiple with commas for AND, or pipes for OR.
+ Release or air date from
+ Release or air date to
+ Use YYYY-MM-DD, for example 2024-01-01.
+ Minimum rating
+ Maximum rating
+ TMDB rating from 0 to 10. Example: 7.0.
+ Minimum votes
+ Use this to avoid obscure low-vote titles. Example: 100.
+ Original language
+ Use two-letter language codes, for example en, ko, ja, hi.
+ Origin country
+ Use two-letter country codes, for example US, KR, JP, IN.
+ Keyword IDs
+ Use TMDB keyword numbers. Quick chips fill common examples.
+ 9715 for superhero
+ Company IDs
+ Use studio/company IDs. Quick chips fill common examples.
+ 420 for Marvel Studios
+ Network IDs
+ For series only. Use network IDs like Netflix 213 or HBO 49.
+ 213 for Netflix
+ Year
+ Use a four-digit year, for example 2024.
+ Presets
+ Search
+ Add Source
+ Action
+ Adventure
+ Animation
+ Comedy
+ Horror
+ Sci-Fi
+ Drama
+ Crime
+ Reality
+ English
+ Korean
+ Japanese
+ Hindi
+ Spanish
+ United States
+ Korea
+ Japan
+ India
+ United Kingdom
+ Superhero
+ Based on Novel
+ Time Travel
+ Space
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Popular
+ Top Rated
+ Recent
+ TMDB List
+ TMDB Movie Collection
+ Production
+ Network
+ TMDB Discover
Create one to organize your catalogs.
No collections yet
%1$d folder(s)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt
index fe9a7297..ca955abb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt
@@ -46,96 +46,105 @@ import nuvio.composeapp.generated.resources.unit_bytes_kb
import nuvio.composeapp.generated.resources.unit_bytes_mb
import org.jetbrains.compose.resources.getString
-fun localizedMediaTypeLabel(type: String): String = runBlocking {
- when (type.trim().lowercase()) {
- "movie" -> getString(Res.string.media_movies)
- "series" -> getString(Res.string.media_series)
- "anime" -> getString(Res.string.media_anime)
- "channel" -> getString(Res.string.media_channels)
- "tv" -> getString(Res.string.media_tv)
- else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
+fun localizedMediaTypeLabel(type: String): String {
+ val fallback = type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
+ return when (type.trim().lowercase()) {
+ "movie" -> resourceString("Movies") { getString(Res.string.media_movies) }
+ "series" -> resourceString("Series") { getString(Res.string.media_series) }
+ "anime" -> resourceString("Anime") { getString(Res.string.media_anime) }
+ "channel" -> resourceString("Channels") { getString(Res.string.media_channels) }
+ "tv" -> resourceString("TV") { getString(Res.string.media_tv) }
+ else -> fallback
}
}
-fun localizedMovieTypeLabel(): String = runBlocking { getString(Res.string.media_movie) }
+fun localizedMovieTypeLabel(): String = resourceString("Movie") { getString(Res.string.media_movie) }
-fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? = runBlocking {
+fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? =
when {
seasonNumber != null && episodeNumber != null ->
- getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
+ resourceString("S${seasonNumber}E${episodeNumber}") {
+ getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
+ }
episodeNumber != null ->
- getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
+ resourceString("E${episodeNumber}") {
+ getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
+ }
else -> null
}
-}
-fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking {
+fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String {
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
- if (episodeCode != null) {
- getString(Res.string.action_play_episode, episodeCode)
+ return if (episodeCode != null) {
+ resourceString("Play $episodeCode") { getString(Res.string.action_play_episode, episodeCode) }
} else {
- getString(Res.string.action_play)
+ resourceString("Play") { getString(Res.string.action_play) }
}
}
-fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking {
+fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String {
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
- if (episodeCode != null) {
- getString(Res.string.action_resume_episode, episodeCode)
+ return if (episodeCode != null) {
+ resourceString("Resume $episodeCode") { getString(Res.string.action_resume_episode, episodeCode) }
} else {
- getString(Res.string.action_resume)
+ resourceString("Resume") { getString(Res.string.action_resume) }
}
}
-fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = runBlocking {
+fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) {
- getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
+ resourceString("Up Next • S${seasonNumber}E${episodeNumber}") {
+ getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
+ }
} else {
- getString(Res.string.continue_watching_up_next)
+ resourceString("Up Next") { getString(Res.string.continue_watching_up_next) }
}
-}
-fun localizedMonthName(month: Int): String = runBlocking {
+fun localizedMonthName(month: Int): String =
when (month) {
- 1 -> getString(Res.string.date_month_january)
- 2 -> getString(Res.string.date_month_february)
- 3 -> getString(Res.string.date_month_march)
- 4 -> getString(Res.string.date_month_april)
- 5 -> getString(Res.string.date_month_may)
- 6 -> getString(Res.string.date_month_june)
- 7 -> getString(Res.string.date_month_july)
- 8 -> getString(Res.string.date_month_august)
- 9 -> getString(Res.string.date_month_september)
- 10 -> getString(Res.string.date_month_october)
- 11 -> getString(Res.string.date_month_november)
- 12 -> getString(Res.string.date_month_december)
+ 1 -> resourceString("January") { getString(Res.string.date_month_january) }
+ 2 -> resourceString("February") { getString(Res.string.date_month_february) }
+ 3 -> resourceString("March") { getString(Res.string.date_month_march) }
+ 4 -> resourceString("April") { getString(Res.string.date_month_april) }
+ 5 -> resourceString("May") { getString(Res.string.date_month_may) }
+ 6 -> resourceString("June") { getString(Res.string.date_month_june) }
+ 7 -> resourceString("July") { getString(Res.string.date_month_july) }
+ 8 -> resourceString("August") { getString(Res.string.date_month_august) }
+ 9 -> resourceString("September") { getString(Res.string.date_month_september) }
+ 10 -> resourceString("October") { getString(Res.string.date_month_october) }
+ 11 -> resourceString("November") { getString(Res.string.date_month_november) }
+ 12 -> resourceString("December") { getString(Res.string.date_month_december) }
else -> month.toString()
}
-}
-fun localizedShortMonthName(month: Int): String = runBlocking {
+fun localizedShortMonthName(month: Int): String =
when (month) {
- 1 -> getString(Res.string.date_month_short_jan)
- 2 -> getString(Res.string.date_month_short_feb)
- 3 -> getString(Res.string.date_month_short_mar)
- 4 -> getString(Res.string.date_month_short_apr)
- 5 -> getString(Res.string.date_month_short_may)
- 6 -> getString(Res.string.date_month_short_jun)
- 7 -> getString(Res.string.date_month_short_jul)
- 8 -> getString(Res.string.date_month_short_aug)
- 9 -> getString(Res.string.date_month_short_sep)
- 10 -> getString(Res.string.date_month_short_oct)
- 11 -> getString(Res.string.date_month_short_nov)
- 12 -> getString(Res.string.date_month_short_dec)
+ 1 -> resourceString("Jan") { getString(Res.string.date_month_short_jan) }
+ 2 -> resourceString("Feb") { getString(Res.string.date_month_short_feb) }
+ 3 -> resourceString("Mar") { getString(Res.string.date_month_short_mar) }
+ 4 -> resourceString("Apr") { getString(Res.string.date_month_short_apr) }
+ 5 -> resourceString("May") { getString(Res.string.date_month_short_may) }
+ 6 -> resourceString("Jun") { getString(Res.string.date_month_short_jun) }
+ 7 -> resourceString("Jul") { getString(Res.string.date_month_short_jul) }
+ 8 -> resourceString("Aug") { getString(Res.string.date_month_short_aug) }
+ 9 -> resourceString("Sep") { getString(Res.string.date_month_short_sep) }
+ 10 -> resourceString("Oct") { getString(Res.string.date_month_short_oct) }
+ 11 -> resourceString("Nov") { getString(Res.string.date_month_short_nov) }
+ 12 -> resourceString("Dec") { getString(Res.string.date_month_short_dec) }
else -> month.toString()
}
-}
-fun localizedByteUnit(unit: String): String = runBlocking {
+fun localizedByteUnit(unit: String): String =
when (unit) {
- "GB" -> getString(Res.string.unit_bytes_gb)
- "MB" -> getString(Res.string.unit_bytes_mb)
- "KB" -> getString(Res.string.unit_bytes_kb)
- else -> getString(Res.string.unit_bytes_b)
+ "GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }
+ "MB" -> resourceString("MB") { getString(Res.string.unit_bytes_mb) }
+ "KB" -> resourceString("KB") { getString(Res.string.unit_bytes_kb) }
+ else -> resourceString("B") { getString(Res.string.unit_bytes_b) }
}
-}
+
+private fun resourceString(
+ fallback: String,
+ provider: suspend () -> String,
+): String = runCatching {
+ runBlocking { provider() }
+}.getOrDefault(fallback)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt
index e5c09ab7..cbb476c8 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt
@@ -2,9 +2,13 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -22,11 +26,32 @@ data class CollectionEditorUiState(
val editingFolder: CollectionFolder? = null,
val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false,
+ val showTmdbSourcePicker: Boolean = false,
val genrePickerSourceIndex: Int? = null,
+ val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
+ val tmdbInput: String = "",
+ val tmdbTitleInput: String = "",
+ val tmdbMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
+ val tmdbMediaBoth: Boolean = false,
+ val tmdbSortBy: String = TmdbCollectionSort.POPULAR_DESC.value,
+ val tmdbFilters: TmdbCollectionFilters = TmdbCollectionFilters(),
+ val tmdbCompanyResults: List = emptyList(),
+ val tmdbCollectionResults: List = emptyList(),
+ val tmdbSearchError: String? = null,
)
+enum class TmdbBuilderMode {
+ PRESETS,
+ LIST,
+ PRODUCTION,
+ NETWORK,
+ COLLECTION,
+ DISCOVER,
+}
+
object CollectionEditorRepository {
private val log = Logger.withTag("CollectionEditorRepository")
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CollectionEditorUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -198,39 +223,40 @@ object CollectionEditorRepository {
catalogId = catalog.catalogId,
genre = defaultGenre,
)
- if (folder.catalogSources.any {
+ if (folder.resolvedCatalogSources.any {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}) return
_uiState.value = _uiState.value.copy(
- editingFolder = folder.copy(catalogSources = folder.catalogSources + source),
+ editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()),
)
}
fun removeCatalogSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return
- if (index !in folder.catalogSources.indices) return
+ val sources = folder.resolvedSources
+ if (index !in sources.indices) return
_uiState.value = _uiState.value.copy(
- editingFolder = folder.copy(
- catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
- ),
+ editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }),
genrePickerSourceIndex = null,
)
}
fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return
- if (index !in folder.catalogSources.indices) return
- val updated = folder.catalogSources.toMutableList()
+ val sources = folder.resolvedSources
+ if (index !in sources.indices || sources[index].isTmdb) return
+ val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy(
- editingFolder = folder.copy(catalogSources = updated),
+ editingFolder = folder.withSources(updated),
)
}
fun toggleCatalogSource(catalog: AvailableCatalog) {
val folder = _uiState.value.editingFolder ?: return
- val existingIndex = folder.catalogSources.indexOfFirst {
- it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
+ val sources = folder.resolvedSources
+ val existingIndex = sources.indexOfFirst {
+ !it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
}
if (existingIndex >= 0) {
removeCatalogSource(existingIndex)
@@ -242,6 +268,7 @@ object CollectionEditorRepository {
fun showCatalogPicker() {
_uiState.value = _uiState.value.copy(
showCatalogPicker = true,
+ showTmdbSourcePicker = false,
genrePickerSourceIndex = null,
)
}
@@ -250,12 +277,27 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showCatalogPicker = false)
}
+ fun showTmdbSourcePicker() {
+ _uiState.value = _uiState.value.copy(
+ showTmdbSourcePicker = true,
+ showCatalogPicker = false,
+ genrePickerSourceIndex = null,
+ tmdbSearchError = null,
+ )
+ }
+
+ fun hideTmdbSourcePicker() {
+ _uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
+ }
+
fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return
- if (index !in folder.catalogSources.indices) return
+ val sources = folder.resolvedSources
+ if (index !in sources.indices || sources[index].isTmdb) return
_uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index,
showCatalogPicker = false,
+ showTmdbSourcePicker = false,
)
}
@@ -265,17 +307,19 @@ object CollectionEditorRepository {
fun saveFolderEdit() {
val folder = _uiState.value.editingFolder ?: return
+ val normalizedFolder = folder.withSources(folder.resolvedSources)
val existing = _uiState.value.folders
- val updated = if (existing.any { it.id == folder.id }) {
- existing.map { if (it.id == folder.id) folder else it }
+ val updated = if (existing.any { it.id == normalizedFolder.id }) {
+ existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it }
} else {
- existing + folder
+ existing + normalizedFolder
}
_uiState.value = _uiState.value.copy(
folders = updated,
editingFolder = null,
showFolderEditor = false,
showCatalogPicker = false,
+ showTmdbSourcePicker = false,
genrePickerSourceIndex = null,
)
}
@@ -285,10 +329,211 @@ object CollectionEditorRepository {
editingFolder = null,
showFolderEditor = false,
showCatalogPicker = false,
+ showTmdbSourcePicker = false,
genrePickerSourceIndex = null,
)
}
+ fun setTmdbBuilderMode(mode: TmdbBuilderMode) {
+ val mediaType = if (mode == TmdbBuilderMode.NETWORK) {
+ TmdbCollectionMediaType.TV
+ } else {
+ _uiState.value.tmdbMediaType
+ }
+ _uiState.value = _uiState.value.copy(
+ tmdbBuilderMode = mode,
+ tmdbMediaType = mediaType,
+ tmdbMediaBoth = if (
+ mode == TmdbBuilderMode.NETWORK ||
+ mode == TmdbBuilderMode.LIST ||
+ mode == TmdbBuilderMode.COLLECTION
+ ) {
+ false
+ } else {
+ _uiState.value.tmdbMediaBoth
+ },
+ tmdbCompanyResults = emptyList(),
+ tmdbCollectionResults = emptyList(),
+ tmdbSearchError = null,
+ )
+ }
+
+ fun setTmdbInput(value: String) {
+ _uiState.value = _uiState.value.copy(tmdbInput = value, tmdbSearchError = null)
+ }
+
+ fun setTmdbTitleInput(value: String) {
+ _uiState.value = _uiState.value.copy(tmdbTitleInput = value)
+ }
+
+ fun setTmdbMediaType(value: TmdbCollectionMediaType) {
+ _uiState.value = _uiState.value.copy(tmdbMediaType = value, tmdbMediaBoth = false)
+ }
+
+ fun setTmdbMediaBoth(value: Boolean) {
+ _uiState.value = _uiState.value.copy(
+ tmdbMediaBoth = value,
+ tmdbMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.tmdbMediaType,
+ )
+ }
+
+ fun setTmdbSortBy(value: String) {
+ _uiState.value = _uiState.value.copy(tmdbSortBy = value)
+ }
+
+ fun updateTmdbFilters(transform: (TmdbCollectionFilters) -> TmdbCollectionFilters) {
+ _uiState.value = _uiState.value.copy(tmdbFilters = transform(_uiState.value.tmdbFilters))
+ }
+
+ fun addTmdbPreset(source: CollectionSource) {
+ addTmdbSource(source)
+ }
+
+ fun searchTmdbCompanies() {
+ val query = _uiState.value.tmdbInput.trim()
+ if (query.isBlank()) return
+ scope.launch {
+ val results = runCatching { TmdbCollectionSourceResolver.searchCompanies(query) }
+ _uiState.value = _uiState.value.copy(
+ tmdbCompanyResults = results.getOrDefault(emptyList()),
+ tmdbSearchError = results.exceptionOrNull()?.message,
+ )
+ }
+ }
+
+ fun searchTmdbCollections() {
+ val query = _uiState.value.tmdbInput.trim()
+ if (query.isBlank()) return
+ scope.launch {
+ val results = runCatching { TmdbCollectionSourceResolver.searchCollections(query) }
+ _uiState.value = _uiState.value.copy(
+ tmdbCollectionResults = results.getOrDefault(emptyList()),
+ tmdbSearchError = results.exceptionOrNull()?.message,
+ )
+ }
+ }
+
+ fun addTmdbSource(source: CollectionSource) {
+ val sourceType = source.tmdbType()
+ if (source.tmdbId != null && sourceType in coverMetadataSourceTypes) {
+ scope.launch {
+ val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, source.tmdbId) }
+ val resolved = metadata.getOrNull()
+ addTmdbSources(
+ sources = listOf(
+ if (source.title.isNullOrBlank()) {
+ source.copy(title = resolved?.title)
+ } else {
+ source
+ },
+ ),
+ coverImageUrl = resolved?.coverImageUrl,
+ )
+ }
+ return
+ }
+ addTmdbSources(listOf(source))
+ }
+
+ fun addTmdbSourcesFromPicker(sources: List) {
+ val metadataSource = sources.firstOrNull {
+ it.tmdbId != null && it.tmdbType() in coverMetadataSourceTypes
+ }
+ if (metadataSource != null) {
+ scope.launch {
+ val sourceType = metadataSource.tmdbType()
+ val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, metadataSource.tmdbId!!) }
+ addTmdbSources(sources, metadata.getOrNull()?.coverImageUrl)
+ }
+ return
+ }
+ addTmdbSources(sources)
+ }
+
+ fun addTmdbSourceFromInput() {
+ val state = _uiState.value
+ val mode = state.tmdbBuilderMode
+ val sourceType = when (mode) {
+ TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER
+ TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST
+ TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
+ TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
+ TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
+ TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
+ }
+ val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
+ if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
+ _uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.")
+ return
+ }
+ val mediaTypes = selectedMediaTypes(state, sourceType)
+ val baseTitle = state.tmdbTitleInput.ifBlank {
+ when (sourceType) {
+ TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim()
+ TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
+ TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
+ TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
+ TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
+ }
+ }
+ val sources = mediaTypes.map { mediaType ->
+ CollectionSource(
+ provider = "tmdb",
+ tmdbSourceType = sourceType.name,
+ title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1),
+ tmdbId = id,
+ mediaType = mediaType.name,
+ sortBy = state.tmdbSortBy,
+ filters = state.tmdbFilters,
+ )
+ }
+ if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
+ scope.launch {
+ val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) }
+ val resolved = metadata.getOrNull()
+ if (metadata.isFailure) {
+ _uiState.value = _uiState.value.copy(
+ tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source",
+ )
+ return@launch
+ }
+ addTmdbSources(
+ sources.map { source ->
+ source.copy(title = state.tmdbTitleInput.ifBlank { resolved?.title ?: baseTitle })
+ },
+ coverImageUrl = resolved?.coverImageUrl,
+ )
+ }
+ return
+ }
+ addTmdbSourcesFromPicker(sources)
+ }
+
+ private fun addTmdbSources(sources: List, coverImageUrl: String? = null) {
+ val folder = _uiState.value.editingFolder ?: return
+ val existingKeys = folder.resolvedSources.mapTo(mutableSetOf(), ::collectionSourceKey)
+ val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
+ if (newSources.isEmpty()) return
+ val shouldApplyCover = newSources.any { it.tmdbType() in coverMetadataSourceTypes } &&
+ !coverImageUrl.isNullOrBlank() &&
+ folder.coverImageUrl.isNullOrBlank()
+ val updatedFolder = if (shouldApplyCover) {
+ folder.withSources(folder.resolvedSources + newSources)
+ .copy(coverImageUrl = coverImageUrl, coverEmoji = null)
+ } else {
+ folder.withSources(folder.resolvedSources + newSources)
+ }
+ _uiState.value = _uiState.value.copy(
+ editingFolder = updatedFolder,
+ showTmdbSourcePicker = false,
+ tmdbInput = "",
+ tmdbTitleInput = "",
+ tmdbCompanyResults = emptyList(),
+ tmdbCollectionResults = emptyList(),
+ tmdbSearchError = null,
+ )
+ }
+
fun save(): Boolean {
val state = _uiState.value
if (state.title.isBlank()) return false
@@ -311,3 +556,65 @@ object CollectionEditorRepository {
return true
}
}
+
+private val coverMetadataSourceTypes = setOf(
+ TmdbCollectionSourceType.COLLECTION,
+ TmdbCollectionSourceType.COMPANY,
+ TmdbCollectionSourceType.NETWORK,
+)
+
+private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
+ CollectionSource(
+ provider = "addon",
+ addonId = addonId,
+ type = type,
+ catalogId = catalogId,
+ genre = genre,
+ )
+
+private fun CollectionFolder.withSources(nextSources: List): CollectionFolder =
+ copy(
+ sources = nextSources,
+ catalogSources = nextSources.mapNotNull { it.addonCatalogSource() },
+ )
+
+private fun collectionSourceKey(source: CollectionSource): String =
+ if (source.isTmdb) {
+ "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
+ } else {
+ "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
+ }
+
+private fun selectedMediaTypes(
+ state: CollectionEditorUiState,
+ sourceType: TmdbCollectionSourceType,
+): List =
+ when (sourceType) {
+ TmdbCollectionSourceType.COMPANY,
+ TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
+ listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
+ } else {
+ listOf(state.tmdbMediaType)
+ }
+ TmdbCollectionSourceType.NETWORK -> listOf(TmdbCollectionMediaType.TV)
+ TmdbCollectionSourceType.COLLECTION,
+ TmdbCollectionSourceType.LIST -> listOf(TmdbCollectionMediaType.MOVIE)
+ }
+
+private fun titleForMedia(
+ title: String,
+ mediaType: TmdbCollectionMediaType,
+ addSuffix: Boolean,
+): String {
+ if (!addSuffix) return title
+ val suffix = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> "Movies"
+ TmdbCollectionMediaType.TV -> "Series"
+ }
+ return "$title $suffix"
+}
+
+private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
+ tmdbSourceType
+ ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
+ ?: TmdbCollectionSourceType.DISCOVER
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
index 3fb927c3..41ee6532 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
@@ -6,10 +6,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,9 +31,11 @@ import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Menu
+import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -85,9 +89,28 @@ fun CollectionEditorScreen(
val editingFolder = state.editingFolder
if (state.showFolderEditor && editingFolder != null) {
+ if (state.showCatalogPicker) {
+ CatalogPickerScreen(
+ availableCatalogs = state.availableCatalogs,
+ selectedSources = editingFolder.resolvedCatalogSources,
+ onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
+ onBack = { CollectionEditorRepository.hideCatalogPicker() },
+ )
+ return
+ }
+
+ if (state.showTmdbSourcePicker) {
+ TmdbSourcePickerScreen(
+ state = state,
+ onBack = { CollectionEditorRepository.hideTmdbSourcePicker() },
+ )
+ return
+ }
+
val genrePickerIndex = state.genrePickerSourceIndex
- val genrePickerSource = genrePickerIndex?.let { editingFolder.catalogSources.getOrNull(it) }
- val genrePickerCatalog = genrePickerSource?.let { source ->
+ val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
+ val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
+ val genrePickerCatalog = genrePickerCatalogSource?.let { source ->
state.availableCatalogs.find {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}
@@ -98,24 +121,15 @@ fun CollectionEditorScreen(
onBack = { CollectionEditorRepository.cancelFolderEdit() },
)
- if (state.showCatalogPicker) {
- CatalogPickerSheet(
- availableCatalogs = state.availableCatalogs,
- selectedSources = editingFolder.catalogSources,
- onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
- onDismiss = { CollectionEditorRepository.hideCatalogPicker() },
- )
- }
-
if (
genrePickerIndex != null &&
- genrePickerSource != null &&
+ genrePickerCatalogSource != null &&
genrePickerCatalog != null &&
genrePickerCatalog.genreOptions.isNotEmpty()
) {
GenrePickerSheet(
title = genrePickerCatalog.catalogName,
- selectedGenre = genrePickerSource.genre,
+ selectedGenre = genrePickerCatalogSource.genre,
genreOptions = genrePickerCatalog.genreOptions,
allowAll = !genrePickerCatalog.genreRequired,
onSelect = {
@@ -129,12 +143,21 @@ fun CollectionEditorScreen(
}
if (state.showCatalogPicker) {
- CatalogPickerSheet(
+ CatalogPickerScreen(
availableCatalogs = state.availableCatalogs,
- selectedSources = state.editingFolder?.catalogSources.orEmpty(),
+ selectedSources = state.editingFolder?.resolvedCatalogSources.orEmpty(),
onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
- onDismiss = { CollectionEditorRepository.hideCatalogPicker() },
+ onBack = { CollectionEditorRepository.hideCatalogPicker() },
)
+ return
+ }
+
+ if (state.showTmdbSourcePicker) {
+ TmdbSourcePickerScreen(
+ state = state,
+ onBack = { CollectionEditorRepository.hideTmdbSourcePicker() },
+ )
+ return
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -451,7 +474,7 @@ private fun FolderListItem(
Column(modifier = Modifier.weight(1f)) {
val summary = stringResource(
Res.string.collections_editor_source_count,
- folder.catalogSources.size,
+ folder.resolvedSources.size,
posterShapeLabel(folder.posterShape),
)
Text(
@@ -683,18 +706,30 @@ private fun FolderEditorPage(
FolderEditorSection(
title = stringResource(Res.string.collections_editor_section_catalog_sources),
actions = {
- TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
- Icon(
- imageVector = Icons.Rounded.Add,
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text(stringResource(Res.string.collections_editor_add_catalog))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
+ Icon(
+ imageVector = Icons.Rounded.Add,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(Res.string.source_tmdb))
+ }
+ TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
+ Icon(
+ imageVector = Icons.Rounded.Add,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(Res.string.collections_editor_add_catalog))
+ }
}
},
) {
- if (folder.catalogSources.isEmpty()) {
+ val sources = folder.resolvedSources
+ if (sources.isEmpty()) {
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
@@ -712,15 +747,25 @@ private fun FolderEditorPage(
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- folder.catalogSources.forEachIndexed { index, source ->
- FolderCatalogSourceCard(
- source = source,
- matchingCatalog = state.availableCatalogs.find {
- it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
- },
- onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
- onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
- )
+ sources.forEachIndexed { index, source ->
+ val addonSource = source.addonCatalogSource()
+ if (source.isTmdb) {
+ FolderTmdbSourceCard(
+ source = source,
+ onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
+ )
+ } else if (addonSource != null) {
+ FolderCatalogSourceCard(
+ source = addonSource,
+ matchingCatalog = state.availableCatalogs.find {
+ it.addonId == addonSource.addonId &&
+ it.type == addonSource.type &&
+ it.catalogId == addonSource.catalogId
+ },
+ onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
+ onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
+ )
+ }
}
}
}
@@ -756,120 +801,823 @@ private fun FolderEditorPage(
}
}
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun CatalogPickerSheet(
+private fun CatalogPickerScreen(
availableCatalogs: List,
selectedSources: List,
onToggle: (AvailableCatalog) -> Unit,
- onDismiss: () -> Unit,
+ onBack: () -> Unit,
) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ PlatformBackHandler(enabled = true) {
+ onBack()
+ }
- NuvioModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- containerColor = MaterialTheme.colorScheme.surface,
- ) {
- LazyColumn(
- contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 12.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ NuvioScreen(modifier = Modifier.fillMaxSize()) {
+ stickyHeader {
+ NuvioScreenHeader(
+ title = stringResource(Res.string.collections_editor_select_catalogs),
+ onBack = onBack,
+ )
+ }
+
+ item {
+ NuvioSurfaceCard {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
- text = stringResource(Res.string.collections_editor_select_catalogs),
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface,
+ text = stringResource(Res.string.collections_editor_select_catalogs_description),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- TextButton(onClick = onDismiss) {
- Text(stringResource(Res.string.collections_editor_done))
+ Text(
+ text = stringResource(Res.string.collections_editor_selected_count, selectedSources.size),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+
+ val grouped = availableCatalogs.groupBy { it.addonName }
+ grouped.forEach { (addonName, catalogs) ->
+ item {
+ val selectedCount = catalogs.count { catalog ->
+ selectedSources.any {
+ it.addonId == catalog.addonId &&
+ it.type == catalog.type &&
+ it.catalogId == catalog.catalogId
}
}
- Text(
- text = stringResource(Res.string.collections_editor_select_catalogs_description),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 8.dp),
+ PickerPanel(
+ title = addonName,
+ subtitle = if (selectedCount > 0) {
+ stringResource(Res.string.collections_editor_catalog_selected_count, selectedCount)
+ } else {
+ stringResource(Res.string.collections_editor_catalog_count, catalogs.size)
+ },
+ ) {
+ catalogs.forEachIndexed { index, catalog ->
+ val isSelected = selectedSources.any {
+ it.addonId == catalog.addonId &&
+ it.type == catalog.type &&
+ it.catalogId == catalog.catalogId
+ }
+ PickerOptionRow(
+ title = catalog.catalogName,
+ subtitle = catalog.type.replaceFirstChar {
+ if (it.isLowerCase()) it.titlecase() else it.toString()
+ },
+ selected = isSelected,
+ onClick = { onToggle(catalog) },
+ )
+ if (index != catalogs.lastIndex) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f))
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(24.dp + nuvioSafeBottomPadding()))
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun TmdbSourcePickerScreen(
+ state: CollectionEditorUiState,
+ onBack: () -> Unit,
+) {
+ val bottomInset = nuvioSafeBottomPadding()
+ val sourceType = when (state.tmdbBuilderMode) {
+ TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER
+ TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST
+ TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
+ TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
+ TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
+ TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
+ }
+ val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER
+ val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
+ state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
+ val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
+ state.tmdbBuilderMode == TmdbBuilderMode.NETWORK ||
+ state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
+ val showFilterControls = state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
+
+ PlatformBackHandler(enabled = true) {
+ onBack()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ NuvioScreen(modifier = Modifier.fillMaxSize()) {
+ stickyHeader {
+ NuvioScreenHeader(
+ title = stringResource(Res.string.collections_editor_tmdb_sources),
+ onBack = onBack,
)
}
- val grouped = availableCatalogs.groupBy { it.addonName }
- grouped.forEach { (addonName, catalogs) ->
- item {
- NuvioSectionLabel(
- text = addonName.uppercase(),
- modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
- )
- }
- catalogs.forEach { catalog ->
- val isSelected = selectedSources.any {
- it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
- }
- item(key = "${catalog.addonId}:${catalog.type}:${catalog.catalogId}") {
- val bgColor = if (isSelected) {
- MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
- } else {
- MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
- }
- val borderColor = if (isSelected) {
- MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
- } else {
- MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(10.dp))
- .background(bgColor)
- .border(1.dp, borderColor, RoundedCornerShape(10.dp))
- .clickable { onToggle(catalog) }
- .padding(horizontal = 14.dp, vertical = 12.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = catalog.catalogName,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = catalog.type.replaceFirstChar {
- if (it.isLowerCase()) it.titlecase() else it.toString()
- },
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- if (isSelected) {
- Icon(
- imageVector = Icons.Rounded.Check,
- contentDescription = stringResource(Res.string.cd_selected),
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(20.dp),
- )
- }
- }
+ item {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TmdbBuilderMode.entries.forEach { mode ->
+ FilterChip(
+ selected = state.tmdbBuilderMode == mode,
+ onClick = { CollectionEditorRepository.setTmdbBuilderMode(mode) },
+ label = { Text(tmdbBuilderModeLabel(mode)) },
+ leadingIcon = if (state.tmdbBuilderMode == mode) {
+ {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ } else null,
+ )
}
}
}
item {
- Spacer(modifier = Modifier.height(24.dp))
+ NuvioSurfaceCard {
+ Text(
+ text = tmdbModeHelpText(state.tmdbBuilderMode),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ if (state.tmdbBuilderMode != TmdbBuilderMode.PRESETS) item {
+ NuvioSurfaceCard {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ if (requiresId) {
+ TmdbLabeledField(
+ label = tmdbInputLabel(state.tmdbBuilderMode),
+ value = state.tmdbInput,
+ onValueChange = { CollectionEditorRepository.setTmdbInput(it) },
+ placeholder = tmdbInputPlaceholder(state.tmdbBuilderMode),
+ helper = tmdbInputHelper(state.tmdbBuilderMode),
+ )
+ }
+ TmdbLabeledField(
+ label = stringResource(Res.string.collections_editor_tmdb_display_title),
+ value = state.tmdbTitleInput,
+ onValueChange = { CollectionEditorRepository.setTmdbTitleInput(it) },
+ placeholder = tmdbTitlePlaceholder(state.tmdbBuilderMode),
+ helper = stringResource(Res.string.collections_editor_tmdb_title_helper),
+ )
+ if (state.tmdbSearchError != null) {
+ Text(
+ text = state.tmdbSearchError,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+
+ if (state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION && state.tmdbCompanyResults.isNotEmpty()) {
+ item {
+ PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_search_results))
+ }
+ itemsIndexed(state.tmdbCompanyResults) { _, result ->
+ val title = result.name ?: stringResource(Res.string.collections_editor_tmdb_company_fallback, result.id)
+ val movieSuffix = stringResource(Res.string.collections_editor_tmdb_movies)
+ val seriesSuffix = stringResource(Res.string.collections_editor_tmdb_series)
+ PickerOptionRow(
+ title = title,
+ subtitle = listOfNotNull(
+ stringResource(Res.string.collections_editor_tmdb_subtitle_production),
+ result.originCountry,
+ ).joinToString(" • "),
+ selected = false,
+ onClick = {
+ val sources = tmdbSelectedMediaTypes(state).map { mediaType ->
+ CollectionSource(
+ provider = "tmdb",
+ tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
+ title = tmdbTitleForMedia(title, mediaType, state.tmdbMediaBoth, movieSuffix, seriesSuffix),
+ tmdbId = result.id,
+ mediaType = mediaType.name,
+ sortBy = state.tmdbSortBy,
+ filters = state.tmdbFilters,
+ )
+ }
+ CollectionEditorRepository.addTmdbSourcesFromPicker(sources)
+ },
+ )
+ }
+ }
+
+ if (state.tmdbBuilderMode == TmdbBuilderMode.COLLECTION && state.tmdbCollectionResults.isNotEmpty()) {
+ item {
+ PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_search_results))
+ }
+ itemsIndexed(state.tmdbCollectionResults) { _, result ->
+ val title = result.name ?: stringResource(Res.string.collections_editor_tmdb_collection_fallback, result.id)
+ PickerOptionRow(
+ title = title,
+ subtitle = stringResource(Res.string.collections_editor_tmdb_collection),
+ selected = false,
+ onClick = {
+ CollectionEditorRepository.addTmdbSource(
+ CollectionSource(
+ provider = "tmdb",
+ tmdbSourceType = TmdbCollectionSourceType.COLLECTION.name,
+ title = title,
+ tmdbId = result.id,
+ mediaType = TmdbCollectionMediaType.MOVIE.name,
+ sortBy = state.tmdbSortBy,
+ ),
+ )
+ },
+ )
+ }
+ }
+
+ if (showMediaControls) {
+ item {
+ PickerPanel(
+ title = stringResource(Res.string.collections_editor_tmdb_type),
+ ) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ FilterChip(
+ selected = state.tmdbMediaType == TmdbCollectionMediaType.MOVIE && !state.tmdbMediaBoth,
+ onClick = {
+ CollectionEditorRepository.setTmdbMediaBoth(false)
+ CollectionEditorRepository.setTmdbMediaType(TmdbCollectionMediaType.MOVIE)
+ },
+ label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) },
+ )
+ FilterChip(
+ selected = state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth,
+ onClick = {
+ CollectionEditorRepository.setTmdbMediaBoth(false)
+ CollectionEditorRepository.setTmdbMediaType(TmdbCollectionMediaType.TV)
+ },
+ label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) },
+ )
+ FilterChip(
+ selected = state.tmdbMediaBoth,
+ onClick = { CollectionEditorRepository.setTmdbMediaBoth(true) },
+ label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) },
+ )
+ }
+ }
+ }
+ }
+
+ if (showSortControls) {
+ item {
+ PickerPanel(
+ title = stringResource(Res.string.collections_editor_tmdb_sort),
+ ) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val sorts = listOf(
+ TmdbCollectionSort.POPULAR_DESC,
+ TmdbCollectionSort.VOTE_AVERAGE_DESC,
+ if (state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth) {
+ TmdbCollectionSort.FIRST_AIR_DATE_DESC
+ } else {
+ TmdbCollectionSort.RELEASE_DATE_DESC
+ },
+ )
+ sorts.forEach { sort ->
+ FilterChip(
+ selected = state.tmdbSortBy == sort.value,
+ onClick = { CollectionEditorRepository.setTmdbSortBy(sort.value) },
+ label = { Text(tmdbSortLabel(sort)) },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (showFilterControls) {
+ item {
+ PickerPanel(
+ title = stringResource(Res.string.collections_editor_tmdb_filters),
+ subtitle = stringResource(Res.string.collections_editor_tmdb_filters_helper),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ TmdbQuickChips(
+ label = stringResource(Res.string.collections_editor_tmdb_quick_genres),
+ chips = tmdbGenreQuickChips(state.tmdbMediaType),
+ onSelect = { value ->
+ CollectionEditorRepository.updateTmdbFilters { it.copy(withGenres = value) }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_genres),
+ helper = stringResource(Res.string.collections_editor_tmdb_genres_helper),
+ value = state.tmdbFilters.withGenres.orEmpty(),
+ placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) "28,12" else "18,35",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(withGenres = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_date_from),
+ helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
+ value = state.tmdbFilters.releaseDateGte.orEmpty(),
+ placeholder = "2020-01-01",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(releaseDateGte = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_date_to),
+ helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
+ value = state.tmdbFilters.releaseDateLte.orEmpty(),
+ placeholder = "2024-12-31",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(releaseDateLte = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_rating_min),
+ helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
+ value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(),
+ placeholder = "7.0",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(voteAverageGte = value.toDoubleOrNull())
+ }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_rating_max),
+ helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
+ value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(),
+ placeholder = "10",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(voteAverageLte = value.toDoubleOrNull())
+ }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_votes_min),
+ helper = stringResource(Res.string.collections_editor_tmdb_votes_helper),
+ value = state.tmdbFilters.voteCountGte?.toString().orEmpty(),
+ placeholder = "100",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(voteCountGte = value.toIntOrNull())
+ }
+ },
+ )
+ TmdbQuickChips(
+ label = stringResource(Res.string.collections_editor_tmdb_quick_languages),
+ chips = listOf(
+ stringResource(Res.string.collections_editor_tmdb_language_english) to "en",
+ stringResource(Res.string.collections_editor_tmdb_language_korean) to "ko",
+ stringResource(Res.string.collections_editor_tmdb_language_japanese) to "ja",
+ stringResource(Res.string.collections_editor_tmdb_language_hindi) to "hi",
+ stringResource(Res.string.collections_editor_tmdb_language_spanish) to "es",
+ ),
+ onSelect = { value ->
+ CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginalLanguage = value) }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_language),
+ helper = stringResource(Res.string.collections_editor_tmdb_language_helper),
+ value = state.tmdbFilters.withOriginalLanguage.orEmpty(),
+ placeholder = "en, ko, ja, hi",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(withOriginalLanguage = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbQuickChips(
+ label = stringResource(Res.string.collections_editor_tmdb_quick_countries),
+ chips = listOf(
+ stringResource(Res.string.collections_editor_tmdb_country_us) to "US",
+ stringResource(Res.string.collections_editor_tmdb_country_korea) to "KR",
+ stringResource(Res.string.collections_editor_tmdb_country_japan) to "JP",
+ stringResource(Res.string.collections_editor_tmdb_country_india) to "IN",
+ stringResource(Res.string.collections_editor_tmdb_country_uk) to "GB",
+ ),
+ onSelect = { value ->
+ CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginCountry = value) }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_country),
+ helper = stringResource(Res.string.collections_editor_tmdb_country_helper),
+ value = state.tmdbFilters.withOriginCountry.orEmpty(),
+ placeholder = "US, KR, JP, IN",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(withOriginCountry = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbQuickChips(
+ label = stringResource(Res.string.collections_editor_tmdb_quick_keywords),
+ chips = listOf(
+ stringResource(Res.string.collections_editor_tmdb_keyword_superhero) to "9715",
+ stringResource(Res.string.collections_editor_tmdb_keyword_based_on_novel) to "818",
+ stringResource(Res.string.collections_editor_tmdb_keyword_time_travel) to "4379",
+ stringResource(Res.string.collections_editor_tmdb_keyword_space) to "9882",
+ ),
+ onSelect = { value ->
+ CollectionEditorRepository.updateTmdbFilters { it.copy(withKeywords = value) }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_keywords),
+ helper = stringResource(Res.string.collections_editor_tmdb_keywords_helper),
+ value = state.tmdbFilters.withKeywords.orEmpty(),
+ placeholder = stringResource(Res.string.collections_editor_tmdb_keywords_placeholder),
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(withKeywords = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbQuickChips(
+ label = stringResource(Res.string.collections_editor_tmdb_quick_studios),
+ chips = listOf(
+ stringResource(Res.string.collections_editor_tmdb_studio_marvel) to "420",
+ stringResource(Res.string.collections_editor_tmdb_studio_disney) to "2",
+ stringResource(Res.string.collections_editor_tmdb_studio_pixar) to "3",
+ stringResource(Res.string.collections_editor_tmdb_studio_lucasfilm) to "1",
+ stringResource(Res.string.collections_editor_tmdb_studio_warner) to "174",
+ ),
+ onSelect = { value ->
+ CollectionEditorRepository.updateTmdbFilters { it.copy(withCompanies = value) }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_companies),
+ helper = stringResource(Res.string.collections_editor_tmdb_companies_helper),
+ value = state.tmdbFilters.withCompanies.orEmpty(),
+ placeholder = stringResource(Res.string.collections_editor_tmdb_companies_placeholder),
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(withCompanies = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbQuickChips(
+ label = stringResource(Res.string.collections_editor_tmdb_quick_networks),
+ chips = listOf(
+ stringResource(Res.string.collections_editor_tmdb_network_netflix) to "213",
+ stringResource(Res.string.collections_editor_tmdb_network_hbo) to "49",
+ stringResource(Res.string.collections_editor_tmdb_network_disney_plus) to "2739",
+ stringResource(Res.string.collections_editor_tmdb_network_prime_video) to "1024",
+ stringResource(Res.string.collections_editor_tmdb_network_hulu) to "453",
+ ),
+ onSelect = { value ->
+ CollectionEditorRepository.updateTmdbFilters { it.copy(withNetworks = value) }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_networks),
+ helper = stringResource(Res.string.collections_editor_tmdb_networks_helper),
+ value = state.tmdbFilters.withNetworks.orEmpty(),
+ placeholder = stringResource(Res.string.collections_editor_tmdb_networks_placeholder),
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(withNetworks = value.ifBlank { null })
+ }
+ },
+ )
+ TmdbFilterField(
+ label = stringResource(Res.string.collections_editor_tmdb_year),
+ helper = stringResource(Res.string.collections_editor_tmdb_year_helper),
+ value = state.tmdbFilters.year?.toString().orEmpty(),
+ placeholder = "2024",
+ onValueChange = { value ->
+ CollectionEditorRepository.updateTmdbFilters {
+ it.copy(year = value.toIntOrNull())
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+
+ if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) item {
+ PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_presets))
+ }
+ if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) {
+ itemsIndexed(TmdbCollectionSourceResolver.presets()) { _, preset ->
+ PickerOptionRow(
+ title = preset.label,
+ subtitle = tmdbSourceSubtitle(preset.source),
+ selected = false,
+ onClick = { CollectionEditorRepository.addTmdbPreset(preset.source) },
+ )
+ }
+ }
+
+ item {
+ val spacerHeight = if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) {
+ 24.dp + bottomInset
+ } else {
+ 96.dp + bottomInset
+ }
+ Spacer(modifier = Modifier.height(spacerHeight))
+ }
+ }
+
+ if (state.tmdbBuilderMode != TmdbBuilderMode.PRESETS) {
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f),
+ tonalElevation = 6.dp,
+ shadowElevation = 10.dp,
+ ) {
+ PickerActionBar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .padding(bottom = bottomInset),
+ ) {
+ if (sourceType == TmdbCollectionSourceType.COMPANY || sourceType == TmdbCollectionSourceType.COLLECTION) {
+ TextButton(
+ onClick = {
+ if (sourceType == TmdbCollectionSourceType.COMPANY) {
+ CollectionEditorRepository.searchTmdbCompanies()
+ } else {
+ CollectionEditorRepository.searchTmdbCollections()
+ }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Search,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(Res.string.collections_editor_tmdb_search))
+ }
+ }
+ NuvioPrimaryButton(
+ text = stringResource(Res.string.collections_editor_add_source),
+ modifier = Modifier.weight(1f),
+ enabled = !requiresId || state.tmdbInput.isNotBlank(),
+ onClick = { CollectionEditorRepository.addTmdbSourceFromInput() },
+ )
+ }
}
}
}
}
+@Composable
+private fun PickerPanel(
+ title: String,
+ subtitle: String? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ NuvioSurfaceCard {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (!subtitle.isNullOrBlank()) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PickerOptionRow(
+ title: String,
+ subtitle: String? = null,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val rowShape = RoundedCornerShape(12.dp)
+ val bgColor = if (selected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(rowShape)
+ .background(bgColor)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f).padding(end = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (!subtitle.isNullOrBlank()) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ if (selected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = stringResource(Res.string.cd_selected),
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun PickerSectionLabel(text: String) {
+ NuvioSectionLabel(
+ text = text.uppercase(),
+ modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
+ )
+}
+
+@Composable
+private fun PickerActionBar(
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+}
+
+@Composable
+private fun TmdbLabeledField(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ helper: String,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ NuvioInputField(
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = placeholder,
+ )
+ if (helper.isNotBlank()) {
+ Text(
+ text = helper,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TmdbFilterField(
+ label: String,
+ helper: String,
+ value: String,
+ placeholder: String,
+ onValueChange: (String) -> Unit,
+) {
+ TmdbLabeledField(
+ label = label,
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = placeholder,
+ helper = helper,
+ )
+}
+
+@Composable
+private fun TmdbQuickChips(
+ label: String,
+ chips: List>,
+ onSelect: (String) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ chips.forEach { (chipLabel, value) ->
+ FilterChip(
+ selected = false,
+ onClick = { onSelect(value) },
+ label = { Text(chipLabel) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun tmdbGenreQuickChips(mediaType: TmdbCollectionMediaType): List> =
+ when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> listOf(
+ stringResource(Res.string.collections_editor_tmdb_genre_action) to "28",
+ stringResource(Res.string.collections_editor_tmdb_genre_adventure) to "12",
+ stringResource(Res.string.collections_editor_tmdb_genre_animation) to "16",
+ stringResource(Res.string.collections_editor_tmdb_genre_comedy) to "35",
+ stringResource(Res.string.collections_editor_tmdb_genre_horror) to "27",
+ stringResource(Res.string.collections_editor_tmdb_genre_scifi) to "878",
+ )
+ TmdbCollectionMediaType.TV -> listOf(
+ stringResource(Res.string.collections_editor_tmdb_genre_drama) to "18",
+ stringResource(Res.string.collections_editor_tmdb_genre_comedy) to "35",
+ stringResource(Res.string.collections_editor_tmdb_genre_animation) to "16",
+ stringResource(Res.string.collections_editor_tmdb_genre_crime) to "80",
+ stringResource(Res.string.collections_editor_tmdb_genre_scifi) to "10765",
+ stringResource(Res.string.collections_editor_tmdb_genre_reality) to "10764",
+ )
+ }
+
+private fun tmdbSelectedMediaTypes(state: CollectionEditorUiState): List =
+ if (state.tmdbMediaBoth) {
+ listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
+ } else {
+ listOf(state.tmdbMediaType)
+ }
+
+private fun tmdbTitleForMedia(
+ title: String,
+ mediaType: TmdbCollectionMediaType,
+ addSuffix: Boolean,
+ movieSuffix: String,
+ seriesSuffix: String,
+): String {
+ if (!addSuffix) return title
+ val suffix = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> movieSuffix
+ TmdbCollectionMediaType.TV -> seriesSuffix
+ }
+ return "$title $suffix"
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GenrePickerSheet(
@@ -997,6 +1745,51 @@ private fun FolderEditorToggleRow(
}
}
+@Composable
+private fun FolderTmdbSourceCard(
+ source: CollectionSource,
+ onRemove: () -> Unit,
+) {
+ NuvioSurfaceCard {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_tmdb),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = stringResource(Res.string.source_tmdb),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(
+ onClick = onRemove,
+ modifier = Modifier.size(36.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = stringResource(Res.string.action_remove),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+
+ Text(
+ text = tmdbSourceSubtitle(source),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FolderCatalogSourceCard(
@@ -1091,6 +1884,109 @@ private fun FolderCatalogSourceCard(
}
}
+@Composable
+private fun tmdbBuilderModeLabel(mode: TmdbBuilderMode): String =
+ when (mode) {
+ TmdbBuilderMode.PRESETS -> stringResource(Res.string.collections_editor_tmdb_presets)
+ TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_public_list_mode)
+ TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_production_mode)
+ TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode)
+ TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_mode)
+ TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_custom_mode)
+ }
+
+@Composable
+private fun tmdbModeHelpText(mode: TmdbBuilderMode): String =
+ when (mode) {
+ TmdbBuilderMode.PRESETS -> stringResource(Res.string.collections_editor_tmdb_help_presets)
+ TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_help_list)
+ TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_help_production)
+ TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network)
+ TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_help_collection)
+ TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_help_discover)
+ }
+
+@Composable
+private fun tmdbInputLabel(mode: TmdbBuilderMode): String =
+ when (mode) {
+ TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_public_list)
+ TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_id)
+ TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id)
+ TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_search)
+ else -> stringResource(Res.string.collections_editor_tmdb_id_or_url)
+ }
+
+@Composable
+private fun tmdbInputPlaceholder(mode: TmdbBuilderMode): String =
+ when (mode) {
+ TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_placeholder)
+ TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_placeholder)
+ TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder)
+ TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_placeholder)
+ else -> stringResource(Res.string.collections_editor_tmdb_id_or_url)
+ }
+
+@Composable
+private fun tmdbInputHelper(mode: TmdbBuilderMode): String =
+ when (mode) {
+ TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_search_helper)
+ TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_helper)
+ TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper)
+ TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper)
+ else -> ""
+ }
+
+@Composable
+private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String =
+ when (mode) {
+ TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_discover_title_placeholder)
+ else -> stringResource(Res.string.collections_editor_tmdb_title_placeholder)
+ }
+
+@Composable
+private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
+ when (sort) {
+ TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular)
+ TmdbCollectionSort.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated)
+ TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
+ TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
+ }
+
+@Composable
+private fun tmdbSourceSubtitle(source: CollectionSource): String {
+ val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {
+ TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies)
+ TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series)
+ }
+ val sort = source.sortBy?.let { value ->
+ TmdbCollectionSort.entries.firstOrNull { it.value == value }?.let { sort ->
+ tmdbSortLabel(sort)
+ }
+ } ?: stringResource(Res.string.collections_editor_tmdb_sort_popular)
+ val sourceType = runCatching {
+ TmdbCollectionSourceType.valueOf(source.tmdbSourceType.orEmpty())
+ }.getOrDefault(TmdbCollectionSourceType.DISCOVER)
+ return when (sourceType) {
+ TmdbCollectionSourceType.LIST -> stringResource(Res.string.collections_editor_tmdb_subtitle_list)
+ TmdbCollectionSourceType.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_subtitle_movie_collection)
+ TmdbCollectionSourceType.COMPANY -> listOf(
+ stringResource(Res.string.collections_editor_tmdb_subtitle_production),
+ media,
+ sort,
+ ).joinToString(" • ")
+ TmdbCollectionSourceType.NETWORK -> listOf(
+ stringResource(Res.string.collections_editor_tmdb_subtitle_network),
+ stringResource(Res.string.collections_editor_tmdb_series),
+ sort,
+ ).joinToString(" • ")
+ TmdbCollectionSourceType.DISCOVER -> listOf(
+ stringResource(Res.string.collections_editor_tmdb_subtitle_discover),
+ media,
+ sort,
+ ).joinToString(" • ")
+ }
+}
+
@Composable
private fun posterShapeLabel(shape: PosterShape): String =
when (shape) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt
index f7da122a..660e8a45 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt
@@ -57,9 +57,22 @@ internal object CollectionJsonPreserver {
folder: CollectionFolder,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject
+ val rawUnifiedSourcesByKey = raw?.get("sources").asObjectArrayByKey(::unifiedSourceKey)
+ val mergedUnifiedSources = buildJsonArray {
+ folder.resolvedSources.forEach { source ->
+ val sourceElement = json.encodeToJsonElement(CollectionSource.serializer(), source)
+ add(
+ mergeUnifiedSource(
+ json = json,
+ raw = rawUnifiedSourcesByKey[unifiedSourceKey(sourceElement)],
+ source = source,
+ ),
+ )
+ }
+ }
val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey)
val mergedSources = buildJsonArray {
- folder.catalogSources.forEach { source ->
+ folder.resolvedCatalogSources.forEach { source ->
val sourceElement =
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
add(
@@ -71,7 +84,23 @@ internal object CollectionJsonPreserver {
)
}
}
- return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources))
+ return mergeObjects(
+ raw,
+ encoded,
+ mapOf(
+ "sources" to mergedUnifiedSources,
+ "catalogSources" to mergedSources,
+ ),
+ )
+ }
+
+ private fun mergeUnifiedSource(
+ json: Json,
+ raw: JsonObject?,
+ source: CollectionSource,
+ ): JsonObject {
+ val encoded = json.encodeToJsonElement(CollectionSource.serializer(), source).jsonObject
+ return mergeObjects(raw, encoded)
}
private fun mergeSource(
@@ -111,4 +140,21 @@ internal object CollectionJsonPreserver {
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
return "$addonId|$type|$catalogId"
}
+
+ private fun unifiedSourceKey(element: JsonElement): String? {
+ val obj = element as? JsonObject ?: return null
+ val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
+ return if (provider.equals("tmdb", ignoreCase = true)) {
+ val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
+ val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ "$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
+ } else {
+ val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
+ val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
+ val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
+ "$provider|$addonId|$type|$catalogId"
+ }
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
index fc1aacef..5d17161b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
@@ -30,6 +30,95 @@ data class CollectionCatalogSource(
val genre: String? = null,
)
+@Immutable
+@Serializable
+data class CollectionSource(
+ val provider: String = "addon",
+ val addonId: String? = null,
+ val type: String? = null,
+ val catalogId: String? = null,
+ val genre: String? = null,
+ val tmdbSourceType: String? = null,
+ val title: String? = null,
+ val tmdbId: Int? = null,
+ val mediaType: String? = null,
+ val sortBy: String? = null,
+ val filters: TmdbCollectionFilters? = null,
+) {
+ val isTmdb: Boolean
+ get() = provider.equals("tmdb", ignoreCase = true)
+
+ fun addonCatalogSource(): CollectionCatalogSource? {
+ if (isTmdb) return null
+ val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
+ val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
+ val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null
+ return CollectionCatalogSource(
+ addonId = sourceAddonId,
+ type = sourceType,
+ catalogId = sourceCatalogId,
+ genre = genre,
+ )
+ }
+}
+
+@Serializable
+enum class TmdbCollectionSourceType {
+ LIST,
+ COLLECTION,
+ COMPANY,
+ NETWORK,
+ DISCOVER,
+}
+
+@Serializable
+enum class TmdbCollectionMediaType(val value: String) {
+ MOVIE("movie"),
+ TV("tv");
+
+ companion object {
+ fun fromString(value: String?): TmdbCollectionMediaType =
+ when (value?.trim()?.lowercase()) {
+ "tv", "series" -> TV
+ else -> MOVIE
+ }
+ }
+}
+
+enum class TmdbCollectionSort(val value: String) {
+ POPULAR_DESC("popularity.desc"),
+ VOTE_AVERAGE_DESC("vote_average.desc"),
+ RELEASE_DATE_DESC("primary_release_date.desc"),
+ FIRST_AIR_DATE_DESC("first_air_date.desc"),
+}
+
+@Immutable
+@Serializable
+data class TmdbCollectionFilters(
+ val withGenres: String? = null,
+ val releaseDateGte: String? = null,
+ val releaseDateLte: String? = null,
+ val voteAverageGte: Double? = null,
+ val voteAverageLte: Double? = null,
+ val voteCountGte: Int? = null,
+ val withOriginalLanguage: String? = null,
+ val withOriginCountry: String? = null,
+ val withKeywords: String? = null,
+ val withCompanies: String? = null,
+ val withNetworks: String? = null,
+ val year: Int? = null,
+)
+
+data class TmdbSourceImportMetadata(
+ val title: String? = null,
+ val coverImageUrl: String? = null,
+)
+
+data class TmdbPresetSource(
+ val label: String,
+ val source: CollectionSource,
+)
+
@Immutable
@Serializable
data class CollectionFolder(
@@ -41,7 +130,10 @@ data class CollectionFolder(
val coverEmoji: String? = null,
val tileShape: String = "poster",
val hideTitle: Boolean = false,
+ val sources: List = emptyList(),
val catalogSources: List = emptyList(),
+ val heroBackdropUrl: String? = null,
+ val titleLogoUrl: String? = null,
) {
val posterShape: PosterShape
get() = when (tileShape.lowercase()) {
@@ -50,6 +142,22 @@ data class CollectionFolder(
"square" -> PosterShape.Square
else -> PosterShape.Poster
}
+
+ val resolvedSources: List
+ get() = sources.ifEmpty {
+ catalogSources.map { source ->
+ CollectionSource(
+ provider = "addon",
+ addonId = source.addonId,
+ type = source.type,
+ catalogId = source.catalogId,
+ genre = source.genre,
+ )
+ }
+ }
+
+ val resolvedCatalogSources: List
+ get() = resolvedSources.mapNotNull { it.addonCatalogSource() }
}
@Immutable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
index 860f97d5..9d57d011 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
@@ -179,8 +179,12 @@ object CollectionRepository {
},
)
}
- f.catalogSources.forEachIndexed { si, s ->
- if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
+ f.resolvedSources.forEachIndexed { si, s ->
+ val invalidAddon = !s.isTmdb &&
+ (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
+ val invalidTmdb = s.isTmdb &&
+ s.tmdbSourceType.isNullOrBlank()
+ if (invalidAddon || invalidTmdb) {
return ValidationResult(
valid = false,
error = runBlocking {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt
index b7a4b096..e853eeba 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt
@@ -27,6 +27,7 @@ import org.jetbrains.compose.resources.getString
data class FolderTab(
val label: String,
val typeLabel: String = "",
+ val source: CollectionSource? = null,
val manifestUrl: String? = null,
val type: String = "",
val catalogId: String = "",
@@ -114,7 +115,8 @@ object FolderDetailRepository {
return
}
- val showAll = collection.showAllTab && folder.catalogSources.size > 1
+ val sources = folder.resolvedSources
+ val showAll = collection.showAllTab && sources.size > 1
val addons = AddonRepository.uiState.value.addons
val tabs = buildList {
@@ -127,26 +129,44 @@ object FolderDetailRepository {
),
)
}
- folder.catalogSources.forEach { source ->
- val addon = addons.find { it.manifest?.id == source.addonId }
- val catalog = addon?.manifest?.catalogs?.find {
- it.id == source.catalogId && it.type == source.type
+ sources.forEach { source ->
+ if (source.isTmdb) {
+ val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
+ val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
+ add(
+ FolderTab(
+ label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
+ typeLabel = "TMDB",
+ source = source,
+ type = type,
+ catalogId = tmdbCatalogId(source),
+ supportsPagination = source.tmdbSourceType != TmdbCollectionSourceType.COLLECTION.name,
+ isLoading = true,
+ ),
+ )
+ } else {
+ val catalogSource = source.addonCatalogSource() ?: return@forEach
+ val addon = addons.find { it.manifest?.id == catalogSource.addonId }
+ val catalog = addon?.manifest?.catalogs?.find {
+ it.id == catalogSource.catalogId && it.type == catalogSource.type
+ }
+ val label = catalog?.name ?: catalogSource.catalogId
+ val typeLabel = localizedMediaTypeLabel(catalogSource.type)
+ val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
+ add(
+ FolderTab(
+ label = "$label ($typeLabel)$genreSuffix",
+ typeLabel = typeLabel,
+ source = source,
+ manifestUrl = addon?.manifestUrl,
+ type = catalogSource.type,
+ catalogId = catalogSource.catalogId,
+ genre = catalogSource.genre,
+ supportsPagination = catalog?.supportsPagination() == true,
+ isLoading = true,
+ ),
+ )
}
- val label = catalog?.name ?: source.catalogId
- val typeLabel = localizedMediaTypeLabel(source.type)
- val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
- add(
- FolderTab(
- label = "$label ($typeLabel)$genreSuffix",
- typeLabel = typeLabel,
- manifestUrl = addon?.manifestUrl,
- type = source.type,
- catalogId = source.catalogId,
- genre = source.genre,
- supportsPagination = catalog?.supportsPagination() == true,
- isLoading = true,
- ),
- )
}
}
@@ -161,15 +181,16 @@ object FolderDetailRepository {
)
// Load catalog data for each source
- folder.catalogSources.forEachIndexed { sourceIndex, source ->
+ sources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
- val addon = addons.find { it.manifest?.id == source.addonId }
- if (addon == null) {
+ val catalogSource = source.addonCatalogSource()
+ val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } }
+ if (!source.isTmdb && addon == null) {
updateTab(tabIndex) {
it.copy(
isLoading = false,
error = runBlocking {
- getString(Res.string.collections_folder_addon_not_found, source.addonId)
+ getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty())
},
)
}
@@ -180,7 +201,7 @@ object FolderDetailRepository {
}
// If no sources, mark as done
- if (folder.catalogSources.isEmpty()) {
+ if (sources.isEmpty()) {
_uiState.value = _uiState.value.copy(isLoading = false)
}
}
@@ -229,8 +250,8 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
- val manifestUrl = currentTab.manifestUrl ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
+ if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return
updateTab(index) { tab ->
if (reset) {
@@ -252,13 +273,21 @@ object FolderDetailRepository {
loadJobs.remove(index)?.cancel()
val job = scope.launch {
runCatching {
- fetchCatalogPage(
- manifestUrl = manifestUrl,
- type = currentTab.type,
- catalogId = currentTab.catalogId,
- genre = currentTab.genre,
- skip = requestedSkip.takeIf { it > 0 },
- )
+ val source = currentTab.source
+ if (source?.isTmdb == true) {
+ TmdbCollectionSourceResolver.resolve(
+ source = source,
+ page = if (reset) 1 else requestedSkip,
+ )
+ } else {
+ fetchCatalogPage(
+ manifestUrl = requireNotNull(currentTab.manifestUrl),
+ type = currentTab.type,
+ catalogId = currentTab.catalogId,
+ genre = currentTab.genre,
+ skip = requestedSkip.takeIf { it > 0 },
+ )
+ }
}.onSuccess { page ->
updateTab(index) { tab ->
val mergedItems = if (reset) {
@@ -279,7 +308,7 @@ object FolderDetailRepository {
}
rebuildAllTab()
}.onFailure { error ->
- log.e(error) { "Failed to load catalog ${currentTab.catalogId} from $manifestUrl" }
+ log.e(error) { "Failed to load source ${currentTab.catalogId}" }
updateTab(index) { tab ->
tab.copy(
isLoading = false,
@@ -353,3 +382,17 @@ object FolderDetailRepository {
}
}
}
+
+private fun Boolean?.orFalse(): Boolean = this == true
+
+private fun tmdbCatalogId(source: CollectionSource): String =
+ buildString {
+ append("tmdb_")
+ append(source.tmdbSourceType?.lowercase().orEmpty())
+ source.tmdbId?.let {
+ append("_")
+ append(it)
+ }
+ append("_")
+ append(source.mediaType?.lowercase().orEmpty())
+ }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt
new file mode 100644
index 00000000..ee25fa48
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt
@@ -0,0 +1,526 @@
+package com.nuvio.app.features.collection
+
+import co.touchlab.kermit.Logger
+import com.nuvio.app.features.addons.httpGetText
+import com.nuvio.app.features.catalog.CatalogPage
+import com.nuvio.app.features.home.MetaPreview
+import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.tmdb.TmdbSettingsRepository
+import com.nuvio.app.features.tmdb.buildTmdbUrl
+import com.nuvio.app.features.tmdb.normalizeTmdbLanguage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.math.roundToInt
+
+object TmdbCollectionSourceResolver {
+ private val log = Logger.withTag("TmdbCollectionSource")
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
+ val settings = TmdbSettingsRepository.snapshot()
+ val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
+ ?: error("Add a TMDB API key in Settings to use TMDB sources.")
+ val language = normalizeTmdbLanguage(settings.language)
+ val sourceType = source.tmdbType()
+
+ when (sourceType) {
+ TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
+ TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
+ TmdbCollectionSourceType.COMPANY,
+ TmdbCollectionSourceType.NETWORK,
+ TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page)
+ }
+ }
+
+ suspend fun importMetadata(sourceType: TmdbCollectionSourceType, id: Int): TmdbSourceImportMetadata =
+ withContext(Dispatchers.Default) {
+ val settings = TmdbSettingsRepository.snapshot()
+ val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
+ ?: error("Add a TMDB API key in Settings to use TMDB sources.")
+ val language = normalizeTmdbLanguage(settings.language)
+ when (sourceType) {
+ TmdbCollectionSourceType.LIST -> {
+ val body = fetch(
+ endpoint = "list/$id",
+ apiKey = apiKey,
+ query = mapOf("language" to language, "page" to "1"),
+ ) ?: error("TMDB list not found")
+ TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() })
+ }
+
+ TmdbCollectionSourceType.COLLECTION -> {
+ val body = fetch(
+ endpoint = "collection/$id",
+ apiKey = apiKey,
+ query = mapOf("language" to language),
+ ) ?: error("TMDB collection not found")
+ TmdbSourceImportMetadata(
+ title = body.name?.takeIf { it.isNotBlank() },
+ coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"),
+ )
+ }
+
+ TmdbCollectionSourceType.COMPANY -> {
+ val body = fetch(
+ endpoint = "company/$id",
+ apiKey = apiKey,
+ ) ?: error("TMDB company not found")
+ TmdbSourceImportMetadata(
+ title = body.name?.takeIf { it.isNotBlank() },
+ coverImageUrl = imageUrl(body.logoPath, "w500"),
+ )
+ }
+
+ TmdbCollectionSourceType.NETWORK -> {
+ val body = fetch(
+ endpoint = "network/$id",
+ apiKey = apiKey,
+ ) ?: error("TMDB network not found")
+ TmdbSourceImportMetadata(
+ title = body.name?.takeIf { it.isNotBlank() },
+ coverImageUrl = imageUrl(body.logoPath, "w500"),
+ )
+ }
+
+ TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
+ }
+ }
+
+ suspend fun searchCompanies(query: String): List = withContext(Dispatchers.Default) {
+ val trimmed = query.trim()
+ if (trimmed.isBlank()) return@withContext emptyList()
+ val settings = TmdbSettingsRepository.snapshot()
+ val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
+ ?: error("Add a TMDB API key in Settings to use TMDB sources.")
+ fetch(
+ endpoint = "search/company",
+ apiKey = apiKey,
+ query = mapOf("query" to trimmed),
+ )?.results.orEmpty()
+ }
+
+ suspend fun searchCollections(query: String): List = withContext(Dispatchers.Default) {
+ val trimmed = query.trim()
+ if (trimmed.isBlank()) return@withContext emptyList()
+ val settings = TmdbSettingsRepository.snapshot()
+ val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
+ ?: error("Add a TMDB API key in Settings to use TMDB sources.")
+ val language = normalizeTmdbLanguage(settings.language)
+ fetch(
+ endpoint = "search/collection",
+ apiKey = apiKey,
+ query = mapOf("query" to trimmed, "language" to language),
+ )?.results.orEmpty()
+ }
+
+ suspend fun searchKeywords(query: String): Map = withContext(Dispatchers.Default) {
+ val trimmed = query.trim()
+ if (trimmed.isBlank()) return@withContext emptyMap()
+ val settings = TmdbSettingsRepository.snapshot()
+ val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
+ ?: error("Add a TMDB API key in Settings to use TMDB sources.")
+ fetch(
+ endpoint = "search/keyword",
+ apiKey = apiKey,
+ query = mapOf("query" to trimmed),
+ )?.results.orEmpty()
+ .mapNotNull { result ->
+ val name = result.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ result.id to name
+ }
+ .toMap()
+ }
+
+ suspend fun genres(mediaType: TmdbCollectionMediaType): Map = withContext(Dispatchers.Default) {
+ val settings = TmdbSettingsRepository.snapshot()
+ val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
+ ?: error("Add a TMDB API key in Settings to use TMDB sources.")
+ val language = normalizeTmdbLanguage(settings.language)
+ val endpoint = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> "genre/movie/list"
+ TmdbCollectionMediaType.TV -> "genre/tv/list"
+ }
+ fetch(
+ endpoint = endpoint,
+ apiKey = apiKey,
+ query = mapOf("language" to language),
+ )?.genres.orEmpty().associate { it.id to it.name }
+ }
+
+ fun parseTmdbId(input: String): Int? {
+ val trimmed = input.trim()
+ trimmed.toIntOrNull()?.let { return it }
+ return Regex("""(?:list|collection|company|network)/(\d+)""")
+ .find(trimmed)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toIntOrNull()
+ ?: Regex("""[?&]id=(\d+)""")
+ .find(trimmed)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toIntOrNull()
+ }
+
+ fun presets(): List = listOf(
+ TmdbPresetSource("Marvel Studios", company("Marvel Studios", 420)),
+ TmdbPresetSource("Walt Disney Pictures", company("Walt Disney Pictures", 2)),
+ TmdbPresetSource("Pixar", company("Pixar", 3)),
+ TmdbPresetSource("Lucasfilm", company("Lucasfilm", 1)),
+ TmdbPresetSource("Warner Bros.", company("Warner Bros.", 174)),
+ TmdbPresetSource("Netflix", network("Netflix", 213)),
+ TmdbPresetSource("HBO", network("HBO", 49)),
+ TmdbPresetSource("Disney+", network("Disney+", 2739)),
+ TmdbPresetSource("Prime Video", network("Prime Video", 1024)),
+ TmdbPresetSource("Hulu", network("Hulu", 453)),
+ TmdbPresetSource("Apple TV+", network("Apple TV+", 2552)),
+ )
+
+ private suspend fun resolveList(
+ source: CollectionSource,
+ apiKey: String,
+ language: String,
+ page: Int,
+ ): CatalogPage {
+ val id = source.tmdbId ?: error("Missing TMDB list ID")
+ val body = fetch(
+ endpoint = "list/$id",
+ apiKey = apiKey,
+ query = mapOf("language" to language, "page" to page.toString()),
+ ) ?: error("TMDB list not found")
+ val items = body.items.orEmpty()
+ .mapNotNull { it.toPreview() }
+ .distinctBy { "${it.type}:${it.id}" }
+ return CatalogPage(
+ items = items,
+ rawItemCount = items.size,
+ nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null,
+ )
+ }
+
+ private suspend fun resolveCollection(
+ source: CollectionSource,
+ apiKey: String,
+ language: String,
+ ): CatalogPage {
+ val id = source.tmdbId ?: error("Missing TMDB collection ID")
+ val body = fetch(
+ endpoint = "collection/$id",
+ apiKey = apiKey,
+ query = mapOf("language" to language),
+ ) ?: error("TMDB collection not found")
+ val items = body.parts.orEmpty()
+ .sortedBy { it.releaseDate ?: "9999" }
+ .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
+ .distinctBy { it.id }
+ return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
+ }
+
+ private suspend fun resolveDiscover(
+ source: CollectionSource,
+ apiKey: String,
+ language: String,
+ page: Int,
+ ): CatalogPage {
+ val sourceType = source.tmdbType()
+ val mediaType = if (sourceType == TmdbCollectionSourceType.NETWORK) {
+ TmdbCollectionMediaType.TV
+ } else {
+ source.tmdbMediaType()
+ }
+ val filters = source.filters ?: TmdbCollectionFilters()
+ val query = buildDiscoverQuery(
+ source = source,
+ sourceType = sourceType,
+ mediaType = mediaType,
+ language = language,
+ page = page,
+ filters = filters,
+ )
+ val endpoint = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> "discover/movie"
+ TmdbCollectionMediaType.TV -> "discover/tv"
+ }
+ val body = fetch(
+ endpoint = endpoint,
+ apiKey = apiKey,
+ query = query,
+ ) ?: error("TMDB discover returned no data")
+ val items = body.results.orEmpty()
+ .mapNotNull { it.toPreview(mediaType) }
+ .distinctBy { it.id }
+ return CatalogPage(
+ items = items,
+ rawItemCount = items.size,
+ nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null,
+ )
+ }
+
+ private fun buildDiscoverQuery(
+ source: CollectionSource,
+ sourceType: TmdbCollectionSourceType,
+ mediaType: TmdbCollectionMediaType,
+ language: String,
+ page: Int,
+ filters: TmdbCollectionFilters,
+ ): Map {
+ val sortBy = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> movieSort(source.sortBy)
+ TmdbCollectionMediaType.TV -> tvSort(source.sortBy)
+ }
+ return buildMap {
+ put("language", language)
+ put("page", page.toString())
+ put("sort_by", sortBy)
+ val companyId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.COMPANY }
+ val networkId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.NETWORK }
+ putIfNotBlank("with_companies", companyId ?: filters.withCompanies)
+ putIfNotBlank("with_networks", networkId ?: filters.withNetworks)
+ putIfNotBlank("with_genres", filters.withGenres)
+ putIfNotBlank("vote_count.gte", filters.voteCountGte?.toString())
+ putIfNotBlank("vote_average.gte", filters.voteAverageGte?.toString())
+ putIfNotBlank("vote_average.lte", filters.voteAverageLte?.toString())
+ putIfNotBlank("with_original_language", filters.withOriginalLanguage)
+ putIfNotBlank("with_origin_country", filters.withOriginCountry)
+ putIfNotBlank("with_keywords", filters.withKeywords)
+ putIfNotBlank("year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.MOVIE }?.toString())
+ putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString())
+ putIfNotBlank(
+ if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.gte" else "first_air_date.gte",
+ filters.releaseDateGte,
+ )
+ putIfNotBlank(
+ if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.lte" else "first_air_date.lte",
+ filters.releaseDateLte,
+ )
+ }
+ }
+
+ private suspend inline fun fetch(
+ endpoint: String,
+ apiKey: String,
+ query: Map = emptyMap(),
+ ): T? {
+ val url = buildTmdbUrl(endpoint = endpoint, apiKey = apiKey, query = query)
+ return runCatching {
+ json.decodeFromString(httpGetText(url))
+ }.onFailure { error ->
+ log.w(error) { "TMDB source request failed for $endpoint" }
+ }.getOrNull()
+ }
+
+ private fun TmdbListItem.toPreview(): MetaPreview? {
+ val media = mediaType?.lowercase()
+ val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE
+ return toPreview(contentType)
+ }
+
+ private fun TmdbListItem.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
+ val title = title?.takeIf { it.isNotBlank() }
+ ?: name?.takeIf { it.isNotBlank() }
+ ?: originalTitle?.takeIf { it.isNotBlank() }
+ ?: originalName?.takeIf { it.isNotBlank() }
+ ?: return null
+ return MetaPreview(
+ id = "tmdb:$id",
+ type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
+ name = title,
+ poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
+ banner = imageUrl(backdropPath, "w1280"),
+ posterShape = PosterShape.Poster,
+ description = overview?.takeIf { it.isNotBlank() },
+ releaseInfo = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
+ TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
+ },
+ rawReleaseDate = when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> releaseDate
+ TmdbCollectionMediaType.TV -> firstAirDate
+ },
+ popularity = popularity,
+ imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
+ )
+ }
+
+ private fun TmdbCollectionPart.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
+ val title = title?.takeIf { it.isNotBlank() } ?: return null
+ return MetaPreview(
+ id = "tmdb:$id",
+ type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
+ name = title,
+ poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
+ banner = imageUrl(backdropPath, "w1280"),
+ posterShape = PosterShape.Poster,
+ description = overview?.takeIf { it.isNotBlank() },
+ releaseInfo = releaseDate?.take(4),
+ rawReleaseDate = releaseDate,
+ popularity = popularity,
+ imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
+ )
+ }
+
+ private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
+ tmdbSourceType
+ ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
+ ?: TmdbCollectionSourceType.DISCOVER
+
+ private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
+ TmdbCollectionMediaType.fromString(mediaType)
+
+ private fun company(title: String, id: Int) = CollectionSource(
+ provider = "tmdb",
+ tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
+ title = title,
+ tmdbId = id,
+ mediaType = TmdbCollectionMediaType.MOVIE.name,
+ sortBy = TmdbCollectionSort.POPULAR_DESC.value,
+ )
+
+ private fun network(title: String, id: Int) = CollectionSource(
+ provider = "tmdb",
+ tmdbSourceType = TmdbCollectionSourceType.NETWORK.name,
+ title = title,
+ tmdbId = id,
+ mediaType = TmdbCollectionMediaType.TV.name,
+ sortBy = TmdbCollectionSort.POPULAR_DESC.value,
+ )
+
+ private fun movieSort(sortBy: String?): String =
+ when (sortBy) {
+ TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
+ null, "" -> TmdbCollectionSort.POPULAR_DESC.value
+ else -> sortBy
+ }
+
+ private fun tvSort(sortBy: String?): String =
+ when (sortBy) {
+ TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
+ null, "" -> TmdbCollectionSort.POPULAR_DESC.value
+ else -> sortBy
+ }
+}
+
+private fun MutableMap.putIfNotBlank(key: String, value: String?) {
+ if (!value.isNullOrBlank()) {
+ put(key, value)
+ }
+}
+
+private fun imageUrl(path: String?, size: String): String? {
+ val clean = path?.takeIf { it.isNotBlank() } ?: return null
+ return "https://image.tmdb.org/t/p/$size$clean"
+}
+
+@Serializable
+private data class TmdbListResponse(
+ val name: String? = null,
+ val page: Int? = null,
+ @SerialName("total_pages") val totalPages: Int? = null,
+ val items: List? = null,
+)
+
+@Serializable
+private data class TmdbCollectionResponse(
+ val name: String? = null,
+ @SerialName("poster_path") val posterPath: String? = null,
+ @SerialName("backdrop_path") val backdropPath: String? = null,
+ val parts: List? = null,
+)
+
+@Serializable
+private data class TmdbDiscoverResponse(
+ val page: Int? = null,
+ @SerialName("total_pages") val totalPages: Int? = null,
+ val results: List? = null,
+)
+
+@Serializable
+private data class TmdbCompanyResponse(
+ val name: String? = null,
+ @SerialName("logo_path") val logoPath: String? = null,
+)
+
+@Serializable
+private data class TmdbNetworkResponse(
+ val name: String? = null,
+ @SerialName("logo_path") val logoPath: String? = null,
+)
+
+@Serializable
+data class TmdbCompanySearchResult(
+ val id: Int,
+ val name: String? = null,
+ @SerialName("origin_country") val originCountry: String? = null,
+)
+
+@Serializable
+private data class TmdbCompanySearchResponse(
+ val results: List? = null,
+)
+
+@Serializable
+data class TmdbCollectionSearchResult(
+ val id: Int,
+ val name: String? = null,
+ @SerialName("poster_path") val posterPath: String? = null,
+ @SerialName("backdrop_path") val backdropPath: String? = null,
+)
+
+@Serializable
+private data class TmdbCollectionSearchResponse(
+ val results: List? = null,
+)
+
+@Serializable
+private data class TmdbKeywordSearchResponse(
+ val results: List? = null,
+)
+
+@Serializable
+private data class TmdbKeywordSearchResult(
+ val id: Int,
+ val name: String? = null,
+)
+
+@Serializable
+private data class TmdbGenreResponse(
+ val genres: List? = null,
+)
+
+@Serializable
+private data class TmdbGenreItem(
+ val id: Int,
+ val name: String,
+)
+
+@Serializable
+private data class TmdbListItem(
+ val id: Int,
+ @SerialName("media_type") val mediaType: String? = null,
+ val title: String? = null,
+ val name: String? = null,
+ @SerialName("original_title") val originalTitle: String? = null,
+ @SerialName("original_name") val originalName: String? = null,
+ val overview: String? = null,
+ @SerialName("poster_path") val posterPath: String? = null,
+ @SerialName("backdrop_path") val backdropPath: String? = null,
+ @SerialName("release_date") val releaseDate: String? = null,
+ @SerialName("first_air_date") val firstAirDate: String? = null,
+ @SerialName("vote_average") val voteAverage: Double? = null,
+ val popularity: Double? = null,
+)
+
+@Serializable
+private data class TmdbCollectionPart(
+ val id: Int,
+ val title: String? = null,
+ val overview: String? = null,
+ @SerialName("poster_path") val posterPath: String? = null,
+ @SerialName("backdrop_path") val backdropPath: String? = null,
+ @SerialName("release_date") val releaseDate: String? = null,
+ @SerialName("vote_average") val voteAverage: Double? = null,
+ val popularity: Double? = null,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
index b2ddbb8c..82659478 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
@@ -40,6 +40,7 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
+import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState
@@ -617,7 +618,11 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
parentMetaType = contentType,
videoId = videoId,
title = name,
- subtitle = episodeTitle.orEmpty(),
+ subtitle = buildContinueWatchingEpisodeSubtitle(
+ seasonNumber = season,
+ episodeNumber = episode,
+ episodeTitle = episodeTitle,
+ ),
imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo,
poster = poster,
@@ -654,7 +659,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
parentMetaType = contentType,
videoId = videoId,
title = name,
- subtitle = episodeTitle.orEmpty(),
+ subtitle = buildContinueWatchingEpisodeSubtitle(
+ seasonNumber = season,
+ episodeNumber = episode,
+ episodeTitle = episodeTitle,
+ ),
imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo,
poster = poster,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt
index 32ab8059..b76fdf7c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt
@@ -65,7 +65,7 @@ private const val HERO_SCROLL_UP_SCALE_MULTIPLIER = 0.002f
private const val HERO_SCROLL_MAX_SCALE = 1.3f
private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f
private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f
-private const val MOBILE_HERO_VIEWPORT_RATIO = 0.78f
+private const val MOBILE_HERO_VIEWPORT_RATIO = 0.82f
private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f
private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
index 7aae75f8..a3983cbf 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
@@ -11,7 +11,6 @@ import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -25,9 +24,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
-import nuvio.composeapp.generated.resources.Res
-import nuvio.composeapp.generated.resources.library_other
-import org.jetbrains.compose.resources.getString
@Serializable
private data class StoredLibraryPayload(
@@ -370,7 +366,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
internal fun String.toLibraryDisplayTitle(): String {
val normalized = trim()
- if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
+ if (normalized.isBlank()) return "Other"
return normalized
.split('-', '_', ' ')
@@ -378,5 +374,5 @@ internal fun String.toLibraryDisplayTitle(): String {
.joinToString(" ") { token ->
token.lowercase().replaceFirstChar { char -> char.uppercase() }
}
- .ifBlank { runBlocking { getString(Res.string.library_other) } }
+ .ifBlank { "Other" }
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt
index 5f4157b0..12efbd73 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt
@@ -190,7 +190,11 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
parentMetaType = normalizedEntry.parentMetaType,
videoId = normalizedEntry.videoId,
title = normalizedEntry.title,
- subtitle = normalizedEntry.episodeTitle.orEmpty(),
+ subtitle = buildContinueWatchingEpisodeSubtitle(
+ seasonNumber = normalizedEntry.seasonNumber,
+ episodeNumber = normalizedEntry.episodeNumber,
+ episodeTitle = normalizedEntry.episodeTitle,
+ ),
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster,
logo = normalizedEntry.logo,
poster = normalizedEntry.poster,
@@ -223,7 +227,11 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
fallbackVideoId = nextEpisode.id,
),
title = title,
- subtitle = nextEpisode.title,
+ subtitle = buildContinueWatchingEpisodeSubtitle(
+ seasonNumber = nextEpisode.season,
+ episodeNumber = nextEpisode.episode,
+ episodeTitle = nextEpisode.title,
+ ),
imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster,
logo = logo,
poster = poster,
@@ -243,6 +251,20 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
)
}
+internal fun buildContinueWatchingEpisodeSubtitle(
+ seasonNumber: Int?,
+ episodeNumber: Int?,
+ episodeTitle: String?,
+): String {
+ val episodeCode = when {
+ seasonNumber != null && episodeNumber != null -> "S${seasonNumber}E${episodeNumber}"
+ episodeNumber != null -> "E${episodeNumber}"
+ else -> null
+ }
+ val title = episodeTitle.orEmpty()
+ return listOfNotNull(episodeCode, title.takeIf { it.isNotBlank() }).joinToString(" • ")
+}
+
fun buildPlaybackVideoId(
parentMetaId: String,
seasonNumber: Int?,
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt
index a7b47fdc..1713004f 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt
@@ -36,7 +36,7 @@ class SeriesPlaybackResolverTest {
)
assertNotNull(action)
- assertEquals("Up Next S1E3", action.label)
+ assertEquals("Up Next • S1E3", action.label)
assertEquals("show:1:3", action.videoId)
assertEquals(1, action.seasonNumber)
assertEquals(3, action.episodeNumber)
@@ -85,7 +85,7 @@ class SeriesPlaybackResolverTest {
)
assertNotNull(action)
- assertEquals("Up Next S1E3", action.label)
+ assertEquals("Up Next • S1E3", action.label)
assertEquals("show:1:3", action.videoId)
}
}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt
index 51727f2e..cb3f6ba7 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt
@@ -39,7 +39,7 @@ class SeriesContinuityTest {
)
assertNotNull(action)
- assertEquals("Up Next S1E3", action.label)
+ assertEquals("Up Next • S1E3", action.label)
assertEquals("show:1:3", action.videoId)
assertEquals(3, action.episodeNumber)
}
@@ -142,7 +142,7 @@ class SeriesContinuityTest {
)
assertNotNull(action)
- assertEquals("Up Next S2E2", action.label)
+ assertEquals("Up Next • S2E2", action.label)
assertEquals("show:2:2", action.videoId)
}
}