diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 71d3b924..5c5811e4 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -260,6 +260,7 @@ kotlin {
commonMain.dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
+ implementation(libs.coil.svg)
implementation("dev.chrisbanes.haze:haze:1.7.2")
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index e899b044..339340ab 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
+import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
@@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() {
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
+ CollectionMobileSettingsStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
index 9edf1191..b7243288 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
@@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform",
"nuvio_watch_progress",
+ "nuvio_collection_mobile_settings",
"nuvio_collections",
"nuvio_plugins",
)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt
new file mode 100644
index 00000000..caaba36c
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt
@@ -0,0 +1,26 @@
+package com.nuvio.app.features.collection
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.nuvio.app.core.storage.ProfileScopedKey
+
+actual object CollectionMobileSettingsStorage {
+ private const val preferencesName = "nuvio_collection_mobile_settings"
+ private const val payloadKey = "collection_mobile_settings_payload"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadPayload(): String? =
+ preferences?.getString(ProfileScopedKey.of(payloadKey), null)
+
+ actual fun savePayload(payload: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(payloadKey), payload)
+ ?.apply()
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt
new file mode 100644
index 00000000..486f1477
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt
@@ -0,0 +1,7 @@
+package com.nuvio.app.features.profiles
+
+internal actual object ProfileHoverHapticFeedback {
+ actual fun prepare() = Unit
+ actual fun perform() = Unit
+ actual fun release() = Unit
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt
index 23f99ee8..a2140bde 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt
@@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import com.nuvio.app.R
import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.introdb_favicon
import nuvio.composeapp.generated.resources.rating_tmdb
import org.jetbrains.compose.resources.painterResource as composePainterResource
@@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
+ IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon)
}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt
index 4eda3a91..585cb863 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt
@@ -5,7 +5,7 @@ import java.time.Instant
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
- actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching {
- Instant.parse(value).toEpochMilli()
- }.getOrNull()
+ actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
+ runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
+ ?: parseTraktIsoDateTimeToEpochMs(value)
}
diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml
index 7d3f2e85..2badd023 100644
--- a/composeApp/src/androidMain/res/xml/locale_config.xml
+++ b/composeApp/src/androidMain/res/xml/locale_config.xml
@@ -9,4 +9,5 @@
+
diff --git a/composeApp/src/commonMain/composeResources/drawable/introdb_favicon.png b/composeApp/src/commonMain/composeResources/drawable/introdb_favicon.png
new file mode 100644
index 00000000..e3e357b9
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/introdb_favicon.png differ
diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml
new file mode 100644
index 00000000..d1c9be28
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml
@@ -0,0 +1,1245 @@
+
+ Otevřené uznání a kredity projektu
+ Zpět
+ Zrušit
+ Zavřít
+ Smazat
+ Hotovo
+ Upravit
+ Importovat
+ Další
+ OK
+ Přehrát
+ Předchozí
+ Odstranit
+ Změnit pořadí
+ Obnovit výchozí
+ Pokračovat
+ Opakovat
+ Uložit
+ Instaluje se
+ Doplňky
+ Aktivní
+ %1$d katalogů
+ Nastavitelné
+ Obnovuje se
+ %1$d zdrojů
+ Nedostupné
+ Konfigurovat doplněk
+ Smazat doplněk
+ Přidejte URL manifestu a začněte do Nuvia načítat katalogy, metadata, streamy nebo titulky.
+ Zatím nejsou nainstalovány žádné doplňky.
+ Zadejte URL doplňku.
+ URL doplňku
+ Nainstalovat doplněk
+ Načítání podrobností manifestu...
+ Ověřování URL manifestu a načítání podrobností o doplňku před instalací.
+ Kontrola doplňku
+ Instalace selhala
+ Doplněk %1$s byl úspěšně ověřen a přidán.
+ Doplněk nainstalován
+ Posunout doplněk dolů
+ Posunout doplněk nahoru
+ Aktivní
+ Doplňky
+ Katalogy
+ Obnovit doplněk
+ Přidat doplněk
+ Nainstalované doplňky
+ Přehled
+ %1$d pravidel id
+ Verze %1$s
+ Vybráno
+ Kopírovat JSON
+ %1$d kolekce, %2$d složka(y)
+ Smazat "%1$s"? Tuto akci nelze vrátit zpět.
+ Smazat kolekci
+ Přidat katalog
+ Přidat složku
+ Všechny žánry
+ Přidejte katalogy z nainstalovaných doplňků a určete, co se má v této složce zobrazovat.
+ Zatím žádné zdroje katalogů
+ Vybrat
+ Emoji
+ URL obrázku
+ Žádný
+ Obal
+ Vytvořit kolekci
+ Hotovo
+ Upravit kolekci
+ Upravit složku
+ Nastavte identitu složky, vzhled a zdroje katalogů se stejnou strukturou jako v hlavním editoru kolekcí.
+ Přidejte jednu a začněte.
+ Zatím žádné složky
+ Složky
+ Filtr žánrů
+ Zobrazit pouze obal
+ Skrýt název
+ Nová složka
+ Zobrazit tuto kolekci nad všemi běžnými katalogy na domovské obrazovce. Více připnutých kolekcí se řadí podle pořadí vytvoření.
+ Připnout nad katalogy
+ URL obrázku na pozadí (volitelné)
+ Název složky
+ URL animovaného GIFu (přehrává se pouze při zaměření)
+ Název kolekce
+ Uložit změny
+ Uložit
+ Vzhled
+ Základy
+ Zdroje katalogů
+ Vyberte katalogy doplňků, které by měla tato složka sdružovat.
+ Vybrat katalogy
+ Vybrat žánr
+ %1$d vybráno
+ %1$d katalogů
+ %1$d vybráno
+ Plakát
+ Čtverec
+ Širokoúhlý
+ Sloučit všechny katalogy do jedné karty
+ Zobrazit kartu \"Vše\"
+ Přehrát nastavený GIF místo statického obalu, pokud je k dispozici.
+ Zobrazit GIF, když je nastavený
+ %1$d zdroj(ů) · %2$s
+ Tvar dlaždice
+ Řádky
+ Karty
+ Režim zobrazení
+ Zdroje TMDB
+ Veřejný seznam
+ Produkce
+ Síť
+ Kolekce
+ Osoba
+ Režisér
+ Vlastní
+ Vyberte si předpřipravený zdroj. Po přidání ho můžete upravit nebo odstranit.
+ Vložte URL veřejného seznamu TMDB nebo pouze číslo z URL.
+ Vyhledávejte podle názvu studia, nebo vložte ID/URL společnosti na TMDB a rovnou ji přidejte.
+ Zadejte ID sítě. Běžné sítě jsou k dispozici v předvolbách a rychlých filtrech.
+ Vyhledejte název filmové kolekce nebo vložte ID kolekce z TMDB.
+ Zadejte ID nebo URL osoby na TMDB pro vytvoření řádku z hereckých rolí.
+ Zadejte ID nebo URL osoby na TMDB pro vytvoření řádku z režisérských rolí.
+ Vytvořte živý řádek TMDB pomocí volitelných filtrů. Pokud filtr nepotřebujete, nechte pole prázdná.
+ Veřejný seznam TMDB
+ ID sítě
+ ID kolekce
+ ID osoby
+ Název, ID nebo URL produkční společnosti
+ TMDB ID nebo URL
+ https://www.themoviedb.org/list/8504994 nebo 8504994
+ 213 pro Netflix, 49 pro HBO, 2739 pro Disney+
+ 10 pro Star Wars Collection
+ Marvel Studios, 420, nebo URL společnosti
+ 31 pro Toma Hankse, nebo URL osoby
+ Příklady: Marvel Studios, 420, nebo https://www.themoviedb.org/company/420.
+ Příklad: Star Wars Collection, Harry Potter Collection, nebo URL kolekce.
+ Příklady ID: Netflix 213, HBO 49, Disney+ 2739.
+ Příklad: https://www.themoviedb.org/list/8504994 nebo 8504994.
+ Příklad: https://www.themoviedb.org/person/31-tom-hanks nebo 31.
+ Zobrazovaný název
+ Zobrazí se jako název řádku/karty. Pokud je prázdné, Nuvio jej vytvoří ze zdroje.
+ Filmy Marvel, Netflix Originals, Pixar
+ Filmy s Tomem Hanksem, Oblíbení herci
+ Filmy od Christophera Nolana, Oblíbení režiséři
+ Nejlepší akční filmy, Korejská dramata, Animace 2024
+ Výsledky vyhledávání
+ Kolekce TMDB
+ Společnost TMDB %1$d
+ Kolekce TMDB %1$d
+ Typ
+ Filmy
+ Seriály
+ Obojí
+ Seřadit
+ Filtry
+ Pokud filtr nepotřebujete, nechte pole prázdná.
+ Rychlé žánry
+ Rychlé jazyky
+ Rychlé země
+ Rychlá klíčová slova
+ Rychlá studia
+ Rychlé sítě
+ ID žánrů
+ Použijte čísla žánrů TMDB. Oddělte více žánrů čárkami pro „A“, nebo svislítky pro „NEBO“.
+ Datum vydání nebo vysílání od
+ Datum vydání nebo vysílání do
+ Použijte formát RRRR-MM-DD, například 2024-01-01.
+ Minimální hodnocení
+ Maximální hodnocení
+ Hodnocení TMDB od 0 do 10. Příklad: 7.0.
+ Minimální počet hlasů
+ Slouží k vynechání neznámých titulů s malým počtem hlasů. Příklad: 100.
+ Původní jazyk
+ Použijte dvoupísmenné kódy jazyků, například en, ko, ja, cs.
+ Země původu
+ Použijte dvoupísmenné kódy zemí, například US, KR, JP, CZ.
+ ID klíčových slov
+ Použijte čísla klíčových slov TMDB. Rychlé volby vyplní běžné příklady.
+ 9715 pro superhrdinu
+ ID společností
+ Použijte ID studií/společností. Rychlé volby vyplní běžné příklady.
+ 420 pro Marvel Studios
+ ID sítí
+ Pouze pro seriály. Použijte ID sítí jako Netflix 213 nebo HBO 49.
+ 213 pro Netflix
+ Rok
+ Použijte čtyřmístný rok, například 2024.
+ Předvolby
+ Hledat
+ Přidat zdroj
+ Přidat seznam Trakt
+ Upravit seznam Trakt
+ Seznamy Trakt
+ Seznam Trakt
+ Vyhledat název, URL na Trakltu, nebo ID seznamu
+ Použijte URL veřejného seznamu Trakt, číselné ID seznamu, nebo vyhledávejte podle názvu.
+ Víkendové sledování, Vítězové ocenění
+ Výsledky vyhledávání
+ Trendy seznamy
+ Populární seznamy
+ Směr řazení
+ Vzestupně
+ Sestupně
+ Pořadí v seznamu
+ Nedávno přidáno
+ Název
+ Datum vydání
+ Délka
+ Populární
+ Procenta
+ Hlasy
+ Akční
+ Dobrodružný
+ Animovaný
+ Komedie
+ Horor
+ Sci-Fi
+ Drama
+ Krimi
+ Reality
+ Angličtina
+ Korejština
+ Japonština
+ Hindština
+ Španělština
+ Spojené státy
+ Korea
+ Japonsko
+ Indie
+ Velká Británie
+ Superhrdina
+ Podle knižní předlohy
+ Cestování v čase
+ Vesmír
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Původní
+ Populární
+ Nejlépe hodnocené
+ Nedávné
+ Seznam TMDB
+ Filmová kolekce TMDB
+ Produkce
+ Síť
+ Osoba
+ Režisér
+ Objevování TMDB
+ Vytvořte ji pro organizaci vašich katalogů.
+ Zatím žádné kolekce
+ %1$d složka(y)
+ Nebyly nalezeny žádné položky
+ Složka nebyla nalezena
+ Kolekce
+ Importovat kolekce
+ JSON
+ Vložte níže JSON vašich kolekcí.
+ Import
+ Nová kolekce
+ Připnuté
+ Vše
+ Vaše kolekce
+ Vytvořeno s ❤️ od Tapframe a přátel
+ Verze %1$s (%2$s)
+ Vypnuto
+ Zapnuto
+ Pozastavit
+ Znovu načíst
+ Už máte účet?
+ Pokračovat bez účtu
+ Vytvořit účet
+ Nemáte účet?
+ E-mail
+ nebo
+ Heslo
+ Přihlaste se pro přístup k vaší knihovně a postupu
+ Přihlásit se
+ Zaregistrujte se pro synchronizaci dat napříč zařízeními
+ Zaregistrovovat se
+ Vaše data budou uložena pouze lokálně
+ Streamujte všechno, všude
+ Vítejte zpět
+ Knihovna
+ Knihovna Trakt
+ Domů
+ Knihovna
+ Profil
+ Hledat
+ Zvukové stopy
+ Zvuk
+ Vestavěné
+ Spodní odsazení
+ Zavřít přehrávač
+ Barva
+ Právě hraje
+ E%1$d
+ S%1$dE%2$d
+ S%1$dE%2$d • %3$s
+ Epizody
+ Velikost písma
+ %1$dsp
+ Uzamknout ovládání přehrávače
+ Nejsou k dispozici žádné zvukové stopy
+ Nejsou k dispozici žádné epizody
+ Nebyly nalezeny žádné streamy
+ Žádné
+ Obrys
+ Epizody
+ Zdroje
+ Streamy
+ Chyba přehrávání
+ Přehrávání
+ Klepnutím načtěte titulky
+ Vrátit se zpět
+ Obnovit výchozí
+ Vyplnit
+ Přizpůsobit
+ Přiblížit
+ Přetočit o 10 sekund zpět
+ -%1$ds
+ +%1$ds
+ -%1$ds
+ +%1$ds
+ Přetočit o 10 sekund vpřed
+ Zdroje
+ Styl
+ Titulky
+ Titulky
+ Jas %1$s
+ Hlasitost %1$s
+ Ztlumeno
+ Staženo
+ Vysílá se
+ Bude oznámeno
+ Klepnutím odemknete
+ Stopa %1$d
+ Odemknout ovládání přehrávače
+ Právě sledujete
+ Přidat profil
+ Vymazat hledání
+ Objevovat
+ Nainstalovaným doplňkům se nepodařilo vrátit platné výsledky vyhledávání.
+ Hledání selhalo
+ Před vyhledáváním nainstalujte a ověřte alespoň jeden doplněk.
+ Žádné aktivní doplňky
+ Nainstalované prohledávatelné katalogy nevrátily žádné shody pro tento dotaz.
+ Nebyly nalezeny žádné výsledky
+ Vaše nainstalované doplňky neumožňují vyhledávání v katalozích.
+ Žádné prohledávatelné katalogy
+ Hledat filmy, seriály...
+ Nedávná hledání
+ Odstranit nedávné hledání
+ O aplikaci
+ Obecné
+ Účet
+ Doplňky
+ Rozvržení
+ Obsah a objevování
+ Pokračovat ve sledování
+ Rozvržení domovské obrazovky
+ Integrace
+ Hodnocení MDBList
+ Stránka podrobností
+ Oznámení
+ Přehrávání
+ Pluginy
+ Styl karty plakátu
+ Nastavení
+ Podporovatelé a přispěvatelé
+ Obohacení TMDB
+ Trakt
+ O APLIKACI
+ Účet a stav synchronizace
+ ÚČET
+ Struktura domovské obrazovky a styly plakátů
+ Stáhnout nejnovější verzi
+ Zkontrolovat aktualizace
+ Spravovat doplňky a zdroje objevování.
+ Spravovat vaše stažené filmy a epizody.
+ Stahování
+ OBECNÉ
+ Spravovat dostupné integrace
+ Spravovat upozornění na vydání epizod a odeslat zkušební oznámení.
+ Přepnout na jiný profil.
+ Přepnout profil
+ Otevřít obrazovku připojení k Trakt
+ Nebylo nalezeno žádné nastavení.
+ Hledat v nastavení...
+ VÝSLEDKY
+ Načítání vašich seznamů Trakt…
+ Vyberte, kam na Trakt chcete tento titul uložit
+ Přispět
+ Přejít na podrobnosti
+ Odstranit
+ Spustit od začátku
+ Přehrát
+ %1$d/10
+ Recenze
+ Spoiler
+ Zatím nejsou k dispozici žádné recenze Trakt.
+ %1$d To se mi líbí
+ Tento komentář obsahuje spoilery.
+ Tento komentář obsahuje spoilery a byl skryt.
+ Komentáře
+ Trailer
+ %1$s (%2$d)
+ Trailery
+ Žádné dokončené epizody
+ Zatím žádná stahování
+ %1$d stažená epizoda (epizody)
+ Aktivní
+ Filmy
+ Seriály
+ Zobrazit stahování
+ Dokončeno • %1$s
+ Stahování • %1$s
+ Selhalo
+ Pozastaveno • %1$s
+ Zhlédnuto
+ Série %1$d
+ Speciály
+ Pokračujte tam, kde jste přestali
+ Přidat do knihovny
+ Označit jako nezhlédnuté
+ Označit jako zhlédnuté
+ Odebrat z knihovny
+ Zobrazit vše
+ Přehrát ručně
+ Logo %1$s
+ Účet
+ Smazat účet
+ Tímto trvale smažete svůj účet a všechna přidružená data.
+ Tuto akci nelze vrátit zpět. Všechna vaše data, profily a historie synchronizace budou trvale smazány.
+ Smazat účet?
+ E-mail
+ Nepřihlášen
+ Odhlásit se
+ Budete vráceni na přihlašovací obrazovku.
+ Odhlásit se?
+ Stav
+ Anonymní
+ Přihlášen
+ AMOLED Černá
+ Použít čistě černé pozadí pro OLED obrazovky.
+ Jazyk aplikace
+ Vyberte jazyk
+ Nastavení pro sekci Pokračovat ve sledování.
+ Tekuté sklo (Liquid Glass)
+ Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Okamžité přepínání profilů z lišty panelů není při zapnutí dostupné.
+ Vyladit šířku karty a poloměr rohů.
+ ZOBRAZENÍ
+ DOMŮ
+ MOTIV
+ Kolekce • %1$s
+ Zobrazovaný název
+ Nainstalujte doplněk s katalogy kompatibilními s dlaždicemi pro konfiguraci řádků domovské obrazovky.
+ Žádné domácí katalogy
+ Zdroj pro Carousel (Hero)
+ Skryto
+ Ponechat zaměření na Domů
+ %1$s • Dosažen limit (max %2$d)
+ Nebyly vybrány žádné zdroje pro Carousel
+ Není v Carouselu
+ Chcete-li kolekci přesunout, odeberte její připnutí nahoru
+ Připnuto
+ Připnuto nahoru
+ Změnit pořadí
+ KATALOGY
+ KATALOGY A KOLEKCE
+ KOLEKCE
+ Rozvržení domovské obrazovky
+ Katalogy Carouselu (Hero)
+ Vybráno %1$d z %2$d
+ Zobrazit sekci Carousel (Hero)
+ Zobrazit carousel na vrcholu domovské obrazovky.
+ Skrýt nevydaný obsah
+ Skrýt filmy a seriály, které ještě nebyly vydány.
+ %1$d z %2$d katalogů je viditelných • %3$d vybraných zdrojů Carouselu
+ Otevřete katalog pouze tehdy, když jej potřebujete přejmenovat nebo změnit jeho pořadí.
+ Viditelné
+ Skrýt hodnotu
+ Přehrávač, titulky a automatické přehrávání
+ Poloměr rohů
+ Styl karty plakátu
+ Šířka
+ Vlastní
+ Vyladit šířku karty a poloměr rohů.
+ Skrýt štítky
+ Plakáty na šířku
+ Živý náhled
+ %1$s (%2$s)
+ Poloměr rohů: %1$ddp
+ Výška: %1$ddp
+ Šířka: %1$ddp
+ Klasický
+ Pilulka
+ Zaoblený
+ Ostrý
+ Decentní
+ Vyvážená
+ Komfortní
+ Kompaktní
+ Hustá
+ Velká
+ Standardní
+ Zobrazit hodnotu
+ Zobrazit vyskakovací okno pro pokračování, když aplikaci otevřete poté, co jste ji opustili z přehrávače.
+ Výzva k pokračování po spuštění
+ Rozmazat náhledy dalších epizod v sekci Pokračovat ve sledování, abyste se vyhnuli spoilerům.
+ Rozmazat nezhlédnuté v Pokračovat ve sledování
+ Zahrnout do Pokračovat ve sledování i nadcházející epizody, než se budou vysílat.
+ Zobrazit nevysílané v Další na řadě
+ Styl karty plakátu
+ PŘI SPUŠTĚNÍ
+ CHOVÁNÍ DALŠÍ NA ŘADĚ
+ VIDITELNOST
+ Zobrazit lištu Pokračovat ve sledování na domovské obrazovce.
+ Zobrazit Pokračovat ve sledování
+ Plakát
+ Karta plakátu zaměřená na grafiku
+ Široký
+ Horizontální karta s vysokou hustotou informací
+ Zobrazit další epizodu podle nejdále zhlédnuté epizody. Vypněte pro opakované sledování, aby se použila naposledy zhlédnutá epizoda.
+ Další na řadě od nejdále zhlédnuté
+ Upřednostňovat náhledy epizod, pokud jsou k dispozici.
+ Upřednostňovat náhledy epizod v Pokračovat ve sledování
+ DOMŮ
+ ZDROJE
+ Instalujte, odebírejte, obnovujte a řaďte své zdroje obsahu.
+ Interní instalace repozitářů JavaScriptových scraperů a testovacích poskytovatelů.
+ Upravte rozložení domovské obrazovky, viditelnost obsahu a chování plakátů.
+ Nastavení pro obrazovky podrobností a epizod.
+ Vytvářejte vlastní seskupení katalogů se složkami na domovské obrazovce.
+ Integrace
+ Ovládací prvky pro obohacení metadat
+ Externí poskytovatelé hodnocení
+ Než zapnete hodnocení, přidejte níže svůj MDBList API klíč.
+ Vyžadováno pro načítání hodnocení z MDBListu
+ API klíč
+ API klíč
+ Povolit hodnocení MDBList
+ Načítat hodnocení od externích poskytovatelů na obrazovce s metadaty
+ API klíč
+ Externí poskytovatelé hodnocení
+ Hodnocení MDBList
+ Akce
+ Ovládací prvky pro přehrávání a ukládání.
+ Obsazení
+ Hlavní seznam herců.
+ Filmové pozadí
+ Rozmazané pozadí za obsahem, podobné obrazovce streamu.
+ Kolekce
+ Související kolekce nebo filmová série.
+ Komentáře
+ Recenze z Trakt
+ Podrobnosti
+ Délka, stav, vydání, jazyk a související informace.
+ Karty epizod
+ Vyberte, jak se budou epizody vykreslovat na obrazovce s metadaty.
+ Horizontální
+ Řádkové karty ve stylu pozadí
+ Seznam
+ Skládané karty zaměřené na detaily
+ Epizody
+ Sezóny a seznam epizod pro seriály.
+ Rozmazat nezhlédnuté epizody
+ Rozmazat náhledy epizod, dokud nebudou zhlédnuty, abyste se vyhnuli spoilerům.
+ Skupina %1$d
+ Podobné
+ Doporučení TMDB na stránce podrobností
+ Žádné
+ Přehled
+ Synopse, hodnocení, žánry a hlavní tvůrci.
+ Produkce
+ Studia a sítě.
+ VZHLED
+ SEKCE
+ Skupina karet %1$d
+ Rozvržení karet
+ Seskupte sekce do karet podobně jako v TV aplikaci. Přiřaďte až 3 sekce do jedné skupiny.
+ Trailery
+ Trailery a zástupci pro přehrávání.
+ Oznámení jsou v Nuviu momentálně zakázána.
+ Upozornění na vydání epizod
+ Naplánovat lokální oznámení, když bude dostupná nová epizoda pro uložený seriál.
+ Systémová oznámení jsou pro Nuvio zakázána. Povolte je pro přijímání upozornění a zkušebních oznámení.
+ Na tomto zařízení je aktuálně naplánováno %1$d upozornění na vydání.
+ UPOZORNĚNÍ
+ TEST
+ Odeslat zkušební oznámení
+ Odesílání zkušebního oznámení...
+ Odeslat lokální zkušební oznámení pro %1$s.
+ Pro otestování oznámení nejprve uložte nějaký seriál do knihovny.
+ Zkušební oznámení
+ Komunita
+ Podívejte se na lidi, kteří budují a podporují Nuvio napříč Mobilem, TV a Webem.
+ API pro podporovatele není nakonfigurováno. Přidejte DONATIONS_BASE_URL do local.properties.
+ Přispěvatelé
+ Podporovatelé
+ Otevřít GitHub
+ Profil na GitHubu není k dispozici
+ Nebyla připojena žádná zpráva.
+ Načítání přispěvatelů...
+ Načítání podporovatelů...
+ Nepodařilo se načíst přispěvatele
+ Nepodařilo se načíst podporovatele
+ Nebyli nalezeni žádní přispěvatelé.
+ Nebyli nalezeni žádní podporovatelé.
+ Nelze načíst přispěvatele.
+ Nelze načíst podporovatele.
+ Momentálně nelze načíst přispěvatele.
+ Momentálně nelze načíst podporovatele.
+ %1$d celkových commitů
+ Led
+ Úno
+ Bře
+ Dub
+ Kvě
+ Čvn
+ Čvc
+ Srp
+ Zář
+ Říj
+ Lis
+ Pro
+ %1$s %2$s, %3$s
+ Všechny nainstalované doplňky
+ Všechny povolené pluginy
+ Povolené doplňky
+ Povolené pluginy
+ Anime Skip (přeskakování)
+ AnimeSkip Client ID
+ Zadejte své AnimeSkip API klientské ID. Získáte jej na anime-skip.com.
+ Povolit odesílání intra
+ Zobrazit tlačítko pro odeslání časových značek intra/outra do komunitní databáze.
+ IntroDB API klíč
+ Zadejte svůj IntroDB API klíč pro odesílání časových značek. Vyžadováno pro odesílání.
+ Hledat také časové značky pro přeskočení v AnimeSkip (vyžaduje klientské ID).
+ Automaticky přehrát další epizodu
+ Po zobrazení výzvy automaticky spustit další epizodu.
+ Pouze dekodéry zařízení
+ Upřednostnit dekodéry aplikace (FFmpeg)
+ Upřednostnit dekodéry zařízení
+ Priorita dekodéru
+ Klepnutím mimo zavřete
+ Klepnutím mimo uložíte a zavřete
+ %1$d den
+ %1$d dní
+ %1$d hodina
+ %1$d hodin
+ Použít libass pro titulky ASS/SSA
+ Experimentální: pokročilé vykreslování ASS/SSA (styly, pozicování, animace)
+ Rychlost při podržení
+ Podržet pro zrychlení
+ Dlouhým stisknutím kdekoli na ploše přehrávače dočasně zvýšíte rychlost přehrávání.
+ Neplatný vzor regulárního výrazu (regex)
+ Doba mezipaměti posledního odkazu
+ Záložní DV7 - HEVC
+ Mapovat Dolby Vision profil 7 na standardní HEVC pro zařízení bez hardwarové podpory DV
+ Prahové minuty
+ Záložní řešení, pokud neexistuje časová značka pro outro.
+ %1$s min
+ Žádné položky nejsou k dispozici
+ Nenastaveno
+ Výchozí (soubor médií)
+ Jazyk zařízení
+ Vynucené
+ Žádné
+ Upřednostnit Binge skupinu (Další epizoda)
+ Zkusit přednostně stejný profil zdroje (stejný doplněk/skupinu kvality) před běžnými pravidly automatického přehrávání.
+ Preferovaný jazyk zvuku
+ Preferovaný jazyk titulků
+ Předvolby
+ Porovnává se s názvem streamu/titulem/popisem/doplňkem/url. Příklad: 4K|2160p|Remux
+ Vzor regulárního výrazu
+ Není nastaven žádný vzor. Příklad: 4K|2160p|Remux
+ Jakékoli 1080p+
+ AVC / x264
+ BluRay kvalita
+ Dolby Atmos / DTS
+ Angličtina
+ HDR / Dolby Vision
+ HEVC / x265
+ Žádné CAM/TS
+ Žádné REMUX/HDR
+ 1080p Standard
+ 4K / Remux
+ 720p / Menší
+ WEB zdroje
+ Režim vykreslování Libass
+ Standardní Cues
+ Efekty Canvas
+ Efekty OpenGL
+ Překrytí Canvas
+ Překrytí OpenGL (Doporučeno)
+ Znovu použít poslední odkaz
+ Automaticky přehrát poslední funkční stream pro tento stejný film/epizodu, pokud je mezipaměť stále platná.
+ Sekundární jazyk zvuku
+ Sekundární preferovaný jazyk
+ DEKODÉR
+ DALŠÍ EPIZODA
+ PŘEHRÁVAČ
+ PŘESKOČENÍ SEGMENTŮ
+ AUTOMATICKÉ PŘEHRÁVÁNÍ STREAMU
+ VÝBĚR STREAMU
+ TITULKY A ZVUK
+ VYKRESLOVÁNÍ TITULKŮ
+ %1$d vybráno
+ Načítací obrazovka
+ Zobrazovat načítací obrazovku, dokud se neobjeví první snímek videa.
+ Přeskočit intro
+ Použít introdb.app k detekci inter a shrnutí děje.
+ Rozsah zdrojů pro auto. přehrávání
+ Všechny nainstalované doplňky
+ Automatické přehrávání zvažuje pouze streamy pocházející z vašich nainstalovaných doplňků.
+ Všechny zdroje
+ Automatické přehrávání může používat nainstalované doplňky i povolené pluginy.
+ Pouze povolené pluginy
+ Automatické přehrávání zvažuje pouze streamy pocházející z povolených pluginů.
+ Pouze nainstalované doplňky
+ Automatické přehrávání zvažuje pouze streamy pocházející z vašich nainstalovaných doplňků.
+ Automatický výběr streamu
+ Automaticky přehrát první zdroj
+ Automaticky přehrát první dostupný zdroj.
+ Ručně (vybrat stream)
+ Vždy zobrazit seznam zdrojů a nechat mě vybrat.
+ Automatické přehrávání (shoda regex)
+ Přehrát první zdroj, jehož text odpovídá vašemu vzoru regulárního výrazu.
+ Časový limit výběru streamu
+ Čas čekání na doplňky před výběrem.
+ Prahové minuty
+ Režim prahu další epizody
+ Minut před koncem
+ Procenta
+ Prahová procenta
+ Záložní řešení, pokud neexistuje časová značka pro outro.
+ %1$s%
+ Okamžitě
+ %1$ss
+ Neomezeně
+ Tunelované přehrávání (Tunneled Playback)
+ Hardwarová synchronizace zvuku a videa. Může zlepšit přehrávání na některých zařízeních Android TV.
+ Před zapnutím obohacení zadejte níže svůj vlastní TMDB API klíč.
+ API klíč
+ Povolit obohacení TMDB
+ Použít TMDB jako zdroj metadat k vylepšení dat z doplňků.
+ Zadejte svůj TMDB v3 API klíč.
+ Kód jazyka
+ Grafika
+ Loga a obrázky na pozadí z TMDB
+ Základní informace
+ Popis, žánry a hodnocení z TMDB
+ Kolekce
+ Filmové kolekce TMDB v pořadí vydání
+ Obsazení a štáb
+ Obsazení s fotkami, režisér a scénárista z TMDB
+ Podrobnosti
+ Délka, stav, země a jazyk z TMDB
+ Epizody
+ Názvy epizod, přehledy, náhledy a délka z TMDB
+ Podobné
+ Doporučení TMDB na stránce podrobností
+ Sítě
+ Sítě s logy z TMDB
+ Produkce
+ Produkční společnosti z TMDB
+ Plakáty sérií
+ Použít plakáty sérií z TMDB ve výběru sérií na obrazovce s metadaty u seriálů.
+ Trailery
+ Kandidáti na trailery z videí na TMDB pro sekci detailů o trailerech
+ Osobní API klíč
+ Jazyk
+ Jazyk metadat z TMDB pro název, logo a povolená pole
+ PŘIHLAŠOVACÍ ÚDAJE
+ LOKALIZACE
+ MODULY
+ Obohacení TMDB
+ Po schválení budete automaticky přesměrováni zpět.
+ AUTENTIZACE
+ Komentáře
+ Zobrazit recenze z Traktu na stránkách s metadaty
+ Připojit Trakt
+ Připojeno jako %1$s
+ Uživatel Traktu
+ Odpojit
+ Nepodařilo se otevřít prohlížeč
+ FUNKCE
+ Dokončete přihlášení k Traktu ve vašem prohlížeči
+ Synchronizujte svůj seznam ke zhlédnutí, postup sledování, pokračování ve sledování, scrobbling a osobní seznamy s Traktem.
+ Chybí přihlašovací údaje k Traktu v local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).
+ Otevřít přihlášení k Traktu
+ Vaše akce uložení nyní mohou cílit na seznam ke zhlédnutí a osobní seznamy na Traktu.
+ Přihlaste se pomocí Traktu pro povolení ukládání do seznamů a režimu knihovny Trakt.
+ Zdroj knihovny
+ Vyberte, kterou knihovnu použít pro ukládání a prohlížení vaší kolekce
+ Zdroj knihovny
+ Vyberte, kam ukládat a spravovat položky vaší knihovny
+ Trakt
+ Knihovna Nuvio
+ Vybrána knihovna Trakt
+ Vybrána knihovna Nuvio
+ Postup sledování
+ Vyberte, který zdroj postupu pohání obnovení a pokračování ve sledování
+ Postup sledování
+ Vyberte, zda by obnovení a pokračování ve sledování mělo využívat Trakt nebo Nuvio Sync, zatímco Trakt scrobbling zůstane aktivní.
+ Trakt
+ Nuvio Sync
+ Zdroj postupu sledování nastaven na Trakt
+ Zdroj postupu sledování nastaven na Nuvio Sync
+ Okno pro pokračování ve sledování
+ Historie Traktu zohledněná pro pokračování ve sledování
+ Okno pro pokračování ve sledování
+ Vyberte, kolik aktivity z Traktu se má objevit v sekci pokračování ve sledování.
+ Celá historie
+ %1$d dní
+ Hodnocení diváků
+ IMDb
+ Letterboxd
+ Metacritic
+ Rotten Tomatoes
+ TMDB
+ Trakt
+ Neznámý
+ Jantarový
+ Karmínový
+ Smaragdový
+ Oceán
+ Růžový
+ Fialový
+ Bílý
+ Další epizoda
+ Hledání zdroje…
+ Přehrávání přes %1$s za %2$d…
+ Náhled další epizody
+ Nevysílané
+ Přeskočit
+ Přeskočit intro
+ Přeskočit outro
+ Přeskočit shrnutí
+ Nebyly nalezeny žádné titulky
+ Afrikánština
+ Albánština
+ Amharština
+ Arabština
+ Arménština
+ Ázerbájdžánština
+ Baskičtina
+ Běloruština
+ Bengálština
+ Bosenština
+ Bulharština
+ Barmština
+ Katalánština
+ Čínština
+ Čínština (zjednodušená)
+ Čínština (tradiční)
+ Chorvatština
+ Čeština
+ Dánština
+ Nizozemština
+ Angličtina
+ Estonština
+ Filipínština
+ Finština
+ Francouzština
+ Galicijština
+ Gruzínština
+ Němčina
+ Řečtina
+ Gudžarátština
+ Hebrejština
+ Hindština
+ Maďarština
+ Islandština
+ Indonéština
+ Irština
+ Italština
+ Japonština
+ Kannadština
+ Kazaština
+ Khmerština
+ Korejština
+ Laoština
+ Lotyština
+ Litevština
+ Makedonština
+ Malajština
+ Malajálamština
+ Maltština
+ Maráthština
+ Mongolština
+ Nepálština
+ Norština
+ Perština
+ Polština
+ Portugalština (Portugalsko)
+ Portugalština (Brazílie)
+ Paňdžábština
+ Rumunština
+ Ruština
+ Srbština
+ Sinhálština
+ Slovenština
+ Slovinština
+ Španělština
+ Španělština (Latinská Amerika)
+ Svahilština
+ Švédština
+ Tamilština
+ Telugština
+ Thajština
+ Turečtina
+ Ukrajinština
+ Urdština
+ Uzbečtina
+ Vietnamština
+ Velština
+ Zuluština
+ Vymazat
+ Pokračovat
+ Ignorovat
+ Instalovat
+ Později
+ Ne
+ Aktualizovat
+ Ano
+ Opravdu chcete ukončit aplikaci?
+ Ukončit aplikaci
+ Tento katalog nevrátil žádné položky.
+ Nebyly nalezeny žádné tituly
+ Zkontrolujte své připojení k Wi-Fi nebo mobilním datům a zkuste to znovu.
+ Režisér
+ Nepodařilo se načíst
+ Podobné
+ Série
+ Tento doplněk vrátil videa pro seriál, ale žádné neobsahovalo čísla série nebo epizody.
+ Tento doplněk neposkytl metadata epizod pro tento seriál.
+ Epizody ještě nebyly tímto doplňkem zveřejněny.
+ Vaše zařízení je online, ale Nuvio se nemohlo spojit s požadovanými servery.
+ Zobrazit méně
+ Zobrazit více ▾
+ Scénárista
+ Všechny žánry
+ Katalog
+ %1$s • %2$s
+ Vybranému katalogu se nepodařilo vrátit položky objevování.
+ Nepodařilo se načíst objevování
+ Nainstalované doplňky neposkytují katalogy kompatibilní s dlaždicemi pro objevování.
+ Žádné katalogy pro objevování
+ Vybraný katalog a filtry nevrátily žádné položky.
+ Nebyly nalezeny žádné tituly
+ Před procházením katalogů pro objevování nainstalujte a ověřte alespoň jeden doplněk.
+ Vybrat katalog
+ Vybrat žánr
+ Vybrat typ
+ Typ
+ Označit předchozí jako nezhlédnuté
+ Označit předchozí jako zhlédnuté
+ Označit %1$s jako nezhlédnutou
+ Označit %1$s jako zhlédnutou
+ Označit jako nezhlédnuté
+ Označit jako zhlédnuté
+ Další na řadě
+ %1$s zhlédnuto
+ Nainstalujte a ověřte alespoň jeden doplněk, než se načtou řádky katalogu na domovské obrazovce.
+ Nainstalované doplňky aktuálně neposkytují katalogy kompatibilní s dlaždicemi bez vyžadovaných doplňujících dat.
+ Žádné řádky domovské obrazovky nejsou k dispozici
+ Zobrazit podrobnosti
+ Ovládací prvky pro přehrávání a ukládání.
+ Akce
+ Hlavní seznam herců.
+ Související kolekce nebo filmová série.
+ Kolekce
+ Sekce komentářů Trakt.
+ Délka, stav, vydání, jazyk a související informace.
+ Podrobnosti
+ Sezóny a seznam epizod pro seriály.
+ Řádek doporučení.
+ Podobné
+ Synopse, hodnocení, žánry a hlavní tvůrci.
+ Přehled
+ Studia a sítě.
+ Produkce
+ Trailery a zástupci pro přehrávání.
+ Zpět online
+ Nelze se spojit se servery
+ Žádné připojení k internetu
+ (věk %1$d)
+ Narozen(a) %1$s%2$s
+ Zemřel(a) %1$s
+ Známý(á) z: %1$s
+ Nejnovější
+ Nepodařilo se načíst podrobnosti pro %1$s
+ Populární
+ Něco se pokazilo
+ Připravované
+ Zpět
+ Zrušit
+ Zadejte PIN
+ Zadejte PIN pro %1$s
+ Zapomněli jste PIN?
+ Nesprávný PIN
+ Uzamčeno. Zkuste to znovu za %1$ds
+ Možnosti avatarů se zobrazí zde, jakmile se načte katalog.
+ Avatar: %1$s
+ Zadejte platnou URL adresu obrázku s http:// nebo https://.
+ Vyberte avatara
+ Níže vyberte avatara.
+ Vytvořit profil
+ Vybrána vlastní URL avatara.
+ Vlastní URL avatara
+ Vložte odkaz na obrázek, nebo ponechte prázdné pro použití vestavěného katalogu avatarů.
+ https://example.com/avatar.png
+ Všechna data pro "%1$s" budou trvale smazána.
+ Smazat profil
+ Přidat profil
+ Upravit profil
+ Zadejte aktuální PIN
+ Zadejte nový PIN
+ Profil %1$d
+ Načítání avatarů...
+ Spravovat profily
+ Název profilu
+ Nový profil
+ Primární doplňky vypnuty
+ Primární doplňky zapnuty
+ Odstranit PIN pro %1$s
+ Odstranit zámek PIN
+ Ukládání...
+ Zabezpečení
+ Přidejte PIN, pokud chcete mít tento profil před přepnutím uzamčený.
+ Tento profil je chráněn kódem PIN.
+ Vyberte avatara pro tento profil.
+ Nastavit zámek PIN
+ Nepojmenovaný profil
+ Použít primární doplňky
+ Sdílet nastavení doplňků hlavního profilu namísto správy samostatného seznamu.
+ Kdo se dívá?
+ Staženo
+ Pokračovat
+ Aktivní scrapery
+ Kontrola dalších doplňků…
+ Kopírovat odkaz na stream
+ Stáhnout soubor
+ Nainstalovaným doplňkům se nepodařilo vrátit platnou odpověď pro streamy.
+ Nepodařilo se načíst streamy
+ Nejprve nainstalujte doplněk, abyste mohli načíst streamy pro tento titul.
+ Vaše nainstalované doplňky neposkytují streamy pro tento typ titulu.
+ Není k dispozici žádný doplněk pro streamování
+ Žádný z vašich nainstalovaných doplňků nevrátil streamy pro tento titul.
+ S%1$d E%2$d
+ Epizoda
+ S%1$dE%2$d - %3$s
+ Načítání…
+ Hledání zdroje…
+ Hledání streamů…
+ Odkaz na stream zkopírován
+ Není k dispozici žádný přímý odkaz na stream
+ Nejsou k dispozici žádná metadata
+ Obnovit streamy
+ Pokračovat od %1$d%%
+ Pokračovat od %1$s
+ VELIKOST %1$s
+ Torrent streamy nejsou podporovány
+ Zavřít trailer
+ Trailer nelze přehrát
+ Nepodařilo se načíst seznamy Trakt
+ Nepodařilo se aktualizovat seznamy Trakt
+ %1$s • %2$s
+ Kontrola aktualizací selhala
+ Stahování selhalo
+ Stahování %1$d%%
+ Nelze zahájit instalaci
+ Používáte nejnovější verzi.
+ Povolte instalace aplikací pro Nuvio, poté se vraťte a pokračujte.
+ Stahování aktualizace...
+ Nebyly nalezeny žádné aktualizace.
+ Nová verze je připravena k instalaci.
+ Aktualizace v aplikaci nejsou v tomto sestavení k dispozici.
+ Příprava stahování
+ Poznámky k vydání
+ Povolte instalace pro pokračování
+ Aktualizace k dispozici
+ Stav aktualizace
+ Tento doplněk je již nainstalován.
+ Zadejte platnou URL doplňku
+ Nelze načíst manifest
+ Nuvio
+ Smazání účtu selhalo
+ Přihlášení selhalo
+ Odhlášení selhalo
+ Registrace selhala
+ Nelze načíst položky katalogu.
+ Další na řadě
+ Další na řadě • S%1$dE%2$d
+ Logo %1$s
+ Nepodařilo se načíst komentáře
+ Nepodařilo se načíst podrobnosti z žádného doplňku.
+ Sítě
+ Žádný doplněk neposkytuje metadata pro tento obsah.
+ Stahování selhalo
+ Zobrazuje aktuální průběh a ovládání stahování.
+ Stahování
+ Stahování dokončeno
+ Stahování %1$s • %2$s
+ Stahování %1$s • %2$s / %3$s
+ Stahování selhalo
+ Pozastaveno %1$s
+ Odebrat
+ Odebrat %1$s z %2$s?
+ Odebrat %1$s z vaší knihovny?
+ Odebrat z knihovny?
+ Film
+ Upozornění na vydání nové epizody uloženého seriálu.
+ Náhled upozornění na vydání epizody.
+ Nepodařilo se odeslat zkušební oznámení.
+ Zkušební oznámení odesláno pro %1$s.
+ Tento stream nelze přehrát.
+ PIN tohoto profilu se změnil. Pro obnovení zámku na tomto zařízení se jednou připojte k internetu.
+ Zámek PIN se nepodařilo odstranit. Zkuste to znovu.
+ Pro odstranění zámku PIN se připojte k internetu.
+ Tento PIN zatím nelze na tomto zařízení ověřit offline. Nejprve se připojte a odemkněte jej online.
+ Nepodařilo se nastavit PIN. Zkuste to znovu.
+ Pro nastavení PIN kódu se připojte k internetu.
+ Tento profil používá primární doplňky.
+ Nepodařilo se načíst %1$s
+ Stream
+ Vložené
+ Autorizace odepřena
+ Dokončete přihlášení k Traktu ve vašem prohlížeči
+ Neplatný Trakt callback
+ Neplatný stav Trakt callbacku
+ Neplatná odpověď s tokenem Trakt
+ Nepodařilo se načíst knihovnu Trakt
+ Seznam %1$d
+ Trakt nevrátil autorizační kód
+ Chybí přihlašovací údaje k Traktu
+ Nepodařilo se načíst postup na Traktu
+ Nepodařilo se dokončit přihlášení k Traktu
+ Uživatel Traktu
+ Seznam ke zhlédnutí
+ Trailer
+ Neznámé
+ Doplněk
+ Uloženo
+ Přehrát %1$s
+ Pokračovat %1$s
+ JSON je prázdný.
+ Kolekce %1$d má prázdné ID.
+ Kolekce '%1$s' má prázdný název.
+ Složka %1$d v '%2$s' má prázdné ID.
+ Složka '%1$s' v '%2$s' má prázdný název.
+ Zdroj %1$d ve složce '%2$s' má prázdná pole.
+ Zdroj %1$d ve složce '%2$s' nemá ID seznamu Trakt.
+ Neplatný JSON: %1$s
+ Doplněk nenalezen: %1$s
+ Leden
+ Únor
+ Březen
+ Duben
+ Květen
+ Červen
+ Červenec
+ Srpen
+ Září
+ Říjen
+ Listopad
+ Prosinec
+ Led
+ Úno
+ Bře
+ Dub
+ Kvě
+ Čvn
+ Čvc
+ Srp
+ Zář
+ Říj
+ Lis
+ Pro
+ Produkční společnost
+ Síť
+ Nepodařilo se načíst %1$s
+ Populární
+ Nedávné
+ %1$s • %2$s
+ Nejlépe hodnocené
+ Věkové hodnocení
+ Podrobnosti o filmu
+ Původní jazyk
+ Země původu
+ Informace o vydání
+ Délka
+ Plakáty
+ Text
+ Podrobnosti o seriálu
+ Stav
+ Videa
+ SOUBOR
+ Není k dispozici žádný přímý odkaz na stream
+ Nahradilo předchozí stahování
+ Stahování zahájeno
+ Nepodporovaný formát streamu pro stahování
+ Prázdné tělo odpovědi
+ Požadavek selhal s chybou HTTP %1$d
+ Systém stahování není inicializován
+ Požadavek na stažení selhal
+ %1$s - %2$s
+ Uložené tituly se zde objeví poté, co klepnete na Uložit na obrazovce s podrobnostmi.
+ Vaše knihovna je prázdná
+ Knihovnu se nepodařilo načíst
+ Ostatní
+ Knihovna
+ Připojte Trakt a ukládejte tituly do svého seznamu ke zhlédnutí nebo do osobních seznamů.
+ Vaše knihovna Trakt je prázdná
+ Knihovnu Trakt se nepodařilo načíst
+ Knihovna Trakt
+ Anime
+ Kanály
+ Filmy
+ Seriály
+ TV
+ %1$s je nyní venku
+ %1$s • %2$s je nyní venku
+ Nová epizoda je nyní venku
+ %1$s je nyní venku
+ Vydání epizod
+ Tvůrce
+ Režisér
+ Scénárista
+ Hodnocení diváků
+ Nebyl nalezen žádný přehratelný stream traileru.
+ Série %1$d - %2$s
+ B
+ KB
+ MB
+ GB
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index f235570b..782a24e0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1,4 +1,5 @@
+ Data sources, acknowledgements, and platform licenses
Open recognition and project credits
Back
Cancel
@@ -366,6 +367,7 @@
Continue Watching
Home Layout
Integrations
+ Licenses & Attribution
MDBList Ratings
Detail Page
Notifications
@@ -391,6 +393,31 @@
Change to a different profile.
Switch Profile
Open Trakt connection screen
+ No settings found.
+ Search settings...
+ RESULTS
+ APP LICENSE
+ DATA & SERVICES
+ PLAYBACK LICENSE
+ Nuvio Mobile
+ Source code and license terms are available in the project repository.
+ Licensed under the GNU General Public License v3.0.
+ The Movie Database (TMDB)
+ Nuvio uses the TMDB API for movie and TV metadata, artwork, trailers, cast, production details, collections, and recommendations. This product uses the TMDB API but is not endorsed or certified by TMDB.
+ IMDb Non-Commercial Datasets
+ Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms.
+ Trakt
+ Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt.
+ MDBList
+ Nuvio uses MDBList for ratings and external score provider data. Nuvio is not affiliated with or endorsed by MDBList.
+ IntroDB
+ Nuvio uses the IntroDB API for community-provided intro, recap, credits, and preview timestamps used by skip controls. Nuvio is not affiliated with or endorsed by IntroDB.
+ MPVKit
+ Used for playback on iOS builds.
+ MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Used for playback on Android builds.
+ Licensed under the Apache License, Version 2.0.
Loading your Trakt lists…
Choose where to save this title on Trakt
Donate
@@ -479,6 +506,8 @@
Display hero carousel at top of home.
Hide Unreleased Content
Hide movies and shows that haven't been released yet.
+ Hide Catalog Underline
+ Remove the accent line under catalog and collection titles throughout the app.
%1$d of %2$d catalogs visible • %3$d hero sources selected
Open a catalog only when you need to rename or reorder it.
Visible
@@ -514,6 +543,12 @@
Blur Unwatched in Continue Watching
Include upcoming episodes in Continue Watching before they air.
Show Unaired Next Up Episodes
+ SORT ORDER
+ Sort Order
+ Default
+ Sort all items by recency
+ Streaming Style
+ Released items first, upcoming at the end
Poster Card Style
ON LAUNCH
UP NEXT BEHAVIOR
@@ -1111,6 +1146,7 @@
Download failed
Paused %1$s
Remove
+ Remove %1$s from %2$s?
Remove %1$s from your library?
Remove from Library?
Movie
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 29249339..140ea86e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -73,6 +73,7 @@ import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.CachePolicy
import coil3.request.crossfade
+import coil3.svg.SvgDecoder
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
@@ -146,6 +147,7 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
+import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen
@@ -238,6 +240,9 @@ object AccountSettingsRoute
@Serializable
object SupportersContributorsSettingsRoute
+@Serializable
+object LicensesAttributionsSettingsRoute
+
@Serializable
object CollectionsRoute
@@ -301,6 +306,9 @@ fun App() {
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
+ .components {
+ add(SvgDecoder.Factory())
+ }
.configurePlatformImageLoader()
.build()
}
@@ -513,6 +521,7 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
+ val currentBackStackEntry by navController.currentBackStackEntryAsState()
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
@@ -1005,6 +1014,7 @@ private fun MainAppContent(
val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
+ val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true
selectedTab = AppScreenTab.Home
@@ -1063,6 +1073,7 @@ private fun MainAppContent(
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
+ animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
@@ -1092,6 +1103,9 @@ private fun MainAppContent(
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
+ onLicensesAttributionsSettingsClick = {
+ navController.navigate(LicensesAttributionsSettingsRoute)
+ },
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
@@ -1354,7 +1368,13 @@ private fun MainAppContent(
reuseHandled = true
if (launch.manualSelection) return@LaunchedEffect
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
- val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
@@ -1394,17 +1414,37 @@ private fun MainAppContent(
}
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
+ val expectedStreamsRequestToken = StreamsRepository.requestToken(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ manualSelection = launch.manualSelection,
+ )
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
- LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
+ LaunchedEffect(
+ streamsUiState.autoPlayStream,
+ streamsUiState.requestToken,
+ expectedStreamsRequestToken,
+ reuseHandled,
+ launch.manualSelection,
+ ) {
if (!reuseHandled) return@LaunchedEffect
if (launch.manualSelection) return@LaunchedEffect
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
+ if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = sourceUrl,
@@ -1484,7 +1524,13 @@ private fun MainAppContent(
if (sourceUrl != null) {
// Persist for Reuse Last Link
if (playerSettings.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = sourceUrl,
@@ -1725,6 +1771,15 @@ private fun MainAppContent(
onBack = onBack,
)
}
+ composable { backStackEntry ->
+ val onBack = rememberGuardedPopBackStack(
+ navController = navController,
+ backStackEntry = backStackEntry,
+ )
+ LicensesAttributionsSettingsScreen(
+ onBack = onBack,
+ )
+ }
composable { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
@@ -2003,6 +2058,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
+ animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@@ -2020,6 +2076,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {},
+ onLicensesAttributionsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
@@ -2033,6 +2090,7 @@ private fun AppTabHost(
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
+ animateCollectionGifs = animateHomeCollectionGifs,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
@@ -2072,6 +2130,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
+ onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
index 603fce83..8892f6e6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
+import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner {
WatchedRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState()
+ CollectionMobileSettingsRepository.clearLocalState()
CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
index 9dd7a999..58df719e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
@@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
+import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
+import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService
@@ -158,6 +160,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
+ CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
@@ -202,6 +205,7 @@ object ProfileSettingsSync {
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
+ collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
@@ -232,6 +236,9 @@ object ProfileSettingsSync {
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
MetaScreenSettingsRepository.onProfileChanged()
+ CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
+ CollectionMobileSettingsRepository.onProfileChanged()
+
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged()
@@ -251,6 +258,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
+ CollectionMobileSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
@@ -272,6 +280,7 @@ object ProfileSettingsSync {
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
+ "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
@@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
+ @SerialName("collection_mobile_settings_payload") val collectionMobileSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt
index 38c88914..d86a1a81 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt
@@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
onSecondary = palette.onSecondaryVariant,
background = if (amoled) Color.Black else palette.background,
onBackground = Color(0xFFF5F7F8),
- surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated,
+ surface = palette.backgroundElevated,
onSurface = Color(0xFFF5F7F8),
- surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard,
+ surfaceVariant = palette.backgroundCard,
onSurfaceVariant = Color(0xFF969CA3),
outline = Color(0xFF252A2A),
error = Color(0xFFE36A8A),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
index 47b852fe..80f913cb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
@@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl(
): String {
val encodedId = id.encodeAddonPathSegment()
val baseUrl = addonTransportBaseUrl(manifestUrl)
- return if (extraPathSegment.isNullOrEmpty()) {
+ val query = manifestUrl.substringAfter("?", "").let { query ->
+ if (query.isBlank()) "" else "?$query"
+ }
+ val resourceUrl = if (extraPathSegment.isNullOrEmpty()) {
"$baseUrl/$resource/$type/$encodedId.json"
} else {
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
}
+ return resourceUrl + query
}
@@ -43,4 +47,4 @@ internal fun String.encodeAddonPathSegment(): String =
}
}
-private const val ADDON_URL_HEX = "0123456789ABCDEF"
\ No newline at end of file
+private const val ADDON_URL_HEX = "0123456789ABCDEF"
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 0a31a9d7..70b5204f 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
@@ -195,10 +195,10 @@ object CollectionEditorRepository {
)
}
- fun updateFolderFocusGifEnabled(enabled: Boolean) {
+ fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
- editingFolder = folder.copy(focusGifEnabled = enabled),
+ editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
)
}
@@ -808,6 +808,8 @@ object CollectionEditorRepository {
folders = state.folders,
)
+ CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
+
if (state.isNew) {
CollectionRepository.addCollection(collection)
} else {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
index 1114ac1b..7219395a 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
@@ -702,8 +702,8 @@ private fun FolderEditorPage(
FolderEditorToggleRow(
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
- checked = folder.focusGifEnabled,
- onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
+ checked = folder.mobileFocusGifEnabled,
+ onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) },
)
FolderEditorToggleRow(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt
new file mode 100644
index 00000000..c122ae63
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt
@@ -0,0 +1,155 @@
+package com.nuvio.app.features.collection
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+data class CollectionMobileSettingsUiState(
+ val folderGifOverrides: Map = emptyMap(),
+)
+
+object CollectionMobileSettingsRepository {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var hasLoaded = false
+
+ fun ensureLoaded() {
+ if (hasLoaded) return
+ loadFromDisk()
+ }
+
+ fun onProfileChanged() {
+ loadFromDisk()
+ CollectionRepository.onMobileSettingsChanged()
+ }
+
+ fun clearLocalState() {
+ hasLoaded = false
+ _uiState.value = CollectionMobileSettingsUiState()
+ }
+
+ fun isFolderGifEnabled(collectionId: String, folderId: String): Boolean {
+ ensureLoaded()
+ return _uiState.value.folderGifOverrides[folderKey(collectionId, folderId)] ?: true
+ }
+
+ fun applyToCollections(collections: List): List {
+ ensureLoaded()
+ return collections.map(::applyToCollection)
+ }
+
+ fun applyToCollection(collection: Collection): Collection {
+ ensureLoaded()
+ return collection.copy(
+ folders = collection.folders.map { folder ->
+ folder.copy(
+ mobileFocusGifEnabled = isFolderGifEnabled(
+ collectionId = collection.id,
+ folderId = folder.id,
+ ),
+ )
+ },
+ )
+ }
+
+ fun replaceCollectionFolderGifSettings(collectionId: String, folders: List) {
+ ensureLoaded()
+ val collectionPrefix = "${collectionId.trim()}$FolderKeySeparator"
+ val next = _uiState.value.folderGifOverrides
+ .filterKeys { key -> !key.startsWith(collectionPrefix) }
+ .toMutableMap()
+ folders.forEach { folder ->
+ val key = folderKey(collectionId, folder.id)
+ if (folder.mobileFocusGifEnabled) {
+ next.remove(key)
+ } else {
+ next[key] = false
+ }
+ }
+ _uiState.value = CollectionMobileSettingsUiState(folderGifOverrides = next)
+ persist()
+ CollectionRepository.onMobileSettingsChanged()
+ }
+
+ private fun loadFromDisk() {
+ hasLoaded = true
+
+ val payload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim()
+ if (payload.isEmpty()) {
+ _uiState.value = CollectionMobileSettingsUiState()
+ return
+ }
+
+ val stored = runCatching {
+ json.decodeFromString(payload)
+ }.getOrNull()
+
+ _uiState.value = CollectionMobileSettingsUiState(
+ folderGifOverrides = stored
+ ?.folderGifOverrides
+ .orEmpty()
+ .mapNotNull { item ->
+ if (item.collectionId.isBlank() || item.folderId.isBlank()) {
+ null
+ } else {
+ folderKey(item.collectionId, item.folderId) to item.enabled
+ }
+ }
+ .toMap(),
+ )
+ }
+
+ private fun persist() {
+ if (_uiState.value.folderGifOverrides.isEmpty()) {
+ CollectionMobileSettingsStorage.savePayload("")
+ return
+ }
+ val payload = StoredCollectionMobileSettingsPayload(
+ folderGifOverrides = _uiState.value.folderGifOverrides
+ .mapNotNull { (key, enabled) ->
+ val parts = key.split(FolderKeySeparator, limit = 2)
+ val collectionId = parts.getOrNull(0).orEmpty()
+ val folderId = parts.getOrNull(1).orEmpty()
+ if (collectionId.isBlank() || folderId.isBlank()) {
+ null
+ } else {
+ StoredFolderGifOverride(
+ collectionId = collectionId,
+ folderId = folderId,
+ enabled = enabled,
+ )
+ }
+ }
+ .sortedWith(compareBy { it.collectionId }.thenBy { it.folderId }),
+ )
+ CollectionMobileSettingsStorage.savePayload(json.encodeToString(payload))
+ }
+
+ private fun folderKey(collectionId: String, folderId: String): String =
+ "${collectionId.trim()}$FolderKeySeparator${folderId.trim()}"
+}
+
+private const val FolderKeySeparator = "\u001F"
+
+@Serializable
+private data class StoredCollectionMobileSettingsPayload(
+ @SerialName("folder_gif_overrides") val folderGifOverrides: List = emptyList(),
+)
+
+@Serializable
+private data class StoredFolderGifOverride(
+ @SerialName("collection_id") val collectionId: String,
+ @SerialName("folder_id") val folderId: String,
+ val enabled: Boolean = true,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt
new file mode 100644
index 00000000..58ac9020
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt
@@ -0,0 +1,6 @@
+package com.nuvio.app.features.collection
+
+internal expect object CollectionMobileSettingsStorage {
+ fun loadPayload(): String?
+ fun savePayload(payload: String)
+}
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 ba9080d6..31962922 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
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
enum class FolderViewMode {
TABBED_GRID,
@@ -13,7 +14,7 @@ enum class FolderViewMode {
companion object {
fun fromString(value: String): FolderViewMode =
when {
- value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS
+ value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> FOLLOW_LAYOUT
value.equals(ROWS.name, ignoreCase = true) -> ROWS
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
else -> TABBED_GRID
@@ -168,6 +169,8 @@ data class CollectionFolder(
val coverImageUrl: String? = null,
val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true,
+ @Transient
+ val mobileFocusGifEnabled: Boolean = true,
val coverEmoji: String? = null,
val tileShape: String = "poster",
val hideTitle: Boolean = false,
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 39916184..270e9781 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
@@ -52,7 +52,8 @@ object CollectionRepository {
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
- _collections.value = json.decodeFromString>(payload)
+ val decoded = json.decodeFromString>(payload)
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
}
@@ -75,14 +76,15 @@ object CollectionRepository {
fun addCollection(collection: Collection) {
ensureLoaded()
- _collections.value = _collections.value + collection
+ _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
persist()
}
fun updateCollection(collection: Collection) {
ensureLoaded()
+ val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
_collections.value = _collections.value.map {
- if (it.id == collection.id) collection else it
+ if (it.id == collection.id) decorated else it
}
persist()
}
@@ -95,7 +97,7 @@ object CollectionRepository {
fun setCollections(collections: List) {
ensureLoaded()
- _collections.value = collections
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist()
}
@@ -127,7 +129,7 @@ object CollectionRepository {
return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString>(jsonString)
- _collections.value = imported
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
persist()
imported
}
@@ -262,10 +264,15 @@ object CollectionRepository {
internal fun applyFromRemote(collections: List, rawJson: JsonElement) {
rawCollectionsJson = rawJson
- _collections.value = collections
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist(sync = false)
}
+ internal fun onMobileSettingsChanged() {
+ if (!hasLoaded) return
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
+ }
+
private fun ensureLoaded() {
if (!hasLoaded) initialize()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
index ac964731..d2210058 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
@@ -98,11 +98,14 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
- val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
+ val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
+ val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
+ normalizeSeasonNumber(episode.season)
+ }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
- if (globalIndex in sortedEpisodes.indices) {
- watchedIndex = globalIndex
+ if (globalIndex in mainEpisodes.indices) {
+ watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
index e920de04..202af87a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
@@ -33,6 +33,7 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
+ val hideCatalogUnderline: Boolean = false,
val items: List = emptyList(),
) {
val signature: String
@@ -41,6 +42,8 @@ data class HomeCatalogSettingsUiState(
append('|')
append(hideUnreleasedContent)
append('|')
+ append(hideCatalogUnderline)
+ append('|')
append(
items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
@@ -59,6 +62,7 @@ internal data class HomeCatalogPreference(
internal data class HomeCatalogSettingsSnapshot(
val heroEnabled: Boolean,
val hideUnreleasedContent: Boolean,
+ val hideCatalogUnderline: Boolean,
val preferences: Map,
)
@@ -75,6 +79,7 @@ private data class StoredHomeCatalogPreference(
private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
+ val hideCatalogUnderline: Boolean = false,
val items: List = emptyList(),
)
@@ -95,12 +100,14 @@ object HomeCatalogSettingsRepository {
private var preferences: MutableMap = mutableMapOf()
private var heroEnabled = true
private var hideUnreleasedContent = false
+ private var hideCatalogUnderline = false
fun onProfileChanged() {
hasLoaded = false
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
+ hideCatalogUnderline = false
definitions = emptyList()
collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState()
@@ -113,6 +120,7 @@ object HomeCatalogSettingsRepository {
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
+ hideCatalogUnderline = false
_uiState.value = HomeCatalogSettingsUiState()
}
@@ -144,6 +152,7 @@ object HomeCatalogSettingsRepository {
return HomeCatalogSettingsSnapshot(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
preferences = preferences.mapValues { (_, value) ->
HomeCatalogPreference(
customTitle = value.customTitle,
@@ -172,6 +181,14 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings()
}
+ fun setHideCatalogUnderline(enabled: Boolean) {
+ ensureLoaded()
+ if (hideCatalogUnderline == enabled) return
+ hideCatalogUnderline = enabled
+ publish()
+ persist()
+ }
+
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference ->
if (!enabled) {
@@ -200,6 +217,7 @@ object HomeCatalogSettingsRepository {
ensureLoaded()
heroEnabled = true
hideUnreleasedContent = false
+ hideCatalogUnderline = false
preferences.clear()
normalizePreferences()
publish()
@@ -246,6 +264,7 @@ object HomeCatalogSettingsRepository {
if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
+ hideCatalogUnderline = parsedPayload.hideCatalogUnderline
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
publish()
return
@@ -345,6 +364,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
items = items,
)
}
@@ -355,6 +375,7 @@ object HomeCatalogSettingsRepository {
StoredHomeCatalogSettingsPayload(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
items = preferences.values.sortedBy { it.order },
),
),
@@ -437,6 +458,7 @@ object HomeCatalogSettingsRepository {
}
return SyncHomeCatalogPayload(
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
items = items,
)
}
@@ -444,6 +466,7 @@ object HomeCatalogSettingsRepository {
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded()
hideUnreleasedContent = payload.hideUnreleasedContent
+ hideCatalogUnderline = payload.hideCatalogUnderline
if (payload.items.isNotEmpty()) {
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item ->
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt
index 5fbf8f7c..bddc4c97 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt
@@ -42,6 +42,7 @@ data class SyncCatalogItem(
@Serializable
data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
+ @SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
val items: List = emptyList(),
)
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 87879839..c3c1a2a6 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
@@ -42,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
+import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock
@@ -70,6 +71,7 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
+ animateCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@@ -246,11 +248,14 @@ fun HomeScreen(
visibleContinueWatchingEntries,
cachedInProgressItems,
effectivNextUpItems,
+ continueWatchingPreferences.sortMode,
) {
buildHomeContinueWatchingItems(
visibleEntries = visibleContinueWatchingEntries,
cachedInProgressByVideoId = cachedInProgressItems,
nextUpItemsBySeries = effectivNextUpItems,
+ sortMode = continueWatchingPreferences.sortMode,
+ todayIsoDate = CurrentDateProvider.todayIsoDate(),
)
}
val availableManifests = remember(addonsUiState.addons) {
@@ -403,6 +408,11 @@ fun HomeScreen(
val enabledHomeItems = remember(homeSettingsUiState.items) {
homeSettingsUiState.items.filter { it.enabled }
}
+ val hasRenderableCollectionRows = remember(enabledHomeItems, collectionsMap) {
+ enabledHomeItems.any { item ->
+ item.isCollection && collectionsMap[item.key] != null
+ }
+ }
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
@@ -465,7 +475,7 @@ fun HomeScreen(
}
when {
- addonsUiState.addons.none { it.manifest != null } -> {
+ addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
@@ -490,7 +500,7 @@ fun HomeScreen(
}
}
- homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
+ homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
@@ -512,7 +522,8 @@ fun HomeScreen(
}
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
- (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> {
+ (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) &&
+ !hasRenderableCollectionRows -> {
item {
if (networkStatusUiState.isOfflineLike) {
NuvioNetworkOfflineCard(
@@ -560,6 +571,7 @@ fun HomeScreen(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
+ animateGifs = animateCollectionGifs,
onFolderClick = onFolderClick,
)
}
@@ -631,6 +643,8 @@ internal fun buildHomeContinueWatchingItems(
visibleEntries: List,
cachedInProgressByVideoId: Map = emptyMap(),
nextUpItemsBySeries: Map>,
+ sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
+ todayIsoDate: String = "",
): List {
val inProgressSeriesIds = visibleEntries
.asSequence()
@@ -639,7 +653,7 @@ internal fun buildHomeContinueWatchingItems(
.filter(String::isNotBlank)
.toSet()
- return buildList {
+ val candidates = buildList {
addAll(
visibleEntries.map { entry ->
val liveItem = entry.toContinueWatchingItem()
@@ -661,13 +675,62 @@ internal fun buildHomeContinueWatchingItems(
},
)
}
+
+ // Deduplicate by series/content id first (order-stable)
+ val seen = mutableSetOf()
+ val deduplicated = candidates
.sortedWith(
compareByDescending { it.lastUpdatedEpochMs }
.thenByDescending { it.isProgressEntry },
)
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
- .distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
+ .filter { candidate ->
+ val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId }
+ seen.add(key)
+ }
+
+ return when (sortMode) {
+ ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item)
+ ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate)
+ }
+}
+
+private fun applyStreamingStyleSort(
+ candidates: List,
+ todayIsoDate: String,
+): List {
+ val (released, unreleased) = candidates.partition { candidate ->
+ val item = candidate.item
+ if (!item.isNextUp) {
+ true // in-progress items are always "released"
+ } else {
+ val itemReleased = item.released
+ if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) {
+ true // no date info → treat as released
+ } else {
+ isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased)
+ }
+ }
+ }
+
+ // Released: most recently watched first (already sorted by dedup pass)
+ val sortedReleased = released.map(HomeContinueWatchingCandidate::item)
+
+ // Unaired: soonest air date first; unknown dates go to the end
+ val sortedUnreleased = unreleased
+ .sortedWith { a, b ->
+ val dateA = a.item.released?.takeIf { it.isNotBlank() }
+ val dateB = b.item.released?.takeIf { it.isNotBlank() }
+ when {
+ dateA == null && dateB == null -> 0
+ dateA == null -> 1
+ dateB == null -> -1
+ else -> dateA.compareTo(dateB)
+ }
+ }
.map(HomeContinueWatchingCandidate::item)
+
+ return sortedReleased + sortedUnreleased
}
private data class CompletedSeriesCandidate(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt
index e7561e09..aecd6626 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt
@@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
+import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey
@@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent(
onPosterLongClick: ((MetaPreview) -> Unit)?,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
+ val homeCatalogSettings by remember {
+ HomeCatalogSettingsRepository.snapshot()
+ HomeCatalogSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
NuvioShelfSection(
title = section.title,
@@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent(
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
+ showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
onViewAllClick = onViewAllClick,
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> item.stableKey() },
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
index 2c3121aa..da63fe5d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
@@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -23,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
import com.nuvio.app.core.ui.landscapePosterWidth
@@ -30,6 +33,7 @@ import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionFolder
+import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape
@Composable
@@ -37,6 +41,7 @@ fun HomeCollectionRowSection(
collection: Collection,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
+ animateGifs: Boolean = true,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) {
if (collection.folders.isEmpty()) return
@@ -46,6 +51,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
+ animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
} else {
@@ -54,6 +60,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
+ animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
}
@@ -65,18 +72,26 @@ private fun HomeCollectionRowSectionContent(
collection: Collection,
modifier: Modifier,
sectionPadding: Dp,
+ animateGifs: Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
+ val homeCatalogSettings by remember {
+ HomeCatalogSettingsRepository.snapshot()
+ HomeCatalogSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+
NuvioShelfSection(
title = collection.title,
entries = collection.folders,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
+ showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
) { folder ->
CollectionFolderCard(
folder = folder,
+ animateGifs = animateGifs,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
)
}
@@ -86,6 +101,7 @@ private fun HomeCollectionRowSectionContent(
private fun CollectionFolderCard(
folder: CollectionFolder,
modifier: Modifier = Modifier,
+ animateGifs: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
@@ -138,7 +154,7 @@ private fun CollectionFolderCard(
contentDescription = folder.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
- animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl),
+ animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
)
}
!folder.coverEmoji.isNullOrBlank() -> {
@@ -180,7 +196,7 @@ private fun CollectionFolderCard(
}
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
- return if (folder.focusGifEnabled) {
+ return if (folder.mobileFocusGifEnabled) {
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
} else {
firstNonBlank(folder.coverImageUrl)
@@ -196,5 +212,5 @@ private fun isAnimatedCollectionFolderImage(
imageUrl: String,
): Boolean {
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
- return folder.focusGifEnabled && imageUrl == gifUrl
+ return folder.mobileFocusGifEnabled && imageUrl == gifUrl
}
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 c93d5caa..46c2acdc 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
@@ -296,6 +296,14 @@ object LibraryRepository {
}
}
+ suspend fun removeFromList(item: LibraryItem, listKey: String) {
+ val desiredMembership = libraryMembershipWithRemovedList(
+ currentMembership = getMembershipSnapshot(item),
+ listKey = listKey,
+ )
+ applyMembershipChanges(item, desiredMembership)
+ }
+
private fun pushToServer() {
syncScope.launch {
runCatching {
@@ -417,6 +425,14 @@ internal fun libraryMembershipWithLocal(
putAll(traktMembership)
}
+internal fun libraryMembershipWithRemovedList(
+ currentMembership: Map,
+ listKey: String,
+): Map =
+ currentMembership.toMutableMap().apply {
+ this[listKey] = false
+ }
+
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId,
type = contentType,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index cf26a399..8c94c676 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioStatusModal
+import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
@@ -33,8 +34,15 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
+private data class LibraryRemovalTarget(
+ val item: LibraryItem,
+ val listKey: String? = null,
+ val listTitle: String? = null,
+)
+
@Composable
fun LibraryScreen(
modifier: Modifier = Modifier,
@@ -47,7 +55,7 @@ fun LibraryScreen(
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
-// var pendingRemovalItem by remember { mutableStateOf(null) }
+ var pendingRemovalTarget by remember { mutableStateOf(null) }
var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
@@ -167,8 +175,16 @@ fun LibraryScreen(
sections = uiState.sections,
onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick,
- onPosterLongClick = { item ->
- onPosterLongClick?.invoke(item)
+ onPosterLongClick = { item, section ->
+ pendingRemovalTarget = if (isTraktSource) {
+ LibraryRemovalTarget(
+ item = item,
+ listKey = section.type,
+ listTitle = section.displayTitle,
+ )
+ } else {
+ LibraryRemovalTarget(item = item)
+ }
},
)
}
@@ -177,17 +193,38 @@ fun LibraryScreen(
NuvioStatusModal(
title = stringResource(Res.string.library_remove_title),
- message = pendingRemovalItem?.let {
- stringResource(Res.string.library_remove_message, it.name)
+ message = pendingRemovalTarget?.let { target ->
+ val listTitle = target.listTitle
+ if (listTitle.isNullOrBlank()) {
+ stringResource(Res.string.library_remove_message, target.item.name)
+ } else {
+ stringResource(Res.string.library_remove_from_list_message, target.item.name, listTitle)
+ }
}.orEmpty(),
- isVisible = pendingRemovalItem != null,
+ isVisible = pendingRemovalTarget != null,
confirmText = stringResource(Res.string.library_remove_confirm),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
- pendingRemovalItem?.id?.let(LibraryRepository::remove)
- pendingRemovalItem = null
+ val target = pendingRemovalTarget
+ pendingRemovalTarget = null
+ target?.let {
+ val listKey = target.listKey
+ if (listKey.isNullOrBlank()) {
+ LibraryRepository.remove(target.item.id)
+ } else {
+ coroutineScope.launch {
+ runCatching {
+ LibraryRepository.removeFromList(target.item, listKey)
+ }.onFailure { error ->
+ NuvioToastController.show(
+ error.message ?: getString(Res.string.trakt_lists_update_failed),
+ )
+ }
+ }
+ }
+ }
},
- onDismiss = { pendingRemovalItem = null },
+ onDismiss = { pendingRemovalTarget = null },
)
}
@@ -195,7 +232,7 @@ private fun LazyListScope.librarySections(
sections: List,
onPosterClick: ((LibraryItem) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
- onPosterLongClick: (LibraryItem) -> Unit,
+ onPosterLongClick: (LibraryItem, LibrarySection) -> Unit,
) {
items(
items = sections,
@@ -218,7 +255,7 @@ private fun LazyListScope.librarySections(
HomePosterCard(
item = item.toMetaPreview(),
onClick = onPosterClick?.let { { it(item) } },
- onLongClick = { onPosterLongClick(item) },
+ onLongClick = { onPosterLongClick(item, section) },
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index fc24fba4..9db6838d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -357,9 +357,10 @@ fun PlayerScreen(
.coerceIn(0f, 100f)
}
- fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
+ suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
+ videoId = activeVideoId,
title = title,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
@@ -367,11 +368,15 @@ fun PlayerScreen(
)
fun emitTraktScrobbleStart() {
- val item = currentTraktScrobbleItem() ?: return
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
scope.launch {
+ val item = currentTraktScrobbleItem()
+ if (item == null) {
+ hasRequestedScrobbleStartForCurrentItem = false
+ return@launch
+ }
TraktScrobbleRepository.scrobbleStart(
item = item,
progressPercent = currentPlaybackProgressPercent(),
@@ -380,12 +385,12 @@ fun PlayerScreen(
}
fun emitTraktScrobbleStop(progressPercent: Float? = null) {
- val item = currentTraktScrobbleItem() ?: return
val provided = progressPercent
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
scope.launch {
+ val item = currentTraktScrobbleItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
item = item,
progressPercent = percent,
@@ -786,8 +791,11 @@ fun PlayerScreen(
flushWatchProgress()
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
val cacheKey = StreamLinkCacheRepository.contentKey(
- contentType ?: parentMetaType,
- activeVideoId!!,
+ type = contentType ?: parentMetaType,
+ videoId = activeVideoId!!,
+ parentMetaId = parentMetaId,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
@@ -846,8 +854,11 @@ fun PlayerScreen(
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
- contentType ?: parentMetaType,
- epVideoId,
+ type = contentType ?: parentMetaType,
+ videoId = epVideoId,
+ parentMetaId = parentMetaId,
+ season = episode.season,
+ episode = episode.episode,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
@@ -1449,12 +1460,15 @@ fun PlayerScreen(
totalDy += delta.y
if (gestureMode == null) {
+ val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
val horizontalDominant =
- !isHoldToSpeedGestureActiveState.value &&
+ !holdToSpeedActive &&
abs(totalDx) > viewConfiguration.touchSlop &&
abs(totalDx) > abs(totalDy)
val verticalDominant =
- abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
+ !holdToSpeedActive &&
+ abs(totalDy) > viewConfiguration.touchSlop &&
+ abs(totalDy) > abs(totalDx)
gestureMode = when {
horizontalDominant -> {
@@ -1555,8 +1569,11 @@ fun PlayerScreen(
val currentVideoId = activeVideoId
if (currentVideoId != null) {
val cacheKey = StreamLinkCacheRepository.contentKey(
- contentType ?: parentMetaType,
- currentVideoId,
+ type = contentType ?: parentMetaType,
+ videoId = currentVideoId,
+ parentMetaId = parentMetaId,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.remove(cacheKey)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt
new file mode 100644
index 00000000..1c939a2e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt
@@ -0,0 +1,7 @@
+package com.nuvio.app.features.profiles
+
+internal expect object ProfileHoverHapticFeedback {
+ fun prepare()
+ fun perform()
+ fun release()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index 0cb6cc27..5760e73e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@@ -156,6 +157,7 @@ object ProfileRepository {
TraktAuthRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged()
CollectionRepository.onProfileChanged()
+ CollectionMobileSettingsRepository.onProfileChanged()
DownloadsRepository.onProfileChanged()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt
index cecd6273..3678398e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt
@@ -14,7 +14,7 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -40,6 +40,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
@@ -64,6 +70,7 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
+import com.nuvio.app.isIos
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
@@ -97,6 +104,52 @@ fun ProfileSwitcherTab(
// Keep popup composed while exit animation plays
var popupVisible by remember { mutableStateOf(false) }
var pinProfile by remember { mutableStateOf(null) }
+ var dragTargetProfileIndex by remember { mutableStateOf(null) }
+ var triggerCoordinates by remember { mutableStateOf(null) }
+ val profileBubbleBounds = remember(profiles.map { it.profileIndex }) {
+ mutableStateMapOf()
+ }
+
+ fun performProfileHoldHaptic() {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+
+ fun performProfileHoverHaptic() {
+ if (isIos) {
+ ProfileHoverHapticFeedback.perform()
+ } else {
+ haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ }
+ }
+
+ fun updateDragTarget(localPosition: Offset) {
+ val trigger = triggerCoordinates ?: return
+ val windowPosition = trigger.localToWindow(localPosition)
+ val nextTargetProfileIndex = profileBubbleBounds.entries
+ .firstOrNull { (_, bounds) -> bounds.contains(windowPosition) }
+ ?.key
+ if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) {
+ performProfileHoverHaptic()
+ }
+ dragTargetProfileIndex = nextTargetProfileIndex
+ }
+
+ fun chooseProfile(profile: NuvioProfile) {
+ if (profile.pinEnabled) {
+ pinProfile = profile
+ } else {
+ onProfileSelected(profile)
+ showPopup = false
+ }
+ }
+
+ fun chooseDragTarget() {
+ val profile = profiles.firstOrNull { it.profileIndex == dragTargetProfileIndex }
+ dragTargetProfileIndex = null
+ if (profile != null) {
+ chooseProfile(profile)
+ }
+ }
// Popup entrance/exit animation
val popupAlpha = remember { Animatable(0f) }
@@ -126,6 +179,7 @@ fun ProfileSwitcherTab(
)
}
} else {
+ ProfileHoverHapticFeedback.release()
// Animate out
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
@@ -134,21 +188,41 @@ fun ProfileSwitcherTab(
// Remove from composition after animation completes
popupVisible = false
pinProfile = null
+ dragTargetProfileIndex = null
}
}
}
Box(
modifier = modifier
+ .onGloballyPositioned { triggerCoordinates = it }
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick,
+ )
.pointerInput(profiles) {
- detectTapGestures(
- onTap = { onClick() },
- onLongPress = {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { startOffset ->
if (profiles.isNotEmpty()) {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ performProfileHoldHaptic()
+ ProfileHoverHapticFeedback.prepare()
showPopup = true
+ updateDragTarget(startOffset)
}
},
+ onDrag = { change, _ ->
+ change.consume()
+ updateDragTarget(change.position)
+ },
+ onDragEnd = {
+ ProfileHoverHapticFeedback.release()
+ chooseDragTarget()
+ },
+ onDragCancel = {
+ ProfileHoverHapticFeedback.release()
+ dragTargetProfileIndex = null
+ },
)
},
contentAlignment = Alignment.Center,
@@ -199,20 +273,20 @@ fun ProfileSwitcherTab(
profile.profileIndex == activeProfile?.profileIndex
val isPinTarget =
pinProfile?.profileIndex == profile.profileIndex
+ val isDragTarget =
+ dragTargetProfileIndex == profile.profileIndex
PopupProfileBubble(
profile = profile,
avatars = avatars,
isActive = isActive,
- isSelected = isPinTarget,
+ isSelected = isPinTarget || isDragTarget,
delayMs = index * 50,
+ onBoundsChanged = { bounds ->
+ profileBubbleBounds[profile.profileIndex] = bounds
+ },
onClick = {
- if (profile.pinEnabled) {
- pinProfile = profile
- } else {
- onProfileSelected(profile)
- showPopup = false
- }
+ chooseProfile(profile)
},
)
}
@@ -335,6 +409,7 @@ private fun PopupProfileBubble(
isActive: Boolean,
isSelected: Boolean,
delayMs: Int,
+ onBoundsChanged: (Rect) -> Unit,
onClick: () -> Unit,
) {
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
@@ -363,7 +438,7 @@ private fun PopupProfileBubble(
}
val pressScale by animateFloatAsState(
- targetValue = if (isSelected) 1.15f else 1f,
+ targetValue = if (isSelected) 1.08f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
@@ -374,6 +449,9 @@ private fun PopupProfileBubble(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
+ .onGloballyPositioned { coordinates ->
+ onBoundsChanged(coordinates.boundsInWindow())
+ }
.graphicsLayer {
alpha = itemAlpha.value
scaleX = itemScale.value * pressScale
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt
index b71d97a2..cee95160 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt
@@ -20,11 +20,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@@ -91,16 +91,57 @@ object SearchRepository {
_uiState.value = SearchUiState(isLoading = true)
activeJob = scope.launch {
- val results = requests.map { request ->
- async {
+ val resultChannel = Channel(Channel.UNLIMITED)
+ val jobs = requests.mapIndexed { index, request ->
+ launch {
runCatching { request.toSection() }
+ .fold(
+ onSuccess = { section ->
+ resultChannel.send(
+ IndexedSearchResult(
+ index = index,
+ section = section,
+ ),
+ )
+ },
+ onFailure = { error ->
+ if (error is CancellationException) throw error
+ resultChannel.send(
+ IndexedSearchResult(
+ index = index,
+ error = error,
+ ),
+ )
+ },
+ )
}
- }.awaitAll()
+ }
+ val closeChannelJob = launch {
+ jobs.joinAll()
+ resultChannel.close()
+ }
+ val results = arrayOfNulls(requests.size)
- val sections = results
- .mapNotNull { it.getOrNull() }
- val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
- val allFailed = results.isNotEmpty() && results.all { it.isFailure }
+ try {
+ for (result in resultChannel) {
+ results[result.index] = result
+ val sections = results.orderedSections()
+ if (sections.isNotEmpty()) {
+ _uiState.value = SearchUiState(
+ isLoading = true,
+ sections = sections,
+ )
+ }
+ }
+ } finally {
+ closeChannelJob.cancel()
+ resultChannel.close()
+ }
+
+ val completedResults = results.filterNotNull()
+ val sections = results.orderedSections()
+ val firstFailure = completedResults.firstNotNullOfOrNull { it.error?.message }
+ val allFailed = completedResults.isNotEmpty() && completedResults.all { it.error != null }
_uiState.value = SearchUiState(
isLoading = false,
@@ -436,6 +477,15 @@ object SearchRepository {
}
}
+private data class IndexedSearchResult(
+ val index: Int,
+ val section: HomeCatalogSection? = null,
+ val error: Throwable? = null,
+)
+
+private fun Array.orderedSections(): List =
+ mapNotNull { result -> result?.section }
+
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
index c25a67fc..26a3c82f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
@@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -220,7 +221,14 @@ fun SearchScreen(
androidx.compose.foundation.layout.Column(
modifier = Modifier
.fillMaxWidth()
- .background(MaterialTheme.colorScheme.background),
+ .background(MaterialTheme.colorScheme.background)
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ awaitPointerEvent()
+ }
+ }
+ },
) {
NuvioScreenHeader(
title = headerTitle,
@@ -277,53 +285,66 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick,
)
} else {
- when {
- uiState.isLoading && uiState.sections.isEmpty() -> {
- items(2) {
- HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ val normalizedQuery = query.trim()
+ val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
+ when {
+ isWaitingForSearch -> {
+ items(2) {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ }
}
- }
- uiState.sections.isEmpty() -> {
- item {
- SearchEmptyStateCard(
- reason = uiState.emptyStateReason,
- errorMessage = uiState.errorMessage,
- networkCondition = networkStatusUiState.condition,
- onRetry = {
- val normalizedQuery = query.trim()
- if (normalizedQuery.isNotBlank()) {
- NetworkStatusRepository.requestRefresh(force = true)
- SearchRepository.search(
- query = normalizedQuery,
- addons = addonsUiState.addons,
- )
- }
- },
- )
+ uiState.isLoading && uiState.sections.isEmpty() -> {
+ items(2) {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ }
}
- }
- else -> {
- items(
- items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
- key = { section -> section.lazyKey },
- ) { keyedSection ->
- val section = keyedSection.value
- HomeCatalogRowSection(
- section = section,
- modifier = Modifier.padding(bottom = 12.dp),
- watchedKeys = watchedUiState.watchedKeys,
- onPosterClick = onPosterClick,
- onPosterLongClick = onPosterLongClick,
- )
+ uiState.sections.isEmpty() -> {
+ item {
+ SearchEmptyStateCard(
+ reason = uiState.emptyStateReason,
+ errorMessage = uiState.errorMessage,
+ networkCondition = networkStatusUiState.condition,
+ onRetry = {
+ if (normalizedQuery.isNotBlank()) {
+ NetworkStatusRepository.requestRefresh(force = true)
+ SearchRepository.search(
+ query = normalizedQuery,
+ addons = addonsUiState.addons,
+ )
+ }
+ },
+ modifier = Modifier.padding(horizontal = homeSectionPadding),
+ )
+ }
+ }
+
+ else -> {
+ items(
+ items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
+ key = { section -> section.lazyKey },
+ ) { keyedSection ->
+ val section = keyedSection.value
+ HomeCatalogRowSection(
+ section = section,
+ modifier = Modifier.padding(bottom = 12.dp),
+ watchedKeys = watchedUiState.watchedKeys,
+ onPosterClick = onPosterClick,
+ onPosterLongClick = onPosterLongClick,
+ )
+ }
+ if (uiState.isLoading) {
+ item(key = "search_loading_more") {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ }
+ }
}
}
}
}
}
}
-}
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
when {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
index 4e17b58a..e80c0822 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
@@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,24 +17,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
-import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
-import nuvio.composeapp.generated.resources.action_delete
import nuvio.composeapp.generated.resources.compose_settings_page_account
-import nuvio.composeapp.generated.resources.settings_account_delete_account
-import nuvio.composeapp.generated.resources.settings_account_delete_account_description
-import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message
-import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title
import nuvio.composeapp.generated.resources.settings_account_email
import nuvio.composeapp.generated.resources.settings_account_not_signed_in
import nuvio.composeapp.generated.resources.settings_account_sign_out
@@ -62,7 +52,6 @@ private fun AccountSettingsBody(
) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
- var showDeleteConfirm by remember { mutableStateOf(false) }
var showSignOutConfirm by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -131,35 +120,6 @@ private fun AccountSettingsBody(
text = stringResource(Res.string.settings_account_sign_out),
onClick = { showSignOutConfirm = true },
)
-
- if (authState is AuthState.Authenticated && !(authState as AuthState.Authenticated).isAnonymous) {
- Spacer(modifier = Modifier.height(20.dp))
-
- Button(
- onClick = { showDeleteConfirm = true },
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp),
- shape = RoundedCornerShape(16.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f),
- contentColor = MaterialTheme.colorScheme.error,
- ),
- ) {
- Text(
- text = stringResource(Res.string.settings_account_delete_account),
- style = MaterialTheme.typography.titleMedium,
- textAlign = TextAlign.Center,
- )
- }
- Text(
- text = stringResource(Res.string.settings_account_delete_account_description),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.Center,
- )
- }
}
NuvioStatusModal(
@@ -174,17 +134,4 @@ private fun AccountSettingsBody(
},
onDismiss = { showSignOutConfirm = false },
)
-
- NuvioStatusModal(
- title = stringResource(Res.string.settings_account_delete_confirm_title),
- message = stringResource(Res.string.settings_account_delete_confirm_message),
- isVisible = showDeleteConfirm,
- confirmText = stringResource(Res.string.action_delete),
- dismissText = stringResource(Res.string.action_cancel),
- onConfirm = {
- showDeleteConfirm = false
- scope.launch { AuthRepository.deleteAccount() }
- },
- onDismiss = { showDeleteConfirm = false },
- )
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt
index 025d6acc..e629434d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt
@@ -10,6 +10,7 @@ import nuvio.composeapp.generated.resources.lang_turkish
import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_greek
import nuvio.composeapp.generated.resources.lang_polish
+import nuvio.composeapp.generated.resources.lang_czech
import org.jetbrains.compose.resources.StringResource
enum class AppLanguage(
@@ -25,6 +26,7 @@ enum class AppLanguage(
ITALIAN("it", Res.string.lang_italian),
GREEK("el", Res.string.lang_greek),
POLISH("pl", Res.string.lang_polish),
+ CZECH("cs", Res.string.lang_czech),
;
companion object {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt
index c3d81354..f0483992 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt
@@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -25,6 +34,7 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
+import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
@@ -34,10 +44,16 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_show_unai
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
+import nuvio.composeapp.generated.resources.settings_continue_watching_section_sort_order
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
+import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default
+import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc
+import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
+import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
+import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
@@ -58,6 +74,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean,
+ sortMode: ContinueWatchingSortMode,
) {
item {
SettingsSection(
@@ -145,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
}
}
+ item {
+ var showSortModeSheet by remember { mutableStateOf(false) }
+ SettingsSection(
+ title = stringResource(Res.string.settings_continue_watching_section_sort_order),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ val currentModeLabel = stringResource(
+ when (sortMode) {
+ ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default
+ ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming
+ }
+ )
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_continue_watching_sort_mode_title),
+ description = currentModeLabel,
+ isTablet = isTablet,
+ onClick = { showSortModeSheet = true },
+ )
+ }
+ }
+
+ if (showSortModeSheet) {
+ ContinueWatchingSortModeDialog(
+ currentMode = sortMode,
+ onModeSelected = { mode ->
+ ContinueWatchingPreferencesRepository.setSortMode(mode)
+ showSortModeSheet = false
+ },
+ onDismiss = { showSortModeSheet = false },
+ )
+ }
+ }
}
@Composable
@@ -250,3 +300,101 @@ private val ContinueWatchingSectionStyle.descriptionRes: StringResource
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ContinueWatchingSortModeDialog(
+ currentMode: ContinueWatchingSortMode,
+ onModeSelected: (ContinueWatchingSortMode) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val options = listOf(
+ Triple(
+ ContinueWatchingSortMode.DEFAULT,
+ Res.string.settings_continue_watching_sort_mode_default,
+ Res.string.settings_continue_watching_sort_mode_default_desc,
+ ),
+ Triple(
+ ContinueWatchingSortMode.STREAMING_STYLE,
+ Res.string.settings_continue_watching_sort_mode_streaming,
+ Res.string.settings_continue_watching_sort_mode_streaming_desc,
+ ),
+ )
+
+ BasicAlertDialog(
+ onDismissRequest = onDismiss,
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.settings_continue_watching_sort_mode_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ options.forEach { (mode, titleRes, descriptionRes) ->
+ val isSelected = mode == currentMode
+ val containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onModeSelected(mode) },
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(titleRes),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(descriptionRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt
index ee44ba7c..254d49e1 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt
@@ -42,6 +42,8 @@ import nuvio.composeapp.generated.resources.layout_hide_unreleased
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
+import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline
+import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline_description
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
@@ -65,6 +67,7 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean,
heroEnabled: Boolean,
hideUnreleasedContent: Boolean,
+ hideCatalogUnderline: Boolean,
items: List,
) {
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
@@ -98,6 +101,14 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
)
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_homescreen_hide_catalog_underline),
+ description = stringResource(Res.string.settings_homescreen_hide_catalog_underline_description),
+ checked = hideCatalogUnderline,
+ isTablet = isTablet,
+ onCheckedChange = HomeCatalogSettingsRepository::setHideCatalogUnderline,
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt
index 4871bb16..8bf7971d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt
@@ -7,6 +7,7 @@ internal enum class IntegrationLogo {
Tmdb,
Trakt,
MdbList,
+ IntroDb,
}
@Composable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt
new file mode 100644
index 00000000..7efecdf5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt
@@ -0,0 +1,341 @@
+package com.nuvio.app.features.settings
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.OpenInNew
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.core.ui.NuvioScreen
+import com.nuvio.app.core.ui.NuvioScreenHeader
+import com.nuvio.app.isIos
+import nuvio.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+
+private const val TmdbUrl = "https://www.themoviedb.org"
+private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/"
+private const val TraktUrl = "https://trakt.tv"
+private const val MdbListUrl = "https://mdblist.com"
+private const val IntroDbUrl = "https://introdb.app/"
+private const val NuvioRepositoryUrl = "https://github.com/NuvioMedia/NuvioMobile"
+private const val MpvKitUrl = "https://github.com/mpvkit/MPVKit"
+private const val ApacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0"
+
+private data class AttributionItem(
+ val titleRes: StringResource,
+ val bodyRes: StringResource,
+ val logo: IntegrationLogo?,
+ val link: String,
+)
+
+private data class LicenseItem(
+ val titleRes: StringResource,
+ val bodyRes: StringResource,
+ val licenseRes: StringResource,
+ val link: String,
+)
+
+@Composable
+fun LicensesAttributionsSettingsScreen(
+ onBack: () -> Unit,
+) {
+ NuvioScreen(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ stickyHeader {
+ NuvioScreenHeader(
+ title = stringResource(Res.string.compose_settings_page_licenses_attributions),
+ onBack = onBack,
+ )
+ }
+ licensesAttributionsContent(isTablet = false)
+ }
+}
+
+internal fun LazyListScope.licensesAttributionsContent(
+ isTablet: Boolean,
+) {
+ item {
+ LicensesAttributionsBody(isTablet = isTablet)
+ }
+}
+
+@Composable
+private fun LicensesAttributionsBody(
+ isTablet: Boolean,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(if (isTablet) 28.dp else 24.dp),
+ ) {
+ PlainSettingsStack(
+ title = stringResource(Res.string.settings_licenses_attributions_section_app),
+ isTablet = isTablet,
+ ) {
+ LicenseRow(
+ item = appLicenseItem(),
+ isTablet = isTablet,
+ )
+ }
+
+ PlainSettingsStack(
+ title = stringResource(Res.string.settings_licenses_attributions_section_data),
+ isTablet = isTablet,
+ ) {
+ val items = attributionItems()
+ items.forEachIndexed { index, item ->
+ AttributionRow(
+ item = item,
+ isTablet = isTablet,
+ )
+ if (index != items.lastIndex) {
+ PlainStackDivider()
+ }
+ }
+ }
+
+ PlainSettingsStack(
+ title = stringResource(Res.string.settings_licenses_attributions_section_playback),
+ isTablet = isTablet,
+ ) {
+ LicenseRow(
+ item = platformLicenseItem(),
+ isTablet = isTablet,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PlainSettingsStack(
+ title: String,
+ isTablet: Boolean,
+ content: @Composable () -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(modifier = Modifier.height(if (isTablet) 12.dp else 10.dp))
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun AttributionRow(
+ item: AttributionItem,
+ isTablet: Boolean,
+) {
+ val uriHandler = LocalUriHandler.current
+ val title = stringResource(item.titleRes)
+ LinkedPlainRow(
+ title = title,
+ body = stringResource(item.bodyRes),
+ link = item.link,
+ isTablet = isTablet,
+ leading = item.logo?.let { logo ->
+ {
+ IntegrationLogoImage(
+ painter = integrationLogoPainter(logo),
+ contentDescription = title,
+ isTablet = isTablet,
+ )
+ }
+ },
+ onOpen = { uriHandler.openUri(item.link) },
+ )
+}
+
+@Composable
+private fun LicenseRow(
+ item: LicenseItem,
+ isTablet: Boolean,
+) {
+ val uriHandler = LocalUriHandler.current
+ val itemBody = stringResource(item.bodyRes)
+ val itemLicense = stringResource(item.licenseRes)
+ val body = buildString {
+ append(itemBody)
+ append("\n")
+ append(itemLicense)
+ }
+ LinkedPlainRow(
+ title = stringResource(item.titleRes),
+ body = body,
+ link = item.link,
+ isTablet = isTablet,
+ onOpen = { uriHandler.openUri(item.link) },
+ )
+}
+
+@Composable
+private fun LinkedPlainRow(
+ title: String,
+ body: String,
+ link: String,
+ isTablet: Boolean,
+ leading: (@Composable () -> Unit)? = null,
+ onOpen: () -> Unit,
+) {
+ val verticalPadding = if (isTablet) 18.dp else 16.dp
+ val horizontalPadding = if (isTablet) 4.dp else 0.dp
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onOpen)
+ .padding(horizontal = horizontalPadding, vertical = verticalPadding),
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(if (isTablet) 18.dp else 14.dp),
+ ) {
+ leading?.invoke()
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = link,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(if (isTablet) 22.dp else 20.dp)
+ .alpha(0.72f),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun IntegrationLogoImage(
+ painter: Painter,
+ contentDescription: String,
+ isTablet: Boolean,
+) {
+ Image(
+ painter = painter,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(if (isTablet) 46.dp else 40.dp),
+ contentScale = ContentScale.Fit,
+ )
+}
+
+@Composable
+private fun PlainStackDivider() {
+ HorizontalDivider(
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.18f),
+ )
+}
+
+private fun attributionItems(): List = listOf(
+ AttributionItem(
+ titleRes = Res.string.settings_licenses_attributions_tmdb_title,
+ bodyRes = Res.string.settings_licenses_attributions_tmdb_body,
+ logo = IntegrationLogo.Tmdb,
+ link = TmdbUrl,
+ ),
+ AttributionItem(
+ titleRes = Res.string.settings_licenses_attributions_trakt_title,
+ bodyRes = Res.string.settings_licenses_attributions_trakt_body,
+ logo = IntegrationLogo.Trakt,
+ link = TraktUrl,
+ ),
+ AttributionItem(
+ titleRes = Res.string.settings_licenses_attributions_mdblist_title,
+ bodyRes = Res.string.settings_licenses_attributions_mdblist_body,
+ logo = IntegrationLogo.MdbList,
+ link = MdbListUrl,
+ ),
+ AttributionItem(
+ titleRes = Res.string.settings_licenses_attributions_introdb_title,
+ bodyRes = Res.string.settings_licenses_attributions_introdb_body,
+ logo = IntegrationLogo.IntroDb,
+ link = IntroDbUrl,
+ ),
+ AttributionItem(
+ titleRes = Res.string.settings_licenses_attributions_imdb_title,
+ bodyRes = Res.string.settings_licenses_attributions_imdb_body,
+ logo = null,
+ link = ImdbDatasetsUrl,
+ ),
+)
+
+private fun appLicenseItem(): LicenseItem =
+ LicenseItem(
+ titleRes = Res.string.settings_licenses_attributions_nuvio_title,
+ bodyRes = Res.string.settings_licenses_attributions_nuvio_body,
+ licenseRes = Res.string.settings_licenses_attributions_nuvio_license,
+ link = NuvioRepositoryUrl,
+ )
+
+private fun platformLicenseItem(): LicenseItem =
+ if (isIos) {
+ LicenseItem(
+ titleRes = Res.string.settings_licenses_attributions_mpvkit_title,
+ bodyRes = Res.string.settings_licenses_attributions_mpvkit_body,
+ licenseRes = Res.string.settings_licenses_attributions_mpvkit_license,
+ link = MpvKitUrl,
+ )
+ } else {
+ LicenseItem(
+ titleRes = Res.string.settings_licenses_attributions_exoplayer_title,
+ bodyRes = Res.string.settings_licenses_attributions_exoplayer_body,
+ licenseRes = Res.string.settings_licenses_attributions_exoplayer_license,
+ link = ApacheLicenseUrl,
+ )
+ }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt
index 143ef517..45c6edf3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt
@@ -78,6 +78,7 @@ fun HomescreenSettingsScreen(
isTablet = false,
heroEnabled = homescreenSettingsUiState.heroEnabled,
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
+ hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
items = homescreenSettingsUiState.items,
)
}
@@ -135,6 +136,7 @@ fun ContinueWatchingSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
+ sortMode = continueWatchingPreferencesUiState.sortMode,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
index e66779fd..d030a785 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
@@ -16,6 +16,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_content_discov
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
+import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
@@ -58,6 +59,11 @@ internal enum class SettingsPage(
category = SettingsCategory.About,
parentPage = Root,
),
+ LicensesAttributions(
+ titleRes = Res.string.compose_settings_page_licenses_attributions,
+ category = SettingsCategory.About,
+ parentPage = Root,
+ ),
Playback(
titleRes = Res.string.compose_settings_page_playback,
category = SettingsCategory.General,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
index e97576f6..71580c35 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
@@ -26,6 +26,7 @@ import nuvio.composeapp.generated.resources.compose_about_version_format
import nuvio.composeapp.generated.resources.compose_settings_page_account
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
+import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
import nuvio.composeapp.generated.resources.compose_settings_page_playback
import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors
@@ -48,6 +49,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_content_discov
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.settings_playback_subtitle
import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle
+import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.settingsRootContent(
@@ -59,6 +61,7 @@ internal fun LazyListScope.settingsRootContent(
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
+ onLicensesAttributionsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit,
@@ -175,6 +178,14 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet,
onClick = onSupportersContributorsClick,
)
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.compose_settings_page_licenses_attributions),
+ description = stringResource(Res.string.about_licenses_attributions_subtitle),
+ icon = Icons.Rounded.Info,
+ isTablet = isTablet,
+ onClick = onLicensesAttributionsClick,
+ )
if (onCheckForUpdatesClick != null) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index b625c9dc..4cd95d64 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@@ -48,6 +58,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
+import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings
@@ -66,8 +77,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_root
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+private val SettingsSearchRevealThreshold = 28.dp
+private const val SettingsSearchRevealAnimationMillis = 240L
+private const val SettingsSearchRevealHapticDelayMillis = 90L
+
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
@@ -80,6 +97,7 @@ fun SettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
+ onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
@@ -144,6 +162,7 @@ fun SettingsScreen(
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
+ val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val metaScreenSettingsUiState by remember {
MetaScreenSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.uiState
@@ -166,6 +185,14 @@ fun SettingsScreen(
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
}
+ LaunchedEffect(Unit) {
+ CollectionRepository.initialize()
+ }
+
+ LaunchedEffect(collections) {
+ HomeCatalogSettingsRepository.syncCollections(collections)
+ }
+
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
val previousPage = page.previousPage()
@@ -210,6 +237,7 @@ fun SettingsScreen(
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
+ homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@@ -217,6 +245,7 @@ fun SettingsScreen(
onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick,
+ onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
@@ -255,6 +284,7 @@ fun SettingsScreen(
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
+ homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@@ -268,6 +298,7 @@ fun SettingsScreen(
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
+ onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
@@ -310,6 +341,7 @@ private fun MobileSettingsScreen(
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
+ homescreenHideCatalogUnderline: Boolean,
homescreenItems: List,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@@ -323,12 +355,73 @@ private fun MobileSettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
+ onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
val saveableStateHolder = rememberSaveableStateHolder()
saveableStateHolder.SaveableStateProvider(page.name) {
- NuvioScreen {
+ var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
+ var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
+ var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
+ val listState = rememberLazyListState()
+ val hapticFeedback = LocalHapticFeedback.current
+ val hapticScope = rememberCoroutineScope()
+ val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
+ page = page,
+ listState = listState,
+ query = settingsSearchQuery,
+ searchVisible = rootSearchVisible,
+ ) {
+ rootSearchVisible = true
+ rootSearchRevealAnimating = true
+ hapticScope.launch {
+ delay(SettingsSearchRevealHapticDelayMillis)
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ }
+ }
+ val searchEntries = settingsSearchEntries(
+ pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
+ liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
+ switchProfileAvailable = onSwitchProfile != null,
+ checkForUpdatesAvailable = onCheckForUpdatesClick != null,
+ )
+
+ fun openSearchTarget(target: SettingsSearchTarget) {
+ when (target) {
+ is SettingsSearchTarget.Page -> when (target.page) {
+ SettingsPage.Account -> onAccountClick()
+ SettingsPage.SupportersContributors -> onSupportersContributorsClick()
+ SettingsPage.LicensesAttributions -> onLicensesAttributionsClick()
+ SettingsPage.ContinueWatching -> onContinueWatchingClick()
+ SettingsPage.Addons -> onAddonsClick()
+ SettingsPage.Plugins -> {
+ if (AppFeaturePolicy.pluginsEnabled) {
+ onPluginsClick()
+ }
+ }
+ SettingsPage.Homescreen -> onHomescreenClick()
+ SettingsPage.MetaScreen -> onMetaScreenClick()
+ else -> onPageChange(target.page)
+ }
+ SettingsSearchTarget.Downloads -> onDownloadsClick()
+ SettingsSearchTarget.Collections -> onCollectionsClick()
+ SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
+ SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
+ }
+ }
+
+ LaunchedEffect(rootSearchRevealAnimating) {
+ if (rootSearchRevealAnimating) {
+ delay(SettingsSearchRevealAnimationMillis)
+ rootSearchRevealAnimating = false
+ }
+ }
+
+ NuvioScreen(
+ modifier = Modifier.nestedScroll(rootSearchRevealConnection),
+ listState = listState,
+ ) {
stickyHeader {
val previousPage = page.previousPage()
NuvioScreenHeader(
@@ -338,26 +431,43 @@ private fun MobileSettingsScreen(
}
when (page) {
- SettingsPage.Root -> settingsRootContent(
- isTablet = false,
- onPlaybackClick = { onPageChange(SettingsPage.Playback) },
- onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
- onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
- onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
- onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
- onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
- onSupportersContributorsClick = onSupportersContributorsClick,
- onCheckForUpdatesClick = onCheckForUpdatesClick,
- onDownloadsClick = onDownloadsClick,
- onAccountClick = onAccountClick,
- onSwitchProfileClick = onSwitchProfile,
- )
+ SettingsPage.Root -> {
+ settingsSearchRootContent(
+ query = settingsSearchQuery,
+ entries = searchEntries,
+ isTablet = false,
+ showSearchField = rootSearchVisible,
+ animateSearchField = rootSearchRevealAnimating,
+ onQueryChange = { settingsSearchQuery = it },
+ onTargetClick = { openSearchTarget(it) },
+ )
+ if (settingsSearchQuery.isBlank()) {
+ settingsRootContent(
+ isTablet = false,
+ onPlaybackClick = { onPageChange(SettingsPage.Playback) },
+ onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
+ onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
+ onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
+ onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
+ onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
+ onSupportersContributorsClick = onSupportersContributorsClick,
+ onLicensesAttributionsClick = onLicensesAttributionsClick,
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
+ onDownloadsClick = onDownloadsClick,
+ onAccountClick = onAccountClick,
+ onSwitchProfileClick = onSwitchProfile,
+ )
+ }
+ }
SettingsPage.Account -> accountSettingsContent(
isTablet = false,
)
SettingsPage.SupportersContributors -> supportersContributorsContent(
isTablet = false,
)
+ SettingsPage.LicensesAttributions -> licensesAttributionsContent(
+ isTablet = false,
+ )
SettingsPage.Playback -> playbackSettingsContent(
isTablet = false,
showLoadingOverlay = showLoadingOverlay,
@@ -402,6 +512,7 @@ private fun MobileSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
+ sortMode = continueWatchingPreferencesUiState.sortMode,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = false,
@@ -422,6 +533,7 @@ private fun MobileSettingsScreen(
isTablet = false,
heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
+ hideCatalogUnderline = homescreenHideCatalogUnderline,
items = homescreenItems,
)
SettingsPage.MetaScreen -> metaScreenSettingsContent(
@@ -453,6 +565,48 @@ private fun MobileSettingsScreen(
}
}
+@Composable
+private fun rememberSettingsRootSearchRevealConnection(
+ page: SettingsPage,
+ listState: LazyListState,
+ query: String,
+ searchVisible: Boolean,
+ onReveal: () -> Unit,
+): NestedScrollConnection {
+ val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() }
+ val currentOnReveal by rememberUpdatedState(onReveal)
+ var pullDistancePx by remember(page) { mutableStateOf(0f) }
+ var revealTriggered by remember(page) { mutableStateOf(false) }
+
+ return remember(page, listState, query, searchVisible, revealThresholdPx) {
+ object : NestedScrollConnection {
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset {
+ val isRootAtTop = page == SettingsPage.Root &&
+ listState.firstVisibleItemIndex == 0 &&
+ listState.firstVisibleItemScrollOffset == 0
+ val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank()
+
+ if (canRevealSearch && available.y > 0f) {
+ pullDistancePx += available.y
+ if (pullDistancePx >= revealThresholdPx) {
+ pullDistancePx = 0f
+ revealTriggered = true
+ currentOnReveal()
+ }
+ } else if (!isRootAtTop || available.y < 0f) {
+ pullDistancePx = 0f
+ }
+
+ return Offset.Zero
+ }
+ }
+ }
+}
+
@Composable
private fun TabletSettingsScreen(
page: SettingsPage,
@@ -488,6 +642,7 @@ private fun TabletSettingsScreen(
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
+ homescreenHideCatalogUnderline: Boolean,
homescreenItems: List,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@@ -495,6 +650,7 @@ private fun TabletSettingsScreen(
onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
+ onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
@@ -559,11 +715,54 @@ private fun TabletSettingsScreen(
}
saveableStateHolder.SaveableStateProvider(page.name) {
+ var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
+ var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
+ var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
+ val hapticFeedback = LocalHapticFeedback.current
+ val hapticScope = rememberCoroutineScope()
+ val searchEntries = settingsSearchEntries(
+ pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
+ liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
+ switchProfileAvailable = onSwitchProfile != null,
+ checkForUpdatesAvailable = onCheckForUpdatesClick != null,
+ )
+
+ fun openSearchTarget(target: SettingsSearchTarget) {
+ when (target) {
+ is SettingsSearchTarget.Page -> openInlinePage(target.page)
+ SettingsSearchTarget.Downloads -> onDownloadsClick()
+ SettingsSearchTarget.Collections -> onCollectionsClick()
+ SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
+ SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
+ }
+ }
+
val listState = rememberLazyListState()
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
+ val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
+ page = page,
+ listState = listState,
+ query = settingsSearchQuery,
+ searchVisible = rootSearchVisible,
+ ) {
+ rootSearchVisible = true
+ rootSearchRevealAnimating = true
+ hapticScope.launch {
+ delay(SettingsSearchRevealHapticDelayMillis)
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ }
+ }
+ LaunchedEffect(rootSearchRevealAnimating) {
+ if (rootSearchRevealAnimating) {
+ delay(SettingsSearchRevealAnimationMillis)
+ rootSearchRevealAnimating = false
+ }
+ }
LazyColumn(
state = listState,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(rootSearchRevealConnection),
contentPadding = PaddingValues(
start = 40.dp,
top = topOffset,
@@ -576,7 +775,11 @@ private fun TabletSettingsScreen(
val previousPage = page.previousPage()
TabletPageHeader(
title = if (page == SettingsPage.Root) {
- stringResource(activeCategory.labelRes)
+ if (settingsSearchQuery.isBlank()) {
+ stringResource(activeCategory.labelRes)
+ } else {
+ stringResource(Res.string.compose_settings_page_root)
+ }
} else {
stringResource(page.titleRes)
},
@@ -585,29 +788,46 @@ private fun TabletSettingsScreen(
)
}
when (page) {
- SettingsPage.Root -> settingsRootContent(
- isTablet = true,
- onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
- onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
- onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
- onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
- onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
- onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
- onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
- onCheckForUpdatesClick = onCheckForUpdatesClick,
- onDownloadsClick = onDownloadsClick,
- onAccountClick = { openInlinePage(SettingsPage.Account) },
- onSwitchProfileClick = onSwitchProfile,
- showAccountSection = activeCategory == SettingsCategory.Account,
- showGeneralSection = activeCategory == SettingsCategory.General,
- showAboutSection = activeCategory == SettingsCategory.About,
- )
+ SettingsPage.Root -> {
+ settingsSearchRootContent(
+ query = settingsSearchQuery,
+ entries = searchEntries,
+ isTablet = true,
+ showSearchField = rootSearchVisible,
+ animateSearchField = rootSearchRevealAnimating,
+ onQueryChange = { settingsSearchQuery = it },
+ onTargetClick = { openSearchTarget(it) },
+ )
+ if (settingsSearchQuery.isBlank()) {
+ settingsRootContent(
+ isTablet = true,
+ onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
+ onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
+ onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
+ onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
+ onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
+ onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
+ onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
+ onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) },
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
+ onDownloadsClick = onDownloadsClick,
+ onAccountClick = { openInlinePage(SettingsPage.Account) },
+ onSwitchProfileClick = onSwitchProfile,
+ showAccountSection = activeCategory == SettingsCategory.Account,
+ showGeneralSection = activeCategory == SettingsCategory.General,
+ showAboutSection = activeCategory == SettingsCategory.About,
+ )
+ }
+ }
SettingsPage.Account -> accountSettingsContent(
isTablet = true,
)
SettingsPage.SupportersContributors -> supportersContributorsContent(
isTablet = true,
)
+ SettingsPage.LicensesAttributions -> licensesAttributionsContent(
+ isTablet = true,
+ )
SettingsPage.Playback -> playbackSettingsContent(
isTablet = true,
showLoadingOverlay = showLoadingOverlay,
@@ -652,6 +872,7 @@ private fun TabletSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
+ sortMode = continueWatchingPreferencesUiState.sortMode,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = true,
@@ -672,6 +893,7 @@ private fun TabletSettingsScreen(
isTablet = true,
heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
+ hideCatalogUnderline = homescreenHideCatalogUnderline,
items = homescreenItems,
)
SettingsPage.MetaScreen -> metaScreenSettingsContent(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
new file mode 100644
index 00000000..381ba569
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -0,0 +1,1004 @@
+package com.nuvio.app.features.settings
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.slideInVertically
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.AccountCircle
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.CloudDownload
+import androidx.compose.material.icons.rounded.CollectionsBookmark
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.Hub
+import androidx.compose.material.icons.rounded.Home
+import androidx.compose.material.icons.rounded.Language
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material.icons.rounded.Notifications
+import androidx.compose.material.icons.rounded.Palette
+import androidx.compose.material.icons.rounded.People
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.Style
+import androidx.compose.material.icons.rounded.Tune
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.isIos
+import nuvio.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.stringResource
+
+internal sealed class SettingsSearchTarget {
+ data class Page(val page: SettingsPage) : SettingsSearchTarget()
+ object Downloads : SettingsSearchTarget()
+ object Collections : SettingsSearchTarget()
+ object SwitchProfile : SettingsSearchTarget()
+ object CheckForUpdates : SettingsSearchTarget()
+}
+
+internal data class SettingsSearchEntry(
+ val key: String,
+ val title: String,
+ val description: String,
+ val page: String,
+ val section: String,
+ val category: String,
+ val icon: ImageVector,
+ val target: SettingsSearchTarget,
+) {
+ val searchableText: String = listOf(title, description, page, section, category)
+ .joinToString(separator = " ")
+ .lowercase()
+
+ val contextLabel: String = listOf(page, section)
+ .filter { it.isNotBlank() }
+ .distinct()
+ .joinToString(separator = " - ")
+}
+
+@Composable
+internal fun settingsSearchEntries(
+ pluginsEnabled: Boolean,
+ liquidGlassNativeTabBarSupported: Boolean,
+ switchProfileAvailable: Boolean,
+ checkForUpdatesAvailable: Boolean,
+): List {
+ val accountCategory = stringResource(SettingsCategory.Account.labelRes)
+ val generalCategory = stringResource(SettingsCategory.General.labelRes)
+ val aboutCategory = stringResource(SettingsCategory.About.labelRes)
+
+ val accountPage = stringResource(Res.string.compose_settings_page_account)
+ val traktPage = stringResource(Res.string.compose_settings_page_trakt)
+ val layoutPage = stringResource(Res.string.compose_settings_page_appearance)
+ val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery)
+ val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title)
+ val playbackPage = stringResource(Res.string.compose_settings_page_playback)
+ val integrationsPage = stringResource(Res.string.compose_settings_page_integrations)
+ val notificationsPage = stringResource(Res.string.compose_settings_page_notifications)
+ val supportersPage = stringResource(Res.string.compose_settings_page_supporters_contributors)
+ val licensesPage = stringResource(Res.string.compose_settings_page_licenses_attributions)
+ val homeLayoutPage = stringResource(Res.string.compose_settings_page_homescreen)
+ val detailPage = stringResource(Res.string.compose_settings_page_meta_screen)
+ val continueWatchingPage = stringResource(Res.string.compose_settings_page_continue_watching)
+ val posterStylePage = stringResource(Res.string.compose_settings_page_poster_customization)
+ val addonsPage = stringResource(Res.string.compose_settings_page_addons)
+ val pluginsPage = stringResource(Res.string.compose_settings_page_plugins)
+ val collectionsPage = stringResource(Res.string.collections_header)
+ val tmdbPage = stringResource(Res.string.compose_settings_page_tmdb_enrichment)
+ val mdbListPage = stringResource(Res.string.compose_settings_page_mdblist_ratings)
+
+ val entries = mutableListOf()
+
+ fun add(
+ key: String,
+ title: String,
+ description: String = "",
+ page: String = title,
+ section: String = "",
+ category: String = generalCategory,
+ icon: ImageVector,
+ target: SettingsSearchTarget,
+ ) {
+ entries += SettingsSearchEntry(
+ key = key,
+ title = title,
+ description = description,
+ page = page,
+ section = section,
+ category = category,
+ icon = icon,
+ target = target,
+ )
+ }
+
+ fun addPage(
+ page: SettingsPage,
+ key: String,
+ title: String,
+ description: String,
+ category: String = generalCategory,
+ icon: ImageVector,
+ ) {
+ add(
+ key = key,
+ title = title,
+ description = description,
+ page = title,
+ category = category,
+ icon = icon,
+ target = SettingsSearchTarget.Page(page),
+ )
+ }
+
+ fun addRow(
+ page: SettingsPage,
+ key: String,
+ title: String,
+ description: String = "",
+ pageLabel: String,
+ section: String,
+ category: String = generalCategory,
+ icon: ImageVector,
+ ) {
+ add(
+ key = key,
+ title = title,
+ description = description,
+ page = pageLabel,
+ section = section,
+ category = category,
+ icon = icon,
+ target = SettingsSearchTarget.Page(page),
+ )
+ }
+
+ if (switchProfileAvailable) {
+ add(
+ key = "switch-profile",
+ title = stringResource(Res.string.compose_settings_root_switch_profile_title),
+ description = stringResource(Res.string.compose_settings_root_switch_profile_description),
+ page = accountPage,
+ section = stringResource(Res.string.compose_settings_root_account_section),
+ category = accountCategory,
+ icon = Icons.Rounded.People,
+ target = SettingsSearchTarget.SwitchProfile,
+ )
+ }
+ addPage(
+ page = SettingsPage.Account,
+ key = "account",
+ title = accountPage,
+ description = stringResource(Res.string.compose_settings_root_account_description),
+ category = accountCategory,
+ icon = Icons.Rounded.AccountCircle,
+ )
+ addPage(
+ page = SettingsPage.TraktAuthentication,
+ key = "trakt",
+ title = traktPage,
+ description = stringResource(Res.string.compose_settings_root_trakt_description),
+ category = accountCategory,
+ icon = Icons.Rounded.Link,
+ )
+ addPage(
+ page = SettingsPage.Appearance,
+ key = "layout",
+ title = layoutPage,
+ description = stringResource(Res.string.compose_settings_root_appearance_description),
+ icon = Icons.Rounded.Palette,
+ )
+ addPage(
+ page = SettingsPage.ContentDiscovery,
+ key = "content-discovery",
+ title = contentDiscoveryPage,
+ description = stringResource(Res.string.compose_settings_root_content_discovery_description),
+ icon = Icons.Rounded.Extension,
+ )
+ add(
+ key = "downloads",
+ title = downloadsPage,
+ description = stringResource(Res.string.compose_settings_root_downloads_description),
+ category = generalCategory,
+ icon = Icons.Rounded.CloudDownload,
+ target = SettingsSearchTarget.Downloads,
+ )
+ addPage(
+ page = SettingsPage.Playback,
+ key = "playback",
+ title = playbackPage,
+ description = stringResource(Res.string.settings_playback_subtitle),
+ icon = Icons.Rounded.PlayArrow,
+ )
+ addPage(
+ page = SettingsPage.Integrations,
+ key = "integrations",
+ title = integrationsPage,
+ description = stringResource(Res.string.compose_settings_root_integrations_description),
+ icon = Icons.Rounded.Link,
+ )
+ addPage(
+ page = SettingsPage.Notifications,
+ key = "notifications",
+ title = notificationsPage,
+ description = stringResource(Res.string.compose_settings_root_notifications_description),
+ icon = Icons.Rounded.Notifications,
+ )
+ addPage(
+ page = SettingsPage.SupportersContributors,
+ key = "supporters",
+ title = supportersPage,
+ description = stringResource(Res.string.about_supporters_contributors_subtitle),
+ category = aboutCategory,
+ icon = Icons.Rounded.Favorite,
+ )
+ addPage(
+ page = SettingsPage.LicensesAttributions,
+ key = "licenses-attributions",
+ title = licensesPage,
+ description = stringResource(Res.string.about_licenses_attributions_subtitle),
+ category = aboutCategory,
+ icon = Icons.Rounded.Info,
+ )
+ listOf(
+ PlaybackSearchRow("nuvio-license", stringResource(Res.string.settings_licenses_attributions_nuvio_title), stringResource(Res.string.settings_licenses_attributions_nuvio_license)),
+ PlaybackSearchRow("tmdb-attribution", stringResource(Res.string.settings_licenses_attributions_tmdb_title), stringResource(Res.string.settings_licenses_attributions_tmdb_body)),
+ PlaybackSearchRow("trakt-attribution", stringResource(Res.string.settings_licenses_attributions_trakt_title), stringResource(Res.string.settings_licenses_attributions_trakt_body)),
+ PlaybackSearchRow("mdblist-attribution", stringResource(Res.string.settings_licenses_attributions_mdblist_title), stringResource(Res.string.settings_licenses_attributions_mdblist_body)),
+ PlaybackSearchRow("introdb-attribution", stringResource(Res.string.settings_licenses_attributions_introdb_title), stringResource(Res.string.settings_licenses_attributions_introdb_body)),
+ PlaybackSearchRow("imdb-datasets", stringResource(Res.string.settings_licenses_attributions_imdb_title), stringResource(Res.string.settings_licenses_attributions_imdb_body)),
+ PlaybackSearchRow(
+ if (isIos) "mpvkit-license" else "exoplayer-license",
+ if (isIos) {
+ stringResource(Res.string.settings_licenses_attributions_mpvkit_title)
+ } else {
+ stringResource(Res.string.settings_licenses_attributions_exoplayer_title)
+ },
+ if (isIos) {
+ stringResource(Res.string.settings_licenses_attributions_mpvkit_license)
+ } else {
+ stringResource(Res.string.settings_licenses_attributions_exoplayer_license)
+ },
+ ),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.LicensesAttributions,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = licensesPage,
+ section = stringResource(Res.string.compose_settings_root_about_section),
+ category = aboutCategory,
+ icon = Icons.Rounded.Info,
+ )
+ }
+ if (checkForUpdatesAvailable) {
+ add(
+ key = "check-updates",
+ title = stringResource(Res.string.compose_settings_root_check_updates_title),
+ description = stringResource(Res.string.compose_settings_root_check_updates_description),
+ page = supportersPage,
+ section = stringResource(Res.string.compose_settings_root_about_section),
+ category = aboutCategory,
+ icon = Icons.Rounded.CloudDownload,
+ target = SettingsSearchTarget.CheckForUpdates,
+ )
+ }
+
+ addRow(
+ page = SettingsPage.Account,
+ key = "account-status",
+ title = stringResource(Res.string.settings_account_status),
+ pageLabel = accountPage,
+ section = accountPage,
+ category = accountCategory,
+ icon = Icons.Rounded.AccountCircle,
+ )
+ addRow(
+ page = SettingsPage.Account,
+ key = "account-sign-out",
+ title = stringResource(Res.string.settings_account_sign_out),
+ pageLabel = accountPage,
+ section = accountPage,
+ category = accountCategory,
+ icon = Icons.Rounded.AccountCircle,
+ )
+
+ addRow(
+ page = SettingsPage.Appearance,
+ key = "theme",
+ title = stringResource(Res.string.settings_appearance_section_theme),
+ pageLabel = layoutPage,
+ section = stringResource(Res.string.settings_appearance_section_theme),
+ icon = Icons.Rounded.Palette,
+ )
+ addRow(
+ page = SettingsPage.Appearance,
+ key = "amoled",
+ title = stringResource(Res.string.settings_appearance_amoled_black),
+ description = stringResource(Res.string.settings_appearance_amoled_description),
+ pageLabel = layoutPage,
+ section = stringResource(Res.string.settings_appearance_section_display),
+ icon = Icons.Rounded.Palette,
+ )
+ if (liquidGlassNativeTabBarSupported) {
+ addRow(
+ page = SettingsPage.Appearance,
+ key = "liquid-glass",
+ title = stringResource(Res.string.settings_appearance_liquid_glass),
+ description = stringResource(Res.string.settings_appearance_liquid_glass_description),
+ pageLabel = layoutPage,
+ section = stringResource(Res.string.settings_appearance_section_display),
+ icon = Icons.Rounded.Palette,
+ )
+ }
+ addRow(
+ page = SettingsPage.Appearance,
+ key = "app-language",
+ title = stringResource(Res.string.settings_appearance_app_language),
+ pageLabel = layoutPage,
+ section = stringResource(Res.string.settings_appearance_section_display),
+ icon = Icons.Rounded.Language,
+ )
+ addPage(
+ page = SettingsPage.ContinueWatching,
+ key = "continue-watching",
+ title = continueWatchingPage,
+ description = stringResource(Res.string.settings_appearance_continue_watching_description),
+ icon = Icons.Rounded.Style,
+ )
+ addPage(
+ page = SettingsPage.PosterCustomization,
+ key = "poster-card-style",
+ title = posterStylePage,
+ description = stringResource(Res.string.settings_appearance_poster_customization_description),
+ icon = Icons.Rounded.Tune,
+ )
+
+ addPage(
+ page = SettingsPage.Addons,
+ key = "addons",
+ title = addonsPage,
+ description = stringResource(Res.string.settings_content_discovery_addons_description),
+ icon = Icons.Rounded.Extension,
+ )
+ if (pluginsEnabled) {
+ addPage(
+ page = SettingsPage.Plugins,
+ key = "plugins",
+ title = pluginsPage,
+ description = stringResource(Res.string.settings_content_discovery_plugins_description),
+ icon = Icons.Rounded.Hub,
+ )
+ }
+ addPage(
+ page = SettingsPage.Homescreen,
+ key = "home-layout",
+ title = homeLayoutPage,
+ description = stringResource(Res.string.settings_content_discovery_homescreen_description),
+ icon = Icons.Rounded.Home,
+ )
+ addPage(
+ page = SettingsPage.MetaScreen,
+ key = "detail-page",
+ title = detailPage,
+ description = stringResource(Res.string.settings_content_discovery_meta_screen_description),
+ icon = Icons.Rounded.Tune,
+ )
+ add(
+ key = "collections",
+ title = collectionsPage,
+ description = stringResource(Res.string.settings_content_discovery_collections_description),
+ page = contentDiscoveryPage,
+ section = stringResource(Res.string.settings_content_discovery_section_home),
+ category = generalCategory,
+ icon = Icons.Rounded.CollectionsBookmark,
+ target = SettingsSearchTarget.Collections,
+ )
+
+ val playbackPlayer = stringResource(Res.string.settings_playback_section_player)
+ val playbackSubtitleAudio = stringResource(Res.string.settings_playback_section_subtitle_audio)
+ val playbackStreamSelection = stringResource(Res.string.settings_playback_section_stream_selection)
+ val playbackStreamAutoPlay = stringResource(Res.string.settings_playback_section_stream_auto_play)
+ val playbackDecoder = stringResource(Res.string.settings_playback_section_decoder)
+ val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering)
+ val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments)
+ val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode)
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackPlayer,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow(
+ "loading-overlay",
+ stringResource(Res.string.settings_playback_show_loading_overlay),
+ stringResource(Res.string.settings_playback_show_loading_overlay_description),
+ ),
+ PlaybackSearchRow(
+ "hold-to-speed",
+ stringResource(Res.string.settings_playback_hold_to_speed),
+ stringResource(Res.string.settings_playback_hold_to_speed_description),
+ ),
+ PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)),
+ ),
+ )
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackSubtitleAudio,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow("preferred-audio", stringResource(Res.string.settings_playback_preferred_audio_language)),
+ PlaybackSearchRow("secondary-audio", stringResource(Res.string.settings_playback_secondary_audio_language)),
+ PlaybackSearchRow("preferred-subtitles", stringResource(Res.string.settings_playback_preferred_subtitle_language)),
+ PlaybackSearchRow("secondary-subtitles", stringResource(Res.string.settings_playback_secondary_subtitle_language)),
+ ),
+ )
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackStreamSelection,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow(
+ "reuse-last-link",
+ stringResource(Res.string.settings_playback_reuse_last_link),
+ stringResource(Res.string.settings_playback_reuse_last_link_description),
+ ),
+ PlaybackSearchRow("last-link-cache", stringResource(Res.string.settings_playback_last_link_cache_duration)),
+ ),
+ )
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackStreamAutoPlay,
+ icon = Icons.Rounded.PlayArrow,
+ rows = buildList {
+ add(PlaybackSearchRow("stream-mode", stringResource(Res.string.settings_playback_stream_selection_mode)))
+ add(PlaybackSearchRow("regex-pattern", stringResource(Res.string.settings_playback_regex_pattern)))
+ add(PlaybackSearchRow("stream-timeout", stringResource(Res.string.settings_playback_stream_timeout), stringResource(Res.string.settings_playback_stream_timeout_description)))
+ add(PlaybackSearchRow("source-scope", stringResource(Res.string.settings_playback_source_scope)))
+ add(PlaybackSearchRow("allowed-addons", stringResource(Res.string.settings_playback_allowed_addons)))
+ if (pluginsEnabled) add(PlaybackSearchRow("allowed-plugins", stringResource(Res.string.settings_playback_allowed_plugins)))
+ },
+ )
+ if (!isIos) {
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackDecoder,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow("decoder-priority", stringResource(Res.string.settings_playback_decoder_priority)),
+ PlaybackSearchRow("dv7-hevc", stringResource(Res.string.settings_playback_map_dv7_to_hevc), stringResource(Res.string.settings_playback_map_dv7_to_hevc_description)),
+ PlaybackSearchRow("tunneled-playback", stringResource(Res.string.settings_playback_tunneled_playback), stringResource(Res.string.settings_playback_tunneled_playback_description)),
+ ),
+ )
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackSubtitleRendering,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow("libass", stringResource(Res.string.settings_playback_enable_libass), stringResource(Res.string.settings_playback_enable_libass_description)),
+ PlaybackSearchRow("libass-render", stringResource(Res.string.settings_playback_render_type)),
+ ),
+ )
+ }
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackSkipSegments,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow("skip-intro", stringResource(Res.string.settings_playback_skip_intro_outro_recap), stringResource(Res.string.settings_playback_skip_intro_outro_recap_description)),
+ PlaybackSearchRow("anime-skip", stringResource(Res.string.settings_playback_anime_skip), stringResource(Res.string.settings_playback_anime_skip_description)),
+ PlaybackSearchRow("anime-skip-client", stringResource(Res.string.settings_playback_anime_skip_client_id), stringResource(Res.string.settings_playback_anime_skip_client_id_description)),
+ PlaybackSearchRow("intro-submit", stringResource(Res.string.settings_playback_intro_submit_enabled), stringResource(Res.string.settings_playback_intro_submit_enabled_description)),
+ PlaybackSearchRow("introdb-key", stringResource(Res.string.settings_playback_introdb_api_key), stringResource(Res.string.settings_playback_introdb_api_key_description)),
+ ),
+ )
+ addPlaybackRows(
+ addRow = ::addRow,
+ pageLabel = playbackPage,
+ section = playbackNextEpisode,
+ icon = Icons.Rounded.PlayArrow,
+ rows = listOf(
+ PlaybackSearchRow("auto-play-next", stringResource(Res.string.settings_playback_auto_play_next_episode), stringResource(Res.string.settings_playback_auto_play_next_episode_description)),
+ PlaybackSearchRow("prefer-binge", stringResource(Res.string.settings_playback_prefer_binge_group), stringResource(Res.string.settings_playback_prefer_binge_group_description)),
+ PlaybackSearchRow("threshold-mode", stringResource(Res.string.settings_playback_threshold_mode)),
+ PlaybackSearchRow("threshold-percent", stringResource(Res.string.settings_playback_threshold_percentage), stringResource(Res.string.settings_playback_threshold_percentage_description)),
+ PlaybackSearchRow("threshold-minutes", stringResource(Res.string.settings_playback_minutes_before_end), stringResource(Res.string.settings_playback_minutes_before_end_description)),
+ ),
+ )
+
+ addContinueWatchingRows(
+ addRow = ::addRow,
+ pageLabel = continueWatchingPage,
+ section = stringResource(Res.string.settings_continue_watching_section_visibility),
+ icon = Icons.Rounded.Style,
+ rows = listOf(
+ PlaybackSearchRow(
+ "show-continue-watching",
+ stringResource(Res.string.settings_continue_watching_show_title),
+ stringResource(Res.string.settings_continue_watching_show_description),
+ ),
+ ),
+ )
+ addContinueWatchingRows(
+ addRow = ::addRow,
+ pageLabel = continueWatchingPage,
+ section = stringResource(Res.string.settings_continue_watching_section_up_next_behavior),
+ icon = Icons.Rounded.Style,
+ rows = listOf(
+ PlaybackSearchRow("episode-thumbnails", stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description)),
+ PlaybackSearchRow("up-next", stringResource(Res.string.settings_continue_watching_up_next_title), stringResource(Res.string.settings_continue_watching_up_next_description)),
+ PlaybackSearchRow("unaired-next-up", stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description)),
+ PlaybackSearchRow("blur-next-up", stringResource(Res.string.settings_continue_watching_blur_next_up_title), stringResource(Res.string.settings_continue_watching_blur_next_up_description)),
+ ),
+ )
+ addContinueWatchingRows(
+ addRow = ::addRow,
+ pageLabel = continueWatchingPage,
+ section = stringResource(Res.string.settings_continue_watching_section_on_launch),
+ icon = Icons.Rounded.Style,
+ rows = listOf(
+ PlaybackSearchRow("resume-prompt", stringResource(Res.string.settings_continue_watching_resume_prompt_title), stringResource(Res.string.settings_continue_watching_resume_prompt_description)),
+ ),
+ )
+
+ val posterSection = stringResource(Res.string.settings_poster_card_style)
+ listOf(
+ PlaybackSearchRow("poster-width", stringResource(Res.string.settings_poster_card_width)),
+ PlaybackSearchRow("poster-radius", stringResource(Res.string.settings_poster_card_radius)),
+ PlaybackSearchRow("poster-landscape", stringResource(Res.string.settings_poster_landscape_mode)),
+ PlaybackSearchRow("poster-hide-labels", stringResource(Res.string.settings_poster_hide_labels)),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.PosterCustomization,
+ key = "poster-${row.key}",
+ title = row.title,
+ description = row.description,
+ pageLabel = posterStylePage,
+ section = posterSection,
+ icon = Icons.Rounded.Tune,
+ )
+ }
+
+ val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero)
+ listOf(
+ PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)),
+ PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)),
+ PlaybackSearchRow("home-hide-catalog-underline", stringResource(Res.string.settings_homescreen_hide_catalog_underline), stringResource(Res.string.settings_homescreen_hide_catalog_underline_description)),
+ PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)),
+ PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.Homescreen,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = homeLayoutPage,
+ section = homeLayoutSection,
+ icon = Icons.Rounded.Home,
+ )
+ }
+
+ val detailAppearanceSection = stringResource(Res.string.settings_meta_section_appearance)
+ listOf(
+ PlaybackSearchRow("meta-cinematic", stringResource(Res.string.settings_meta_cinematic_background), stringResource(Res.string.settings_meta_cinematic_background_description)),
+ PlaybackSearchRow("meta-tabs", stringResource(Res.string.settings_meta_tab_layout), stringResource(Res.string.settings_meta_tab_layout_description)),
+ PlaybackSearchRow("meta-episode-cards", stringResource(Res.string.settings_meta_episode_cards), stringResource(Res.string.settings_meta_episode_cards_description)),
+ PlaybackSearchRow("meta-blur-episodes", stringResource(Res.string.settings_meta_blur_unwatched_episodes), stringResource(Res.string.settings_meta_blur_unwatched_episodes_description)),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.MetaScreen,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = detailPage,
+ section = detailAppearanceSection,
+ icon = Icons.Rounded.Tune,
+ )
+ }
+ val detailSectionsSection = stringResource(Res.string.settings_meta_section_sections)
+ listOf(
+ PlaybackSearchRow("meta-overview", stringResource(Res.string.settings_meta_overview), stringResource(Res.string.settings_meta_overview_description)),
+ PlaybackSearchRow("meta-actions", stringResource(Res.string.settings_meta_actions), stringResource(Res.string.settings_meta_actions_description)),
+ PlaybackSearchRow("meta-details", stringResource(Res.string.settings_meta_details), stringResource(Res.string.settings_meta_details_description)),
+ PlaybackSearchRow("meta-trailers", stringResource(Res.string.settings_meta_trailers), stringResource(Res.string.settings_meta_trailers_description)),
+ PlaybackSearchRow("meta-cast", stringResource(Res.string.settings_meta_cast), stringResource(Res.string.settings_meta_cast_description)),
+ PlaybackSearchRow("meta-episodes", stringResource(Res.string.settings_meta_episodes), stringResource(Res.string.settings_meta_episodes_description)),
+ PlaybackSearchRow("meta-production", stringResource(Res.string.settings_meta_production), stringResource(Res.string.settings_meta_production_description)),
+ PlaybackSearchRow("meta-more-like-this", stringResource(Res.string.settings_meta_more_like_this), stringResource(Res.string.settings_meta_more_like_this_description)),
+ PlaybackSearchRow("meta-collection", stringResource(Res.string.settings_meta_collection), stringResource(Res.string.settings_meta_collection_description)),
+ PlaybackSearchRow("meta-comments", stringResource(Res.string.settings_meta_comments), stringResource(Res.string.settings_meta_comments_description)),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.MetaScreen,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = detailPage,
+ section = detailSectionsSection,
+ icon = Icons.Rounded.Tune,
+ )
+ }
+
+ addPage(
+ page = SettingsPage.TmdbEnrichment,
+ key = "tmdb",
+ title = tmdbPage,
+ description = stringResource(Res.string.settings_integrations_tmdb_description),
+ icon = Icons.Rounded.Link,
+ )
+ addPage(
+ page = SettingsPage.MdbListRatings,
+ key = "mdblist",
+ title = mdbListPage,
+ description = stringResource(Res.string.settings_integrations_mdblist_description),
+ icon = Icons.Rounded.Link,
+ )
+ val tmdbModulesSection = stringResource(Res.string.settings_tmdb_section_modules)
+ listOf(
+ PlaybackSearchRow("tmdb-enable", stringResource(Res.string.settings_tmdb_enable_enrichment), stringResource(Res.string.settings_tmdb_enable_enrichment_description), stringResource(Res.string.settings_tmdb_section_title)),
+ PlaybackSearchRow("tmdb-api-key", stringResource(Res.string.settings_tmdb_personal_api_key), "", stringResource(Res.string.settings_tmdb_section_credentials)),
+ PlaybackSearchRow("tmdb-language", stringResource(Res.string.settings_tmdb_preferred_language), stringResource(Res.string.settings_tmdb_preferred_language_description), stringResource(Res.string.settings_tmdb_section_localization)),
+ PlaybackSearchRow("tmdb-trailers", stringResource(Res.string.settings_tmdb_module_trailers), stringResource(Res.string.settings_tmdb_module_trailers_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-artwork", stringResource(Res.string.settings_tmdb_module_artwork), stringResource(Res.string.settings_tmdb_module_artwork_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-basic-info", stringResource(Res.string.settings_tmdb_module_basic_info), stringResource(Res.string.settings_tmdb_module_basic_info_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-details", stringResource(Res.string.settings_tmdb_module_details), stringResource(Res.string.settings_tmdb_module_details_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-credits", stringResource(Res.string.settings_tmdb_module_credits), stringResource(Res.string.settings_tmdb_module_credits_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-companies", stringResource(Res.string.settings_tmdb_module_production_companies), stringResource(Res.string.settings_tmdb_module_production_companies_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-networks", stringResource(Res.string.settings_tmdb_module_networks), stringResource(Res.string.settings_tmdb_module_networks_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-episodes", stringResource(Res.string.settings_tmdb_module_episodes), stringResource(Res.string.settings_tmdb_module_episodes_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-season-posters", stringResource(Res.string.settings_tmdb_module_season_posters), stringResource(Res.string.settings_tmdb_module_season_posters_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-more-like-this", stringResource(Res.string.settings_tmdb_module_more_like_this), stringResource(Res.string.settings_tmdb_module_more_like_this_description), tmdbModulesSection),
+ PlaybackSearchRow("tmdb-collections", stringResource(Res.string.settings_tmdb_module_collections), stringResource(Res.string.settings_tmdb_module_collections_description), tmdbModulesSection),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.TmdbEnrichment,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = tmdbPage,
+ section = row.sectionOverride ?: tmdbModulesSection,
+ icon = Icons.Rounded.Link,
+ )
+ }
+
+ listOf(
+ PlaybackSearchRow("mdb-enable", stringResource(Res.string.settings_mdb_enable_ratings), stringResource(Res.string.settings_mdb_enable_ratings_description), stringResource(Res.string.settings_mdb_section_title)),
+ PlaybackSearchRow("mdb-api-key", stringResource(Res.string.settings_mdb_api_key_title), stringResource(Res.string.settings_mdb_api_key_description), stringResource(Res.string.settings_mdb_section_api_key)),
+ PlaybackSearchRow("mdb-imdb", stringResource(Res.string.source_imdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ PlaybackSearchRow("mdb-tmdb", stringResource(Res.string.source_tmdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ PlaybackSearchRow("mdb-tomatoes", stringResource(Res.string.source_rotten_tomatoes), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ PlaybackSearchRow("mdb-metacritic", stringResource(Res.string.source_metacritic), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ PlaybackSearchRow("mdb-trakt", stringResource(Res.string.source_trakt), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ PlaybackSearchRow("mdb-letterboxd", stringResource(Res.string.source_letterboxd), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ PlaybackSearchRow("mdb-audience", stringResource(Res.string.source_audience_score), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.MdbListRatings,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = mdbListPage,
+ section = row.sectionOverride ?: stringResource(Res.string.settings_mdb_section_title),
+ icon = Icons.Rounded.Link,
+ )
+ }
+
+ val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts)
+ addRow(
+ page = SettingsPage.Notifications,
+ key = "episode-release-alerts",
+ title = stringResource(Res.string.settings_notifications_episode_release_alerts),
+ description = stringResource(Res.string.settings_notifications_episode_release_alerts_description),
+ pageLabel = notificationsPage,
+ section = notificationsAlerts,
+ icon = Icons.Rounded.Notifications,
+ )
+ addRow(
+ page = SettingsPage.Notifications,
+ key = "notification-test",
+ title = stringResource(Res.string.settings_notifications_test_title),
+ pageLabel = notificationsPage,
+ section = stringResource(Res.string.settings_notifications_section_test),
+ icon = Icons.Rounded.Notifications,
+ )
+
+ addRow(
+ page = SettingsPage.TraktAuthentication,
+ key = "trakt-authentication",
+ title = stringResource(Res.string.settings_trakt_authentication),
+ description = stringResource(Res.string.settings_trakt_intro_description),
+ pageLabel = traktPage,
+ section = stringResource(Res.string.settings_trakt_authentication),
+ category = accountCategory,
+ icon = Icons.Rounded.Link,
+ )
+ listOf(
+ PlaybackSearchRow("trakt-library-source", stringResource(Res.string.trakt_library_source_title), stringResource(Res.string.trakt_library_source_subtitle)),
+ PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)),
+ PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)),
+ PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)),
+ ).forEach { row ->
+ addRow(
+ page = SettingsPage.TraktAuthentication,
+ key = row.key,
+ title = row.title,
+ description = row.description,
+ pageLabel = traktPage,
+ section = stringResource(Res.string.settings_trakt_features),
+ category = accountCategory,
+ icon = Icons.Rounded.Link,
+ )
+ }
+
+ return entries
+}
+
+private data class PlaybackSearchRow(
+ val key: String,
+ val title: String,
+ val description: String = "",
+ val sectionOverride: String? = null,
+)
+
+private fun addPlaybackRows(
+ addRow: (
+ page: SettingsPage,
+ key: String,
+ title: String,
+ description: String,
+ pageLabel: String,
+ section: String,
+ category: String,
+ icon: ImageVector,
+ ) -> Unit,
+ pageLabel: String,
+ section: String,
+ icon: ImageVector,
+ rows: List,
+) {
+ rows.forEach { row ->
+ addRow(
+ SettingsPage.Playback,
+ "playback-${row.key}",
+ row.title,
+ row.description,
+ pageLabel,
+ section,
+ "",
+ icon,
+ )
+ }
+}
+
+private fun addContinueWatchingRows(
+ addRow: (
+ page: SettingsPage,
+ key: String,
+ title: String,
+ description: String,
+ pageLabel: String,
+ section: String,
+ category: String,
+ icon: ImageVector,
+ ) -> Unit,
+ pageLabel: String,
+ section: String,
+ icon: ImageVector,
+ rows: List,
+) {
+ rows.forEach { row ->
+ addRow(
+ SettingsPage.ContinueWatching,
+ "continue-watching-${row.key}",
+ row.title,
+ row.description,
+ pageLabel,
+ section,
+ "",
+ icon,
+ )
+ }
+}
+
+internal fun LazyListScope.settingsSearchRootContent(
+ query: String,
+ entries: List,
+ isTablet: Boolean,
+ showSearchField: Boolean,
+ animateSearchField: Boolean,
+ onQueryChange: (String) -> Unit,
+ onTargetClick: (SettingsSearchTarget) -> Unit,
+) {
+ if (showSearchField || query.isNotBlank()) {
+ item(key = "settings-search-field") {
+ SettingsSearchRevealItem(animate = animateSearchField) {
+ SettingsSearchField(
+ query = query,
+ onQueryChange = onQueryChange,
+ )
+ }
+ }
+ }
+
+ if (query.isBlank()) return
+
+ val results = settingsSearchResults(
+ query = query,
+ entries = entries,
+ )
+
+ item(key = "settings-search-results") {
+ if (results.isEmpty()) {
+ SettingsSearchEmptyState(isTablet = isTablet)
+ } else {
+ SettingsSection(
+ title = stringResource(Res.string.settings_search_results_section),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ results.forEachIndexed { index, entry ->
+ if (index > 0) {
+ SettingsGroupDivider(isTablet = isTablet)
+ }
+ SettingsNavigationRow(
+ title = entry.title,
+ description = entry.resultDescription(),
+ icon = entry.icon,
+ isTablet = isTablet,
+ onClick = { onTargetClick(entry.target) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SettingsSearchRevealItem(
+ animate: Boolean,
+ content: @Composable () -> Unit,
+) {
+ if (!animate) {
+ content()
+ return
+ }
+
+ val visibleState = remember {
+ MutableTransitionState(false).apply {
+ targetState = true
+ }
+ }
+ AnimatedVisibility(
+ visibleState = visibleState,
+ enter = expandVertically(
+ animationSpec = tween(durationMillis = 220),
+ expandFrom = Alignment.Top,
+ ) + fadeIn(
+ animationSpec = tween(durationMillis = 180),
+ ) + slideInVertically(
+ animationSpec = tween(durationMillis = 220),
+ initialOffsetY = { -it / 4 },
+ ),
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun SettingsSearchField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ shape = RoundedCornerShape(14.dp),
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Rounded.Search,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ trailingIcon = if (query.isNotBlank()) {
+ {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = stringResource(Res.string.compose_search_clear),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ } else {
+ null
+ },
+ placeholder = {
+ Text(
+ text = stringResource(Res.string.settings_search_placeholder),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = MaterialTheme.colorScheme.outline,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ cursorColor = MaterialTheme.colorScheme.primary,
+ ),
+ )
+}
+
+@Composable
+private fun SettingsSearchEmptyState(isTablet: Boolean) {
+ SettingsSection(
+ title = stringResource(Res.string.settings_search_results_section),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 18.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.settings_search_empty),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+}
+
+private fun settingsSearchResults(
+ query: String,
+ entries: List,
+): List {
+ val terms = query
+ .trim()
+ .lowercase()
+ .split(Regex("\\s+"))
+ .filter { it.isNotBlank() }
+
+ if (terms.isEmpty()) return emptyList()
+
+ return entries.filter { entry ->
+ terms.all { term -> entry.searchableText.contains(term) }
+ }
+}
+
+private fun SettingsSearchEntry.resultDescription(): String {
+ return description.ifBlank { contextLabel }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt
index 0d497166..648eaa9e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt
@@ -22,8 +22,20 @@ internal expect fun epochMs(): Long
object StreamLinkCacheRepository {
private val json = Json { ignoreUnknownKeys = true }
- fun contentKey(type: String, videoId: String): String =
- "${type.lowercase()}|$videoId"
+ fun contentKey(
+ type: String,
+ videoId: String,
+ parentMetaId: String? = null,
+ season: Int? = null,
+ episode: Int? = null,
+ ): String {
+ val normalizedType = type.lowercase()
+ return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) {
+ "$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId"
+ } else {
+ "$normalizedType|$videoId"
+ }
+ }
fun save(
contentKey: String,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt
index c7db8b2d..784dff47 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt
@@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
}
data class StreamsUiState(
+ val requestToken: String? = null,
val groups: List = emptyList(),
val activeAddonIds: Set = emptySet(),
val selectedFilter: String? = null,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt
index 674e3352..daa96a7b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt
@@ -36,6 +36,15 @@ object StreamsRepository {
private var activeJob: Job? = null
private var activeRequestKey: String? = null
+ fun requestToken(
+ type: String,
+ videoId: String,
+ season: Int? = null,
+ episode: Int? = null,
+ manualSelection: Boolean = false,
+ ): String =
+ "$type::$videoId::$season::$episode::$manualSelection"
+
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(
type = type,
@@ -65,7 +74,14 @@ object StreamsRepository {
} else {
PluginsUiState(pluginsEnabled = false)
}
- val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
+ val requestToken = requestToken(
+ type = type,
+ videoId = videoId,
+ season = season,
+ episode = episode,
+ manualSelection = manualSelection,
+ )
+ val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val currentState = _uiState.value
if (
!forceRefresh &&
@@ -78,7 +94,7 @@ object StreamsRepository {
activeRequestKey = requestKey
activeJob?.cancel()
- _uiState.value = StreamsUiState()
+ _uiState.value = StreamsUiState(requestToken = requestToken)
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
@@ -90,6 +106,7 @@ object StreamsRepository {
if (isDirectAutoPlayFlow) {
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
isDirectAutoPlayFlow = true,
showDirectAutoPlayOverlay = true,
)
@@ -105,6 +122,7 @@ object StreamsRepository {
isLoading = false,
)
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
@@ -125,6 +143,7 @@ object StreamsRepository {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
)
@@ -151,8 +170,9 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
- if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
+ if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
)
@@ -176,6 +196,7 @@ object StreamsRepository {
)
}
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
groups = initialGroups,
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
index a0cadbc0..22e877bb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
@@ -160,7 +160,7 @@ fun StreamsScreen(
}
}
- LaunchedEffect(type, videoId, manualSelection) {
+ LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
StreamsRepository.load(
type = type,
videoId = videoId,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt
index 378b8ef3..a2bd8a03 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt
@@ -15,8 +15,7 @@ private const val COMMENTS_SORT = "likes"
private const val COMMENTS_LIMIT = 100
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
private val INLINE_SPOILER_REGEX = Regex(
- "\\[spoiler\\].*?\\[/spoiler\\]",
- setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
+ "(?is)\\[spoiler\\].*?\\[/spoiler\\]"
)
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt
index 50fa7baf..5ecb0492 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt
@@ -51,6 +51,7 @@ object TraktEpisodeMappingService {
videoId: String?,
season: Int?,
episode: Int?,
+ episodeTitle: String? = null,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
@@ -77,7 +78,7 @@ object TraktEpisodeMappingService {
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
- requestedTitle = null,
+ requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
@@ -176,18 +177,18 @@ object TraktEpisodeMappingService {
// ── Season structure comparison ───────────────────────────────────────
- private fun hasSameSeasonStructure(
+ internal fun hasSameSeasonStructure(
addonEpisodes: List,
traktEpisodes: List,
): Boolean {
- val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
- val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
- return addonSeasons == traktSeasons
+ val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size }
+ val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size }
+ return addonPerSeason == traktPerSeason
}
// ── Forward mapping: addon → Trakt ──────────────────────────────────
- private fun remapEpisodeByTitleOrIndex(
+ internal fun remapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
@@ -195,63 +196,72 @@ object TraktEpisodeMappingService {
addonEpisodes: List,
traktEpisodes: List,
): EpisodeMappingEntry? {
- // Find the addon episode entry
- val addonEntry = addonEpisodes.firstOrNull {
- it.season == requestedSeason && it.episode == requestedEpisode
- } ?: addonEpisodes.firstOrNull {
- !requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId
- } ?: return null
-
- // Try title match first
- val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
- if (!titleToMatch.isNullOrBlank()) {
- val titleMatch = traktEpisodes.firstOrNull { target ->
- !target.title.isNullOrBlank() &&
- normalizeTitle(target.title) == normalizeTitle(titleToMatch)
- }
- if (titleMatch != null) {
- return titleMatch
- }
- }
-
- // Fallback: global index mapping
- val addonIndex = addonEpisodes.indexOf(addonEntry)
- if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null
-
- return traktEpisodes[addonIndex]
+ return remapEpisodeBetweenLists(
+ requestedSeason = requestedSeason,
+ requestedEpisode = requestedEpisode,
+ requestedVideoId = requestedVideoId,
+ requestedTitle = requestedTitle,
+ sourceEpisodes = addonEpisodes,
+ targetEpisodes = traktEpisodes,
+ )
}
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
- private fun reverseRemapEpisodeByTitleOrIndex(
+ internal fun reverseRemapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedTitle: String?,
addonEpisodes: List,
traktEpisodes: List,
): EpisodeMappingEntry? {
- // Find the Trakt episode entry
- val traktEntry = traktEpisodes.firstOrNull {
- it.season == requestedSeason && it.episode == requestedEpisode
- } ?: return null
+ return remapEpisodeBetweenLists(
+ requestedSeason = requestedSeason,
+ requestedEpisode = requestedEpisode,
+ requestedVideoId = null,
+ requestedTitle = requestedTitle,
+ sourceEpisodes = traktEpisodes,
+ targetEpisodes = addonEpisodes,
+ )
+ }
- // Try title match first
- val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
- if (!titleToMatch.isNullOrBlank()) {
- val titleMatch = addonEpisodes.firstOrNull { target ->
- !target.title.isNullOrBlank() &&
- normalizeTitle(target.title) == normalizeTitle(titleToMatch)
+ private fun remapEpisodeBetweenLists(
+ requestedSeason: Int,
+ requestedEpisode: Int,
+ requestedVideoId: String?,
+ requestedTitle: String?,
+ sourceEpisodes: List,
+ targetEpisodes: List,
+ ): EpisodeMappingEntry? {
+ if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null
+
+ val orderedSourceEpisodes = sourceEpisodes
+ .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
+ val orderedTargetEpisodes = targetEpisodes
+ .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
+
+ val currentSourceEpisode = requestedVideoId
+ ?.takeIf { it.isNotBlank() }
+ ?.let { videoId -> orderedSourceEpisodes.firstOrNull { it.videoId == videoId } }
+ ?: orderedSourceEpisodes.firstOrNull {
+ it.season == requestedSeason && it.episode == requestedEpisode
}
- if (titleMatch != null) {
- return titleMatch
+ ?: return null
+
+ val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title)
+ if (isUsefulEpisodeTitle(normalizedTitle)) {
+ val titleMatches = orderedTargetEpisodes.filter {
+ normalizeEpisodeTitle(it.title) == normalizedTitle
+ }
+ if (titleMatches.size == 1) {
+ return titleMatches.first()
}
}
- // Fallback: global index mapping
- val traktIndex = traktEpisodes.indexOf(traktEntry)
- if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
+ val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode)
+ if (sourceIndex !in orderedTargetEpisodes.indices) return null
- return addonEpisodes[traktIndex]
+ return orderedTargetEpisodes[sourceIndex]
}
// ── Addon episodes fetching (with dedup) ───────────────────────────
@@ -396,7 +406,7 @@ object TraktEpisodeMappingService {
return when {
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
contentIds.trakt != null -> contentIds.trakt.toString()
- contentIds.tmdb != null -> contentIds.tmdb.toString()
+ !contentIds.slug.isNullOrBlank() -> contentIds.slug
else -> null
}
}
@@ -405,13 +415,13 @@ object TraktEpisodeMappingService {
return when {
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
videoIds.trakt != null -> videoIds.trakt.toString()
- videoIds.tmdb != null -> videoIds.tmdb.toString()
+ !videoIds.slug.isNullOrBlank() -> videoIds.slug
else -> null
}
}
private fun TraktExternalIds.hasAnyId(): Boolean =
- !imdb.isNullOrBlank() || trakt != null || tmdb != null
+ !imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank()
private fun cacheKey(
contentId: String?,
@@ -461,9 +471,22 @@ object TraktEpisodeMappingService {
.toList()
}
- private fun normalizeTitle(title: String?): String =
- title.orEmpty().trim().lowercase()
- .replace(Regex("[^a-z0-9]"), "")
+ private fun normalizeEpisodeTitle(title: String?): String {
+ return title
+ .orEmpty()
+ .lowercase()
+ .replace(Regex("[^a-z0-9]+"), " ")
+ .trim()
+ .replace(Regex("\\s+"), " ")
+ }
+
+ private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean {
+ if (normalizedTitle.isBlank()) return false
+ if (normalizedTitle.matches(Regex("episode \\d+"))) return false
+ if (normalizedTitle.matches(Regex("ep \\d+"))) return false
+ if (normalizedTitle.matches(Regex("e \\d+"))) return false
+ return true
+ }
}
// ── Data classes ────────────────────────────────────────────────────────
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt
new file mode 100644
index 00000000..79b5bd07
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt
@@ -0,0 +1,70 @@
+package com.nuvio.app.features.trakt
+
+private val TraktIsoDateTimeRegex = Regex(
+ """^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""",
+)
+
+internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? {
+ val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null
+ val year = match.groupValues[1].toIntOrNull() ?: return null
+ val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
+ val day = match.groupValues[3].toIntOrNull() ?: return null
+ val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null
+ val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
+ val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
+ if (day !in 1..daysInMonth(year, month)) return null
+
+ val millisecond = match.groupValues[7]
+ .takeIf { it.isNotEmpty() }
+ ?.padEnd(3, '0')
+ ?.take(3)
+ ?.toIntOrNull()
+ ?: 0
+ val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null
+
+ return isoEpochDay(year, month, day) * MillisPerDay +
+ hour * MillisPerHour +
+ minute * MillisPerMinute +
+ second * MillisPerSecond +
+ millisecond -
+ offsetMs
+}
+
+private fun parseOffsetMs(value: String): Long? {
+ if (value == "Z") return 0L
+ val sign = when (value.firstOrNull()) {
+ '+' -> 1L
+ '-' -> -1L
+ else -> return null
+ }
+ val digits = value.drop(1).replace(":", "")
+ if (digits.length != 4) return null
+ val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null
+ val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null
+ return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute))
+}
+
+private fun isoEpochDay(year: Int, month: Int, day: Int): Long {
+ val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L
+ val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
+ val yearOfEra = adjustedYear - era * 400L
+ val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L
+ val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
+ val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
+ return era * 146_097L + dayOfEra - 719_468L
+}
+
+private fun daysInMonth(year: Int, month: Int): Int =
+ when (month) {
+ 2 -> if (isLeapYear(year)) 29 else 28
+ 4, 6, 9, 11 -> 30
+ else -> 31
+ }
+
+private fun isLeapYear(year: Int): Boolean =
+ year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
+
+private const val MillisPerSecond = 1_000L
+private const val MillisPerMinute = 60L * MillisPerSecond
+private const val MillisPerHour = 60L * MillisPerMinute
+private const val MillisPerDay = 24L * MillisPerHour
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
index de3e429f..dc43c983 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
@@ -475,27 +475,27 @@ object TraktProgressRepository {
var resolvedEpisode = entry.episodeNumber
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
- // Try direct match first
val directMatch = meta.videos.firstOrNull { video ->
video.season == resolvedSeason && video.episode == resolvedEpisode
}
if (directMatch != null) {
directMatch
} else {
- // Fallback: reverse-remap from Trakt numbering to addon numbering
- val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
- if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
- val sorted = meta.videos
- .filter { it.season != null && it.episode != null }
- .sortedWith(compareBy({ it.season }, { it.episode }))
- val globalIndex = resolvedEpisode!! - 1
- if (globalIndex in sorted.indices) {
- val remapped = sorted[globalIndex]
- resolvedSeason = remapped.season
- resolvedEpisode = remapped.episode
- remapped
- } else null
- } else null
+ val remapped = resolveAddonEpisodeProgress(
+ contentId = entry.parentMetaId,
+ season = resolvedSeason,
+ episode = resolvedEpisode,
+ episodeTitle = entry.episodeTitle,
+ )
+ if (remapped != null) {
+ resolvedSeason = remapped.season
+ resolvedEpisode = remapped.episode
+ meta.videos.firstOrNull { video ->
+ video.season == remapped.season && video.episode == remapped.episode
+ }
+ } else {
+ null
+ }
}
} else {
null
@@ -540,7 +540,7 @@ object TraktProgressRepository {
).normalizedCompletion()
}
- private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
+ private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
val show = item.show ?: return null
val episode = item.episode ?: return null
val season = episode.season ?: return null
@@ -551,6 +551,14 @@ object TraktProgressRepository {
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
if (progressPercent <= 0f) return null
+ val resolvedEpisode = resolveAddonEpisodeProgress(
+ contentId = parentMetaId,
+ season = season,
+ episode = number,
+ episodeTitle = episode.title,
+ )
+ val resolvedSeason = resolvedEpisode?.season ?: season
+ val resolvedNumber = resolvedEpisode?.episode ?: number
return WatchProgressEntry(
contentType = "series",
@@ -558,14 +566,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
- seasonNumber = season,
- episodeNumber = number,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
),
title = show.title ?: parentMetaId,
- seasonNumber = season,
- episodeNumber = number,
- episodeTitle = episode.title,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
+ episodeTitle = resolvedEpisode?.title ?: episode.title,
lastPositionMs = 0L,
durationMs = 0L,
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
@@ -575,7 +583,7 @@ object TraktProgressRepository {
).normalizedCompletion()
}
- private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
+ private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
val show = item.show ?: return null
val episode = item.episode ?: return null
val season = episode.season ?: return null
@@ -583,6 +591,14 @@ object TraktProgressRepository {
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
if (parentMetaId.isBlank()) return null
+ val resolvedEpisode = resolveAddonEpisodeProgress(
+ contentId = parentMetaId,
+ season = season,
+ episode = number,
+ episodeTitle = episode.title,
+ )
+ val resolvedSeason = resolvedEpisode?.season ?: season
+ val resolvedNumber = resolvedEpisode?.episode ?: number
return WatchProgressEntry(
contentType = "series",
@@ -590,14 +606,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
- seasonNumber = season,
- episodeNumber = number,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
),
title = show.title ?: parentMetaId,
- seasonNumber = season,
- episodeNumber = number,
- episodeTitle = episode.title,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
+ episodeTitle = resolvedEpisode?.title ?: episode.title,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
@@ -627,7 +643,7 @@ object TraktProgressRepository {
)
}
- private fun mapWatchedShowSeed(
+ private suspend fun mapWatchedShowSeed(
item: TraktWatchedShowItem,
useFurthestEpisode: Boolean,
): WatchProgressEntry? {
@@ -670,6 +686,14 @@ object TraktProgressRepository {
)
},
) ?: return null
+ val resolvedEpisode = resolveAddonEpisodeProgress(
+ contentId = parentMetaId,
+ season = completedEpisode.season,
+ episode = completedEpisode.episode,
+ episodeTitle = null,
+ )
+ val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season
+ val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode
return WatchProgressEntry(
contentType = "series",
@@ -677,13 +701,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
- seasonNumber = completedEpisode.season,
- episodeNumber = completedEpisode.episode,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
fallbackVideoId = null,
),
title = show.title ?: parentMetaId,
- seasonNumber = completedEpisode.season,
- episodeNumber = completedEpisode.episode,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
+ episodeTitle = resolvedEpisode?.title,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = completedEpisode.watchedAt,
@@ -710,6 +735,26 @@ object TraktProgressRepository {
?.let { return it }
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
}
+
+ private suspend fun resolveAddonEpisodeProgress(
+ contentId: String,
+ season: Int,
+ episode: Int,
+ episodeTitle: String?,
+ ): EpisodeMappingEntry? {
+ return runCatching {
+ TraktEpisodeMappingService.resolveAddonEpisodeMapping(
+ contentId = contentId,
+ contentType = "series",
+ season = season,
+ episode = episode,
+ episodeTitle = episodeTitle,
+ )
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" }
+ }.getOrNull()
+ }
}
@Serializable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
index 217e2f70..69445d7d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
@@ -63,9 +63,10 @@ internal object TraktScrobbleRepository {
sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
}
- fun buildItem(
+ suspend fun buildItem(
contentType: String,
parentMetaId: String,
+ videoId: String?,
title: String?,
seasonNumber: Int?,
episodeNumber: Int?,
@@ -81,12 +82,20 @@ internal object TraktScrobbleRepository {
seasonNumber != null &&
episodeNumber != null
) {
+ val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
+ contentId = parentMetaId,
+ contentType = contentType,
+ videoId = videoId,
+ season = seasonNumber,
+ episode = episodeNumber,
+ episodeTitle = episodeTitle,
+ )
TraktScrobbleItem.Episode(
showTitle = title,
showYear = parsedYear,
showIds = ids,
- season = seasonNumber,
- number = episodeNumber,
+ season = mappedEpisode?.season ?: seasonNumber,
+ number = mappedEpisode?.episode ?: episodeNumber,
episodeTitle = episodeTitle,
)
} else {
@@ -247,6 +256,9 @@ internal object TraktScrobbleRepository {
val isSameAction = last.action == action
val isSameItem = last.itemKey == itemKey
val isNearProgress = abs(last.progress - progress) <= progressWindow
+ if (action == "stop" && last.action == "start" && isSameItem) {
+ return false
+ }
return isSameWindow && isSameAction && isSameItem && isNearProgress
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
index 59c074ee..10263a55 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
@@ -53,11 +53,14 @@ fun nextReleasedEpisodeAfter(
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
- val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
+ val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 }
+ val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
+ normalizeSeasonNumber(episode.seasonNumber)
+ }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
- if (globalIndex in sortedEpisodes.indices) {
- watchedIndex = globalIndex
+ if (globalIndex in mainEpisodes.indices) {
+ watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt
index 9845b680..93704067 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt
@@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences(
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
+ @SerialName("sort_mode")
+ val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
)
object ContinueWatchingPreferencesRepository {
@@ -97,6 +99,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
+ sortMode = stored.sortMode,
)
} else {
ContinueWatchingPreferencesUiState()
@@ -155,6 +158,13 @@ object ContinueWatchingPreferencesRepository {
persist()
}
+ fun setSortMode(mode: ContinueWatchingSortMode) {
+ ensureLoaded()
+ if (_uiState.value.sortMode == mode) return
+ _uiState.value = _uiState.value.copy(sortMode = mode)
+ persist()
+ }
+
fun removeDismissedNextUpKeysForContent(contentId: String) {
ensureLoaded()
val normalizedContentId = contentId.trim()
@@ -178,6 +188,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
+ sortMode = _uiState.value.sortMode,
),
),
)
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 1c27213d..0485986b 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
@@ -17,6 +17,12 @@ enum class ContinueWatchingSectionStyle {
Poster,
}
+@Serializable
+enum class ContinueWatchingSortMode {
+ DEFAULT,
+ STREAMING_STYLE,
+}
+
@Serializable
data class WatchProgressEntry(
val contentType: String,
@@ -175,6 +181,7 @@ data class ContinueWatchingPreferencesUiState(
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
+ val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
)
internal fun nextUpDismissKey(
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
index 66f227dd..5f83cd99 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
@@ -3,8 +3,13 @@ package com.nuvio.app.features.collection
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -178,4 +183,69 @@ class CollectionSourceSerializationTest {
assertTrue(merged.contains(""""customField":"keep-me""""))
assertTrue(merged.contains(""""traktListId":123456"""))
}
+
+ @Test
+ fun mobileGifToggleDoesNotEnterCollectionJsonOrOverwriteTvGifToggle() {
+ val raw = json.parseToJsonElement(
+ """
+ [
+ {
+ "id": "collection-1",
+ "title": "Favorites",
+ "folders": [
+ {
+ "id": "folder-1",
+ "title": "Movies",
+ "coverImageUrl": "https://example.com/poster.jpg",
+ "focusGifUrl": "https://example.com/focus.gif",
+ "focusGifEnabled": true
+ }
+ ]
+ }
+ ]
+ """.trimIndent(),
+ )
+ val collection = json.decodeFromString>(raw.toString()).single()
+ val mobileDisabled = collection.copy(
+ folders = collection.folders.map { folder ->
+ folder.copy(mobileFocusGifEnabled = false)
+ },
+ )
+
+ val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled))
+ val mergedFolder = merged
+ .single()
+ .jsonObject["folders"]!!
+ .jsonArray
+ .single()
+ .jsonObject
+
+ assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean)
+ assertTrue(mergedFolder["mobileFocusGifEnabled"] == null)
+ }
+
+ @Test
+ fun mobileGifToggleDefaultsIndependentOfTvGifToggle() {
+ val payload = """
+ [
+ {
+ "id": "collection-1",
+ "title": "Favorites",
+ "folders": [
+ {
+ "id": "folder-1",
+ "title": "Movies",
+ "focusGifUrl": "https://example.com/focus.gif",
+ "focusGifEnabled": false
+ }
+ ]
+ }
+ ]
+ """.trimIndent()
+
+ val folder = json.decodeFromString>(payload).single().folders.single()
+
+ assertFalse(folder.focusGifEnabled)
+ assertTrue(folder.mobileFocusGifEnabled)
+ }
}
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 1713004f..e5428e16 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
@@ -88,4 +88,31 @@ class SeriesPlaybackResolverTest {
assertEquals("Up Next • S1E3", action.label)
assertEquals("show:1:3", action.videoId)
}
+
+ @Test
+ fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
+ val meta = MetaDetails(
+ id = "show",
+ type = "series",
+ name = "Show",
+ videos = listOf(
+ MetaVideo(id = "sp1", title = "Special 1", season = 0, episode = 1, released = "2026-01-01"),
+ MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-08"),
+ MetaVideo(id = "s1e2", title = "Episode 2", season = 1, episode = 2, released = "2026-01-15"),
+ MetaVideo(id = "s2e1", title = "Episode 3", season = 2, episode = 1, released = "2026-01-22"),
+ MetaVideo(id = "s2e2", title = "Episode 4", season = 2, episode = 2, released = "2026-01-29"),
+ ),
+ )
+
+ val nextEpisode = meta.nextReleasedEpisodeAfter(
+ seasonNumber = 1,
+ episodeNumber = 3,
+ todayIsoDate = "2026-02-01",
+ )
+
+ assertNotNull(nextEpisode)
+ assertEquals(2, nextEpisode.season)
+ assertEquals(2, nextEpisode.episode)
+ assertEquals("s2e2", nextEpisode.id)
+ }
}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt
new file mode 100644
index 00000000..bf43cd42
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt
@@ -0,0 +1,39 @@
+package com.nuvio.app.features.streams
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class StreamLinkCacheRepositoryTest {
+
+ @Test
+ fun `movie cache key keeps legacy type and video id shape`() {
+ val key = StreamLinkCacheRepository.contentKey(
+ type = "movie",
+ videoId = "tt123",
+ )
+
+ assertEquals("movie|tt123", key)
+ }
+
+ @Test
+ fun `episode cache key is scoped to parent show and episode`() {
+ val firstEpisode = StreamLinkCacheRepository.contentKey(
+ type = "series",
+ videoId = "video-id",
+ parentMetaId = "tt999",
+ season = 1,
+ episode = 1,
+ )
+ val secondEpisode = StreamLinkCacheRepository.contentKey(
+ type = "series",
+ videoId = "video-id",
+ parentMetaId = "tt999",
+ season = 1,
+ episode = 2,
+ )
+
+ assertNotEquals(firstEpisode, secondEpisode)
+ assertEquals("series|tt999|s1|e1|video-id", firstEpisode)
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt
new file mode 100644
index 00000000..295668b8
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt
@@ -0,0 +1,215 @@
+package com.nuvio.app.features.trakt
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TraktEpisodeMappingServiceTest {
+
+ @Test
+ fun `same structure compares per-season episode counts`() {
+ val addon = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ episode(2, 1),
+ )
+ val sameSeasonsDifferentCounts = listOf(
+ episode(1, 1),
+ episode(2, 1),
+ episode(2, 2),
+ )
+ val sameCounts = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ episode(2, 1),
+ )
+
+ assertFalse(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameSeasonsDifferentCounts))
+ assertTrue(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameCounts))
+ }
+
+ @Test
+ fun `forward mapping uses global sorted index for anime numbering`() {
+ val addon = listOf(
+ episode(1, 1, videoId = "show:1:1"),
+ episode(1, 2, videoId = "show:1:2"),
+ episode(2, 1, videoId = "show:2:1"),
+ episode(2, 2, videoId = "show:2:2"),
+ )
+ val trakt = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ episode(1, 3),
+ episode(1, 4),
+ )
+
+ val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
+ requestedSeason = 2,
+ requestedEpisode = 1,
+ requestedVideoId = null,
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertEquals(1, mapped?.season)
+ assertEquals(3, mapped?.episode)
+ }
+
+ @Test
+ fun `reverse mapping uses global sorted index for Trakt absolute numbering`() {
+ val addon = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ episode(2, 1),
+ episode(2, 2),
+ )
+ val trakt = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ episode(1, 3),
+ episode(1, 4),
+ )
+
+ val mapped = TraktEpisodeMappingService.reverseRemapEpisodeByTitleOrIndex(
+ requestedSeason = 1,
+ requestedEpisode = 3,
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertEquals(2, mapped?.season)
+ assertEquals(1, mapped?.episode)
+ }
+
+ @Test
+ fun `unique normalized title wins over index`() {
+ val addon = listOf(
+ episode(1, 1, title = "The Storm"),
+ episode(1, 2, title = "Aftermath"),
+ )
+ val trakt = listOf(
+ episode(1, 1, title = "Aftermath"),
+ episode(1, 2, title = "The Storm!"),
+ )
+
+ val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
+ requestedSeason = 1,
+ requestedEpisode = 1,
+ requestedVideoId = null,
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertEquals(1, mapped?.season)
+ assertEquals(2, mapped?.episode)
+ }
+
+ @Test
+ fun `generic title falls back to index`() {
+ val addon = listOf(
+ episode(1, 1, title = "Episode 1"),
+ episode(2, 1, title = "Actual Title"),
+ )
+ val trakt = listOf(
+ episode(1, 1, title = "Actual Title"),
+ episode(1, 2, title = "Episode 1"),
+ )
+
+ val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
+ requestedSeason = 1,
+ requestedEpisode = 1,
+ requestedVideoId = null,
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertEquals(1, mapped?.season)
+ assertEquals(1, mapped?.episode)
+ }
+
+ @Test
+ fun `duplicate title falls back to index`() {
+ val addon = listOf(
+ episode(1, 1, title = "Pilot"),
+ episode(2, 1, title = "Other"),
+ )
+ val trakt = listOf(
+ episode(1, 1, title = "Pilot"),
+ episode(1, 2, title = "Pilot"),
+ )
+
+ val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
+ requestedSeason = 1,
+ requestedEpisode = 1,
+ requestedVideoId = null,
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertEquals(1, mapped?.season)
+ assertEquals(1, mapped?.episode)
+ }
+
+ @Test
+ fun `video id selects source episode before season episode`() {
+ val addon = listOf(
+ episode(1, 1, videoId = "show:1:1"),
+ episode(2, 1, videoId = "show:2:1"),
+ )
+ val trakt = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ )
+
+ val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
+ requestedSeason = 1,
+ requestedEpisode = 1,
+ requestedVideoId = "show:2:1",
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertEquals(1, mapped?.season)
+ assertEquals(2, mapped?.episode)
+ }
+
+ @Test
+ fun `index outside target range returns null`() {
+ val addon = listOf(
+ episode(1, 1),
+ episode(1, 2),
+ )
+ val trakt = listOf(episode(1, 1))
+
+ val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
+ requestedSeason = 1,
+ requestedEpisode = 2,
+ requestedVideoId = null,
+ requestedTitle = null,
+ addonEpisodes = addon,
+ traktEpisodes = trakt,
+ )
+
+ assertNull(mapped)
+ }
+
+ private fun episode(
+ season: Int,
+ episode: Int,
+ title: String? = null,
+ videoId: String? = null,
+ ) = EpisodeMappingEntry(
+ season = season,
+ episode = episode,
+ title = title,
+ videoId = 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 cb3f6ba7..5aab3131 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
@@ -97,6 +97,30 @@ class SeriesContinuityTest {
assertEquals("show:1:1", action.videoId)
}
+ @Test
+ fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
+ val episodesWithSpecials = listOf(
+ WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
+ WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-08"),
+ WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-15"),
+ WatchingReleasedEpisode(videoId = "s2e1", seasonNumber = 2, episodeNumber = 1, title = "Episode 3", releasedDate = "2026-01-22"),
+ WatchingReleasedEpisode(videoId = "s2e2", seasonNumber = 2, episodeNumber = 2, title = "Episode 4", releasedDate = "2026-01-29"),
+ )
+
+ val nextEpisode = nextReleasedEpisodeAfter(
+ content = show,
+ episodes = episodesWithSpecials,
+ seasonNumber = 1,
+ episodeNumber = 3,
+ todayIsoDate = "2026-02-01",
+ )
+
+ assertNotNull(nextEpisode)
+ assertEquals(2, nextEpisode.seasonNumber)
+ assertEquals(2, nextEpisode.episodeNumber)
+ assertEquals("s2e2", nextEpisode.videoId)
+ }
+
@Test
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
val specialsOnly = listOf(
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
index 553140ee..b3e60b1d 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
@@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"trakt_auth_payload",
"trakt_library_payload",
"trakt_settings_payload",
+ "collection_mobile_settings_payload",
"collections_payload",
)
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt
new file mode 100644
index 00000000..e214807d
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt
@@ -0,0 +1,15 @@
+package com.nuvio.app.features.collection
+
+import com.nuvio.app.core.storage.ProfileScopedKey
+import platform.Foundation.NSUserDefaults
+
+actual object CollectionMobileSettingsStorage {
+ private const val payloadKey = "collection_mobile_settings_payload"
+
+ actual fun loadPayload(): String? =
+ NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
+
+ actual fun savePayload(payload: String) {
+ NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt
index 11d9fe42..7f1e5c69 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt
@@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -51,6 +52,16 @@ private data class ExpandedGifFrames(
val tickCentiseconds: Int,
)
+private class GifImageViewHolder {
+ var imageView: UIImageView? = null
+
+ fun clear() {
+ imageView?.stopAnimating()
+ imageView?.image = null
+ imageView = null
+ }
+}
+
@OptIn(ExperimentalForeignApi::class)
@Composable
internal actual fun CollectionCardRemoteImage(
@@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage(
gifImage = loadGifImage(imageUrl)
}
+ val imageViewHolder = remember(imageUrl) { GifImageViewHolder() }
+ DisposableEffect(imageUrl) {
+ onDispose {
+ imageViewHolder.clear()
+ }
+ }
+
UIKitView(
modifier = modifier,
factory = {
@@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage(
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
clipsToBounds = true
userInteractionEnabled = false
- image = gifImage
tag = imageUrl.hashCode().toLong()
+ imageViewHolder.imageView = this
+ updateGifImage(gifImage)
}
},
update = { imageView ->
+ imageViewHolder.imageView = imageView
if (imageView.tag != imageUrl.hashCode().toLong()) {
imageView.tag = imageUrl.hashCode().toLong()
}
- imageView.image = gifImage
+ imageView.updateGifImage(gifImage)
},
)
}
+private fun UIImageView.updateGifImage(image: UIImage?) {
+ if (this.image != image) {
+ stopAnimating()
+ this.image = image
+ }
+ if (image != null) {
+ startAnimating()
+ }
+}
+
private fun cachedGifImage(imageUrl: String): UIImage? {
val image = gifImageCache[imageUrl] ?: return null
gifImageCacheOrder.remove(imageUrl)
@@ -311,4 +341,4 @@ private fun ByteArray.readUnsignedShort(startIndex: Int): Int {
return this[startIndex].unsignedInt() or (this[startIndex + 1].unsignedInt() shl 8)
}
-private fun Byte.unsignedInt(): Int = toInt() and 0xFF
\ No newline at end of file
+private fun Byte.unsignedInt(): Int = toInt() and 0xFF
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt
new file mode 100644
index 00000000..7bd03336
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt
@@ -0,0 +1,23 @@
+package com.nuvio.app.features.profiles
+
+import platform.UIKit.UISelectionFeedbackGenerator
+
+internal actual object ProfileHoverHapticFeedback {
+ private var generator: UISelectionFeedbackGenerator? = null
+
+ actual fun prepare() {
+ generator = UISelectionFeedbackGenerator().also { it.prepare() }
+ }
+
+ actual fun perform() {
+ val activeGenerator = generator ?: UISelectionFeedbackGenerator().also {
+ generator = it
+ }
+ activeGenerator.selectionChanged()
+ activeGenerator.prepare()
+ }
+
+ actual fun release() {
+ generator = null
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt
index dfbe18a4..6aa94391 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.features.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.introdb_favicon
import nuvio.composeapp.generated.resources.mdblist_logo
import nuvio.composeapp.generated.resources.rating_tmdb
import nuvio.composeapp.generated.resources.trakt_tv_favicon
@@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon)
IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo)
+ IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon)
}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt
index 77e6d585..0094c594 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt
@@ -1,14 +1,11 @@
package com.nuvio.app.features.trakt
import platform.Foundation.NSDate
-import platform.Foundation.NSISO8601DateFormatter
import platform.Foundation.timeIntervalSince1970
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
- NSISO8601DateFormatter()
- .dateFromString(value)
- ?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() }
+ parseTraktIsoDateTimeToEpochMs(value)
}
diff --git a/gradle.properties b/gradle.properties
index ddcd9b5f..01e9d962 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,14 +1,14 @@
#Kotlin
kotlin.code.style=official
-kotlin.daemon.jvmargs=-Xmx4096M
-kotlin.native.jvmArgs=-Xmx6144M
+kotlin.daemon.jvmargs=-Xmx6144M
+kotlin.native.jvmArgs=-Xmx12288M
kotlin.mpp.enableCInteropCommonization=true
#Gradle
-org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m
+org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m
org.gradle.configuration-cache=true
org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ae480b9f..d260a9e5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -51,6 +51,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
+coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 965f9e75..9b4b9d6b 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=54
+CURRENT_PROJECT_VERSION=58
MARKETING_VERSION=0.1.0