diff --git a/.gitignore b/.gitignore index 80df2402..77f5bac8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ asset scripts/scrape_android_compose_animation_docs.py tools AGENTS.md -server \ No newline at end of file +PERFORMANCE_FINDINGS.md + +# Local MPVKit iOS build environment (sparse APFS image, see MPVKit docs) +.mpvkit-build.sparseimage diff --git a/Strings HR.xml.txt b/Strings HR.xml.txt new file mode 100644 index 00000000..5b6e7a89 --- /dev/null +++ b/Strings HR.xml.txt @@ -0,0 +1,1908 @@ + + Izvori podataka, zahvale i licence platforme + Javno priznanje i zasluge za projekt + Nazad + Poništi + Zatvori + Obriši + Gotovo + Uredi + Unesi + Sljedeće + OK + Repodurciraj + Prethodno + Ukloni + Promijeni poredak + Ponovno postavi + Nastavi + Pokušaj ponovno + Spremi + Spremanje… + Potvrdi + Instaliraj + Naslov dodatka + Uključen + %1$d katalozi + Konfiguriraj + Isključen + Obnovuje sa + %1$d izvori + Nedostupni + Konfiguriraj dodatak + Izbriši dodatak + Dodajte URL manifesta i započnite učitavanje kataloga, metapodataka, prijenosa ili titlova u Nuvio. + Još nisu instalirani nikakvi dodaci. + Unesite URL dodatka. + URL dodatka + Instaliraj dodatak + Učitavanje detalja manifesta... + Provjera URL-a manifesta i učitavanje pojedinosti o dodatku prije instalacije. + Provjera dodatka + Instalacija nije uspjela. + %1$s uspješno je potvrđen i dodan. + Dodatak instaliran + Pomakni dodatak prema dolje + Pomakni dodatak prema gore + Aktivan + Dodatci + Katalozi + Obnovi dodatke + Dodaj dodatak + Instalirani dodaci + Pregled + %1$d pravila o identifikaciji + Verzija %1$s + Odabrano + Kopiraj JSON + %1$d zbirka(-e), %2$d mapa(-e) + Izbriši „%1$s"? Ova se radnja ne može poništiti. + Izbriši kolekciju + Dodaj katalog + Dodaj mapu + Svi žanrovi + Dodajte kataloge iz instaliranih dodataka i definirajte što ta mapa prikazuje. + Još nema izvora kataloga. + Odaberi + Emoji + URL obrazac + Nijedan + Zapakiraj + Napravi kolekciju + Gotovo + Uredi kolekciju + Uredi mapu + Postavite identitet mape, prikaz i izvore kataloga koristeći istu strukturu kao u glavnom uređivaču kolekcije. + Dodajte jedan i započnite. + Još nema mapa + Mape + Filter žanrova + Prikaži samo naslovnu sliku + Sakrij naslov + Nova mapa + Prikaži ovu kolekciju iznad svih standardnih kataloga na početnom zaslonu. Više prikvačenih kolekcija prikazuju se redoslijedom kojim su stvorene. + Prikvači iznad kataloga + URL pozadinske slike (opcionalno) + Naziv mape + URL animiranog GIF-a (reproducira se samo pri prelasku mišem) + Naziv kolekcije + Spremi promjene + Spremi + Izgled + Osnove + Izvori kataloga + Odaberite kataloge dodataka koje bi ova mapa trebala ujediniti. + Odaberite kataloge + Odaberite žanr + %1$d odabrano + %1$d kataloga + %1$d odabrano + Poster + Kvadrat + Široki + Spojite sve kataloge u jednu karticu. + Prikaži karticu "Sve" + Reproducirajte konfigurirani GIF umjesto statične naslovne slike, ako je dostupan. + Prikaži GIF ako je konfigurirano + %1$d izvori(-i) · %2$s + Oblik pločice + Linije + Oznake + Način prikaza + TMDB izvori + Javni popis + Produkcija + Mreža + Kolekcija + Osoba + Redatelj + Prilagođeno + Odaberite postojeći izvor. Nakon što ga dodate, možete ga urediti ili ukloniti. + Unesite URL javne TMDB liste ili broj iz URL-a. + Pretražite prema nazivu studija ili unesite TMDB ID/URL kako biste ga izravno dodali. + Unesite ID mreže. Uobičajene mreže dostupne su u pretpostavkama i brzim filtrima. + Potražite naziv filmske kolekcije ili unesite ID kolekcije s TMDB-a. + Unesite ID osobe ili TMDB URL i kreirajte redak na temelju podataka o glumačkoj postavi. + Unesite ID osobe ili TMDB URL i kreirajte redak na temelju redateljskih zasluga. + Izradite TMDB feed u stvarnom vremenu koristeći opcionalne filtre. Ostavite polja praznima ako vam određeni filter nije potreban. + Javna TMDB lista + ID mreže + ID kolekcije + ID osobe + Naziv produkcijske kuće, ID ili URL + ID ili URL TMDB + https://www.themoviedb.org/list/8504994 ili 8504994 + 213 za Netflix, 49 za HBO, 2739 za Disney+ + 10 za Star Wars kolekciju + Marvel Studios, 420 ili URL studija + 31 za Toma Hanksa ili URL osobe + Primjerice: Marvel Studios, 420 ili https://www.themoviedb.org/company/420. + Primjer: Kolekcija Ratovi zvijezda, kolekcija Harry Potter ili URL kolekcije. + Primjeri ID-a: Netflix 213, HBO 49, Disney+ 2739. + Primjer: https://www.themoviedb.org/list/8504994 alebo 8504994. + Primjer: https://www.themoviedb.org/person/31-tom-hanks ili 31. + Prikazno ime + Prikazuje se kao naziv retka ili kartice. Ako se polje ostavi praznim, Nuvio ga generira iz izvora. + Marvel Filmovi, Netflix Originali, Pixar + Filmovi Toma Hanksa, omiljeni glumci + Filmovi Christophera Nolana, omiljeni redatelji + Najbolji akcijski filmovi, korejske drame i animirani filmovi 2024. + Rezultati pretraživanja + Kolekcija TMDB + Tvrtka TMDB %1$d + Kolekcija TMDB %1$d + Typ + Filmovi + Serije + Oboje + Sortiraj + Filtriraj + Ostavite polja praznima ako vam određeni filtar nije potreban. + Žanrovi brzog tempa + Brzi jezici + Zemlje s brzim rastom + Ključne riječi za brzi pregled + Brzi studiji + Mreže velike brzine + ID žanrova + Koristite TMDB ID-ove žanrova. Odvojite više ID-ova zarezima za logičko „I” ili okomitom crtom za logičko „ILI”. + 28,12 + 18,35 + Datum objavljivanja ili emitiranja od + Datum izdanja ili emitiranja + Koristite format GGGG-MM-DD, na primjer, 2024-01-01. + 2020-01-01 + 2024-12-31 + Minimalna ocjena + Maksimalna ocjena + Ocjene TMDB-a od 0 do 10. Primjer: 7.0. + 7.0 + 10 + Minimalan broj glasova + Upotrijebite ovo za isključivanje naslova s ​​malim brojem glasova. Primjer: 100. + 100 + Izvorni jezik + Koristite dvoslovne kodove jezika, kao što su en, ko, ja, hi. + en, ko, ja, hi + Zemlja podrijetla + Koristite dvoslovne oznake zemalja, kao što su US, KR, JP, IN. + US, KR, JP, IN + ID ključne riječi + Korištenje broja ključnih riječi s TMDB-a. Brzi elementi nude uobičajene primjere. + 9715 za superheroje + ID tvrtke + Koristite ID-ove studija/tvrtke. Brzi elementi obrađuju rutinske primjere. + 420 za Marvel Studios + Mrežni identifikatori + Samo za TV serije. Koristite identifikatore mreža kao što su Netflix (213) ili HBO (49). + 213 za Netflix + Godina + Koristite četveroznamenkastu godinu, na primjer, 2024. + 2024 + Unaprijed postavljene postavke + Pretraživanje + Dodaj izvor + Dodaj na Trakt listu + Uredi Trakt listu + Trakt liste + Popis serija + Potražite naziv, Trakt URL ili ID popisa. + Upotrijebite URL javne Traktove liste ili brojčani ID liste, odnosno pretražite prema nazivu. + Filmovi za vikend – nagrađivana ostvarenja + Rezultati pretraživanja + Popisi trendova + Popularni popisi + Smjer + Uspinjanje + Spuštanje + Redoslijed popisa + Nedavno dodano + Naziv + Izdano + Duljina + Popularne + U postotku + Glasovi + Unesite naziv, URL ili ID Trakt liste. + Unesite ID ili URL Trakt liste + Učitavanje Trakt liste nije uspjelo. + Nisu pronađene Trakt liste. + Riješeni Trakt popis + Trakt popis %1$d + Akcija + Pustolovan + Animirani + Komedija + Horor + Znanstvena Fantastika + Drama + Krimi + Reality + Engleski + Kórejski + Japanski + Indijski + Španjolski + SAD + Koreja + Japan + India + Ujedinjeno Kraljevstvo + Superheroji + Prema romanu + Putovanje kroz vrijeme + Svemir + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Originali + Popularne + Najbolje ocijenjene + Nedavno + Najviše glasova + Područje nadzora + ISO 3166-1 oznaka zemlje u kojoj je naslov dostupan. Primjer: US, GB. + Regije s ubrzanim gledanjem + Praćenje ID-ova pružatelja usluga + Koristite ID-ove pružatelja usluga gledanja s TMDB-a. Više ID-ova odvojite zarezima za logičko „I” ili okomitom crtom za logičko „ILI”. + 8|337|350 + Pružatelji usluga ubrzanog gledanja + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + Zoznam TMDB + TMDB filmska kolekcija + Produkcija + Mreža + Osoba + Redatelj + Otkrivanje TMDB + Izradi jedan za organizaciju kataloga. + Još nema kolekcija. + %1$d mapa(-e) + Nema pronađenih stavki. + Mapa nije pronađena + Kolekcije + Unesi kolekcije + JSON + Zalijepite JSON kolekcija u nastavku. + Unesi + Nova kolekcija + Prikvačeno + Sve + Vaše kolekcije + Izradili sa ❤️ Tapframe i prijatelji + Verzija %1$s (%2$s) + Isključeno + Uključeno + Zaustavi + Ponovno učitaj + Već imaš račun? + Nastavi bez računa + Izradi račun + Nemate račun? + E-mail + ili + Lozinka + Prijavite se za pristup svojoj biblioteci i napretku. + Prijava + Registrirajte se i sinkronizirajte podatke na svim uređajima. + Registrirajte se + Vaši će se podaci pohranjivati ​​samo lokalno. + Streamajte sve, bilo gdje. + Dobro došli natrag + Knjižnica + Knjižnica Trakt + Početna + Knjižnica + Profil + Pretraživanje + Audiozapisi + Zvuk + Automatska sinkronizacija + Podebljano + Ugrađeni + Donji pomak + Dohvati + Zatvori reproduktor + Boja + Trenutno se reproducira + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Epizode + Veličina fonta + %1$dsp + Zaključaj kontrole reprodukcije + Učitavanje redaka titlova... + Nisu pronađeni retci titlova. + Učitavanje redaka titlova nije uspjelo. + Nisu dostupni audiozapisi + Nema dostupnih epizoda. + Nisu pronađeni prijenosi. + Nijedan + Pregled + Boja obruba + Epizode + Izvori + Prijenosi + Pogreška pri reprodukciji + Trenutno se reproducira + Dodirnite za učitavanje titlova. + Natrag + Ponovno učitaj + Ponovno postavi + Vrati na zadane vrijednosti + Popuni + Prilagodi + Približi + Premotaj 10 sekundi unatrag + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Premotaj 10 sekundi unaprijed + Izvori + Stil + Najprije odaberite naziv dodatka. + Titlovi + Kašnjenje titlova + Titlovi + Kašnjenje titlova + Svjetlina %1$s + Glasnoća %1$s + Mutirano + Preuzeto + Trenutno se prikazuje + TBA + Dodirnite za otključavanje + Zapis %1$d + Otključaj kontrole reprodukcije + Gledate + Dodaj profil + Očisti pretragu + Otkrij + Instalirani dodaci nisu vratili valjane rezultate. + Pretraživanje nije uspjelo. + Instalirajte i provjerite barem jedan dodatak prije pretraživanja. + Nema aktivnih dodataka + Instalirani katalozi za pretraživanje nisu pronašli podudaranja za ovaj upit. + Nema rezultata. + Vaši instalirani dodaci ne omogućuju pretraživanje kataloga. + Nema pretraživih kataloga + Pretraži filmove, serije... + Nedavna pretraživanja + Ukloni nedavna pretraživanja + O aplikaciji + Općenito + Račun + Dodatci + Napredno + Raspored + Sadržaj i otkrivanje + Nastavi gledati + Povezane usluge + Raspored početnog zaslona + Integracije + Licence i pripisivanja + Ocjene na MDBListu + Stranica s detaljima + Obavijesti + Reprodukcija + Plugin-ovi + Stil postera + Postavke + Prijenosi + Podržavatelji i suradnici + Obogaćivanje podataka putem TMDB-a + Trakt + O APLIKACIJI + Status računa i sinkronizacije + RAČUN + Ponašanje pri pokretanju i ponašanje profila. + NAPREDNO + Struktura početnog zaslona i stilovi plakata + Preuzmite najnovije izdanje + Provjeri ima li ažuriranja + Upravljajte dodacima i izvorima za otkrivanje sadržaja. + Upravljajte preuzetim filmovima i epizodama. + Preuzeto + OPĆENITO + Upravljajte dostupnim integracijama + Upravljajte obavijestima o objavi epizoda i pošaljite testnu obavijest. + Prikaz rezultata prijenosa i pravila za značke URL-ova. + Prebacite se na drugi profil. + Promijeni profil + Otvorite zaslon za povezivanje s Traktom. + Postavke nisu pronađene. + Postavke pretraživanja... + REZULTATI + POKRETANJE + PREDMEMORIJA + Zapamti zadnji profil + Zapamti posljednji odabrani profil pri pokretanju + Očisti predmemoriju Nastavi gledati + Izbriši spremljene podatke Nastavi gledanje i nastavi reprodukciju + Predmemorija očišćena + LICENCA APLIKACIJE + PODACI I USLUGE + LICENCA ZA REPRODUKCIJU + Nuvio Mobile + Izvorni kod i uvjeti licence dostupni su u repozitoriju projekta. + Licencirano pod GNU Općom Javnom Licencom v3.0. + The Movie Database (TMDB) + Nuvio koristi TMDB API za metapodatke o filmovima i TV serijama, vizualne materijale, najave, podatke o glumačkoj postavi i produkciji, kolekcije te preporuke. Ovaj proizvod koristi TMDB API, ali ga TMDB nije podržao niti certificirao. + Nekomercijalni IMDb skupovi podataka + Nuvio koristi nekomercijalne skupove podataka s IMDb-a, uključujući title.ratings.tsv.gz, za ocjene i broj glasova s ​​IMDb-a. Podatke osigurava IMDb (https://www.imdb.com). Koriste se uz dopuštenje. Podaci s IMDb-a namijenjeni su za osobnu i nekomercijalnu upotrebu u skladu s uvjetima korištenja IMDb-a. + Trakt + Nuvio se povezuje s uslugom Trakt radi autentifikacije računa, povijesti gledanja, sinkronizacije napretka, podataka o biblioteci, ocjena, popisa i komentara. Nuvio nije povezan s uslugom Trakt niti ona podržava tu aplikaciju. + Premiumize + Nuvio se povezuje s uslugom Premiumize radi autentifikacije računa, pristupa biblioteci u oblaku, provjere predmemorije i značajki reprodukcije iz oblaka. Nuvio nije povezan s uslugom Premiumize niti ona podržava tu aplikaciju. + TorBox + Nuvio se povezuje s TorBoxom radi autentifikacije računa, pristupa biblioteci u oblaku, provjere predmemorije i značajki reprodukcije iz oblaka. Nuvio nije povezan s TorBoxom niti ga TorBox podržava. + MDBList + Nuvio koristi MDBList za ocjene i podatke vanjskih pružatelja informacija o ocjenama. Nuvio nije povezan s uslugom MDBList niti ona podržava Nuvio. + IntroDB + Nuvio koristi IntroDB API za vremenske oznake koje osigurava zajednica, a odnose se na uvodne špice, sažetke, odjavne špice i najave koje koriste kontrole za preskakanje. Nuvio nije povezan s uslugom IntroDB niti ona podržava Nuvio. + MPVKit + Koristi se za reprodukciju na iOS sustavima. + Izvorni kod samog MPVKita licenciran je pod licencom LGPL v3.0. Paketi MPVKita, uključujući biblioteke libmpv i FFmpeg, također su licencirani pod licencom LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Koristi se za reprodukciju na Android sustavima. + Licencirane pod Apache License, Version 2.0. + Učitavanje vaših Trakt popisa… + Odaberite gdje želite spremiti ovaj naslov na Traktu. + Daruj nam + Idi na detalje + Ukloni + Kreni od početka + Reproduciraj + %1$d/10 + Recenzija + Spoiler + Još nema dostupnih recenzija na Traktu. + %1$d sviđanja + Ovaj komentar sadrži spojlere. + Ovaj komentar sadrži otkrivanje radnje i skriven je. + Komentari + Trailer + %1$s (%2$d) + Traileri + Nema dovršenih epizoda + Još nema preuzetih datoteka + %1$d preuzeta(-e) epizoda(-e) + Aktivno + Filmovi + Serije + Pregledaj preuzimanja + Završeno • %1$s + Preuzimanje u tijeku• %1$s + Nije uspjelo. + Pauzirano • %1$s + Pregledano + Serija %1$d + Speciali + Nastavi gdje si stao. + Dodaj u knjižnicu + Označi kao nepogledano + Označi kao pogledano + Ukloni iz knjižnice + Prikaži sve + Reproduciraj ručno + Logo %1$s + Račun + Izbriši račun + Ovo će trajno izbrisati vaš račun i sve povezane podatke. + Ova se radnja ne može poništiti. Svi vaši podaci, profili i povijest sinkronizacije bit će trajno izbrisani. + Potvrdi brisanje računa? + E-mail + Niste prijavljeni. + Odjavi se + Biti ćete preusmjereni na zaslon za prijavu. + Odjava? + Status + Anonimni + Prijavljeni + Backend sinkronizacije + AMOLED crna + Za OLED zaslone koristite potpuno crnu pozadinu. + Jezik aplikacije + Odaberite jezik + Postavke odjeljka „Nastavi gledati”. + Liquid Glass + Koristite izvornu traku s karticama za iPhone na sustavu iOS 26 ili novijem. Kada je to omogućeno, trenutačno prebacivanje profila s trake s karticama nije dostupno. + Postavite širinu kartice i polumjer zaobljenja kutova. + PRIKAZ + POČETNA + TEMA + Kolekcija • %1$s + Prikazno ime + Instalirajte dodatak koji sadrži kataloge kompatibilne s nadzornom pločom kako biste konfigurirali retke na početnom zaslonu. + Bez mapa na početnom zaslonu + Izvor glavnog dijela stranice + Sakrijte + Zadržite početni zaslon u prvom planu. + %1$s • Dosegnut limit (max %2$d) + Nisu odabrani izvori za hero sekciju. + Ne u zaglavlju hero sekcije + Uklonite stavku iz zbirke kako biste je mogli premjestiti. + Prikvačeno + Prikvačeno na vrhu + Izmjeni redoslijed + KATALOZI + KATALOZI A KOLEKCIJE + KOLEKCIJE + Raspored početnog zaslona + Katalozi za istaknute sekcije + %1$d od %2$d odabrano + Prikaži uvodni dio + Prikaži glavni vrtuljak na vrhu početnog zaslona. + Sakrij neobjavljeni sadržaj + Sakrij filmove i serije koji još nisu objavljeni. + Sakrij podcrtavanje u katalogu + Uklonite istaknutu liniju ispod naziva kataloga i zbirki u cijeloj aplikaciji. + %1$d od %2$d vidljivih kataloga • Odabrano %3$d izvora za glavni odjeljak + Otvorite katalog samo kada ga trebate preimenovati ili promijeniti njegov redoslijed. + Vidljivo + Sakrij vrijednost + Reproduktor, titlovi i automatska reprodukcija + Polumjer kuta + Stil postera + Širina + Prilagodi + Postavite širinu kartice i polumjer zaobljenja kutova. + Sakrij oznake + Horizontalni posteri + Prikaz uživo + %1$s (%2$s) + Polumjer kuta: %1$ddp + Visina: %1$ddp + Širina: %1$ddp + Klasični + Pilula + Zaobljeni + Oštri + Blag + Uravnotežen + Komforan + Kompaktni + Gust + Velik + Standardni + Prikaži vrijednost + Prikaži skočni prozor za nastavak tamo gdje ste stali prilikom otvaranja aplikacije nakon izlaska iz reproduktora. + Zatraži nastavak pri pokretanju + Zamuti sličice nadolazećih epizoda u odjeljku „Nastavi gledati” kako bi izbjegli spojlere. + Zamuti nepogledane stavke u odjeljku „Nastavi gledati” + Uvrsti nadolazeće epizode u odjeljak „Nastavi gledati” prije nego što se emitiraju. + Prikaži dodatne neemitirane epizode + REDOSLIJED SORTIRANJA + Redoslijed sortiranja + Zadano + Sortiraj sve stavke prema datumu + Stil streamanja + Objavljene stavke prve, nadolazeće posljednje + Stil kartice postera + NA STARTUPU + PONAŠANJE SLJEDEĆE EPIZODE + VIDLJIVOST + Prikaži redak „Nastavi gledati” na početnom zaslonu. + Pogledaj Nastavi gledati + Karta + Horizontalna kartica u stilu TV-a + Poster + Poster s naglaskom na umjetničkom djelu + Široka + Horizontalna kartica s puno informacija + Prikaži sljedeću epizodu na temelju najdalje pogledane epizode. Isključite ovu opciju ako želite koristiti posljednju pogledanu epizodu. + Epizoda koja slijedi nakon one najgledanije + Dajte prednost sličicama epizoda ako su dostupne. + Dajte prednost slikama epizoda u Nastavi gledati + POČETNO + IZVORI + Instalirajte, uklonite, vratite i sortirajte izvore sadržaja. + Instalirajte repozitorije JavaScript scrapera i interno testirajte pružatelje usluga. + Prilagodite raspored početnog zaslona, ​​vidljivost sadržaja i ponašanje plakata. + Postavke za detalje i zaslone epizoda. + Izradite prilagođene grupe kataloga s mapama prikazanima na početnom zaslonu. + Integracije + Kontrole za obogaćivanje metapodataka + Pružatelji vanjskog ocjenjivanja + Povežite račune za poveznice i pristup knjižnici + Povezane usluge + Ove su integracije eksperimentalne te bi mogle biti zadržane, izmijenjene ili kasnije uklonjene. + Knjižnica na oblaku + Pregledavajte i reproducirajte datoteke koje se već nalaze na vašim povezanim računima. + Rukovanje poveznicama koje se mogu reproduciati + Zatražite poveznice za reprodukciju od povezane usluge kada je rezultat potreban. To može dodati stavku toj usluzi. + Riješite pomoću + Odaberite koji povezani račun obrađuje veze koje se mogu reproducirati. + Prvo povežite račun. + Računi + Povežite svoj %1$s račun. + Povežite svoj %1$s račun u pregledniku. + API ključ %1$s + Unesite vaš API ključ %1$s. + Zadajte API ključ %1$s + Nije postavljeno + U prilogu + Poveži %1$s + Odvežite %1$s + Odvežite + %1$s je povezan s ovim uređajem. + Sigurna prijava započinje... + Otvorite poveznicu i unesite ovaj kod kako biste odobrili Nuvio. + Kod kopiran. + Otvori poveznicu + Čeka se odobrenje... + Pokretanje postupka prijave nije uspjelo. + Ovaj način prijave nije konfiguriran u ovoj postavci. + Ovaj je kod istekao. Molimo pokušajte ponovno. + Priprema poveznica + Pripremite poveznice + Razriješi veze za reprodukciju prije početka reprodukcije. + Broj spominjanja pripravka + Ako je moguće, upotrijebite manji broj. Povezane usluge mogu ograničiti broj poveznica koje se mogu razriješiti unutar određenog vremenskog razdoblja. Otvaranje filma ili epizode može se ubrojiti u ta ograničenja – čak i ako ne pritisnete „Gledaj” – jer se poveznice pripremaju unaprijed. + 1 poveznica + %1$d poveznice + Formatiranje + Predložak naslova + Određuje način prikazivanja naziva rezultata. Ostavite prazno za korištenje izvornog naziva rezultata. + Predložak opisa + Upravlja metapodacima prikazanima ispod svakog rezultata. Ostavite prazno za korištenje izvornih pojedinosti rezultata. + Ukloni oblikovanje + Vrati zadano oblikovanje rezultata. + Fusion stil + Oznake veličine + Prikaži oznake veličine datoteke u rezultatima prijenosa i na pločama izvora reproduktora. + Logotip dodatka + Prikaži logotip i naziv dodatka pokraj izvora prijenosa. + Prikaz + Položaj oznake + Odaberite hoće li se Fusion oznake i veličine prikazivati ​​iznad ili ispod kartica sa prijenosima. + Položaj oznake + Odaberite gdje će se oznake prijenosa prikazivati ​​na karticama prijenosa. + Gore + Dolje + URL značka Fusion + Uvezite do %1$d JSON URL-ova za značke prijenosa u stilu Fusiona. Svaki se URL može zasebno ažurirati ili izbrisati. + Upravljajte uvezenim JSON URL-ovima za značke prijenosa u stilu Fusiona. + Uvoz značke nije uspio. + Unesite URL JSON značke. + URL oznake mora započinjati s http:// ili https://. + Možete uvesti do %1$d URL-ova značaka. + %1$d/%2$d URL-ova, %3$d aktivne Fusion značke + Ne uvoze se URL-ovi Fusion znački. + JSON URL za Fusion značku + Uvezeno %1$d/%2$d Fusion URL-ova + Aktivno + Neaktivno + %1$s, %2$d dozvoljenih znački, %3$d grupa + Pregled + Pregled značke Fusion + %1$d znački u stilu Fusiona s ovog URL-a + Na ovom URL-u nema slika znački u stilu Fusiona. + Skupina %1$d + OZnačke spajanja + API ključ potvrđen. + Provjera ovog API ključa nije uspjela. + Dodajte svoj MDBList API ključ u nastavku prije nego što omogućite ocjenjivanje. + Potrebno za učitavanje ocjena s MDBLista. + API ključ + API ključ + Omogući MDBList ocjene + Učitajte ocjene vanjskih pružatelja usluga na zaslonu s detaljima metapodataka. + API ključ + Pružatelji vanjskog ocjenjivanja + Ocjene na MDBListu + Akcije + Kontrole za reprodukciju i spremanje. + Glumačka postava + Popis glavne glumačke postave. + Kinematografska pozadina + Zamućena pozadina iza sadržaja, nalik zaslonu za streaming. + Kolekcija + Povezana kolekcija ili franšiza. + Komentari + Recenzije s Trakta + Detalji + Duljina, status, izdanje, jezik i povezane informacije. + Reprodukcija najave u glavnom dijelu stranice + Reproduciraj pretpreglede najava u glavnom dijelu s metapodacima kada je najava dostupna. + Kartice epizoda + Odaberite način prikaza epizoda na zaslonu s metapodacima. + Horizontalne + Kartice u retku sa stilom pozadine + Popis + Detaljne složene kartice + Epizode + Popisi serija i epizoda za TV serije. + Zamuti nepogledane epizode + Zamuti sličice epizoda dok ih ne pogledaš kako bi izbjegao spojlere.. + Skupina %1$d + Slični naslovi + Preporučene TMDB pozadine na stranici s detaljima + Ništa + Pregled + Sinopsis, ocjene, žanrovi i ključni podaci o autorima i ekipi. + Produkcija + Studiji i mreže. + IZGLED + SEKCIJE + Grupa kartica %1$d + Raspored kartica + Grupirajte odjeljke u kartice, slično kao u TV aplikaciji. Dodijelite do 3 odjeljka svakoj grupi kartica. + Traileri + Niz najava i prečaca za reprodukciju. + Obavijesti su trenutačno onemogućene u Nuviju. + Obavijesti o objavi epizoda + Zakažite lokalne obavijesti za trenutak kada nova epizoda spremljene serije postane dostupna. + Sustavne obavijesti za Nuvio su isključene. Uključite ih kako biste primali upozorenja i testne obavijesti. + Na ovom uređaju trenutačno je zakazano %1$d obavijesti o izdanjima. + UPOZORNENJA + TEST + Pošalji probnu obavijest + Slanje testne obavijesti... + Pošalji lokalnu testnu obavijest za %1$s. + Najprije spremi seriju u svoju knjižnicu kako bi mogao testirati obavijesti. + Probna obavijest + Zajednica + Upoznajte ljude koji razvijaju i podržavaju Nuvio na mobilnim uređajima, televizorima i webu. + Ovosezonsko održavanje poslužitelja + Pokriveno. Dodatna sredstva usmjeravaju se u razvoj. + Nakon početnih 100 %, daljnja sredstva usmjeravaju se u razvoj. + Učitavanje napretka prikupljanja sredstava... + API za podržavatelje nije konfiguriran. Dodajte DONATIONS_BASE_URL u local.properties. + Suradnici/string> + Podržavatelji + Otvorite GitHub + GitHub profil nije dostupan. + Nema priložene poruke. + Učitavanje suradnika... + Učitavanje podržavatelja... + Učitavanje suradnika nije uspjelo. + Učitavanje podržavatelja nije uspjelo. + Nisu pronađeni suradnici. + Nisu pronađeni nikakvi podržavatelji. + Učitavanje suradnika nije uspjelo. + Učitavanje podržavatelja nije uspjelo. + Trenutačno nije moguće učitati suradnike. + Trenutačno nije moguće učitati podržavatelje. + Ukupno %1$d commitova + Sij. + Velj. + Ožu. + Tra. + Svi. + Lip. + Srp. + Kol. + Ruj. + Lis. + Stu. + Pro. + %1$s %2$s, %3$s + Svi instalirani dodaci + Svi instalirani plugin-ovi + Dopušteni dodatci + Dopušteni plugin-ovi + Anime Skip + ID klijenta AnimeSkip + Zadajte ID klienta API AnimeSkip. Nabavite ga na anime-skip.com. + Omogući slanje uvoda + Prikaži gumb za slanje vremenskih oznaka za uvod i odjavu u bazu podataka zajednice. + API ključ IntroDB + Unesite svoj IntroDB API ključ za slanje vremenskih oznaka. On je obavezan za slanje. + Također pretražite AnimeSkip za vremenske oznake za preskakanje (potreban je ID klijenta). + Automatski reproduciraj sljedeću epizodu + Automatski pokreni sljedeću epizodu kada se pojavi upit. + Samo dekoderi uređaja + Dajte prednost dekoderima aplikacije (FFmpeg) + Dajte prednost dekoderima uređaja + Prioritet dekodera + Dodirnite izvan za zatvaranje. + Dodirnite izvan okvira za spremanje i zatvaranje. + %1$d dan + %1$d dani + %1$d sat + %1$d sati + Koristite libass za ASS/SSA titlove + Eksperimentalno: napredno iscrtavanje ASS/SSA formata (stilovi, pozicioniranje, animacije) + Vanjski reproduktor + Aplikacija vanjskog reproduktora + Pokrenite reprodukciju pomoću zadane Androidove aplikacije za videozapise ili sustavnog alata za odabir. + Pokrenite reprodukciju pomoću odabranog instaliranog reproduktora. + Proslijedi titlove vanjskom reproduktoru + Učitajte titlove dodatka na željenom jeziku i proslijedite ih vanjskom reproduktoru. + Nisu instalirani podržani vanjski reproduktori. + Player + Interni + Eksterni + Odaberite koji će reproduktor upravljati novom reprodukcijom. + Održavajte brzinu + Zadrži radi brzine + Dugo pritisnite bilo gdje na površini reproduktora kako biste privremeno povećali brzinu reprodukcije. + Dodirne geste + Omogućite pokrete klizanja i dvostruke dodire po površini reproduktora za premotavanje ili prilagodbu svjetline i glasnoće. + Neispravan uzorak regularnog izraza + Trajanje predmemorije posljednje veze + DV7 -HEVC sigurnosna kopija + Mapirajte Dolby Vision profil 7 u standardni HEVC za uređaje bez hardverske podrške za DV. + Minute na pragu + Rezervno rješenje za slučaj da nedostaje vremenska oznaka za odjavu. + %1$s min + Nema dostupnih stavki. + Nije postavljeno + Zadano (multimedijska datoteka) + Jezik uređaja + Prisilno + Nijedno + Svi naslovi + Učitaj i prikaži sve titlove za videododatak. + Brzi početak + Preskoči automatsko učitavanje dodatnih titlova dok ih ne zatražiš u reproduktoru. + Pokretanje dodatnih titlova + Samo preferirane + Učitaj dodatne titlove, ali prikazuj samo one na željenom jeziku. + Dajte prednost grupi za uzastopno gledanje (sljedeća epizoda). + Prvo isprobajte isti izvorni profil (isti dodatak / grupu kvalitete) prije primjene standardnih pravila za automatsku reprodukciju. + Ponovno upotrijebite grupu za maratonsko gledanje + Zapamti i ponovno koristi posljednju grupu za uzastopno gledanje u različitim sesijama (Nastavi gledati, Detalji itd.). + Preferirani jezik zvuka + Preferirani jezik + Unaprijed postavljene postavke + Uspoređuje se s nazivom, naslovom, opisom, dodatnim informacijama ili URL-om prijenosa. Primjer: 4K|2160p|Remux + Regex uzorak + Uzorak nije postavljen. Primjer: 4K|2160p|Remux + Sve u rezoluciji 1080p ili višoj + AVC / x264 + Kvaliteta BluRay + Dolby Atmos / DTS + Engleski + HDR / Dolby Vision + HEVC / x265 + Bez CAM/TS + Bez REMUX/HDR + 1080p standardno + 4K / Remux + 720p / Manje + WEB izvori + Način renderanja Libass + Standardni signali + Efekti platna + Efekti OpenGL + Prekrivni sloj na platnu + OpenGL sloj (preporučeno) + Ponovno upotrijebi posljednju poveznicu + Automatski reproduciraj posljednji ispravan prijenos za isti film/epizodu dok je predmemorija još uvijek važeća. + Sekundarni jezik audiozapisa + Sekundarni preferirani jezik + DEKODER + SLJEDEĆA EPIZODA + PLAYER + PRESKOČI SEGMENTE + AUTOMATSKA REPRODUKCIJA PRIJENOSA + ODABIR PRIJENOSA + TITLOVI I ZVUK + ISPISIVANJE TITLOVA + Odabrano %1$d + Učitavanje sloja + Prikaži zaslon učitavanja dok se ne pojavi prvi kadar videozapisa. + Boja pozadine + Podebljano + Za naslove koristite deblji font. + Transparentan + Obris + Boja obruba + Napravite okvir oko teksta natpisa. + Prikaži samo željene jezike + Prikaži samo titlove koji odgovaraju jezicima titlova koje ste odabrali. + Veličina titlova + Boja teksta + Koristi prisilne titlove + Dajte prednost prisilnim titlovima kada se podudaraju s postavkama jezika titlova. + Okomiti pomak + Preskoči intro + Koristite introdb.app za prepoznavanje uvodnih špica i sažetaka prethodnih događaja. + Opseg izvora za automatsku reprodukciju + Svi instalirani dodaci + Automatska reprodukcija uzima u obzir samo prijenose iz vaših instaliranih dodataka. + Svi izvori + Automatska reprodukcija može koristiti instalirane dodatke, kao i omogućene plugin-ove. + Samo dopušteni dodaci + Automatska reprodukcija uzima u obzir samo prijenose iz dopuštenih plugin-ova. + Samo instalirani dodaci + Automatska reprodukcija uzima u obzir samo prijenose iz vaših instaliranih dodataka. + Automatski odabir prijenosa + Automatski reproduciraj prvi izvor + Automatski reproduciraj prvi dostupni izvor. + Ručni odabir prijenosa + Uvijek prikaži popis izvora i dopusti mi da odaberem. + Automatska reprodukcija na temelju regularnog izraza + Reproducirajte prvi izvor čiji se tekst podudara s vašim regex uzorkom. + Istek vremena odabira prijenosa + Vrijeme čekanja na dodatke prije odabira. + Minute na pragu + Sljedeća epizoda: Način rada Threshold + Minute prije kraja + U postotku + Granični postotak + Rezervno rješenje za slučaj da nedostaje vremenska oznaka za odjavu. + %1$s% + Odmah + %1$ss + Neograničen + Reprodukcija putem tuneliranja + Sinkronizacija zvuka i slike na razini hardvera. Može poboljšati reprodukciju na nekim Android TV uređajima. + Dodajte vlastiti TMDB API ključ u nastavku prije nego što omogućite ovo poboljšanje. + API ključ + Omogući obogaćivanje putem TMDB-a + Koristite TMDB kao izvor metapodataka kako biste obogatili podatke dodatka. + Unesite vaš API ključ TMDB v3. + Kod jezika + Umjetnička djela + Logotip i pozadinske slike s TMDB-a + Osnovni podaci + Opis, žanrovi i ocjena s TMDB-a + Kolekcije + TMDB filmske kolekcije prema redoslijedu objavljivanja + Zasluge + Glumačka postava s fotografijama, redatelj i scenarist s TMDB-a + Detalji + Trajanje, status, zemlja i jezik s TMDB-a + Epizode + Naslovi epizoda, sažetci, sličice i trajanje s TMDB-a + Slični naslovi + Preporučene TMDB pozadine na stranici s detaljima + Mreže + Mreže s logotipima s TMDB-a + Produkcije + Produkcijske kuće s TMDB-a + Posteri serije + Koristite postere serija s TMDB-a pri odabiru serija na zaslonu s metapodacima za TV serije. + Traileri + Kandidati za najave iz TMDB videozapisa za odjeljak s najavama u prikazu detalja + Osobni API ključ + Jezik + Jezik TMDB metapodataka za naslov, logotip i dopuštena polja + PODACI ZA PRIJAVU + LOKALIZACIJA + MODULI + Obogaćivanje podataka putem TMDB-a + Nakon odobrenja bit ćete automatski preusmjereni natrag. + AUTENTIFIKACIJA + Komentari + Prikaži Trakt recenzije na stranicama s metapodacima + Povežite Trakt + Povezano kao %1$s + Korisnik Trakta + Odspojiti + Nije moguće otvoriti preglednik + FUNKCIJE + Dovršite prijavu na Trakt u svom pregledniku. + Sinkronizirajte svoj popis za gledanje, napredak gledanja, nastavak gledanja, zapise o gledanju (scrobbles) i osobne liste s Traktom. + Nedostaju podaci za prijavu na Trakt u datoteci local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Otvori Trakt prijavu + Radnje spremanja sada mogu biti usmjerene na vašu Trakt listu za gledanje i osobne liste. + Prijavite se putem Trakta kako biste omogućili spremanje na popise i način rada Traktove knjižnice. + Izvor iz knjižnice + Odaberite koju ćete knjižnicu koristiti za pohranu i pregled svoje zbirke. + Izvor iz knjižnice + Odaberite gdje ćete pohranjivati ​​stavke knjižnice i upravljati njima. + Trakt + Knjižnica Nuvio + Odabrana Trakt knjižnica + Odabrana Nuvio Knjižnica + Postupak praćenja + Odaberite izvor napretka koji određuje nastavak i praćenje. + Postupak praćenja + Odaberite hoće li se za nastavak reprodukcije i praćenje koristiti Trakt ili Nuvio Sync, dok Trakt scrobbling ostaje aktivan. + Trakt + Nuvio Sync + Izvor za praćenje napretka postavljen na Trakt + Izvor za praćenje napretka postavljen na Nuvio Sync + Izvor sličnih naslova + Odaberite izvor preporuka na stranicama s detaljima. + Izvor sličnih naslova + Odaberite izvor preporuka prikazanih na stranicama s detaljima. + Trakt + TMDB + Nastavi gledati u prozoru + Povijest s Trakta uzima se u obzir za nastavak reprodukcije. + Nastavi gledati u prozoru + Odaberite koliko se Trakt aktivnosti treba prikazivati ​​u odjeljku „Nastavi gledati”. + Cijela povijest + %1$d dani + Ocjene gledatelja + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Nepoznato + Jantar + Grimizna + Smaragd + Oceansko plava + Ružičasta + Ljubičasta + Bijela + Sljedeća epizoda + Traženje izvora… + Reprodukcija putem %1$s za %2$d… + Sličica za sljedeću epizodu + Neprikazano + Preskoči + Preskoči intro + Preskoči outro + Preskoči sažetak + Titlovi nisu pronađeni. + Afrikanerski + Albanski + Amharski + Arapski + Armenski + Azerbajdžanski + Baskičtina + Bieloruski + Bengálčina + Bosanski + Bugarski + Burmanski + Katalonski + Kineski + Kineski(pojednostavljeni) + Kineski (tradicionalni) + Hrvatski + Češki + Danski + Nizozemski + Engleski + Estonski + Filipinski + Finski + Francuski + Galícijčina + Gruzijski + Njemački + Grčki + Gudžaratski + Hebrejski + Indijski + Mađarski + Islandski + Indonezijski + Irski + Talijanski + Japanski + Kanadski + Kazaški + Khmerski + Korejski + Laoski + Latvijski + Litavski + Makedonski + Malajski + Malajalski + Malteški + Marati + Mongolski + Nepalski + Norveški + Perzijski + Poljski + Portugalski (Portugalski) + Portugalski (Brazilski) + Pandžapski + Rumunjski + Ruski + Srpski + Sinhalski + Slovački + Slovenski + Španjolski + Španjolski (Latinska Amerika) + Svahilski + Švedski + Tamilski + Telugu + Tajlandski + Turski + Ukrajinski + Urdu + Uzbekistanski + Vietnamski + Velški + Zulu + Izbriši + Nastavi + Zanemari + Instaliraj + Kasnije + Ne + Ažurirati + Da + Želite li zatvoriti aplikaciju? + Izlaz iz aplikacije + Ovaj katalog nije vratio nijednu stavku. + Naslovi nisu pronađeni. + Ostale radnje + Provjerite svoju Wi-Fi ili mobilnu podatkovnu vezu i pokušajte ponovno. + Redatelj + Učitavanje nije uspjelo + Slični naslovi + Pokreće TMDB + Pokeće Trakt + Serije + Ovaj je dodatak vratio videozapise za seriju, no nijedan od njih nije sadržavao brojeve sezona ili epizoda. + Ovaj dodatak nije pružio metapodatke o epizodama za ovu seriju. + Ovaj dodatak još nije objavio epizode. + Vaš je uređaj povezan s internetom, ali Nuvio nije mogao pristupiti potrebnim poslužiteljima. + Prikaži manje + Prikaži više▾ + Scenarist + Svi žanrovi + Katalog + %1$s • %2$s + Odabrani katalog nije vratio nijednu stavku otkrivanja. + Učitavanje značajke Discover nije uspjelo. + Instalirani dodaci ne objavljuju kataloge kompatibilne s pločom za otkrivanje sadržaja. + Nema kataloga za otkrivanje + Odabrani katalog i filtri nisu dali nikakve rezultate. + Naslovi nisu pronađeni. + Instalirajte i provjerite barem jedan dodatak prije pregledavanja kataloga za otkrivanje. + Odaberi katalog + Odaberi žanr + Odaberi typ + Typ + Označi prethodno kao nepregledano + Označi prethodno kao pregledano + Označi %1$s kao nepogledano + Označi %1$s kao pogledano + Označi prethodne sezone kao pogledane + Označi kao nepogledano + Označi kao pogledano + Sljedeće na redu + %1$s pogledano + Ostalo %1$dh %2$dm + Ostalo %1$dm + Instalirajte i provjerite barem jedan dodatak prije učitavanja redaka kataloga na početnom zaslonu. + Instalirani dodaci trenutačno ne objavljuju kataloge kompatibilne s pločom kojoj nedostaju potrebni dodaci. + Bez redaka na početnom zaslonu + Pogledajte detalje + Kontrole za reprodukciju i spremanje. + Akcije + Popis glavne glumačke postave. + Povezana kolekcija ili franšiza. + Kolekcija + Sekcija Trakt komentara. + Duljina, status, izdanje, jezik i povezane informacije. + Detalji + Popisi serija i epizoda televizijskih serija. + Niz preporuka. + Slični naslovi + Sinopsis, ocjene, žanrovi i ključni podaci o autorima i ekipi. + Naslov + Studiji i mreže. + Produkcija + Popis najava i tipkovničkih prečaca za reprodukciju. + Ponovno na mreži + Nije moguće povezati se s poslužiteljima + Prazno tijelo odgovora + Zahtjev nije uspio putem HTTP-a %1$d + Nema internetske veze + (dob %1$d) + Rođen %1$s%2$s + Preminuo/la %1$s + Poznat po: %1$s + Najnovije + Učitavanje detalja za %1$s nije uspjelo. + Popularne + Nešto je pošlo po krivu. + Nadolazeće + Izbrisati + Poništi + Unesi PIN + Unesite PIN za %1$s + Zaboravili ste PIN? + Neispravan PIN + Zaključano. Pokušajte ponovno za %1$ds. + Opcije za avatare pojavit će se ovdje nakon što se katalog učita. + Avatar: %1$s + Unesite valjan URL slike http:// ili https://. + Odaberi avatar + Odaberite avatar u nastavku. + Stvorite profil + Odabrani URL prilagođenog avatara. + URL prilagođenog avatara + Unesite poveznicu na sliku ili ostavite polje praznim kako biste koristili ugrađeni katalog avatara. + https://example.com/avatar.png + Svi podaci za „%1$s” bit će trajno izbrisani. + Izbriši profil + Dodaj profil + Upravljaj profilom + Unesi trenutni PIN + Unesite novi PIN + Profil %1$d + Učitavanje avatara... + Upravljanje profilima + Naziv profila + Novi profil + Glavni dodaci onemogućeni + Omogućeni primarni dodaci + Isključi PIN za %1$s + Ukloni PIN zaključavanje + Spremanje... + Sigurnost + Dodajte PIN ako želite zaključati ovaj profil prije prebacivanja na njega. + Ovaj je profil zaštićen PIN-om. + Odaberite avatar za ovaj profil. + Postavi PIN zaključavanje + Neimenovan profil + Koristite osnovne dodatke prehrani. + Dijelite postavke dodataka glavnog profila umjesto upravljanja zasebnim popisom. + Tko Gleda? + Preuzeto + Nastaviti + Aktivni scraperi + Provjera dodatnih dodataka… + Kopiraj poveznicu na prijenos + Preuzmi datoteku + Otvori u vanjskom reproduktoru + Otvori u ugrađenom reproduktoru + Instalirani dodaci za streaming nisu vratili valjan odgovor za prijenos. + Učitavanje prijenosa nije uspjelo. + Prvo instalirajte dodatak za učitavanje prijenosa za ovaj naslov. + Instalirani dodaci ne pružaju prijenose za ovu vrstu naslova. + Nije dostupan dodatak za streaming. + Nijedan od vaših instaliranih dodataka nije pronašao streamove za ovaj naslov. + S%1$d E%2$d + Epizoda + S%1$dE%2$d - %3$s + Učitavanje… + Traženje izvora… + Učitavanje titlova iz dodataka… + Traženje prijenosa… + Poveznica na prijenos kopirana + Izravna poveznica na prijenos nije dostupna. + Nema dostupnih metapodataka + Osvježi prijenose + Nastavi od %1$d%% + Nastavi od %1$s + VELIČINA %1$s + Ova vrsta prijenosa nije podržana. + Povežite račun u postavkama. + Nije pohranjeno u predmemoriji na Torboxu. + Ova je poveznica istekla. Rezultati se osvježavaju. + Otvaranje ove poveznice nije uspjelo. + Nije uspjelo otvaranje vanjskog reproduktora. + Prvo odaberite vanjski reproduktor u postavkama. + Vanjski reproduktor nije dostupan. + Zatvori trailer + Reprodukcija najave nije uspjela. + Učitavanje Trakt lista nije uspjelo. + Ažuriranje Trakt lista nije uspjelo. + %1$s • %2$s + Provjera ažuriranja nije uspjela. + Preuzimanje nije uspjelo. + Prazan sadržaj preuzimanja + Preuzeta datoteka ažuriranja nedostaje. + Preuzimanje %1$d%% + Pokretanje instalacije nije uspjelo. + Koristite najnoviju verziju. + Omogući instalaciju aplikacije za Nuvio, a zatim se vrati i nastavi. + Preuzimanje ažuriranja... + Nisu pronađena ažuriranja. + Nova verzija spremna je za instalaciju. + Ažuriranja unutar aplikacije nisu dostupna u ovoj verziji. + Priprema za preuzimanje + Napomene o izdanju + Dopusti nastavak instalacija + Dostupno je ažuriranje + Status Ažuriranja + Ovaj dodatak je već instaliran. + Unesite valjani URL dodatka. + Učitavanje manifesta nije uspjelo. + Nuvio + Brisanje računa nije uspjelo. + Prijava nije uspjela. + Odjava nije uspjela. + Registracija nije uspjela + Učitavanje stavki kataloga nije uspjelo. + Sljedeće na redu + Sljedeće • S%1$dE%2$d + Logo %1$s + Učitavanje komentara nije uspjelo. + Učitavanje detalja ni iz jednog dodatka nije uspjelo. + Mreže + Nijedan dodatak ne pruža metapodatke za ovaj sadržaj. + Preuzimanje nije uspjelo. + Prikazuje napredak preuzimanja i kontrole u stvarnom vremenu. + Preuzeto + Preuzimanje dovršeno + Preuzima se %1$s • %2$s + Preuzima se %1$s • %2$s / %3$s + Preuzimanje nije uspjelo. + Zaustavljeno %1$s + Ukloni + Ukloniti %1$s zo %2$s? + Ukloni %1$s iz knjižnice? + Ukloni iz knjižnice? + Film + Obavijesti o objavi nove epizode spremljene serije. + Pregled obavijesti o objavi epizode. + Slanje testne obavijesti nije uspjelo. + Probna obavijest poslana za %1$s. + Reprodukcija ovog prijenosa nije uspjela. + Pogonski mehanizam MPV reproduktora nije dostupan. Molimo ponovno izgradite aplikaciju. + PIN za ovaj profil je promijenjen. Povežite se jednom kako biste poništili zaključavanje na ovom uređaju. + Uklanjanje PIN-a nije uspjelo. Molimo pokušajte ponovno. + Povežite se s internetom kako biste uklonili zaključavanje PIN-om. + Ovaj se PIN još ne može provjeriti izvanmrežno na ovom uređaju. Povežite se jednom kako biste ga otključali putem interneta. + Postavljanje PIN-a nije uspjelo. Molimo pokušajte ponovno. + Povežite se s internetom kako biste postavili PIN. + Ovaj profil prvenstveno koristi dodatke. + Učitavanje datoteke %1$s nije uspjelo + Prijenos + Ugrađeno + Autorizacija odbijena + Dovršite prijavu na Trakt u svom pregledniku. + Povezano s Traktom + Prekinuta veza s Traktom + Neispravan callback Trakta + Neispravno stanje callbacka za Trakt + Neispravan odgovor Trakt tokena + Učitavanje Trakt knjižnice nije uspjelo. + Lista %1$d + Trakt nije vratio autorizacijski kod. + Nedostaju podaci za prijavu na Trakt + Učitavanje Trakt napretka nije uspjelo. + Javni popis na Traktu + Unesite valjani ID ili URL Trakt liste. + %1$d stavki + %1$d sviđanja + Nedostaju podaci za prijavu na Trakt u datoteci local.properties(TRAKT_CLIENT_ID). + Nedostaje ID Trakt liste + Trakt lista nije sadržavala brojčani ID. + Trakt lista nije pronađena ili nije javna. + Dosegnuto ograničenje Trakta + Trakt zahtjev nije uspio. + Prijava na Trakt nije uspjela. + Korisnik Trakt + Popis za praćenje + + Trakt autorizacija je istekla. + Popis Trakt nije pronađen + Dosegnuto je ograničenje popisa na Traktu. + Dosegnuto ograničenje na Traktu + Trakt zahtjev nije uspio. + Dodavanje na Traktov popis za gledanje nije uspjelo. + Dodavanje Trakt popisa nije uspjelo. + Nedostaje kompatibilni Trakt ID + Prazno tijelo odgovora + Dodajte TMDB API ključ u Postavkama kako biste koristili TMDB izvore. + Kolekcija TMDB %1$d + Kolekcija TMDB nije pronađena + Produkcija TMDB %1$d + Tvrtka TMDB nije pronađena + Redatelj TMDB %1$d + TMDB pretraga nije vratila nikakve podatke. + Otkrivanje TMDB-a + Unesite valjani TMDB ID ili URL. + TMDB lista %1$d + TMDB popis nije pronađen + Učitavanje TMDB izvora nije uspjelo. + Nedostaje ID TMDB kolekcije + Nedostaje ID TMDB lista + Nedostaje TMDB ID osobe + TMDB mreža %1$d + TMDB mreža nije pronađena + Podaci o zaslugama osobe na TMDB-u nisu pronađeni. + Osoba TMDB %1$d + Osoba TMDB nije pronađena + Trailer + Nepoznato + Dodatak + Spremljeno + Reproduciraj %1$s + Nastavi u %1$s + JSON je prazan. + Kolekcija %1$d ima prazan ID. + Kolekcija „%1$s” ima prazan naziv. + Mapa %1$d u „%2$s” ima prazan ID. + Mapa „%1$s” u „%2$s” ima prazan naziv. + Resurs %1$d u mapi „%2$s” ima prazna polja. + Izvoru %1$d u mapi „%2$s” nedostaje ID Trakt-liste. + Neispravan JSON: %1$s + Dodatak nije pronađen: %1$s + Traktova lista filmova + Traktova lista serija + Siječanj + Veljača + Ožujak + Travanj + Svibanj + Lipanj + Srpanj + Kolovoz + Rujan + Listopad + Studeni + Prosinac + Sij. + Velj. + Ožu. + Tra. + Svi. + Lip. + Srp. + Kol. + Ruj. + Lis. + Stu. + Pro. + Produkcijska kuća + Mreža + Učitavanje datoteke %1$s nije uspjelo. + Popularne + Nedavno + %1$s • %2$s + Najbolje ocijenjeno + Certifikacija + Detalji o filmu + Izvorni jezik + Zemlja podrijetla + Informacije o izdanju + Duljina + Posteri + Tekst + Detalji o seriji + Status + Videa + DATOTEKA + Izravna poveznica prijenosa nije dostupna. + Prethodno preuzimanje zamijenjeno + Preuzimanje je započelo + Nepodržani format prijenosa za preuzimanje + Prazno tijelo odgovora + Preuzimanje nije uspjelo + Zahtjev nije uspio uz HTTP %1$d + Sustav za preuzimanje nije inicijaliziran. + Nije uspjelo otvaranje datoteke djelomičnog preuzimanja. + Datoteka djelomičnog preuzimanja nije otvorena. + Zahtjev za preuzimanje nije uspio. + Nije uspjelo zapisivanje djelomične datoteke preuzimanja. + %1$s - %2$s + Spremljeni naslovi pojavit će se ovdje nakon što dodirnete „Spremi” na zaslonu s pojedinostima. + Vaša knjižnica je prazna + Učitavanje knjižnice nije uspjelo + Ostale + Cloud + Spremljene + Knjižnica + Poveži Trakt i spremi naslove na svoj popis za gledanje ili osobne popise. + Vaša Trakt knjižnica je prazna + Učitavanje Trakt knjižnice nije uspjelo. + Trakt Knjižnica + Poveži račun + Povežite račun u postavkama povezanih usluga kako biste pregledavali datoteke koje se mogu reproducirati iz svoje knjižnice u oblaku. + Nije povezan nijedan račun u oblaku. + Otvori povezane usluge + Omogućite Cloud Knjižnicu u postavkama povezanih usluga kako biste pregledavali datoteke s povezanih računa. + Knjižnica u oblaku je isključena. + Nijedna datoteka u oblaku koju je moguće reproducirati ne odgovara trenutačnim filtrima. + Ovdje još ničega nema. + Odaberite datoteku za reprodukciju + Učitavanje knjžnice u oblaku %1$s nije uspjelo. + Ova stavka ne sadrži videozapis koji se može reproducirati. + Nema datoteka za reprodukciju + Nema datoteka za reprodukciju + Knjižnica u oblaku je isključena. + Reprodukcija ove datoteke iz oblaka nije uspjela. + Reproduciraj datoteku + Usluga u oblaku nije povezana. + %1$s nije povezan. + %1$d datoteka za reprodukciju + Sve + Osvježi knjižnicu na oblaku + Odaberite pružatelja usluge + Odaberite vrstu + Spremno za reprodukciju + Sve + Torrenti + Usenet + Web + Datoteke + Anime + Kanali + Filmovi + Serije + TV + %1$s je sada dostupan + %1$s • %2$s je sada dostupan + Nova epizoda je sada dostupna + %1$s je sada dostupan + Izlasci epizoda + Alkohol/Droga + Zastrašujuće + Goltinja + Psovke + Blago + Srednje + Ozbiljno + Nasilje + Tvorac + Redatelj + Scenarist + Ocjene gledatelja + Nije pronađen reproduktivni prijenos najave. + Serija %1$d - %2$s + B + KB + MB + GB + + Pošalji intro + Postavke videozapisa + + Knjižnica na oblaku nije dostupna za %1$s. + + Video + Poništi podešavanja + Predpostavka izlaza + Detekcija vršne vrijednosti HDR-a + Procijenite vršnu HDR svjetlinu kada su metapodatci netočni ili nedostaju. + Mapiranje tonova + Deband + Smanjite pojavu trakastih prijelaza boja uz mali gubitak performansi. + Interpolacija kadrova + Glatko kretanje, pri čemu mpv može besprijekorno iskoristiti sinkronizaciju prikaza. + Svjetlina + Kontrast + Sitost + Gama + + Izvorni EDR + Najbolje za iPhone i iPad uređaje s podrškom za HDR. + SDR s mapiranjem tonova + Predvidljivije bijele i crne nijanse u SDR izlazu. + Kompatibilnost + Najbliže starijem ponašanju MPV-a na iOS-u. + Običaj + Upotrijebite svoje napredne vrijednosti u nastavku. + Isključeno + + iOS videoizlaz + Hardverski dekoder + Prošireni dinamički raspon + Zadani Metal izlazni način rada za nove sesije reprodukcije. + Pomoć za boje zaslona + Neka mpv prema zadanim postavkama cilja na aktivni prostor boja zaslona. + Ciljajte na osnovne boje + Ciljani prijenos + + iOS audio izlaz + Audioizlaz + Prvo isprobajte AVFoundation, a zatim prijeđite na AudioUnit. + Eksperimentalna podrška za prostorni zvuk i višekanalni izlaz. + Koristi stari AudioUnit izlaz. + + Upravljanje rezultatima + Maksimalan broj rezultata + Ograničite broj prikazanih rezultata. + Sortiraj rezultate + Odaberite način sortiranja rezultata. + Granica razlučivosti + Ograniči duplicirane rezultate u rezolucijama 2160p, 1080p i 720p nakon sortiranja. + Limit na kvalitetu + Ograniči duplicirane rezultate za BluRay, WEB-DL i REMUX nakon sortiranja. + Raspon veličina + Filtriraj rezultate prema veličini datoteke. + + Saznajte više + Zadani format + Izvorni format + Unesite jednu grupu po retku. + + Izvorni redoslijed + Vrhunska kvaliteta na prvom mjestu + Najprije ono najveće + Prvo ono najmanje + Najbolji zvuk kao prvi + Jezik kao prvi + + Bilo koji + Odabrano %1$d + + Svi rezultati + %1$d rezultata + + Do %1$d GB + %1$dGB+ + %1$d-%2$dGB + + Preferirane razlučivosti + Prvo navedite odabrane rezolucije, prema zadanom redoslijedu. + Obavezne rezolucije + Prikaži samo odabrane rezolucije. + Isključene rezolucije + Sakrij odabrane rezolucije. + + Preferirane kvalitete + Prvo sortiraj odabrane značajke, prema zadanom redoslijedu. + Potrebne kvalitete + Prikaži samo odabrane značajke. + Isključene kvalitete + Sakrij odabrane značajke. + + Preferirane vizualne oznake + Sortirajte prema oznakama DV, HDR, 10-bit, IMAX i sličnima. + Obvezne vizualne oznake + Potražite oznake kao što su DV, HDR, 10-bit, IMAX, SDR i slične. + Isključeni vizualni znakovi + Sakrij oznake za DV, HDR, 10-bit, 3D i slično. + + Preferirani brendovi audioopreme + Razvrstajte Atmos, TrueHD, DTS, AAC i slične formate. + Obavezni zvučni zapisi + Obavezne oznake kao što su Atmos, TrueHD, DTS, AAC i slične. + Isključeni zvučni zapisi + Sakrij odabrane audio-oznake. + + Preferirani kanali + Prvo sortiraj željene rasporede kanala. + Obavezni kanali + Prikaži samo odabrane rasporede kanala. + Isključeni kanali + Sakrij odabrane rasporede kanala. + + Preferirano kodiranje + Poredajte AV1, HEVC, AVC i slične kodove prema rangu. + Obvezna kodiranja + Zahtijeva AV1, HEVC, AVC i slična kodiranja. + Isključena kodiranja + Sakrij odabrane kodiranja. + + Preferirani jezici + Prvo sortiraj željene jezike zvuka. + Potrebni jezici + Prikaži samo rezultate na odabranim jezicima. + Isključeni jezici + Sakrij rezultate kod kojih su isključeni svi jezici. + + Obavezne grupe izdanja + Prikaži samo odabrane grupe izdanja. + Isključene grupe za izdavanje + Sakrij odabrane grupe izdanja. + + Pošalji vremenske oznake + VRSTA SEGMENTA + Intro + Sažetak + Outro + VRIJEME POČETKA (MM:SS) + VRIJEME ZAVRŠETKA (MM:SS) + Pošalji + Uhvati + + Hardverski dekoder + Audioizlaz + Ciljajte na osnovne boje + Ciljani prijenos + + Riješena Trakt lista + TMDB Otkrivanje + US, KR, JP, IN + + Unesite naziv, URL ili Trakt ID liste. + Unesite valjani TMDB ID ili URL. + Učitavanje TMDB izvora nije uspjelo. + Unesite ID ili URL Trakt liste. + Učitavanje Trakt liste nije uspjelo. + + Kanada + Australija + Njemačka + + Filmovi + Serije + + Knjižnica u oblaku je isključena. + + Neispravan API ključ ili neuspjela veza + "%1$s" nedostaje u manifestu. + TMDB Kolekcija %1$s + TMDB Redatelj %1$s + TMDB Otkrij + Unesite valjani TMDB ID ili URL. + TMDB lista %1$s + TMDB Mreža %1$s + TMDB Osoba %1$s + TMDB Produkcija %1$s + Učitavanje TMDB izvora nije uspjelo. + ID %1$s + Dodajte TMDB API ključ u Postavkama kako biste koristili TMDB izvore. + TMDB kolekcija nije pronađena + Tvrtka TMDB nije pronađena + TMDB pretraga nije vratila nikakve podatke. + TMDB lista nije pronađena + Nedostaje TMDB ID kolekcije + Nedostaje TMDB ID liste + Nedostaje TMDB ID osobe. + TMDB mreža nije pronađena + Podaci o zaslugama osobe na TMDB-u nisu pronađeni. + Osoba s TMDB-a nije pronađena + Nedostaju podaci za prijavu na Trakt. + %1$s (%2$d) + Unesite valjani ID ili URL Trakt liste. + %1$d stavki + %1$d sviđanja + Trakt lista nije pronađena ili nije javna + Nedostaje ID Trakt liste + Trakt lista nije sadržavala brojčani ID. + Odjel za javni registar + Dosegnuto je ograničenje zahtjeva za Trakt + Zahtjev za Trakt nije uspio. + Učitavanje Trakt komentara nije uspjelo (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Preuzimanje nije uspjelo + Nije uspjelo otvaranje djelomično preuzete datoteke + Djelomično preuzeta datoteka nije otvorena + Nije uspjelo zapisivanje djelomične datoteke preuzimanja. + Nuvio Knjižnica + Problem s povezivanjem + Prazno tijelo odgovora + Molimo provjerite svoju vezu i pokušajte ponovno. + Zahtjev nije uspio uz HTTP %1$d + %1$s (%2$s) + MPV reproduktor nije dostupan. Molimo ponovno izgradite aplikaciju. + Ovaj se prijenos ne može reproducirati. + Plugin-ovi isključeni + Plugin-ovi uključeni + %1$d pružatelja usluga + Obnavlja se + %1$d repozitorija + Nedostaje TMDB API ključ + Postavljen TMDB API ključ + Instaliraj repozitorij dodataka + Instaliranje… + Testirajte pružatelja usluge + Testiranje je u tijeku… + Ukloni repozitorij plugin-ova + Osvježi repozitorij plugin-ova + Još uvijek nema dostupnih pružatelja usluga. + Dodajte URL repozitorija kako biste instalirali dodatke pružatelja usluga za pretraživanje prijenosa. + Još nisu instalirana nikakva spremišta dodataka. + Koristite dodatke pružatelja usluga prilikom pretraživanja prijenosa. + Omogući dodatke pružatelja usluga globalno + Ovaj repozitorij dodataka je već instaliran. + Unesite URL repozitorija plugin-a. + Unesite valjani URL plugin-a. + Instalacija repozitorija plugin-a nije uspjela + Pružatelj usluge nije pronađen + Osvježavanje repozitorija nije uspjelo. + Plugin-ovi nisu dostupni u ovoj inačici. + U prikazu Prijenosa prikazujte jednog pružatelja usluge po repozitoriju umjesto jednog po izvoru. + Grupiraj plugin-ove pružatelja usluga prema repozitoriju + URL manifesta plugin-a + Nedostaje naziv manifesta. + Manifest nema pružatelje usluga. + Verzija manifesta nedostaje. + Nedostaje naslov manifesta. + Manifest nema pružatelje usluga. + Verzija manifesta nedostaje. + %1$s je instaliran. + Onemogućeno od strane repozitorija + Bez opisa + u%1$s + Repozitorij plugin-ova + Verzija %1$s + Ovaj repozitorij plugin-ova već je instaliran. + Instalacija repozitorija dodataka nije uspjela. + Osvježavanje repozitorija nije uspjelo + DODAJ REPOZITORIJ + INSTALIRANA SPREMIŠTA + PREGLED + PRUŽATELJI USLUGA + Pogreška + Test pružatelja usluge nije uspio. + Rezultati testa (%1$d) + Plugin-ovima pružatelja usluga potreban je TMDB API ključ. Konfigurirajte ga na zaslonu TMDB; u protivnom plugin-ovi pružatelja usluga neće ispravno raditi. + Nema rezultata pretraživanja za %1$s. + Neispravan API ključ ili neuspjela veza + Repozitorij plugin-ova + Pošalji Intro + Pošalji + Uhvati + VRIJEME ZAVRŠETKA (MM:SS) + VRSTA SEGMENTA + VRIJEME POČETKA (MM:SS) + Povezano s Traktom + Prekinuta veza s Traktom + TB + U izdanju nije pronađena APK datoteka. + Preuzimanje nije uspjelo uz HTTP %1$d + Preuzeta datoteka ažuriranja nedostaje. + Prazan sadržaj preuzimanja + Pogreška API-ja za izdanja na GitHubu: %1$d + Još nije objavljeno nikakvo ažuriranje. + Izdanje nema ni oznaku ni naslov. + + Emitiranje %1$s + Emitiranje danas + Emitiranje sutra + + Emitiranje za %1$d dan + Emitiranje za %1$d dana + + %1$s + Danas + Sutra + + O %1$d dan + O %1$d dani + + Nova epizoda + Nova serija + + Trakt Zoznam %1$s + Reproduktor na sustavu Android + P2P streaming + Ovaj prijenos koristi tehnologiju izravne razmjene podataka između korisnika (P2P). Omogućavanjem P2P-a potvrđujete i prihvaćate sljedeće: + +• Vaša IP adresa bit će vidljiva drugim sudionicima u mreži +• Isključivo ste odgovorni za sadržaj kojem pristupate +• Potvrđujete da imate zakonsko pravo na prijenos ovog sadržaja u svojoj jurisdikciji +• Nuvio ne hostira, ne distribuira niti kontrolira bilo kakav P2P sadržaj +• Nuvio ne snosi nikakvu odgovornost za pravne posljedice proizašle iz vašeg korištenja P2P prijenosa + +Ovu značajku koristite isključivo na vlastitu odgovornost. P2P možete u bilo kojem trenutku onemogućiti u postavkama. + Omogući P2P + Poništi + Nepoznata pogreška torrenta + P2P Streaming + Omogući peer-to-peer (torrent) prijenose + Sakrij statistiku torrenta + Sakrij međuspremnik, izvore, sudionike i brzinu preuzimanja tijekom učitavanja i reprodukcije. + %1$d peerova· %2$d seedera + %1$s buffer · %2$s · %3$s + %1$s · %2$s + %1$d peerova · %2$d seedera · %3$d%% + Povezivanje s drugim korisnicima… + Pokretanje P2P mehanizma… + Pokretanje torrenta nije uspjelo: %1$s + Pogreška torrenta: %1$s + diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 1e658cae..246c825b 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -16,6 +16,7 @@ fun readXcconfigValue(file: File, key: String): String? { plugins { alias(libs.plugins.androidApplication) + alias(libs.plugins.sentry.android.gradle) } val localProps = Properties().apply { @@ -27,6 +28,14 @@ val releaseStorePassword = localProps.getProperty("NUVIO_RELEASE_STORE_PASSWORD" val releaseKeyAlias = localProps.getProperty("NUVIO_RELEASE_KEY_ALIAS")?.takeIf { it.isNotBlank() } val releaseKeyPassword = localProps.getProperty("NUVIO_RELEASE_KEY_PASSWORD")?.takeIf { it.isNotBlank() } val releaseKeystore = releaseStoreFile?.let(rootProject::file) +fun envOrLocalProperty(key: String): String? = + providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } + ?: localProps.getProperty(key)?.trim()?.takeIf { it.isNotBlank() } + +val sentryAuthToken = envOrLocalProperty("SENTRY_AUTH_TOKEN") +val sentryOrg = envOrLocalProperty("SENTRY_ORG") +val sentryProject = envOrLocalProperty("SENTRY_PROJECT") +val sentryMappingUploadEnabled = sentryAuthToken != null && sentryOrg != null && sentryProject != null val appVersionConfigFile = rootProject.file("iosApp/Configuration/Version.xcconfig") val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_VERSION") ?: error("MARKETING_VERSION is missing from ${appVersionConfigFile.path}") @@ -111,6 +120,34 @@ android { } } +androidComponents { + onVariants(selector().withBuildType("debug")) { variant -> + variant.applicationId.set("com.nuviodebug.com") + } +} + +sentry { + includeProguardMapping.set(true) + autoUploadProguardMapping.set(sentryMappingUploadEnabled) + uploadNativeSymbols.set(false) + autoUploadNativeSymbols.set(false) + includeNativeSources.set(false) + includeSourceContext.set(false) + autoUploadSourceContext.set(false) + includeDependenciesReport.set(false) + telemetry.set(false) + sentryAuthToken?.let(authToken::set) + sentryOrg?.let(org::set) + sentryProject?.let(projectName::set) + ignoredBuildTypes.set(setOf("debug")) + autoInstallation { + enabled.set(false) + } + tracingInstrumentation { + enabled.set(false) + } +} + dependencies { implementation(project(":composeApp")) coreLibraryDesugaring(libs.desugar.jdk.libs) diff --git a/androidApp/src/debug/res/values/strings.xml b/androidApp/src/debug/res/values/strings.xml new file mode 100644 index 00000000..0e9eb7af --- /dev/null +++ b/androidApp/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Nuvio Debug + diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 029223fa..b858d537 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -5,9 +5,11 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -51,7 +83,7 @@ android:grantUriPermissions="true"> + android:resource="@xml/file_paths" /> diff --git a/build.gradle.kts b/build.gradle.kts index 11682b29..d44c8765 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.sentry.android.gradle) apply false } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1d971e6a..02d3a58d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -32,16 +32,16 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { abstract val supabaseAnonKey: Property @get:Input - abstract val nuvioSupabaseUrl: Property + abstract val supabaseFallbackUrl: Property @get:Input - abstract val nuvioSupabaseAnonKey: Property + abstract val sentryDsn: Property @get:Input - abstract val syncBackendManifestUrl: Property + abstract val sentryEnvironment: Property @get:Input - abstract val debugBuild: Property + abstract val realtimeSyncEnabled: Property @TaskAction fun generate() { @@ -58,17 +58,34 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |object SupabaseConfig { | const val URL = "${supabaseUrl.get()}" | const val ANON_KEY = "${supabaseAnonKey.get()}" - | const val NUVIO_URL = "${nuvioSupabaseUrl.get()}" - | const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}" + | const val FALLBACK_URL = "${supabaseFallbackUrl.get()}" |} """.trimMargin() ) - resolve("SyncBackendBootstrapConfig.kt").writeText( + } + + outDir.resolve("com/nuvio/app/core/diagnostics").apply { + mkdirs() + resolve("SentryConfig.kt").writeText( """ - |package com.nuvio.app.core.network + |package com.nuvio.app.core.diagnostics | - |object SyncBackendBootstrapConfig { - | const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}" + |object SentryConfig { + | const val DSN = "${sentryDsn.get()}" + | const val ENVIRONMENT = "${sentryEnvironment.get()}" + |} + """.trimMargin() + ) + } + + outDir.resolve("com/nuvio/app/core/sync").apply { + mkdirs() + resolve("RealtimeSyncConfig.kt").writeText( + """ + |package com.nuvio.app.core.sync + | + |object RealtimeSyncConfig { + | const val ENABLED = ${realtimeSyncEnabled.get()} |} """.trimMargin() ) @@ -143,15 +160,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |} """.trimMargin() ) - resolve("AppBuildConfig.kt").writeText( - """ - |package com.nuvio.app.core.build - | - |object AppBuildConfig { - | const val IS_DEBUG_BUILD = ${debugBuild.get()} - |} - """.trimMargin() - ) } outDir.resolve("com/nuvio/app/features/settings").apply { @@ -268,46 +276,30 @@ fun runtimeConfigValue(key: String, fallback: String = ""): String = ?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } ?: fallback -fun booleanConfigValue(key: String): Boolean? { - val rawValue = runtimeLocalProperties.getProperty(key) - ?: providers.environmentVariable(key).orNull - ?: providers.gradleProperty(key).orNull - return rawValue - ?.trim() - ?.lowercase() - ?.let { value -> - when (value) { - "1", "true", "yes", "y", "debug" -> true - "0", "false", "no", "n", "release" -> false - else -> null - } - } -} - -val xcodeConfiguration = providers.environmentVariable("CONFIGURATION").orNull - ?.trim() - ?.lowercase() -val kotlinFrameworkBuildType = providers.environmentVariable("KOTLIN_FRAMEWORK_BUILD_TYPE").orNull - ?.trim() - ?.lowercase() -val inferredDebugBuild = requestedGradleTasks.any { "debug" in it } || - xcodeConfiguration == "debug" || - kotlinFrameworkBuildType == "debug" -val isDebugBuild = booleanConfigValue("NUVIO_DEBUG_BUILD") - ?: booleanConfigValue("nuvio.debugBuild") - ?: inferredDebugBuild +fun runtimeConfigBoolean(key: String, default: Boolean): Boolean = + when (runtimeConfigValue(key).lowercase()) { + "1", "true", "yes", "y", "on" -> true + "0", "false", "no", "n", "off" -> false + else -> default + } val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) - supabaseUrl.set(runtimeConfigValue("SUPABASE_URL")) - supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY")) - nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) - nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) - syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL")) - debugBuild.set(isDebugBuild) + supabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) + supabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) + supabaseFallbackUrl.set(runtimeConfigValue("NUVIO_SUPABASE_FALLBACK_URL")) + sentryDsn.set(runtimeConfigValue("SENTRY_DSN")) + sentryEnvironment.set( + when { + requestedGradleTasks.any { "benchmark" in it } -> "benchmark" + requestedGradleTasks.any { "debug" in it } -> "debug" + else -> "production" + } + ) + realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true)) } tasks.withType>().configureEach { @@ -324,6 +316,7 @@ kotlin { } minSdk = libs.versions.android.minSdk.get().toInt() androidResources.enable = true + withHostTest {} compilerOptions { jvmTarget.set(JvmTarget.JVM_11) @@ -385,7 +378,8 @@ kotlin { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.google.code.gson:gson:2.11.0") implementation("io.github.peerless2012:ass-media:0.4.0-beta01") - implementation(libs.ktor.client.android) + implementation(libs.ktor.client.okhttp) + implementation(libs.sentry.android) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.exoplayer.smoothstreaming) @@ -423,15 +417,18 @@ kotlin { implementation(libs.compose.ui) implementation(libs.compose.components.resources) implementation(libs.compose.uiToolingPreview) + implementation(libs.compottie) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.atomicfu) - implementation(libs.androidx.navigation.compose) + implementation(libs.kmpalette.core) + implementation(libs.androidx.navigation3.ui) implementation(libs.kermit) implementation(libs.supabase.postgrest) implementation(libs.supabase.auth) implementation(libs.supabase.functions) + implementation(libs.supabase.realtime) implementation(libs.reorderable) } commonTest.dependencies { diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index c09e2c29..364def91 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -10,5 +10,4 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = true actual val inAppUpdaterEnabled: Boolean = true actual val imdbRatingLogoEnabled: Boolean = true - actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml b/composeApp/src/androidFull/res/xml/updater_file_paths.xml similarity index 100% rename from composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml rename to composeApp/src/androidFull/res/xml/updater_file_paths.xml diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 745c91d5..da28ad7e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -10,9 +10,10 @@ import androidx.activity.SystemBarStyle import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.nuvio.app.core.auth.AuthStorage +import com.nuvio.app.core.diagnostics.SentryInitializer import com.nuvio.app.core.deeplink.handleAppUrl -import com.nuvio.app.core.network.SyncBackendStorage import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner +import com.nuvio.app.core.sync.SyncClientIdentityStorage import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.collection.CollectionStorage @@ -31,6 +32,7 @@ import com.nuvio.app.features.player.PlayerTrackPreferenceStorage import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.SubtitleFileCache import com.nuvio.app.features.player.PlayerPictureInPictureManager +import com.nuvio.app.features.player.PipRemoteActionReceiver import com.nuvio.app.features.p2p.P2pSettingsStorage import com.nuvio.app.features.p2p.P2pStreamingEngine import com.nuvio.app.features.plugins.PluginStorage @@ -39,6 +41,7 @@ import com.nuvio.app.features.profiles.ProfilePinCacheStorage import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.search.SearchHistoryStorage +import com.nuvio.app.features.settings.SentrySettingsStorage import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktCommentsStorage @@ -46,6 +49,7 @@ import com.nuvio.app.features.trakt.TraktLibraryStorage import com.nuvio.app.features.trakt.TraktSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform +import com.nuvio.app.core.ui.CardDepthStyleStorage import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage @@ -57,6 +61,8 @@ import com.nuvio.app.features.watchprogress.ResumePromptStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage class MainActivity : AppCompatActivity() { + private var pipRemoteActionReceiver: PipRemoteActionReceiver? = null + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge( @@ -65,11 +71,14 @@ class MainActivity : AppCompatActivity() { ), ) ThemeSettingsStorage.initialize(applicationContext) + SentrySettingsStorage.initialize(applicationContext) + SentryInitializer.start(application) super.onCreate(savedInstanceState) window.setBackgroundDrawableResource(R.color.nuvio_background) + pipRemoteActionReceiver = PipRemoteActionReceiver.register(this) + SyncClientIdentityStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext) AuthStorage.initialize(applicationContext) - SyncBackendStorage.initialize(applicationContext) LibraryStorage.initialize(applicationContext) WatchedStorage.initialize(applicationContext) MetaScreenSettingsStorage.initialize(applicationContext) @@ -86,6 +95,7 @@ class MainActivity : AppCompatActivity() { SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext) + CardDepthStyleStorage.initialize(applicationContext) DebridSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext) @@ -139,6 +149,11 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { EpisodeReleaseNotificationPlatform.unbindActivity(this) + val receiver = pipRemoteActionReceiver + if (receiver != null) { + runCatching { unregisterReceiver(receiver) } + pipRemoteActionReceiver = null + } super.onDestroy() } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryInitializer.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryInitializer.kt new file mode 100644 index 00000000..67af8032 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryInitializer.kt @@ -0,0 +1,106 @@ +package com.nuvio.app.core.diagnostics + +import android.app.Application +import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.features.settings.SentrySettingsRepository +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroid +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +object SentryInitializer { + private val droppedIssueText = listOf( + "Large HTTP payload", + "File IO on Main Thread", + ) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile + private var started = false + + @Volatile + private var active = false + + fun start(application: Application) { + if (started || !SentrySettingsRepository.isSupported) return + started = true + SentrySettingsRepository.ensureLoaded() + applyEnabled(application, SentrySettingsRepository.enabled.value) + scope.launch { + SentrySettingsRepository.enabled.collect { enabled -> + applyEnabled(application, enabled) + } + } + } + + private fun applyEnabled(application: Application, enabled: Boolean) { + if (!enabled) { + if (Sentry.isEnabled()) { + Sentry.close() + } + active = false + return + } + + val dsn = SentryConfig.DSN.trim() + if (dsn.isBlank()) return + if (active && Sentry.isEnabled()) return + + SentryAndroid.init(application) { options -> + options.dsn = dsn + options.release = "${application.packageName}@${AppVersionConfig.VERSION_NAME}+${AppVersionConfig.VERSION_CODE}" + options.environment = SentryConfig.ENVIRONMENT + options.isSendDefaultPii = false + options.isAttachScreenshot = false + options.isAttachViewHierarchy = false + options.tracesSampleRate = 0.0 + options.setMaxBreadcrumbs(50) + options.setIgnoredErrors(droppedIssueText) + options.setIgnoredTransactions(droppedIssueText) + options.setTraceOptionsRequests(false) + options.setEnableAutoActivityLifecycleTracing(false) + options.setEnableTimeToFullDisplayTracing(false) + options.setEnableFramesTracking(false) + options.setEnablePerformanceV2(false) + options.setEnableNetworkEventBreadcrumbs(false) + options.setReportHistoricalAnrs(false) + options.setAttachAnrThreadDump(false) + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + event.request = null + event.user = null + if (shouldDrop(event)) null else event + } + } + + Sentry.configureScope { scope -> + scope.setTag("app.package_name", application.packageName) + scope.setTag("app.version_name", AppVersionConfig.VERSION_NAME) + scope.setTag("app.version_code", AppVersionConfig.VERSION_CODE.toString()) + } + active = true + } + + private fun shouldDrop(event: SentryEvent): Boolean = + eventText(event).any { text -> + droppedIssueText.any { dropped -> + text.contains(dropped, ignoreCase = true) + } + } + + private fun eventText(event: SentryEvent): List { + val values = mutableListOf() + event.message?.formatted?.let(values::add) + event.message?.message?.let(values::add) + event.logger?.let(values::add) + event.transaction?.let(values::add) + event.exceptions?.forEach { exception -> + exception.type?.let(values::add) + exception.value?.let(values::add) + } + return values + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryNetworkBreadcrumbInterceptor.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryNetworkBreadcrumbInterceptor.kt new file mode 100644 index 00000000..2b60a45d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryNetworkBreadcrumbInterceptor.kt @@ -0,0 +1,91 @@ +package com.nuvio.app.core.diagnostics + +import io.sentry.Breadcrumb +import io.sentry.Sentry +import io.sentry.SentryLevel +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class SentryNetworkBreadcrumbInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!Sentry.isEnabled()) return chain.proceed(request) + + val startedAtNs = System.nanoTime() + try { + val response = chain.proceed(request) + record( + url = request.url, + method = request.method, + statusCode = response.code, + elapsedMs = elapsedMs(startedAtNs), + error = null, + ) + return response + } catch (error: IOException) { + record( + url = request.url, + method = request.method, + statusCode = null, + elapsedMs = elapsedMs(startedAtNs), + error = error, + ) + throw error + } catch (error: RuntimeException) { + record( + url = request.url, + method = request.method, + statusCode = null, + elapsedMs = elapsedMs(startedAtNs), + error = error, + ) + throw error + } + } + + private fun record( + url: HttpUrl, + method: String, + statusCode: Int?, + elapsedMs: Long, + error: Throwable?, + ) { + val breadcrumb = if (statusCode == null) { + Breadcrumb.http(scrubbedUrl(url), method) + } else { + Breadcrumb.http(scrubbedUrl(url), method, statusCode) + } + breadcrumb.setCategory("http.client") + breadcrumb.setLevel(levelFor(statusCode, error)) + breadcrumb.setData("host", url.host) + breadcrumb.setData("path", url.encodedPath) + breadcrumb.setData("elapsed_ms", elapsedMs) + if (error != null) { + breadcrumb.setData("error_type", error.javaClass.name) + error.message?.let { breadcrumb.setData("error_message", it.take(240)) } + } + Sentry.addBreadcrumb(breadcrumb) + } + + private fun levelFor(statusCode: Int?, error: Throwable?): SentryLevel { + if (error != null) return SentryLevel.ERROR + val code = statusCode ?: return SentryLevel.INFO + return when { + code >= 500 -> SentryLevel.ERROR + code >= 400 -> SentryLevel.WARNING + else -> SentryLevel.INFO + } + } + + private fun scrubbedUrl(url: HttpUrl): String = + url.newBuilder() + .query(null) + .fragment(null) + .build() + .toString() + + private fun elapsedMs(startedAtNs: Long): Long = + (System.nanoTime() - startedAtNs) / 1_000_000L +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt deleted file mode 100644 index e87613d2..00000000 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.nuvio.app.core.network - -import android.content.Context -import android.content.SharedPreferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import java.net.Proxy -import java.util.concurrent.TimeUnit - -internal actual object SyncBackendStorage { - private const val PREFS_NAME = "nuvio_sync_backend" - private const val KEY_SELECTION_PAYLOAD = "selection_payload_v1" - - private var preferences: SharedPreferences? = null - - fun initialize(context: Context) { - preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - actual fun loadSelectionPayload(): String? = - preferences?.getString(KEY_SELECTION_PAYLOAD, null) - - actual fun saveSelectionPayload(payload: String) { - preferences - ?.edit() - ?.putString(KEY_SELECTION_PAYLOAD, payload) - ?.apply() - } -} - -private val syncBackendHttpClient = OkHttpClient.Builder() - .dns(IPv4FirstDns()) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .followRedirects(true) - .followSslRedirects(true) - .proxy(Proxy.NO_PROXY) - .build() - -internal actual suspend fun fetchSyncBackendManifestText(url: String): String = - withContext(Dispatchers.IO) { - val request = Request.Builder() - .url(url) - .get() - .header("Accept", "application/json") - .build() - - syncBackendHttpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - error("Sync backend manifest request failed with HTTP ${response.code}") - } - response.body?.string()?.takeIf { it.isNotBlank() } - ?: error("Sync backend manifest response was empty") - } - } 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 d289ea5a..89b593d1 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 @@ -17,6 +17,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_debrid_settings", "nuvio_mdblist_settings", "nuvio_downloads", + "nuvio_auth", "nuvio_trakt_auth", "nuvio_trakt_library", "nuvio_trakt_settings", @@ -24,6 +25,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_stream_link_cache", "nuvio_stream_badge_settings", "nuvio_continue_watching_preferences", + "nuvio_cw_enrichment", "nuvio_episode_release_notifications", "nuvio_episode_release_notifications_platform", "nuvio_watch_progress", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.android.kt new file mode 100644 index 00000000..6dae30d5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.android.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.core.sync + +import android.content.Context +import android.content.SharedPreferences + +actual object SyncClientIdentityStorage { + private const val preferencesName = "nuvio_sync_client_identity" + private const val clientIdKey = "client_instance_id" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadClientId(): String? = + preferences?.getString(clientIdKey, null) + + actual fun saveClientId(clientId: String) { + preferences + ?.edit() + ?.putString(clientIdKey, clientId) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.android.kt new file mode 100644 index 00000000..badca773 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.android.kt @@ -0,0 +1,93 @@ +package com.nuvio.app.core.ui + +import android.graphics.Canvas as AndroidCanvas +import android.graphics.Paint as AndroidPaint +import android.os.Build +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas + +internal actual val ashSwarmMaxGrainBudget: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 26000 else 15000 + +internal actual class AshSwarmRenderer actual constructor(maxGrains: Int) { + private val batched = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + private val positions = if (batched) FloatArray(maxGrains * 12) else EMPTY_FLOATS + private val vertexColors = if (batched) IntArray(maxGrains * 6) else EMPTY_INTS + private val paint = AndroidPaint() + + actual fun draw( + scope: DrawScope, + count: Int, + centersX: FloatArray, + centersY: FloatArray, + halfWidths: FloatArray, + halfHeights: FloatArray, + colors: IntArray, + ) { + if (count == 0) return + if (!batched) { + drawUnbatched(scope, count, centersX, centersY, halfWidths, halfHeights, colors) + return + } + var pi = 0 + var ci = 0 + for (i in 0 until count) { + val x0 = centersX[i] - halfWidths[i] + val x1 = centersX[i] + halfWidths[i] + val y0 = centersY[i] - halfHeights[i] + val y1 = centersY[i] + halfHeights[i] + positions[pi++] = x0; positions[pi++] = y0 + positions[pi++] = x1; positions[pi++] = y0 + positions[pi++] = x0; positions[pi++] = y1 + positions[pi++] = x1; positions[pi++] = y0 + positions[pi++] = x1; positions[pi++] = y1 + positions[pi++] = x0; positions[pi++] = y1 + val c = colors[i] + vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c + vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c + } + scope.drawContext.canvas.nativeCanvas.drawVertices( + AndroidCanvas.VertexMode.TRIANGLES, + count * 12, + positions, + 0, + null, + 0, + vertexColors, + 0, + null, + 0, + 0, + paint, + ) + } + + private fun drawUnbatched( + scope: DrawScope, + count: Int, + centersX: FloatArray, + centersY: FloatArray, + halfWidths: FloatArray, + halfHeights: FloatArray, + colors: IntArray, + ) { + for (i in 0 until count) { + val hw = halfWidths[i] + val hh = halfHeights[i] + scope.drawRect( + color = Color(colors[i]), + topLeft = Offset(centersX[i] - hw, centersY[i] - hh), + size = Size(hw * 2f, hh * 2f), + ) + } + } + + private companion object { + val EMPTY_FLOATS = FloatArray(0) + val EMPTY_INTS = IntArray(0) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt new file mode 100644 index 00000000..a0eaa831 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.core.ui + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object CardDepthStyleStorage { + private const val preferencesName = "nuvio_card_depth_style" + private const val payloadKey = "card_depth_style_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/core/ui/NuvioPlatformInsets.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt similarity index 69% rename from composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.android.kt rename to composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt index a1820c50..d9e4e7b4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.android.kt @@ -1,7 +1,9 @@ package com.nuvio.app.core.ui import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -11,3 +13,7 @@ internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp @Composable internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.navigationBars + +@Composable +internal actual fun platformPhysicalTopInset(): Dp = + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 01afe4ef..8d60a430 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.addons import android.content.Context import android.content.SharedPreferences +import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor import com.nuvio.app.core.network.IPv4FirstDns import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -84,6 +85,7 @@ private val addonHttpClient = OkHttpClient.Builder() .writeTimeout(60, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) + .addInterceptor(SentryNetworkBreadcrumbInterceptor()) .proxy(Proxy.NO_PROXY) .build() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.android.kt new file mode 100644 index 00000000..a6e3b256 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.android.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import coil3.request.SuccessResult +import coil3.toBitmap + +internal actual fun loadedBackdropImageBitmap(result: SuccessResult): ImageBitmap? = + runCatching { result.image.toBitmap().asImageBitmap() }.getOrNull() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index 52c7e112..8bda6002 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.downloads import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -186,6 +188,42 @@ internal actual object DownloadsPlatformDownloader { val localFile = File(downloadsDir, fileName) return localFile.takeIf { it.exists() }?.toURI()?.toString() } + + actual fun openDownloadsDirectory(): Boolean { + val context = appContext ?: return false + val downloadsDir = File(context.filesDir, "downloads").apply { mkdirs() } + val uri = runCatching { + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + downloadsDir, + ) + }.getOrNull() ?: return false + + val intents = listOf( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "resource/folder") + }, + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "vnd.android.document/directory") + }, + Intent(Intent.ACTION_VIEW).apply { + data = uri + }, + ) + + return intents.any { intent -> + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + + runCatching { + context.startActivity(intent) + true + }.getOrDefault(false) + } + } } private class AndroidDownloadsTaskHandle( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt index b1856bf7..2122d32a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt @@ -23,7 +23,7 @@ import androidx.work.WorkManager import com.nuvio.app.core.storage.ProfileScopedKey import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.engine.android.Android +import io.ktor.client.engine.okhttp.OkHttp import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -53,7 +53,7 @@ internal actual object EpisodeReleaseNotificationPlatform { private var currentActivity: ComponentActivity? = null private var pendingPermissionContinuation: kotlin.coroutines.Continuation? = null private val httpClient by lazy { - HttpClient(Android) { + HttpClient(OkHttp) { install(HttpTimeout) { requestTimeoutMillis = 15_000 connectTimeoutMillis = 15_000 diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt index 2afd14d5..e2e5e2f2 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt @@ -93,6 +93,10 @@ internal actual object ExternalPlayerPlatform { // Required by MX Player; harmless for other players. putExtra("return_result", true) + // Intro/outro skip segments for players that support auto-skipping. + // Players that don't understand this extra simply ignore it. + request.skipSegmentsJson?.let { putExtra("skip_segments", it) } + // Headers if (request.sourceHeaders.isNotEmpty()) { val headerArray = request.sourceHeaders.entries diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index f3a6a421..fc13217b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView @@ -73,6 +74,7 @@ import `is`.xyz.mpv.Utils import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -368,6 +370,33 @@ private fun ExoPlayerSurface( player } + val nowPlayingController = remember(context, exoPlayer) { + AndroidPlayerNowPlayingController( + context = context, + controls = AndroidPlayerNowPlayingController.PlaybackControls( + play = { + exoPlayer.playWhenReady = true + exoPlayer.play() + }, + pause = exoPlayer::pause, + seekTo = { positionMs -> exoPlayer.seekTo(positionMs.coerceAtLeast(0L)) }, + seekBy = { offsetMs -> + exoPlayer.seekTo((exoPlayer.currentPosition + offsetMs).coerceAtLeast(0L)) + }, + ), + ) + } + + fun dispatchExoPlayerSnapshot() { + val snapshot = exoPlayer.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController.syncPlayback(snapshot) + } + + DisposableEffect(nowPlayingController) { + onDispose { nowPlayingController.release() } + } + LaunchedEffect(exoPlayer, resolvedMediaItem) { val mediaItem = resolvedMediaItem ?: return@LaunchedEffect exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs) @@ -379,6 +408,8 @@ private fun ExoPlayerSurface( var playerViewRef by remember { mutableStateOf(null) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(null) } + val isInPip = rememberIsInPictureInPicture() + val pipSubtitleScale by rememberUpdatedState(if (isInPip) 0.4f else 1.0f) fun syncPlayerViewKeepScreenOn() { playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn() @@ -395,6 +426,16 @@ private fun ExoPlayerSurface( PlayerPictureInPictureManager.registerPausePlaybackCallback { exoPlayer.pause() } + PlayerPictureInPictureManager.registerTogglePlaybackCallback { + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + if (exoPlayer.playbackState == androidx.media3.common.Player.STATE_ENDED) { + exoPlayer.seekTo(0L) + } + exoPlayer.play() + } + } fun reportPlayerError(error: PlaybackException) { if ( @@ -476,16 +517,20 @@ private fun ExoPlayerSurface( exoPlayer.logCurrentTracks("STATE_READY") } syncPlayerViewKeepScreenOn() + dispatchExoPlayerSnapshot() + } + + override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { latestOnSnapshot.value(exoPlayer.snapshot()) } override fun onIsPlayingChanged(isPlaying: Boolean) { syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) { - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { @@ -509,13 +554,14 @@ private fun ExoPlayerSurface( exoPlayer.selectTrackByIndex(C.TRACK_TYPE_TEXT, idx) } } - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } } exoPlayer.addListener(listener) onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + PlayerPictureInPictureManager.registerTogglePlaybackCallback(null) exoPlayer.removeListener(listener) playerViewRef?.keepScreenOn = false subtitleSelectionJob?.cancel() @@ -531,7 +577,8 @@ private fun ExoPlayerSurface( val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true val isFinishing = activity?.isFinishing == true - if (!isInPictureInPicture || isFinishing) { + val hasActiveNowPlayingSession = nowPlayingController.isActive + if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) { exoPlayer.pause() } } @@ -548,7 +595,7 @@ private fun ExoPlayerSurface( LaunchedEffect(exoPlayer, playWhenReady) { exoPlayer.playWhenReady = latestPlayWhenReady.value syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } LaunchedEffect(exoPlayer) { @@ -601,6 +648,14 @@ private fun ExoPlayerSurface( ) } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + nowPlayingController.updateMetadata(info) + } + + override fun clearNowPlayingInfo() { + nowPlayingController.clear() + } + override fun getAudioTracks(): List = exoPlayer.extractAudioTracks(context) @@ -717,7 +772,7 @@ private fun ExoPlayerSurface( override fun applySubtitleStyle(style: SubtitleStyleState) { currentSubtitleStyle = style - playerViewRef?.applySubtitleStyle(style) + playerViewRef?.applySubtitleStyle(style, pipSubtitleScale) } override fun setSubtitleDelayMs(delayMs: Int) { @@ -729,7 +784,7 @@ private fun ExoPlayerSurface( LaunchedEffect(exoPlayer) { while (isActive) { - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() delay(250L) } } @@ -750,7 +805,7 @@ private fun ExoPlayerSurface( enabled = useLibass, renderType = libassRenderType, ) - applySubtitleStyle(currentSubtitleStyle) + applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale) } }, update = { playerView -> @@ -764,7 +819,7 @@ private fun ExoPlayerSurface( enabled = useLibass, renderType = libassRenderType, ) - playerView.applySubtitleStyle(currentSubtitleStyle) + playerView.applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale) }, ) } @@ -795,8 +850,25 @@ private fun LibmpvPlayerSurface( sanitizePlaybackHeaders(sourceHeaders) } var playerViewRef by remember { mutableStateOf(null) } + val nowPlayingController = remember(context, playerViewRef) { + playerViewRef?.let { view -> + AndroidPlayerNowPlayingController( + context = context, + controls = AndroidPlayerNowPlayingController.PlaybackControls( + play = { view.setPaused(false) }, + pause = { view.setPaused(true) }, + seekTo = { positionMs -> view.seekToMs(positionMs) }, + seekBy = { offsetMs -> view.seekByMs(offsetMs) }, + ), + ) + } + } - DisposableEffect(lifecycleOwner) { + DisposableEffect(nowPlayingController) { + onDispose { nowPlayingController?.release() } + } + + DisposableEffect(lifecycleOwner, nowPlayingController) { val activity = context.findActivity() val observer = LifecycleEventObserver { _, event -> val view = playerViewRef ?: return@LifecycleEventObserver @@ -806,7 +878,8 @@ private fun LibmpvPlayerSurface( val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true val isFinishing = activity?.isFinishing == true - if (!isInPictureInPicture || isFinishing) { + val hasActiveNowPlayingSession = nowPlayingController?.isActive == true + if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) { view.setPaused(true) } } @@ -819,11 +892,13 @@ private fun LibmpvPlayerSurface( } } - DisposableEffect(playerViewRef) { + DisposableEffect(playerViewRef, nowPlayingController) { val view = playerViewRef ?: return@DisposableEffect onDispose {} fun dispatchSnapshot(updateKeepScreenOn: Boolean = false) { coroutineScope.launch(Dispatchers.Main.immediate) { - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) if (updateKeepScreenOn) { view.keepScreenOn = view.shouldKeepScreenOn() } @@ -852,11 +927,21 @@ private fun LibmpvPlayerSurface( } override fun event(eventId: Int, data: MPVNode) { when (eventId) { + MPV.mpvEvent.MPV_EVENT_START_FILE -> { + coroutineScope.launch(Dispatchers.Main.immediate) { + latestOnError.value(null) + val snapshot = PlayerPlaybackSnapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) + } + } MPV.mpvEvent.MPV_EVENT_FILE_LOADED, MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { coroutineScope.launch(Dispatchers.Main.immediate) { latestOnError.value(null) - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } } MPV.mpvEvent.MPV_EVENT_END_FILE -> dispatchSnapshot() @@ -874,14 +959,29 @@ private fun LibmpvPlayerSurface( PlayerPictureInPictureManager.registerPausePlaybackCallback { view.setPaused(true) } + PlayerPictureInPictureManager.registerTogglePlaybackCallback { + val snapshot = view.snapshot() + if (snapshot.isPlaying) { + view.setPaused(true) + } else { + if (snapshot.isEnded) { + view.seekToMs(0L) + } + view.setPaused(false) + } + } onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + PlayerPictureInPictureManager.registerTogglePlaybackCallback(null) view.keepScreenOn = false } } LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { val view = playerViewRef ?: return@LaunchedEffect + val snapshot = PlayerPlaybackSnapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) view.loadSource( sourceUrl = sourceUrl, sourceAudioUrl = sourceAudioUrl, @@ -889,29 +989,32 @@ private fun LibmpvPlayerSurface( externalSubtitles = externalSubtitles, playWhenReady = latestPlayWhenReady.value, ) - latestOnSnapshot.value(view.snapshot()) } LaunchedEffect(playerViewRef, playWhenReady) { val view = playerViewRef ?: return@LaunchedEffect view.setPaused(!latestPlayWhenReady.value) view.keepScreenOn = view.shouldKeepScreenOn() - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } LaunchedEffect(playerViewRef, resizeMode) { playerViewRef?.applyResizeMode(resizeMode) } - LaunchedEffect(playerViewRef) { + LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { val view = playerViewRef ?: return@LaunchedEffect - onControllerReady(view.controller(context)) + onControllerReady(view.controller(context, nowPlayingController)) } LaunchedEffect(playerViewRef) { val view = playerViewRef ?: return@LaunchedEffect while (isActive) { - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) view.keepScreenOn = view.shouldKeepScreenOn() delay(250L) } @@ -1021,24 +1124,39 @@ private class NuvioLibmpvView( currentSourceAudioUrl = sourceAudioUrl currentRequestHeaders = requestHeaders currentExternalSubtitles = externalSubtitles - applyRequestHeaders(requestHeaders) - setPaused(!playWhenReady) if (!sameSource) { - playFile(sourceUrl) - if (!sourceAudioUrl.isNullOrBlank()) { - mpv.command("audio-add", sourceAudioUrl, "auto") - } - externalSubtitles.forEachIndexed { index, subtitle -> - val flag = if (index == 0) "auto" else "cached" - mpv.command("sub-add", subtitle.url, flag) - } + loadCurrentSource(playWhenReady = playWhenReady) + } else { + applyRequestHeaders(requestHeaders) + setPaused(!playWhenReady) } } + private fun loadCurrentSource(playWhenReady: Boolean) { + val sourceUrl = currentSourceUrl ?: return + applyRequestHeaders(currentRequestHeaders) + setPaused(!playWhenReady) + mpv.command("loadfile", sourceUrl, "replace") + currentSourceAudioUrl?.takeIf { it.isNotBlank() }?.let { sourceAudioUrl -> + mpv.command("audio-add", sourceAudioUrl, "auto") + } + currentExternalSubtitles.forEachIndexed { index, subtitle -> + val flag = if (index == 0) "auto" else "cached" + mpv.command("sub-add", subtitle.url, flag) + } + setPaused(!playWhenReady) + } + fun setPaused(paused: Boolean) { runCatching { mpv.setPropertyBoolean("pause", paused) } } + fun seekToMs(positionMs: Long) { + runCatching { + mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") + } + } + fun snapshot(): PlayerPlaybackSnapshot { val paused = mpv.getPropertyBoolean("pause") ?: true val pausedForCache = mpv.getPropertyBoolean("paused-for-cache") ?: false @@ -1052,6 +1170,12 @@ private class NuvioLibmpvView( val isCacheBuffering = cacheBufferingState != null && cacheBufferingState in 0 until 100 val isLoading = pausedForCache || (!paused && !ended && (seeking || isCacheBuffering || (idle && durationMs <= 0L))) + val videoWidth = mpv.getPropertyInt("video-out-params/dw") + ?: mpv.getPropertyInt("video-params/dw") + ?: 0 + val videoHeight = mpv.getPropertyInt("video-out-params/dh") + ?: mpv.getPropertyInt("video-params/dh") + ?: 0 return PlayerPlaybackSnapshot( isLoading = isLoading, isPlaying = !paused && !isLoading && !idle && !ended, @@ -1060,6 +1184,8 @@ private class NuvioLibmpvView( positionMs = positionMs, bufferedPositionMs = maxOf(positionMs, cachePositionMs), playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(), + videoWidth = videoWidth, + videoHeight = videoHeight, ) } @@ -1085,29 +1211,39 @@ private class NuvioLibmpvView( } } - fun controller(context: Context): PlayerEngineController = + fun seekByMs(offsetMs: Long) { + mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") + } + + fun controller( + context: Context, + nowPlayingController: AndroidPlayerNowPlayingController?, + ): PlayerEngineController = object : PlayerEngineController { override fun play() = setPaused(false) override fun pause() = setPaused(true) - override fun seekTo(positionMs: Long) { - mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") - } + override fun seekTo(positionMs: Long) = this@NuvioLibmpvView.seekToMs(positionMs) - override fun seekBy(offsetMs: Long) { - mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") - } + override fun seekBy(offsetMs: Long) = this@NuvioLibmpvView.seekByMs(offsetMs) override fun retry() { - currentSourceUrl?.let { playFile(it) } - setPaused(false) + loadCurrentSource(playWhenReady = true) } override fun setPlaybackSpeed(speed: Float) { mpv.setPropertyDouble("speed", speed.coerceIn(0.25f, 4f).toDouble()) } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + nowPlayingController?.updateMetadata(info) + } + + override fun clearNowPlayingInfo() { + nowPlayingController?.clear() + } + override fun setMuted(muted: Boolean) { mpv.setPropertyBoolean("mute", muted) } @@ -1291,8 +1427,9 @@ private const val MPV_SUBTITLE_FONT_SIZE_MIN = 36 private const val MPV_SUBTITLE_FONT_SIZE_MAX = 122 private const val MPV_SUBTITLE_OUTLINE_SIZE_SCALE = 1.5 -private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = - PlayerPlaybackSnapshot( +private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot { + val (videoWidth, videoHeight) = videoDimensions() + return PlayerPlaybackSnapshot( isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING, isPlaying = isPlaying, isEnded = playbackState == Player.STATE_ENDED, @@ -1300,7 +1437,21 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = positionMs = currentPosition.coerceAtLeast(0L), bufferedPositionMs = bufferedPosition.coerceAtLeast(0L), playbackSpeed = playbackParameters.speed, + videoWidth = videoWidth, + videoHeight = videoHeight, ) +} + +private fun ExoPlayer.videoDimensions(): Pair { + val format = videoFormat ?: return videoSize.width to videoSize.height + val hasCrop = format.decodedWidth != Format.NO_VALUE && + format.decodedHeight != Format.NO_VALUE && + (format.decodedWidth > format.width || format.decodedHeight > format.height) + val baseWidth = if (hasCrop) format.width else (format.width.takeIf { it > 0 } ?: videoSize.width) + val baseHeight = if (hasCrop) format.height else (format.height.takeIf { it > 0 } ?: videoSize.height) + val ratio = format.pixelWidthHeightRatio + return if (ratio != 1f) (baseWidth * ratio).roundToInt() to baseHeight else baseWidth to baseHeight +} private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean = playerError == null && @@ -1463,7 +1614,7 @@ private fun android.widget.FrameLayout.removeAssOverlayChildren() { } } -private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { +private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState, pipScale: Float = 1.0f) { subtitleView?.apply { val baseBottomPaddingFraction = SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION * 2f / 3f val offsetFraction = (style.bottomOffset / 1000f).coerceIn(0f, 0.2f) @@ -1482,7 +1633,7 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT, ) ) - setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat()) + setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat() * pipScale) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt new file mode 100644 index 00000000..b13954a0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt @@ -0,0 +1,551 @@ +package com.nuvio.app.features.player + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import com.nuvio.app.R +import java.io.ByteArrayOutputStream +import java.lang.ref.WeakReference +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs + +private const val NOW_PLAYING_TAG = "NuvioNowPlaying" +private const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback" +private const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55 +private const val SEEK_INTERVAL_MS = 10_000L +private const val MAX_ARTWORK_DOWNLOAD_BYTES = 12 * 1024 * 1024 +private const val MAX_ARTWORK_EDGE_PX = 1_024 + +private const val ACTION_PLAY = "com.nuvio.app.nowplaying.PLAY" +private const val ACTION_PAUSE = "com.nuvio.app.nowplaying.PAUSE" +private const val ACTION_REWIND = "com.nuvio.app.nowplaying.REWIND" +private const val ACTION_FAST_FORWARD = "com.nuvio.app.nowplaying.FAST_FORWARD" +private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND" + +private data class AndroidNowPlayingMetadata( + val title: String, + val subtitle: String?, + val artworkUrl: String?, +) + +internal class AndroidPlayerNowPlayingController( + context: Context, + private val controls: PlaybackControls, +) { + internal data class PlaybackControls( + val play: () -> Unit, + val pause: () -> Unit, + val seekTo: (Long) -> Unit, + val seekBy: (Long) -> Unit, + ) + + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + private val artworkExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "NuvioNowPlayingArtwork").apply { isDaemon = true } + } + private val artworkGeneration = AtomicInteger(0) + private val mediaSession = MediaSession(appContext, NOW_PLAYING_TAG).apply { + setFlags( + MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or + MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS, + ) + setCallback( + object : MediaSession.Callback() { + override fun onPlay() = controls.play() + + override fun onPause() = controls.pause() + + override fun onStop() = controls.pause() + + override fun onSeekTo(pos: Long) = controls.seekTo(pos.coerceAtLeast(0L)) + + override fun onFastForward() = controls.seekBy(SEEK_INTERVAL_MS) + + override fun onRewind() = controls.seekBy(-SEEK_INTERVAL_MS) + }, + mainHandler, + ) + buildContentIntent(appContext)?.let(::setSessionActivity) + } + + private var metadata: AndroidNowPlayingMetadata? = null + private var snapshot = PlayerPlaybackSnapshot() + private var artworkBitmap: Bitmap? = null + private var released = false + private var lastPublishedPositionMs = Long.MIN_VALUE + private var lastPublishedDurationMs = Long.MIN_VALUE + private var lastPublishedPlaying: Boolean? = null + private var lastPublishedLoading: Boolean? = null + private var lastPublishedEnded: Boolean? = null + private var lastPublishedSpeed = Float.NaN + + val isActive: Boolean + get() = !released && metadata != null + + init { + createNotificationChannel(appContext) + AndroidNowPlayingActionDispatcher.register(this) + } + + fun updateMetadata(info: PlayerNowPlayingInfo) { + runOnMain { + if (released) return@runOnMain + + val normalized = AndroidNowPlayingMetadata( + title = info.title.trim(), + subtitle = info.subtitle?.trim()?.takeIf(String::isNotEmpty), + artworkUrl = info.artworkUrl?.trim()?.takeIf(String::isNotEmpty), + ) + if (normalized.title.isEmpty()) { + clearInternal() + return@runOnMain + } + + val artworkChanged = metadata?.artworkUrl != normalized.artworkUrl + metadata = normalized + mediaSession.isActive = true + + if (artworkChanged) { + artworkBitmap = null + loadArtwork(normalized.artworkUrl) + } + + publishMetadata() + publishPlaybackState(force = true) + publishNotification() + } + } + + fun syncPlayback(nextSnapshot: PlayerPlaybackSnapshot) { + runOnMain { + if (released || metadata == null) return@runOnMain + + val durationChanged = snapshot.durationMs != nextSnapshot.durationMs + val playingChanged = snapshot.isPlaying != nextSnapshot.isPlaying + val loadingChanged = snapshot.isLoading != nextSnapshot.isLoading + val endedChanged = snapshot.isEnded != nextSnapshot.isEnded + snapshot = nextSnapshot + + if (durationChanged) publishMetadata() + publishPlaybackState(force = false) + if (playingChanged || loadingChanged || endedChanged) publishNotification() + } + } + + fun clear() { + runOnMain { + if (released) return@runOnMain + clearInternal() + } + } + + fun release() { + runOnMain { + if (released) return@runOnMain + released = true + clearInternal() + AndroidNowPlayingActionDispatcher.unregister(this) + mediaSession.release() + artworkExecutor.shutdownNow() + } + } + + internal fun handleAction(action: String?) { + if (released) return + when (action) { + ACTION_PLAY -> controls.play() + ACTION_PAUSE -> controls.pause() + ACTION_REWIND -> controls.seekBy(-SEEK_INTERVAL_MS) + ACTION_FAST_FORWARD -> controls.seekBy(SEEK_INTERVAL_MS) + } + } + + private fun clearInternal() { + artworkGeneration.incrementAndGet() + metadata = null + snapshot = PlayerPlaybackSnapshot() + artworkBitmap = null + resetPublishedPlaybackState() + mediaSession.setMetadata(null) + mediaSession.setPlaybackState( + PlaybackState.Builder() + .setState(PlaybackState.STATE_NONE, 0L, 0f) + .build(), + ) + mediaSession.isActive = false + PlayerNowPlayingService.hide(appContext) + } + + private fun publishMetadata() { + val currentMetadata = metadata ?: return + val builder = MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, currentMetadata.title) + .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, currentMetadata.title) + + currentMetadata.subtitle?.let { subtitle -> + builder.putString(MediaMetadata.METADATA_KEY_ARTIST, subtitle) + builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle) + } + snapshot.durationMs.takeIf { it > 0L }?.let { durationMs -> + builder.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs) + } + artworkBitmap?.let { artwork -> + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, artwork) + builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artwork) + builder.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, artwork) + } + + mediaSession.setMetadata(builder.build()) + } + + private fun publishPlaybackState(force: Boolean) { + val current = snapshot + val positionChanged = lastPublishedPositionMs == Long.MIN_VALUE || + abs(current.positionMs - lastPublishedPositionMs) >= 1_000L + val durationChanged = lastPublishedDurationMs != current.durationMs + val playingChanged = lastPublishedPlaying != current.isPlaying + val loadingChanged = lastPublishedLoading != current.isLoading + val endedChanged = lastPublishedEnded != current.isEnded + val speedChanged = lastPublishedSpeed.isNaN() || lastPublishedSpeed != current.playbackSpeed + + if (!force && !positionChanged && !durationChanged && !playingChanged && !loadingChanged && !endedChanged && !speedChanged) { + return + } + + val state = when { + current.isEnded -> PlaybackState.STATE_STOPPED + current.isLoading -> PlaybackState.STATE_BUFFERING + current.isPlaying -> PlaybackState.STATE_PLAYING + else -> PlaybackState.STATE_PAUSED + } + val playbackSpeed = if (current.isPlaying) current.playbackSpeed.coerceAtLeast(0.1f) else 0f + val actions = PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_FAST_FORWARD or + PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_STOP + + mediaSession.setPlaybackState( + PlaybackState.Builder() + .setActions(actions) + .setState( + state, + current.positionMs.coerceAtLeast(0L), + playbackSpeed, + SystemClock.elapsedRealtime(), + ) + .setBufferedPosition(current.bufferedPositionMs.coerceAtLeast(0L)) + .build(), + ) + + lastPublishedPositionMs = current.positionMs + lastPublishedDurationMs = current.durationMs + lastPublishedPlaying = current.isPlaying + lastPublishedLoading = current.isLoading + lastPublishedEnded = current.isEnded + lastPublishedSpeed = current.playbackSpeed + } + + private fun publishNotification() { + val currentMetadata = metadata ?: return + val notification = buildNotification( + context = appContext, + sessionToken = mediaSession.sessionToken, + metadata = currentMetadata, + snapshot = snapshot, + artwork = artworkBitmap, + ) + PlayerNowPlayingService.publish(appContext, notification) + } + + private fun loadArtwork(urlString: String?) { + val generation = artworkGeneration.incrementAndGet() + if (urlString.isNullOrBlank()) return + + artworkExecutor.execute { + val bitmap = runCatching { downloadArtwork(urlString) } + .onFailure { error -> Log.w(NOW_PLAYING_TAG, "Failed to load artwork", error) } + .getOrNull() + + mainHandler.post { + if (released || generation != artworkGeneration.get() || metadata?.artworkUrl != urlString) { + bitmap?.recycle() + return@post + } + artworkBitmap = bitmap + publishMetadata() + publishNotification() + } + } + } + + private fun resetPublishedPlaybackState() { + lastPublishedPositionMs = Long.MIN_VALUE + lastPublishedDurationMs = Long.MIN_VALUE + lastPublishedPlaying = null + lastPublishedLoading = null + lastPublishedEnded = null + lastPublishedSpeed = Float.NaN + } + + private fun runOnMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } +} + +class PlayerNowPlayingActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AndroidNowPlayingActionDispatcher.dispatch(intent.action) + } +} + +class PlayerNowPlayingService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action != ACTION_START_FOREGROUND) { + stopSelf(startId) + return START_NOT_STICKY + } + + val notification = PlayerNowPlayingServiceState.notification + if (notification == null) { + stopSelf(startId) + return START_NOT_STICKY + } + + startForeground(NOW_PLAYING_NOTIFICATION_ID, notification) + return START_NOT_STICKY + } + + override fun onDestroy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + super.onDestroy() + } + + companion object { + internal fun publish(context: Context, notification: Notification) { + PlayerNowPlayingServiceState.notification = notification + val intent = Intent(context, PlayerNowPlayingService::class.java) + .setAction(ACTION_START_FOREGROUND) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + context.getSystemService(NotificationManager::class.java) + ?.notify(NOW_PLAYING_NOTIFICATION_ID, notification) + }.onFailure { error -> + Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error) + } + } + + internal fun hide(context: Context) { + PlayerNowPlayingServiceState.notification = null + runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) } + context.getSystemService(NotificationManager::class.java) + ?.cancel(NOW_PLAYING_NOTIFICATION_ID) + } + } +} + +private object PlayerNowPlayingServiceState { + @Volatile + var notification: Notification? = null +} + +private object AndroidNowPlayingActionDispatcher { + @Volatile + private var controllerRef: WeakReference? = null + + fun register(controller: AndroidPlayerNowPlayingController) { + controllerRef = WeakReference(controller) + } + + fun unregister(controller: AndroidPlayerNowPlayingController) { + if (controllerRef?.get() === controller) controllerRef = null + } + + fun dispatch(action: String?) { + controllerRef?.get()?.handleAction(action) + } +} + +private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + NOW_PLAYING_CHANNEL_ID, + "Playback", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Media playback controls" + setSound(null, null) + enableVibration(false) + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + manager.createNotificationChannel(channel) +} + +private fun buildNotification( + context: Context, + sessionToken: MediaSession.Token, + metadata: AndroidNowPlayingMetadata, + snapshot: PlayerPlaybackSnapshot, + artwork: Bitmap?, +): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, NOW_PLAYING_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + + val playPauseAction = if (snapshot.isPlaying) { + Notification.Action.Builder( + android.R.drawable.ic_media_pause, + "Pause", + buildActionIntent(context, ACTION_PAUSE, 2), + ).build() + } else { + Notification.Action.Builder( + android.R.drawable.ic_media_play, + "Play", + buildActionIntent(context, ACTION_PLAY, 2), + ).build() + } + + return builder + .setSmallIcon(R.drawable.ic_notification_small) + .setContentTitle(metadata.title) + .setContentText(metadata.subtitle) + .setLargeIcon(artwork) + .setContentIntent(buildContentIntent(context)) + .setCategory(Notification.CATEGORY_TRANSPORT) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setOngoing(snapshot.isPlaying) + .setShowWhen(false) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_media_rew, + "Rewind 10 seconds", + buildActionIntent(context, ACTION_REWIND, 1), + ).build(), + ) + .addAction(playPauseAction) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_media_ff, + "Forward 10 seconds", + buildActionIntent(context, ACTION_FAST_FORWARD, 3), + ).build(), + ) + .setStyle( + Notification.MediaStyle() + .setMediaSession(sessionToken) + .setShowActionsInCompactView(0, 1, 2), + ) + .build() +} + +private fun buildActionIntent(context: Context, action: String, requestCode: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + Intent(context, PlayerNowPlayingActionReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + +private fun buildContentIntent(context: Context): PendingIntent? { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + ?: return null + return PendingIntent.getActivity( + context, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +private fun downloadArtwork(urlString: String): Bitmap? { + val connection = (URL(urlString).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + instanceFollowRedirects = true + requestMethod = "GET" + setRequestProperty("Accept", "image/*") + } + + return try { + connection.connect() + if (connection.responseCode !in 200..299) return null + val bytes = connection.inputStream.use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > MAX_ARTWORK_DOWNLOAD_BYTES) return null + output.write(buffer, 0, read) + } + output.toByteArray() + } + decodeSampledBitmap(bytes, MAX_ARTWORK_EDGE_PX) + } finally { + connection.disconnect() + } +} + +private fun decodeSampledBitmap(bytes: ByteArray, maxEdgePx: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sampleSize = 1 + while (bounds.outWidth / sampleSize > maxEdgePx || bounds.outHeight / sampleSize > maxEdgePx) { + sampleSize *= 2 + } + + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt index 33a05be4..70c9467d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt @@ -2,37 +2,52 @@ package com.nuvio.app.features.player import android.app.Activity import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon import android.os.Build +import kotlin.math.roundToInt +import kotlinx.coroutines.runBlocking import android.os.Handler import android.os.Looper import android.util.Rational import androidx.activity.ComponentActivity import androidx.compose.ui.unit.IntSize import androidx.lifecycle.Lifecycle +import com.nuvio.app.R +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + +internal const val PIP_ACTION_TOGGLE_PLAY_PAUSE = "com.nuvio.app.action.PIP_TOGGLE_PLAY_PAUSE" internal object PlayerPictureInPictureManager { private data class SessionState( val isActive: Boolean = false, val isPlaying: Boolean = false, - val playerSize: IntSize = IntSize.Zero, + val videoSize: IntSize = IntSize.Zero, ) private var sessionState = SessionState() + private var lastAppliedSessionState: SessionState? = null private val mainHandler = Handler(Looper.getMainLooper()) private var wasInPictureInPictureMode = false private var pendingPictureInPictureExitCheck: Runnable? = null private var pausePlaybackCallback: (() -> Unit)? = null + private var togglePlaybackCallback: (() -> Unit)? = null fun updateSession( activity: Activity, isActive: Boolean, isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) { sessionState = SessionState( isActive = isActive, isPlaying = isPlaying, - playerSize = playerSize, + videoSize = videoSize, ) applyPictureInPictureParams(activity) } @@ -51,6 +66,10 @@ internal object PlayerPictureInPictureManager { } } + fun registerTogglePlaybackCallback(callback: (() -> Unit)?) { + togglePlaybackCallback = callback + } + fun onUserLeaveHint(activity: Activity): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return false @@ -80,9 +99,20 @@ internal object PlayerPictureInPictureManager { mainHandler.postDelayed(exitCheck, 250L) } + fun toggleViaRemoteAction(context: Context) { + val wasPlaying = sessionState.isPlaying + togglePlaybackCallback?.invoke() + sessionState = sessionState.copy(isPlaying = !wasPlaying) + if (context is Activity) { + applyPictureInPictureParams(context) + } + } + private fun applyPictureInPictureParams(activity: Activity) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - activity.setPictureInPictureParams(buildParams()) + if (sessionState == lastAppliedSessionState) return + lastAppliedSessionState = sessionState + activity.setPictureInPictureParams(buildParams(activity)) } private fun enterIfEligible(activity: Activity): Boolean { @@ -90,12 +120,13 @@ internal object PlayerPictureInPictureManager { if (!sessionState.isActive || !sessionState.isPlaying) return false if (activity.isFinishing) return false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode) return false - return activity.enterPictureInPictureMode(buildParams()) + return activity.enterPictureInPictureMode(buildParams(activity)) } - private fun buildParams(): PictureInPictureParams { + private fun buildParams(activity: Activity): PictureInPictureParams { val builder = PictureInPictureParams.Builder() - buildAspectRatio(sessionState.playerSize)?.let(builder::setAspectRatio) + buildAspectRatio(sessionState.videoSize)?.let(builder::setAspectRatio) + builder.setActions(buildActions(activity)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(sessionState.isActive && sessionState.isPlaying) builder.setSeamlessResizeEnabled(true) @@ -103,25 +134,68 @@ internal object PlayerPictureInPictureManager { return builder.build() } - private fun buildAspectRatio(playerSize: IntSize): Rational? { - if (playerSize.width <= 0 || playerSize.height <= 0) return null + private fun buildAspectRatio(videoSize: IntSize): Rational? { + if (videoSize.width <= 0 || videoSize.height <= 0) return null - val width = playerSize.width.coerceAtLeast(1) - val height = playerSize.height.coerceAtLeast(1) + val width = videoSize.width.coerceAtLeast(1) + val height = videoSize.height.coerceAtLeast(1) val ratio = width.toDouble() / height.toDouble() return when { - ratio > MaxPictureInPictureAspectRatio -> Rational(239, 100) - ratio < MinPictureInPictureAspectRatio -> Rational(100, 239) + ratio > MaxPictureInPictureAspectRatio -> + Rational((MaxPictureInPictureAspectRatio * 100).roundToInt(), 100) + ratio < MinPictureInPictureAspectRatio -> + Rational(100, (MaxPictureInPictureAspectRatio * 100).roundToInt()) else -> Rational(width, height) } } + private fun buildActions(activity: Activity): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList() + + val isPlaying = sessionState.isPlaying + val iconRes = if (isPlaying) R.drawable.ic_player_pause else R.drawable.ic_player_play + val title = if (isPlaying) pipPauseLabel else pipPlayLabel + + val toggleIntent = Intent(PIP_ACTION_TOGGLE_PLAY_PAUSE).setPackage(activity.packageName) + val flags = android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = android.app.PendingIntent.getBroadcast(activity, REQUEST_CODE_TOGGLE, toggleIntent, flags) + val icon = Icon.createWithResource(activity, iconRes) + icon.setTint(android.graphics.Color.WHITE) + return listOf(RemoteAction(icon, title, title, pendingIntent)) + } + private fun clearPendingPictureInPictureExitCheck() { pendingPictureInPictureExitCheck?.let(mainHandler::removeCallbacks) pendingPictureInPictureExitCheck = null } + + private const val REQUEST_CODE_TOGGLE = 1 + private const val MaxPictureInPictureAspectRatio = 2.39 + private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio + + private val pipPlayLabel: String by lazy { runBlocking { getString(Res.string.action_play) } } + private val pipPauseLabel: String by lazy { runBlocking { getString(Res.string.compose_action_pause) } } } -private const val MaxPictureInPictureAspectRatio = 2.39 -private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio \ No newline at end of file +internal class PipRemoteActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null) return + if (intent?.action != PIP_ACTION_TOGGLE_PLAY_PAUSE) return + PlayerPictureInPictureManager.toggleViaRemoteAction(context) + } + + companion object { + fun register(context: Context): PipRemoteActionReceiver { + val filter = IntentFilter(PIP_ACTION_TOGGLE_PLAY_PAUSE) + val receiver = PipRemoteActionReceiver() + androidx.core.content.ContextCompat.registerReceiver( + context, + receiver, + filter, + androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED + ) + return receiver + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt index c02f9f6a..8f5c9704 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -5,15 +5,21 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.media.AudioManager +import androidx.activity.ComponentActivity import android.os.Build import android.provider.Settings import android.view.WindowManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.IntSize import androidx.compose.ui.platform.LocalContext +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -57,7 +63,7 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { @Composable actual fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) { val activity = LocalContext.current.findActivity() ?: return @@ -72,11 +78,30 @@ actual fun ManagePlayerPictureInPicture( activity = activity, isActive = true, isPlaying = isPlaying, - playerSize = playerSize, + videoSize = videoSize, ) } } +@Composable +actual fun rememberIsInPictureInPicture(): Boolean { + val context = LocalContext.current + val activity = context.findActivity() ?: return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + val componentActivity = activity as? ComponentActivity ?: return false + var pipState by remember(activity) { mutableStateOf(componentActivity.isInPictureInPictureMode) } + DisposableEffect(componentActivity) { + val listener = Consumer { info -> + pipState = info.isInPictureInPictureMode + } + componentActivity.addOnPictureInPictureModeChangedListener(listener) + onDispose { + componentActivity.removeOnPictureInPictureModeChangedListener(listener) + } + } + return pipState +} + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val context = LocalContext.current diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt index 09cc9866..075ce0e4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor import com.nuvio.app.core.network.IPv4FirstDns import okhttp3.OkHttpClient import java.net.HttpURLConnection @@ -55,6 +56,7 @@ internal object PlayerPlaybackNetworking { .followRedirects(true) .followSslRedirects(true) .retryOnConnectionFailure(true) + .addInterceptor(SentryNetworkBreadcrumbInterceptor()) .build() } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 98c55080..74d31347 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -20,12 +20,14 @@ import kotlinx.serialization.json.put actual object PlayerSettingsStorage { private const val preferencesName = "nuvio_player_settings" private const val showLoadingOverlayKey = "show_loading_overlay" + private const val showParentalGuideKey = "show_parental_guide" private const val resizeModeKey = "resize_mode" private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedValueKey = "hold_to_speed_value" private const val touchGesturesEnabledKey = "touch_gestures_enabled" private const val externalPlayerEnabledKey = "external_player_enabled" private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" + private const val externalPlayerSendSkipSegmentsKey = "external_player_send_skip_segments" private const val externalPlayerIdKey = "external_player_id" private const val preferredAudioLanguageKey = "preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" @@ -87,12 +89,14 @@ actual object PlayerSettingsStorage { private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, + showParentalGuideKey, resizeModeKey, holdToSpeedEnabledKey, holdToSpeedValueKey, touchGesturesEnabledKey, externalPlayerEnabledKey, externalPlayerForwardSubtitlesKey, + externalPlayerSendSkipSegmentsKey, externalPlayerIdKey, preferredAudioLanguageKey, secondaryPreferredAudioLanguageKey, @@ -175,6 +179,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadShowParentalGuide(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(showParentalGuideKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveShowParentalGuide(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(showParentalGuideKey), enabled) + ?.apply() + } + actual fun loadResizeMode(): String? = preferences?.getString(ProfileScopedKey.of(resizeModeKey), null) @@ -270,6 +291,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadExternalPlayerSendSkipSegments(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, false) + } else { + null + } + } + + actual fun saveExternalPlayerSendSkipSegments(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey), enabled) + ?.apply() + } + actual fun loadExternalPlayerId(): String? = preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null) @@ -1036,12 +1074,14 @@ actual object PlayerSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } + loadShowParentalGuide()?.let { put(showParentalGuideKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) } loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } + loadExternalPlayerSendSkipSegments()?.let { put(externalPlayerSendSkipSegmentsKey, encodeSyncBoolean(it)) } loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } @@ -1109,12 +1149,14 @@ actual object PlayerSettingsStorage { }?.apply() payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncBoolean(showParentalGuideKey)?.let(::saveShowParentalGuide) payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled) payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) + payload.decodeSyncBoolean(externalPlayerSendSkipSegmentsKey)?.let(::saveExternalPlayerSendSkipSegments) payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt index 3fa7106d..c78a52f5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.util.Log import androidx.core.content.FileProvider +import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -19,7 +20,11 @@ object SubtitleFileCache { private const val SUBTITLE_CACHE_DIR = "subtitles" private var appContext: Context? = null - private val okHttpClient: OkHttpClient by lazy { OkHttpClient() } + private val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(SentryNetworkBreadcrumbInterceptor()) + .build() + } fun initialize(context: Context) { appContext = context.applicationContext diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.android.kt new file mode 100644 index 00000000..6117cf4e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.android.kt @@ -0,0 +1,31 @@ +package com.nuvio.app.features.settings + +import android.content.Context +import android.content.SharedPreferences + +internal actual object SentrySettingsPlatform { + actual val crashReportsSupported: Boolean = true +} + +internal actual object SentrySettingsStorage { + private const val preferencesName = "nuvio_sentry_settings" + private const val enabledKey = "enabled" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadEnabled(): Boolean? = + preferences?.let { prefs -> + if (prefs.contains(enabledKey)) prefs.getBoolean(enabledKey, true) else null + } + + actual fun saveEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(enabledKey, enabled) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt index 35f23eb7..eea1e948 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt @@ -7,6 +7,7 @@ import com.nuvio.app.core.storage.ProfileScopedKey internal actual object TraktSettingsStorage { private const val preferencesName = "nuvio_trakt_settings" private const val payloadKey = "trakt_settings_payload" + private const val pendingWatchProgressSourceKey = "pending_watch_progress_source" private var preferences: SharedPreferences? = null @@ -23,4 +24,21 @@ internal actual object TraktSettingsStorage { ?.putString(ProfileScopedKey.of(payloadKey), payload) ?.apply() } + + actual fun loadPendingWatchProgressSourcePayload(profileId: Int): String? = + preferences?.getString(ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), null) + + actual fun savePendingWatchProgressSourcePayload(profileId: Int, payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), payload) + ?.commit() + } + + actual fun clearPendingWatchProgressSourcePayload(profileId: Int) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId)) + ?.apply() + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt index 880700f4..bd123c85 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt @@ -1,8 +1,16 @@ package com.nuvio.app.features.watchprogress import java.time.LocalDate +import java.time.ZoneId actual object CurrentDateProvider { actual fun todayIsoDate(): String = LocalDate.now().toString() -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? = + runCatching { + LocalDate.parse(isoDate) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml b/composeApp/src/androidMain/res/xml/file_paths.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/nuvio_file_paths.xml rename to composeApp/src/androidMain/res/xml/file_paths.xml diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml index 494b1d36..422ded11 100644 --- a/composeApp/src/androidMain/res/xml/locale_config.xml +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -1,17 +1,23 @@ + + + + + + diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 3be7ac2f..f0ee56ff 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -10,5 +10,4 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false - actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/commonMain/composeResources/files/nuvio_loading_indicator.json b/composeApp/src/commonMain/composeResources/files/nuvio_loading_indicator.json new file mode 100644 index 00000000..afc53d40 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/nuvio_loading_indicator.json @@ -0,0 +1 @@ +{"nm":"ios-spinner","ddd":0,"h":600,"w":600,"meta":{"g":"@lottiefiles/toolkit-js 0.33.2"},"layers":[{"ty":4,"nm":"1","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-180,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":0},{"s":[0],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"2","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-150,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[8],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":4},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":5},{"s":[8],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2},{"ty":4,"nm":"3","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-120,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[16],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":9},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":10},{"s":[16],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3},{"ty":4,"nm":"4","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-90,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":14},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":15},{"s":[24],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":4},{"ty":4,"nm":"5","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-60,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[32],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":19},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":20},{"s":[32],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":5},{"ty":4,"nm":"6","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-30,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[40],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":24},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":25},{"s":[40],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":6},{"ty":4,"nm":"7","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[48],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":29},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":30},{"s":[48],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":7},{"ty":4,"nm":"8","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-330,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[56],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":34},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":35},{"s":[56],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":8},{"ty":4,"nm":"9","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-300,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[64],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":39},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":40},{"s":[64],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":9},{"ty":4,"nm":"10","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-270,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[72],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":44},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":45},{"s":[72],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":10},{"ty":4,"nm":"11","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-240,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[80],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":49},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":50},{"s":[80],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":11},{"ty":4,"nm":"12","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[300,300,0],"ix":2},"r":{"a":0,"k":-210,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[88],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":54},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":55},{"s":[88],"t":60}],"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13,22],[13,124]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"c":{"a":0,"k":[0,0,0],"ix":3}},{"ty":"tr","a":{"a":0,"k":[11,-82],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":12}],"v":"5.7.5","fr":60,"op":61,"ip":0,"assets":[]} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-bg/strings.xml b/composeApp/src/commonMain/composeResources/values-bg/strings.xml new file mode 100644 index 00000000..b14cd5ff --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-bg/strings.xml @@ -0,0 +1,1899 @@ + + + Източници на данни, благодарности и лицензи на платформата + Открито признание и кредити на проекта + Назад + Отказ + Затвори + Изтрий + Готово + Редактирай + Импортирай + Напред + OK + Пусни + Назад + Премахни + Пренареди + Върни по подразбиране + Продължи + Опитай отново + Запази + Запазване… + Провери + Инсталиране + Добавки + Активна + %1$d каталога + Конфигурируема + Деактивирана + Обновяване + %1$d ресурса + Недостъпна + Настрой добавката + Изтрий добавката + Добавете URL на манифест, за да започнете да зареждате каталози, метаданни, потоци или субтитри в Nuvio. + Все още няма инсталирани добавки. + Въведете URL на добавка. + URL на добавка + Инсталирай добавка + Зареждане на детайли на манифеста... + Проверка на URL на манифеста и зареждане на детайли преди инсталация. + Проверка на добавката + Инсталацията не бе успешна + %1$s бе проверена и добавена успешно. + Добавката е инсталирана + Премести добавката надолу + Премести добавката нагоре + Активни + Добавки + Каталози + Обнови добавката + Добави добавка + Инсталирани добавки + Преглед + %1$d правила за идентификатор + Версия %1$s + Избрано + Копирай JSON + %1$d колекции, %2$d папки + Изтрий „%1$s"? Това не може да бъде отменено. + Изтрий колекцията + Добави каталог + Добави папка + Всички жанрове + Добавете каталози от инсталираните добавки, за да определите какво показва тази папка. + Все още няма източници на каталози + Избери + Емоджи + URL на изображение + Няма + Корица + Създай колекция + Готово + Редактирай колекцията + Редактирай папката + Задайте самоличността, представянето и изворите на каталога на папката, използвайки същата структура като основния редактор на колекции. + Добавете едно, за да започнете. + Все още няма папки + Папки + Филтър по жанр + Показвай само изображението на корицата + Скрий заглавието + Нова папка + Покажи тази колекция над всички обикновени начални каталози. Множеството закачени колекции следват реда на тяхното създаване. + Закачи над каталозите + URL на фоново изображение (незадължително) + Име на папка + URL на анимиран GIF (възпроизвежда се само при фокус) + Име на колекцията + Запази промените + Запази + Външен вид + Основни + Източници на каталога + Изберете каталозите на добавките, които тази папка трябва да обединява. + Избери каталози + Избери жанр + %1$d избрани + %1$d каталога + %1$d избрани + Постер + Квадрат + Широк + Обедини всички каталози в един раздел + Покажи раздел „Всички" + Пусни конфигурирания GIF вместо статичната корица, когато е наличен. + Покажи GIF при конфигуриране + %1$d извор(а) · %2$s + Форма на плочката + Редове + Раздели + Режим на изглед + TMDB източници + Публичен списък + Продукция + Мрежа + Колекция + Личност + Режисьор + Персонализиран + Изберете готов извор. Можете да го редактирате или премахнете след добавяне. + Поставете публичен TMDB URL на списък или само числото от URL. + Търсете по име на студио или поставете TMDB ID/URL на компания и я добавете директно. + Въведете ID на мрежа. Общите мрежи са налични в предварителни настройки и бързи филтри. + Потърсете по ime на колекция от филми или поставете ID на колекция от TMDB. + Въведете TMDB ID или URL на личност, за да създадете ред от участия в актьорски състав. + Въведете TMDB ID или URL на личност, за да създадете ред от режисьорски участия. + Създайте динамичен TMDB ред с незадължителни филтри. Оставете полетата празни, когато не се нуждаете от съответния филтър. + Публичен TMDB списък + ID на мрежа + ID на колекция + ID на личност + Ime, ID или URL на продукционна компания + TMDB ID или URL + https://www.themoviedb.org/list/8504994 или 8504994 + 213 за Netflix, 49 за HBO, 2739 за Disney+ + 10 за Колекцията „Междузвездни войни" + Marvel Studios, 420 или URL на компания + 31 за Tom Hanks или URL на личност + Примери: Marvel Studios, 420 или https://www.themoviedb.org/company/420. + Пример: Колекцията „Междузвездни войни", Колекцията „Хари Потър" или URL на колекция. + Примерни ID-та: Netflix 213, HBO 49, Disney+ 2739. + Пример: https://www.themoviedb.org/list/8504994 или 8504994. + Пример: https://www.themoviedb.org/person/31-tom-hanks или 31. + Показвано заглавие + Показва се като ime на ред/раздел. Ако е празно, Nuvio го генерира от извора. + Филми на Marvel, Netflix Originals, Pixar + Филми с Tom Hanks, Любими актьори + Филми на Christopher Nolan, Любими режисьори + Най-добри екшъни, Корейски драми, Анимации от 2024 + Резултати от търсенето + TMDB колекция + TMDB компания %1$d + TMDB колекция %1$d + Тип + Филми + Сериали + И двете + Сортиране + Филтри + Оставете полетата празни, когато не се нуждаете от съответния филтър. + Бързи жанрове + Бързи езици + Бързи държави + Бързи ключови думи + Бързи студия + Бързи мрежи + ID на жанрове + Използвайте TMDB номера на жанрове. Разделяйте с запетаи за И, или с вертикална черта за ИЛИ. + 28,12 + 18,35 + Дата на излизане или излъчване от + Дата на излизане или излъчване до + Използвайте ГГГГ-ММ-ДД, например 2024-01-01. + 2020-01-01 + 2024-12-31 + Минимална оценка + Максимална оценка + TMDB оценка от 0 до 10. Пример: 7.0. + 7.0 + 10 + Минимален брой гласове + Използвайте това, за да избегнете малко известни заглавия с малко гласове. Пример: 100. + 100 + Оригинален език + Използвайте двубуквени езикови кодове, например en, ko, ja, hi. + en, ko, ja, hi + Държава на произход + Използвайте двубуквени кодове на държави, например US, KR, JP, IN. + US, KR, JP, IN + ID на ключови думи + Използвайте TMDB номера на ключови думи. Бързите чипове попълват чести примери. + 9715 за супергерой + ID на компании + Използвайте ID на студия/компании. Бързите чипове попълват чести примери. + 420 за Marvel Studios + ID на мрежи + Само за сериали. Използвайте ID на мрежи като Netflix 213 или HBO 49. + 213 за Netflix + Година + Използвайте четирицифрена година, например 2024. + 2024 + Предварителни настройки + Търси + Добави извор + Добави Trakt списък + Редактирай Trakt списък + Trakt списъци + Trakt списък + Търси заглавие, Trakt URL или ID на списък + Използвайте публичен Trakt URL на списък или числов ID, или търсете по ime. + Гледане за уикенда, Носители на награди + Резултати от търсенето + Набиращи популярност списъци + Популярни списъци + Посока + Възходящо + Низходящо + Ред на списъка + Наскоро добавени + Заглавие + Дата на излизане + Продължителност + Популярни + Процент + Гласове + Въведете ime, URL или ID на Trakt списък + Въведете ID или URL на Trakt списък + Не може да се зареди Trakt списъкът + Не са намерени Trakt списъци + Разрешен Trakt списък + Trakt списък %1$d + Екшън + Приключения + Анимация + Комедия + Ужас + Научна фантастика + Драма + Криминален + Реалити + Английски + Корейски + Японски + Хинди + Испански + Съединени щати + Корея + Япония + Индия + Обединено кралство + Супергерой + По роман + Пътуване във времето + Космос + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Оригинален + Популярни + Най-високо оценени + Скорошни + Най-гласувани + Регион за гледане + Код на държава по ISO 3166-1, където заглавието е налично. Пример: US, GB. + Бързи региони за гледане + ID на доставчици за гледане + Използвайте TMDB ID на доставчици. Разделяйте с запетаи за И, или с вертикална черта за ИЛИ. + 8|337|350 + Бързи доставчици за гледане + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + TMDB списък + TMDB колекция от филми + Продукция + Мрежа + Личност + Режисьор + TMDB Discover + Създайте колекция, за да организирате каталозите си. + Все още няма колекции + %1$d папки + Не са намерени елементи + Папката не е намерена + Колекции + Импортирай колекции + JSON + Поставете JSON на колекциите по-долу. + Импортирай + Нова колекция + Закачени + Всички + Вашите колекции + Направено с ❤️ от Tapframe и приятели + Версия %1$s (%2$s) + Изкл. + Вкл. + Пауза + Презареди + Вече имате акаунт? + Продължи без акаунт + Създай акаунт + Нямате акаунт? + Имейл + или + Парола + Влезте, за да получите достъп до библиотеката и напредъка си + Влез + Регистрирайте се, за да синхронизирате данните си между устройствата + Регистрирай се + Данните ви ще се съхраняват само локално + потоквай всичко, навсякъде + Добре дошли отново + Библиотека + Trakt библиотека + Начало + Библиотека + Профил + Търси + Аудио записи + Аудио + Автоматична синхронизация + Удебелен + Вграден + Отместване в долната част + Запиши + Затвори плейъра + Цвят + В момента се възпроизвежда + Е%1$d + С%1$dЕ%2$d + С%1$dЕ%2$d • %3$s + Епизоди + Размер на шрифта + %1$dsp + Заключи контролите на плейъра + Зареждане на редове субтитри... + Няма налични аудио записи + Няма налични епизоди + Не са намерени потоци + Нищо + Контур + Цвят на контура + Епизоди + Източници + Потоци + Грешка при възпроизвеждане + Възпроизвеждане + Докоснете, за да заредите субтитри + Назад + Презареди + Нулиране + Нулирай по подразбиране + Запълни + Побери + Мащабирай + Превъртане назад с 10 секунди + -%1$dс + +%1$dс + -%1$dс + +%1$dс + Превъртане напред с 10 секунди + Източници + Стил + Първо изберете субтитри от добавка + Субт. + Закъснение на субтитрите + Субтитри + Непрозрачност на текста + Яркост %1$s + Сила на звука %1$s + Заглушен + Изтеглен + Излъчва се + ПО + Докоснете, за да отключите + Запис %1$d + Отключи контролите на плейъра + Гледате + Добави профил + Изчисти търсенето + Открий + Инсталираните добавки не успяха да върнат валидни резултати от търсенето. + Търсенето не бе успешно + Инсталирайте и проверете поне една добавка преди да търсите. + Няма активни добавки + Инсталираните търсими каталози не върнаха съвпадения за тази заявка. + Не са намерени резултати + Инсталираните добавки не предоставят търсене в каталог. + Няма търсими каталози + Търси филми, сериали... + Скорошни търсения + Премахни скорошното търсене + Относно + Общи + Акаунт + Добавки + Разширени + Оформление + Съдържание и Discovery + Продължи гледането + Свързани услуги + Начален екран + Интеграции + Лицензи и авторство + MDBList оценки + Страница с детайли + Известия + Възпроизвеждане + Плъгини + Стил на картичката с постер + Настройки + Потоци + Поддръжници и сътрудници + TMDB обогатяване + Trakt + ОТНОСНО + Статус на акаунт и синхронизация + АКАУНТ + Поведение при стартиране и профил. + РАЗШИРЕНИ + Начална структура и стилове на постери + Изтегли последна версия + Провери за актуализации + Управлявай добавки и източници за discovery. + Управлявайте добавките и изберете какво да се показва в Nuvio. + Управлявай изтеглените филми и епизоди. + Управлявайте запазеното на това устройство. + Изтегляния + ОБЩИ + Управлявай наличните интеграции + Управлявай известия за излизане на епизоди и изпрати тестово известие. + Показване на резултатите от потоци и правила за URL на значки. + Смени към друг профил. + Смени профила + Отвори екрана за връзка с Trakt + Не са намерени настройки. + Търси настройки... + РЕЗУЛТАТИ + СТАРТИРАНЕ + КЕШ + Запомни последния профил + Запомни последно избрания профил при стартиране + Изчисти кеша на „Продължи гледането" + Премахни кешираните миниатюри, заглавия и данни за обогатяване за „Продължи гледането" + Кешът е изчистен + ЛИЦЕНЗ НА ПРИЛОЖЕНИЕТО + ДАННИ И УСЛУГИ + ЛИЦЕНЗ ЗА ВЪЗПРОИЗВЕЖДАНЕ + Nuvio Mobile + Изходният код и условията на лиценза са достъпни в хранилището на проекта. + Лицензиран под GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio използва TMDB API за метаданни на филми и телевизия, изображения, трейлъри, актьорски състав, продукционни детайли, колекции и препоръки. Този продукт използва TMDB API, но не е одобрен или сертифициран от TMDB. + IMDb нетърговски набори от данни + Nuvio използва IMDb нетърговски набори от данни, включително title.ratings.tsv.gz, за IMDb оценки и брой гласове. Информацията е предоставена от IMDb (https://www.imdb.com). Използва се с разрешение. IMDb данните са за лично и нетърговско ползване съгласно условията на IMDb. + Trakt + Nuvio се свързва с Trakt за удостоверяване на акаунт, история на гледанете, синхронизиране на напредъка, данни от библиотеката, оценки, списъци и коментари. Nuvio не е свързан с или одобрен от Trakt. + Premiumize + Nuvio се свързва с Premiumize за удостоверяване на акаунт, достъп до облачна библиотека, проверки на кеша и функции за облачно възпроизвеждане. Nuvio не е свързан с или одобрен от Premiumize. + TorBox + Nuvio се свързва с TorBox за удостоверяване на акаунт, достъп до облачна библиотека, проверки на кеша и функции за облачно възпроизвеждане. Nuvio не е свързан с или одобрен от TorBox. + MDBList + Nuvio използва MDBList за оценки и данни от доставчици на външни резултати. Nuvio не е свързан с или одобрен от MDBList. + IntroDB + Nuvio използва IntroDB API за предоставени от общността времеви маркери за въведения, резюмета, надписи и прегледи, използвани от контролите за пропускане. Nuvio не е свързан с или одобрен от IntroDB. + MPVKit + Използва се за възпроизвеждане на iOS версии. + Самият изходен код на MPVKit е лицензиран под LGPL v3.0. Пакетите MPVKit, включително библиотеките libmpv и FFmpeg, също са лицензирани под LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Използва се за възпроизвеждане на Android версии. + Лицензиран под Apache License, Version 2.0. + Зареждане на вашите Trakt списъци… + Изберете къде да запазите това заглавие в Trakt + Дарете + Отиди към детайлите + Премахни + Започни от началото + Пусни + %1$d/10 + Рецензия + Спойлер + Все още няма налични Trakt рецензии. + %1$d харесвания + Този коментар съдържа спойлери. + Този коментар съдържа спойлери и е скрит. + Коментари + Трейлър + %1$s (%2$d) + Трейлъри + Няма завършени епизоди + Все още няма изтегляния + %1$d изтеглен(и) епизод(а) + Активни + Филми + Сериали + Покажи изтеглянията + Завършено • %1$s + Изтегляне • %1$s + Неуспешно + Паузирано • %1$s + Гледано + Сезон %1$d + Специални + Продължи от там, където спря + Добави към библиотеката + Маркирай като негледано + Маркирай като гледано + Премахни от библиотеката + Виж всички + Пусни ръчно + Лого на %1$s + Акаунт + Изтрий акаунта + Това ще изтрие трайно акаунта ви и всички свързани данни. + Това действие не може да бъде отменено. Всички ваши данни, профили и история на синхронизацията ще бъдат трайно премахнати. + Изтриване на акаунта? + Имейл + Не сте влезли + Излез + Ще бъдете върнати към екрана за вход. + Изход? + Статус + Анонимен + Влезли сте + AMOLED черен + Използвай чисто черни фонове за OLED екрани. + Език на приложението + Избери език + Настройки за раздела „Продължи гледането". + Liquid Glass + Използвай родния iPhone таб бар на iOS 26 и по-нови версии. При активирана опция, незабавното превключване на профила от таб бара не е налично. + Настрой ширината на картичката и радиуса на ъглите. + ДИСПЛЕЙ + НАЧАЛО + ТЕМА + Колекция • %1$s + Показвано ime + Инсталирайте добавка с каталози, съвместими с дъската, за да конфигурирате редовете на началния екран. + Няма начални каталози + Основен извор + Скрит + Запази фокуса върху началото + %1$s • Достигнат лимит (макс. %2$d) + Не са избрани основни източници + Не в основната секция + Премахни закачването в горната част от колекцията, за да я преместите + Закачено + Закачено в горната част + Пренареди + КАТАЛОЗИ + КАТАЛОЗИ И КОЛЕКЦИИ + КОЛЕКЦИИ + Оформление на началото + Основни каталози + %1$d от %2$d избрани + Покажи основната секция + Покажи въртележка в горната част на началото. + Скрий невипуснатото съдържание + Скрий филми и сериали, които все още не са излезли. + Скрий подчертаването на каталога + Премахни акцентната линия под заглавията на каталозите и колекциите в цялото приложение. + %1$d от %2$d каталога видими • %3$d основни извора избрани + Отворете каталог само когато трябва да го преименувате или пренаредите. + Видим + Скрий стойността + Плейър, субтитри и автоматично възпроизвеждане + Радиус на ъглите + Стил на картичката с постер + Ширина + Персонализиран + Настрой ширината на картичката и радиуса на ъглите. + Скрий надписите + Хоризонтални постери + Преглед на живо + %1$s (%2$s) + Радиус на ъглите: %1$ddp + Височина: %1$ddp + Ширина: %1$ddp + Класически + Хапче + Заоблен + Остър + Фин + Балансиран + Комфортен + Компактен + Плътен + Голям + Стандартен + Покажи стойността + Показвай изскачащ прозорец за продължаване от там, където сте спрели, при отваряне на приложението след излизане от плейъра. + Подкана за продължаване при стартиране + Замъгли миниатюрите на следващите епизоди в „Продължи гледането", за да избегнете спойлери. + Замъгли негледаното в „Продължи гледането" + Включи предстоящи епизоди в „Продължи гледането" преди да са излъчени. + Покажи неизлъчени следващи епизоди + РЕД НА СОРТИРАНЕ + Ред на сортиране + По подразбиране + Сортирай всички елементи по актуалност + потокинг стил + Излезлите елементи първо, предстоящите в края + Стил на картичката с постер + ПРИ СТАРТИРАНЕ + ПОВЕДЕНИЕ НА „СЛЕДВАЩО" + ВИДИМОСТ + Покажи полицата „Продължи гледането" на началния екран. + Покажи „Продължи гледането" + Картичка + Хоризонтална картичка в стил телевизор + Постер + Картичка с постер с приоритет на изображението + Широк + Хоризонтална картичка с много информация + Покажи следващия епизод въз основа на най-далеч гледания. Деактивирайте при повторно гледане, за да използвате вместо това последно гледания епизод. + Следващо от най-далечния епизод + Предпочитай миниатюри на епизоди, когато са налични. + Предпочитай миниатюри на епизоди в „Продължи гледането" + НАЧАЛО + ИЗТОЧНИЦИ + Инсталирай, премахвай, обновявай и сортирай вашите източници на съдържание. + Инсталирай JavaScript scraper хранилища и тествай доставчиците вътрешно. + Настрой оформлението на началото, видимостта на съдържанието и поведението на постерите + Настройки за екраните с детайли и епизоди. + Създавай персонализирани групи от каталози с папки, показвани на началото. + Интеграции + Контроли за обогатяване на метаданни + Доставчици на външни оценки + Свързвай акаунти за връзки и достъп до библиотека + Свързани услуги + Тези интеграции са експериментални и могат да бъдат запазени, променени или премахнати по-късно. + Облачна библиотека + Разглеждай и пускай файлове вече в свързаните акаунти. + Разреши пускащи се връзки + Поискай от свързана услуга пускащи се връзки, когато резултатът се нуждае от тях. Това може да добави елемента към тази услуга. + Разреши с + Избери кой свързан акаунт обработва пускащи се връзки. + Първо свържете акаунт. + Акаунти + Свържете вашия акаунт в %1$s. + Свържете вашия акаунт в %1$s чрез браузъра. + %1$s API ключ + Въведете вашия API ключ за %1$s. + Въведете API ключ за %1$s + Не е зададен + Свързан + Свържи %1$s + Прекъсни %1$s + Прекъсни + %1$s е свързан на това устройство. + Стартиране на сигурно влизане... + Отворете връзката и въведете този код, за да одобрите Nuvio. + Кодът е копиран. + Отвори връзката + Изчаква одобрение... + Не можа да се стартира влизането. + Този метод за влизане не е конфигуриран в тази версия. + Кодът изтече. Опитайте отново. + Подготовка на връзки + Подготви връзки + Разреши пускащи се връзки преди да започне възпроизвеждането. + Връзки за подготовка + Използвайте по-малък брой, когато е възможно. Свързаните услуги може да ограничат броя на връзките, които могат да бъдат разрешени за период от време. Отварянето на филм или епизод може да се брои за тези ограничения, дори ако не натиснете Гледай, тъй като връзките се подготвят предварително. + 1 връзка + %1$d връзки + Форматиране + Шаблон за ime + Контролира как се показват имената на резултатите. Оставете празно, за да използвате оригиналното ime на резултата. + Шаблон за описание + Контролира метаданните, показани под всеки резултат. Оставете празно, за да използвате оригиналните детайли на резултата. + Нулирай форматирането + Възстанови форматирането на резултатите по подразбиране. + Fusion стил + Значки за размер + Покажи значки за размер на файла в резултатите от потоци и панелите с източници на плейъра. + Лого на добавка + Покажи логото и името на добавката до изворите на потоци. + Показване + Позиция на значката + Изберете дали Fusion и значките за размер да се показват над или под картичките с потоци. + Позиция на значката + Изберете къде да се показват значките на потоците върху картичките с потоци. + Горе + Долу + Fusion URL на значки + Импортирайте до %1$d JSON URL-а на значки в стил Fusion. Всеки URL може да се актуализира или изтрива поотделно. + Управлявай импортирани JSON URL-а на значки в стил Fusion. + %1$d/%2$d URL-а, %3$d активни Fusion значки + Няма импортирани Fusion URL-а на значки. + Fusion JSON URL на значка + %1$d/%2$d Fusion URL-а импортирани + Активен + Неактивен + %1$s, %2$d активирани значки, %3$d групи + Преглед + Преглед на Fusion значки + %1$d значки в стил Fusion от този URL + Няма изображения на значки в стил Fusion в този URL. + Група %1$d + Други Fusion значки + API ключът е проверен. + Не можа да се провери този API ключ. + Добавете вашия MDBList API ключ по-долу, преди да включите оценките. + Необходим за извличане на оценки от MDBList + API ключ + API ключ + Активирай MDBList оценки + Извличай оценки от външни доставчици в екрана с детайли на метаданните + API ключ + Доставчици на външни оценки + MDBList оценки + Действия + Контроли за пускане и запазване. + Актьорски състав + Списък на основния актьорски състав. + Кинематографичен фон + Замъглен фон зад съдържанието, подобно на екрана с потоци. + Колекция + Ред на свързана колекция или франчайз. + Коментари + Рецензии от Trakt + Детайли + Продължителност, статус, дата на излизане, език и свързана информация. + Възпроизвеждане на трейлър в основната секция + Пускай трейлъри в основната секция на метаданните, когато е наличен трейлър. + Картички на епизоди + Изберете как да се изобразяват епизодите на екрана с метаданни. + Хоризонтален + Редови картички в стил фон + Списък + Наредени картички с приоритет на детайлите + Епизоди + Сезони и списък с епизоди за сериали. + Замъгли негледаните епизоди + Замъгли миниатюрите на епизодите, докато не са гледани, за да избегнете спойлери. + Група %1$d + Подобни + TMDB препоръчани фонове на страницата с детайли + Нищо + Преглед + Синопсис, оценки, жанрове и основни кредити. + Продукция + Студия и мрежи. + ВЪНШЕН ВИД + СЕКЦИИ + Група раздели %1$d + Оформление на разделите + Групирай секции в раздели като телевизионното приложение. Задайте до 3 секции на група раздели. + Трейлъри + Ред с трейлъри и преки пътища за възпроизвеждане. + Известията в момента са деактивирани в Nuvio. + Известия за излизане на епизоди + Планирай локални известия, когато е наличен нов епизод от запазен сериал. + Системните известия за Nuvio са деактивирани. Активирайте ги, за да получавате известия и тестови уведомления. + %1$d известия за излизане са планирани в момента на това устройство. + ИЗВЕСТИЯ + ТЕСТ + Изпрати тестово известие + Изпращане на тестово известие... + Изпрати локално тестово известие за %1$s. + Запазете сериал в библиотеката си, преди да тествате известията. + Тестово известие + Общност + Вижте хората, изграждащи и поддържащи Nuvio за Mobile, TV и Web. + API за поддръжници не е конфигуриран. Добавете DONATIONS_BASE_URL към local.properties. + Сътрудници + Поддръжници + Отвори GitHub + GitHub профилът не е наличен + Няма прикачено съобщение. + Зареждане на сътрудници... + Зареждане на поддръжници... + Не можаха да се заредят сътрудниците + Не можаха да се заредят поддръжниците + Не са намерени сътрудници. + Не са намерени поддръжници. + Не може да се зареди списъкът с сътрудници. + Не може да се зареди списъкът с поддръжници. + Не можаха да се заредят сътрудниците в момента. + Не можаха да се заредят поддръжниците в момента. + %1$d общо комита + Ян + Фев + Мар + Апр + Май + Юни + Юли + Авг + Сеп + Окт + Ное + Дек + %1$s %2$s, %3$s + Всички инсталирани добавки + Всички активирани плъгини + Разрешени добавки + Разрешени плъгини + Anime Skip + AnimeSkip клиентско ID + Въведете вашето AnimeSkip API клиентско ID. Вземете го от anime-skip.com. + Активирай изпращане на въведения + Показвай бутон за изпращане на времеви маркери за въведения/финали към общностната база данни. + IntroDB API ключ + Въведете вашия IntroDB API ключ, за да изпращате времеви маркери. Необходим за изпращане. + Търси също в AnimeSkip за времеви маркери за пропускане (изисква клиентско ID). + Автоматично пускане на следващия епизод + Стартирай следващия епизод автоматично, когато се появи подкана. + Само декодери на устройството + Предпочитай декодери на приложението (FFmpeg) + Предпочитай декодери на устройството + Приоритет на декодера + Докоснете извън, за да затворите + Докоснете извън, за да запазите и затворите + %1$d ден + %1$d дни + %1$d час + %1$d часа + Използвай libass за ASS/SSA субтитри + Експериментално: разширено ASS/SSA изобразяване (стилове, позициониране, анимации) + Външен плейър + Приложение за външен плейър + Отвори ново възпроизвеждане с приложението за видео по подразбиране на Android или системния избор. + Отвори ново възпроизвеждане с избрания инсталиран плейър. + Препрати субтитрите към външния плейър + Извличай субтитрите на добавките на предпочитания от вас език и ги предавай към външния плейър. + Няма инсталирани поддържани външни плейъри + Плейър + Вграден + Външен + Изберете кой плейър да обработва новото възпроизвеждане. + Скорост при задържане + Задръж за скорост + Задръжте натиснато навсякъде върху плейъра, за да увеличите временно скоростта на възпроизвеждане. + Невалиден regex шаблон + Продължителност на кеша на последната връзка + DV7 - HEVC резервен вариант + Преобразувай Dolby Vision Profile 7 към стандартен HEVC за устройства без хардуерна поддръжка на DV + Минути преди края + Резервен вариант, когато няма времеви маркер за финал. + %1$s мин + Няма налични елементи + Не е зададено + По подразбиране (медиен файл) + Език на устройството + Принудителен + Нищо + Всички субтитри + Извличай и показвай всички субтитри на добавките за видеото. + Бързо стартиране + Пропусни автоматичното извличане на субтитри на добавки, докато не ги поискате в плейъра. + Стартиране на субтитри на добавки + Само предпочитани + Извличай субтитри на добавки, но показвай само съвпадения на предпочитания език. + Предпочитай Binge Group (следващ епизод) + Опитай първо същия профил на извора (същата добавка/група с качество) преди нормалните правила за автоматично пускане. + Използвай повторно Binge Group + Запомни и използвай повторно последната binge group между сесиите (Продължи гледането, Детайли и т.н.). + Предпочитан аудио език + Предпочитан език + Предварителни настройки + Съвпада с ime/заглавие/описание/добавка/url на поток. Пример: 4K|2160p|Remux + Regex шаблон + Няма зададен шаблон. Пример: 4K|2160p|Remux + Всяко 1080p+ + AVC / x264 + BluRay качество + Dolby Atmos / DTS + Английски + HDR / Dolby Vision + HEVC / x265 + Без CAM/TS + Без REMUX/HDR + 1080p стандартно + 4K / Remux + 720p / По-малко + WEB източници + Режим на изобразяване Libass + Стандартни сигнали + Canvas с ефекти + OpenGL с ефекти + Canvas наслагване + OpenGL наслагване (препоръчан) + Използвай повторно последната връзка + Автоматично пускане на последния работещ поток за същия филм/епизод, когато кешът е все още валиден + Вторичен аудио език + Вторичен предпочитан език + ДЕКОДЕР + СЛЕДВАЩ ЕПИЗОД + ПЛЕЙЪР + ПРОПУСКАНЕ НА СЕГМЕНТИ + АВТОМАТИЧНО ПУСКАНЕ НА ПОТОК + ИЗБОР НА ПОТОК + СУБТИТРИ И АУДИО + ИЗОБРАЗЯВАНЕ НА СУБТИТРИ + %1$d избрани + Наслагване при зареждане + Показвай екран за зареждане до появата на първия видео кадър. + Цвят на фона + Удебелен + Използвай по-тежка дебелина на шрифта за субтитри. + Прозрачен + Контур + Цвят на контура + Нарисувай рамка около текста на субтитрите. + Покажи само предпочитани езици + Показвай само субтитри, съответстващи на предпочитаните от вас езици за субтитри. + Размер на субтитрите + Цвят на текста + Използвай принудителни субтитри + Предпочитай принудителни субтитри при съответствие с настройките ви за субтитри. + Вертикално отместване + Пропусни въведението + Използвай introdb.app за засичане на въведения и резюмета. + Обхват на автоматичното пускане + Всички инсталирани добавки + Автоматичното пускане разглежда само потоци от вашите инсталирани добавки. + Всички източници + Автоматичното пускане може да използва и инсталирани добавки, и активирани плъгини. + Само активирани плъгини + Автоматичното пускане разглежда само потоци от активирани плъгини. + Само инсталирани добавки + Автоматичното пускане разглежда само потоци от вашите инсталирани добавки. + Автоматичен избор на поток + Автоматично пускане на първия извор + Пускай автоматично първия наличен извор. + Ръчен (избери поток) + Винаги показвай списъка с източници и ми позволи да избера. + Автоматично пускане по regex съвпадение + Пускай първия извор, чийто текст съответства на вашия regex шаблон. + Изчакване за избор на поток + Времe за изчакване на добавки преди избор. + Прагови минути + Режим на праг за следващ епизод + Минути преди края + Процент + Прагов процент + Резервен вариант, когато няма времеви маркер за финал. + %1$s% + Незабавно + %1$sс + Неограничено + Тунелирано възпроизвеждане + Синхронизация на аудио/видео на хардуерно ниво. Може да подобри възпроизвеждането на някои Android TV устройства + Добавете свой собствен TMDB API ключ по-долу, преди да активирате обогатяването. + API ключ + Активирай TMDB обогатяване + Използвай TMDB като извор на метаданни за подобряване на данните от добавките + Въведете вашия TMDB v3 API ключ. + Езиков код + Изображения + Лого и фонови изображения от TMDB + Основна информация + Описание, жанрове и оценка от TMDB + Колекции + TMDB колекции от филми по ред на излизане + Кредити + Актьорски състав със снимки, режисьор и сценарист от TMDB + Детайли + Продължителност, статус, страна и език от TMDB + Епизоди + Заглавия, описания, миниатюри и продължителност на епизоди от TMDB + Подобни + TMDB препоръчани фонове на страницата с детайли + Мрежи + Мрежи с лога от TMDB + Продукции + Продукционни компании от TMDB + Постери на сезони + Използвай TMDB постери на сезони в селектора за сезони на екрана с метаданни за сериали. + Трейлъри + Кандидати за трейлъри от TMDB видеа за раздела с трейлъри в детайлите + Личен API ключ + Език + Език на TMDB метаданни за заглавие, лого и активирани полета + ИДЕНТИФИКАЦИОННИ ДАННИ + ЛОКАЛИЗАЦИЯ + МОДУЛИ + TMDB обогатяване + След одобрение, ще бъдете пренасочени автоматично. + УДОСТОВЕРЯВАНЕ + Коментари + Показвай Trakt рецензии на страниците с метаданни + Свържи Trakt + Свързан като %1$s + Trakt потребител + Прекъсни + Браузърът не можа да се отвори + ФУНКЦИИ + Завърши влизането в Trakt в браузъра си + Синхронизирай своя списък за гледане, напредък, продължи гледането, scrobbles и лични списъци с Trakt. + Липсват Trakt идентификационни данни в local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Отвори Trakt вход + Вашите действия за запазване вече могат да насочват към Trakt списък за гледане и лични списъци. + Влезте с Trakt, за да активирате запазване базирано на списъци и режим на Trakt библиотека. + Извор на библиотеката + Изберете коя библиотека да използвате за запазване и преглед на вашата колекция + Извор на библиотеката + Изберете къде да запазвате и управлявате вашите елементи от библиотеката + Trakt + Nuvio библиотека + Избрана е Trakt библиотека + Избрана е Nuvio библиотека + Напредък при гледане + Изберете кой извор на напредък захранва продължаването и „Продължи гледането" + Напредък при гледане + Изберете дали продължаването и „Продължи гледането" да използват Trakt или Nuvio Sync, докато Trakt scrobbling остава активен. + Trakt + Nuvio Sync + Изворът на напредък при гледане е зададен на Trakt + Изворът на напредък при гледане е зададен на Nuvio Sync + Извор за „Подобни" + Изберете откъде идват препоръките на страниците с детайли + Извор за „Подобни" + Изберете извора за препоръките, показвани на страниците с детайли. + Trakt + TMDB + Прозорец на „Продължи гледането" + Trakt история, взета предвид за „Продължи гледането" + Прозорец на „Продължи гледането" + Изберете каква активност в Trakt да се показва в „Продължи гледането". + Цялата история + %1$d дни + Зрителска оценка + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Неизвестен + Кехлибарен + Тъмночервен + Смарагден + Океан + Розов + Виолетов + Бял + Следващ епизод + Търсене на извор… + Пускане чрез %1$s след %2$d… + Миниатюра на следващ епизод + Неизлъчен + Пропусни + Пропусни въведението + Пропусни финала + Пропусни резюмето + Не са намерени субтитри + Африкаанс + Албански + Амхарски + Арабски + Арменски + Азербайджански + Баски + Беларуски + Бенгалски + Босненски + Български + Бирмански + Каталонски + Китайски + Китайски (опростен) + Китайски (традиционен) + Хърватски + Чешки + Датски + Нидерландски + Английски + Естонски + Филипински + Финландски + Френски + Галисийски + Грузински + Немски + Гръцки + Гуджарати + Иврит + Хинди + Унгарски + Исландски + Индонезийски + Ирландски + Италиански + Японски + Канада + Казахски + Кхмерски + Корейски + Лаоски + Латвийски + Литовски + Македонски + Малайски + Малаялам + Малтийски + Маратхи + Монголски + Непалски + Норвежки + Персийски + Полски + Португалски (Португалия) + Португалски (Бразилия) + Пенджабски + Румънски + Руски + Сръбски + Синхала + Словашки + Словенски + Испански + Испански (Латинска Америка) + Суахили + Шведски + Тамилски + Телугу + Тайски + Турски + Украински + Урду + Узбекски + Виетнамски + Уелски + Зулу + Изчисти + Продължи + Игнорирай + Инсталирай + По-късно + Не + Актуализирай + Да + Искате ли да излезете от приложението? + Изход от приложението + Този каталог не върна никакви елементи. + Не са намерени заглавия + Проверете вашата Wi-Fi или мобилна интернет връзка и опитайте отново. + Режисьор + Неуспешно зареждане + Подобни + Захранвано от TMDB + Захранвано от Trakt + Сезони + Тази добавка върна видеа за сериала, но нито едно не включваше номера на сезон или епизод. + Тази добавка не предостави метаданни за епизоди за този сериал. + Епизодите все още не са публикувани от тази добавка. + Вашето устройство е онлайн, но Nuvio не можа да достигне необходимите сървъри. + Покажи по-малко + Покажи повече ▾ + Сценарист + Всички жанрове + Каталог + %1$s • %2$s + Избраният каталог не успя да върне елементи за discovery. + Discovery не можа да се зареди + Инсталираните добавки не предоставят каталози, съвместими с дъската за discovery. + Няма каталози за discovery + Избраният каталог и филтри не върнаха никакви елементи. + Не са намерени заглавия + Инсталирайте и проверете поне една добавка, преди да разглеждате каталозите за discovery. + Избери каталог + Избери жанр + Избери тип + Тип + Маркирай предишните като негледани + Маркирай предишните като гледани + Маркирай %1$s като негледан + Маркирай %1$s като гледан + Маркирай предишните сезони като гледани + Маркирай като негледано + Маркирай като гледано + Следващо + %1$s гледано + Остават %1$dч %2$dм + Остават %1$dм + Инсталирайте и проверете поне една добавка, преди да зареждате редовете на каталога на началото. + Инсталираните добавки в момента не предоставят каталози, съвместими с дъската без необходими допълнения. + Няма налични начални редове + Виж детайлите + Контроли за пускане и запазване. + Действия + Списък на основния актьорски състав. + Ред на свързана колекция или франчайз. + Колекция + Секция с Trakt коментари. + Продължителност, статус, дата на излизане, език и свързана информация. + Детайли + Сезони и списък с епизоди за сериали. + Ред с препоръки. + Подобни + Синопсис, оценки, жанрове и основни кредити. + Преглед + Студия и мрежи. + Продукция + Ред с трейлъри и преки пътища за възпроизвеждане. + Отново онлайн + Сървърите не могат да бъдат достигнати + Празно тяло на отговора + Заявката не бе успешна с HTTP %1$d + Няма интернет връзка + (на възраст %1$d) + Роден %1$s%2$s + Починал %1$s + Известен с: %1$s + Последни + Не можаха да се заредят детайлите за %1$s + Популярни + Нещо се обърка + Предстоящи + Изтрий + Отказ + Въведете PIN + Въведете PIN за %1$s + Забравен PIN? + Грешен PIN + Заключен. Опитайте отново след %1$dс + Опциите за аватар ще се появят тук, когато каталогът се зареди. + Аватар: %1$s + Въведете валиден http:// или https:// URL на изображение. + Изберете аватар + Изберете аватар по-долу. + Създай профил + Избран е персонализиран URL на аватар. + Персонализиран URL на аватар + Поставете връзка към изображение или оставете празно, за да използвате вградения каталог с аватари. + https://example.com/avatar.png + Всички данни за „%1$s" ще бъдат трайно изтрити. + Изтрий профила + Добави профил + Редактирай профила + Въведете текущия PIN + Въведете нов PIN + Профил %1$d + Зареждане на аватари... + Управлявай профилите + Ime на профила + Нов профил + Основните добавки са изключени + Основните добавки са включени + Премахни PIN за %1$s + Премахни PIN заключване + Запазване... + Сигурност + Добавете PIN, ако искате този профил да е заключен преди превключване към него. + Този профил е защитен с PIN. + Изберете аватар за този профил. + Задай PIN заключване + Неименуван профил + Използвай основни добавки + Споделяй настройките на добавките от основния профил, вместо да управляваш отделен списък. + Кой гледа? + Изтеглено + Продължи + Активни скрепъри + Проверяване на още добавки… + Копирай връзката на потока + Изтегли файла + Отвори във външен плейър + Отвори в вграден плейър + Инсталираните добавки за потоци не успяха да върнат валиден отговор. + Потоците не можаха да се заредят + Инсталирайте добавка, за да заредите потоци за това заглавие. + Вашите инсталирани добавки не предоставят потоци за този тип заглавие. + Няма налична добавка за потоци + Нито една от вашите инсталирани добавки не върна потоци за това заглавие. + С%1$d Е%2$d + Епизод + С%1$dЕ%2$d - %3$s + Извличане… + Търсене на извор… + Зареждане на субтитри от добавки… + Търсене на потоци… + Връзката на потока е копирана + Няма директна връзка към поток + Няма налични метаданни + Обнови потоците + Продължи от %1$d% + Продължи от %1$s + РАЗМЕР %1$s + Този тип поток не се поддържа + Свържете акаунт в Настройки. + Не е кеширано в Torbox. + Тази връзка изтече. Обновяване на резултатите. + Тази връзка не можа да бъде отворена. + Не можа да се отвори външен плейър + Първо изберете外ен плейър в настройките + Няма наличен外ен плейър + Затвори трейлъра + Трейлърът не можа да се пусне + Trakt списъците не можаха да се заредят + Trakt списъците не можаха да се актуализират + %1$s • %2$s + Проверката за актуализации не бе успешна + Изтеглянето не бе успешно + Празно тяло на изтегленото + Изтегленият файл с актуализация липсва. + Изтегляне %1$d% + Инсталацията не можа да стартира + Използвате последната версия. + Активирайте инсталации на приложения за Nuvio, след това се върнете и продължете. + Изтегляне на актуализацията... + Не са намерени актуализации. + Нова версия е готова за инсталиране. + Актуализациите в приложението не са налични за тази версия. + Подготовка на изтеглянето + Бележки за версията + Разрешете инсталациите да продължат + Налична актуализация + Статус на актуализацията + Тази добавка вече е инсталирана. + Въведете валиден URL на добавка + Манифестът не можа да се зареди + Nuvio + Изтриването на акаунта не бе успешно + Влизането не бе успешно + Излизането не бе успешно + Регистрацията не бе успешна + Елементите на каталога не можаха да се заредят. + Следващо + Следващо • С%1$dЕ%2$d + Лого на %1$s + Коментарите не можаха да се заредят + Детайлите не можаха да се заредят от нито една добавка. + Мрежи + Никоя добавка не предоставя метаданни за това съдържание. + Изтеглянето не бе успешно + Показва напредъка и контролите на изтеглянето на живо. + Изтегляния + Изтеглянето завърши + Изтегляне на %1$s • %2$s + Изтегляне на %1$s • %2$s / %3$s + Изтеглянето не бе успешно + Паузирано %1$s + Премахни + Премахни %1$s от %2$s? + Премахни %1$s от вашата библиотека? + Премахване от библиотеката? + Филм + Известия, когато излезе нов епизод от запазен сериал. + Преглед на известие за излизане на епизод. + Тестовото известие не можа да се изпрати. + Тестовото известие е изпратено за %1$s. + Потокът не може да се пусне. + MPV плейър двигателят не е наличен. Моля, прекомпилирайте приложението. + PIN-ът на този профил е сменен. Свържете се веднъж, за да опресните заключването на това устройство. + PIN заключването не можа да бъде премахнато. Опитайте отново. + Свържете се с интернет, за да премахнете PIN заключването. + Този PIN не може да бъде проверен офлайн на това устройство все още. Свържете се веднъж и го отключете онлайн първо. + PIN не можа да бъде зададен. Опитайте отново. + Свържете се с интернет, за да зададете PIN. + Този профил използва основни добавки. + %1$s не можа да се зареди + Поток + Вграден + Разрешението е отказано + Завършете влизането в Trakt в браузъра си + Свързан с Trakt + Прекъснат от Trakt + Невалиден Trakt callback + Невалидно Trakt callback състояние + Невалиден Trakt отговор с токен + Trakt библиотеката не можа да се зареди + Списък %1$d + Trakt не върна код за оторизация + Липсват Trakt идентификационни данни + Trakt напредъкът не можа да се зареди + Публичен Trakt списък + Въведете валиден Trakt ID или URL на списък + %1$d елемента + %1$d харесвания + Липсват Trakt идентификационни данни в local.properties (TRAKT_CLIENT_ID). + Липсва Trakt ID на списък + Trakt списъкът не включва числов ID + Trakt списъкът не е намерен или не е публичен + Достигнат е лимитът на Trakt заявки + Trakt заявката не бе успешна + Влизането в Trakt не можа да завърши + Trakt потребител + Списък за гледане + Добавете TMDB API ключ в Настройки, за да използвате TMDB източници. + TMDB колекция %1$d + TMDB колекцията не е намерена + TMDB продукция %1$d + TMDB компанията не е намерена + TMDB режисьор %1$d + TMDB discover не върна данни + TMDB Discover + Въведете валиден TMDB ID или URL. + TMDB списък %1$d + TMDB списъкът не е намерен + TMDB изворът не можа да се зареди + Липсва TMDB ID на колекция + Липсва TMDB ID на списък + Липсва TMDB ID на личност + TMDB мрежа %1$d + TMDB мрежата не е намерена + TMDB кредитите на личността не са намерени + TMDB личност %1$d + TMDB личността не е намерена + Трейлър + Неизвестен + Добавка + Запазено + Пусни %1$s + Продължи %1$s + JSON е празен. + Колекция %1$d има празен ID. + Колекцията „%1$s" има празно заглавие. + Папка %1$d в „%2$s" има празен ID. + Папката „%1$s" в „%2$s" има празно заглавие. + Извор %1$d в папка „%2$s" има празни полета. + На извор %1$d в папка „%2$s" липсва Trakt ID на списък. + Невалиден JSON: %1$s + Добавката не е намерена: %1$s + Trakt списък с филми + Trakt списък с сериали + Януари + Февруари + Март + Април + Май + Юни + Юли + Август + Септември + Октомври + Ноември + Декември + Ян + Фев + Мар + Апр + Май + Юни + Юли + Авг + Сеп + Окт + Ное + Дек + Продукционна компания + Мрежа + %1$s не можа да се зареди + Популярни + Скорошни + %1$s • %2$s + Най-високо оценени + Сертификация + Детайли за филма + Оригинален език + Държава на произход + Информация за излизане + Продължителност + Постери + Текст + Детайли за сериала + Статус + Видеа + ФАЙЛ + Няма директна връзка към поток + Предишното изтегляне е заменено + Изтеглянето е стартирано + Неподдържан формат на поток за изтегляния + Празно тяло на отговора + Финализирането на файла за изтегляне не бе успешно + Заявката не бе успешна с HTTP %1$d + Системата за изтегляния не е инициализирана + Отварянето на частичния файл за изтегляне не бе успешно + Частичният файл за изтегляне не е отворен + Заявката за изтегляне не бе успешна + Записването на частичния файл за изтегляне не бе успешно + %1$s - %2$s + Запазените заглавия ще се появят тук, след като докоснете Запази на екран с детайли. + Вашата библиотека е празна + Библиотеката не можа да се зареди + Друго + Облак + Запазени + Библиотека + Свържете Trakt и запазвайте заглавия в своя списък за гледане или лични списъци. + Вашата Trakt библиотека е празна + Trakt библиотеката не можа да се зареди + Trakt библиотека + Свържи акаунт + Свържете акаунт в настройките на Свързани услуги, за да разглеждате файлове от вашата облачна библиотека. + Няма свързан облачен акаунт + Отвори Свързани услуги + Включете облачната библиотека в настройките на Свързани услуги, за да разглеждате файлове от свързани акаунти. + Облачната библиотека е изключена + Няма облачни файлове за пускане, съответстващи на текущите филтри. + Все още нищо тук + Изберете файл за пускане + Облачната библиотека на %1$s не можа да се зареди + Този елемент не предоставя файл за пускане. + Няма файлове за пускане + Няма файлове за пускане + Облачната библиотека е изключена. + Облачният файл не можа да се пусне. + Пусни файла + Облачната услуга не е свързана. + %1$s не е свързан. + %1$d файла за пускане + Всички + Обнови облачната библиотека + Избери доставчик + Избери тип + Готово за пускане + Всички + Торенти + Usenet + Уеб + Файлове + Аниме + Канали + Филми + Сериали + ТВ + %1$s вече е наличен + %1$s • %2$s вече е наличен + Нов епизод вече е наличен + %1$s вече е наличен + Излизания на епизоди + Алкохол/Наркотици + Страшно + Голота + Псувни + Леко + Умерено + Тежко + Насилие + Създател + Режисьор + Сценарист + Зрителска оценка + Не е намерен поток за трейлър за пускане. + Сезон %1$d - %2$s + Б + КБ + МБ + ГБ + Изпрати въведение + Видео настройки + Облачната библиотека не е налична за %1$s. + Видео + Нулирай настройките + Предварителна настройка на изхода + HDR пикова детекция + Оценявай пиковата яркост на HDR, когато метаданните са лоши или липсват. + Tone mapping + Deband + Намали цветовото разслояване при малка цена за производителност. + Интерполация на кадри + Плавно движение, когато mpv може да използва синхронизация с дисплея чисто. + Яркост + Контраст + Наситеност + Гама + Роден EDR + Най-добър за iPhone и iPad с HDR поддръжка. + SDR с tone mapping + По-предсказуеми бели и черни при SDR изход. + Съвместимост + Най-близо до старото поведение на iOS MPV. + Персонализиран + Използвай разширените стойности по-долу. + Изкл. + iOS видео изход + Хардуерен декодер + Разширен динамичен обхват + Режим на Metal изход по подразбиране за нови сесии на възпроизвеждане. + Подсказка за цвят на дисплея + Нека mpv насочва цветовото пространство на активния дисплей по подразбиране. + Целеви примари + Целево пренасяне + iOS аудио изход + Аудио изход + Опитай AVFoundation първо, след това се върни към AudioUnit. + Експериментална поддръжка за Spatial Audio и многоканален изход. + Използвай стария AudioUnit изход. + Управление на резултатите + Макс. резултати + Ограничи колко резултата се показват. + Сортирай резултатите + Изберете как да се наредят резултатите. + Лимит по резолюция + Ограничи повтарящи се 2160p, 1080p, 720p резултати след сортиране. + Лимит по качество + Ограничи повтарящи се BluRay, WEB-DL, REMUX резултати след сортиране. + Диапазон на размера + Филтрирай резултати по размер на файла. + Научи повече + Формат по подразбиране + Оригинален формат + Въведете по една група на ред. + Оригинален ред + Най-добро качество първо + Най-голям първо + Най-малък първо + Най-добро аудио първо + Език първо + Всякакво + %1$d избрани + Всички резултати + %1$d резултата + До %1$dГБ + %1$dГБ+ + %1$d-%2$dГБ + Предпочитани резолюции + Сортирай избраните резолюции първо, в ред по подразбиране. + Необходими резолюции + Показвай само избраните резолюции. + Изключени резолюции + Скрий избраните резолюции. + Предпочитани качества + Сортирай избраните качества първо, в ред по подразбиране. + Необходими качества + Показвай само избраните качества. + Изключени качества + Скрий избраните качества. + Предпочитани визуални тагове + Сортирай DV, HDR, 10bit, IMAX и подобни тагове. + Необходими визуални тагове + Изисквай DV, HDR, 10bit, IMAX, SDR и подобни тагове. + Изключени визуални тагове + Скрий DV, HDR, 10bit, 3D и подобни тагове. + Предпочитани аудио тагове + Сортирай Atmos, TrueHD, DTS, AAC и подобни тагове. + Необходими аудио тагове + Изисквай Atmos, TrueHD, DTS, AAC и подобни тагове. + Изключени аудио тагове + Скрий избраните аудио тагове. + Предпочитани канали + Сортирай предпочитаните оформления на канали първо. + Необходими канали + Показвай само избраните оформления на канали. + Изключени канали + Скрий избраните оформления на канали. + Предпочитани кодирания + Сортирай AV1, HEVC, AVC и подобни кодирания. + Необходими кодирания + Изисквай AV1, HEVC, AVC и подобни кодирания. + Изключени кодирания + Скрий избраните кодирания. + Предпочитани езици + Сортирай предпочитаните аудио езици първо. + Необходими езици + Показвай само резултати с избраните езици. + Изключени езици + Скрий резултатите, при които всеки език е изключен. + Необходими release групи + Показвай само избраните release групи. + Изключени release групи + Скрий избраните release групи. + Изпрати времеви маркери + ТИП НА СЕГМЕНТА + Въведение + Резюме + Финал + НАЧАЛНО ВРЕМЕ (ММ:СС) + КРАЙНО ВРЕМЕ (ММ:СС) + Изпрати + Запиши + Хардуерен декодер + Аудио изход + Целеви примари + Целево пренасяне + Разрешен Trakt списък + TMDB Discover + US, KR, JP, IN + Въведете ime, URL или ID на Trakt списък + Въведете валиден TMDB ID или URL. + TMDB изворът не можа да се зареди + Въведете ID или URL на Trakt списък + Trakt списъкът не можа да се зареди + Канада + Австралия + Германия + Филми + Сериали + Облачната библиотека е деактивирана. + Невалиден API ключ или неуспешна връзка + В манифеста липсва „%1$s" + TMDB колекция %1$s + TMDB режисьор %1$s + TMDB Discover + Въведете валиден TMDB ID или URL. + TMDB списък %1$s + TMDB мрежа %1$s + TMDB личност %1$s + TMDB продукция %1$s + TMDB изворът не можа да се зареди + ID %1$s + Добавете TMDB API ключ в Настройки, за да използвате TMDB източници. + TMDB колекцията не е намерена + TMDB компанията не е намерена + TMDB discover не върна данни + TMDB списъкът не е намерен + Липсва TMDB ID на колекция + Липсва TMDB ID на списък + Липсва TMDB ID на личност + TMDB мрежата не е намерена + TMDB кредитите на личността не са намерени + TMDB личността не е намерена + Липсват Trakt идентификационни данни. + %1$s (%2$d) + Въведете валиден Trakt ID или URL на списък + %1$d елемента + %1$d харесвания + Trakt списъкът не е намерен или не е публичен + Липсва Trakt ID на списък + Trakt списъкът не включва числов ID + Публичен Trakt списък + Достигнат е лимитът на Trakt заявки + Trakt заявката не бе успешна + Trakt коментарите не можаха да се заредят (%1$d) + %1$dч %2$dм + %1$dч + %1$dм + Финализирането на файла за изтегляне не бе успешно + Отварянето на частичния файл за изтегляне не бе успешно + Частичният файл за изтегляне не е отворен + Записването на частичния файл за изтегляне не бе успешно + Nuvio библиотека + Проблем с връзката + Празно тяло на отговора + Моля, проверете връзката си и опитайте отново. + Заявката не бе успешна с HTTP %1$d + %1$s (%2$s) + MPV плейър двигателят не е наличен. Моля, прекомпилирайте приложението. + Потокът не може да се пусне. + Плъгините са деактивирани + Плъгините са активирани + %1$d доставчика + Обновяване + %1$d хранилища + TMDB API ключът липсва + TMDB API ключът е зададен + Инсталирай хранилище с плъгини + Инсталиране… + Тествай доставчик + Тестване… + Изтрий хранилището с плъгини + Обнови хранилището с плъгини + Все още няма налични доставчици. + Добавете URL на хранилище, за да инсталирате плъгини за доставчици за discovery на потоци. + Все още няма инсталирани хранилища с плъгини. + Използвай доставчиците на плъгини по време на discovery на потоци. + Активирай доставчиците на плъгини глобално + Това хранилище с плъгини вече е инсталирано. + Въведете URL на хранилище с плъгини. + Въведете валиден URL на плъгин. + Хранилището с плъгини не можа да се инсталира + Доставчикът не е намерен + Хранилището не можа да се обнови + Плъгините не са налични в тази версия. + В Потоци, показвай по един доставчик на хранилище вместо по един на извор. + Групирай доставчиците на плъгини по хранилище + URL на манифест на плъгин + Imeto на манифеста липсва. + Манифестът няма доставчици. + Версията на манифеста липсва. + Imeto на манифеста липсва. + Манифестът няма доставчици. + Версията на манифеста липсва. + %1$s е инсталиран. + Деактивиран от хранилището + Няма описание + v%1$s + Хранилище с плъгини + Версия %1$s + Това хранилище с плъгини вече е инсталирано. + Хранилището с плъгини не можа да се инсталира + Хранилището не можа да се обнови + ДОБАВИ ХРАНИЛИЩЕ + ИНСТАЛИРАНИ ХРАНИЛИЩА + ПРЕГЛЕД + ДОСТАВЧИЦИ + Грешка + Тестът на доставчика не бе успешен + Резултати от теста (%1$d) + Доставчиците на плъгини изискват TMDB API ключ. Задайте го на TMDB екрана или доставчиците на плъгини няма да работят правилно. + Не са върнати резултати от търсенето за %1$s. + Невалиден API ключ или неуспешна връзка + Хранилище с плъгини + Изпрати въведение + Изпрати + Запиши + КРАЙНО ВРЕМЕ (ММ:СС) + ТИП НА СЕГМЕНТА + НАЧАЛНО ВРЕМЕ (ММ:СС) + Свързан с Trakt + Прекъснат от Trakt + ТБ + Не е намерен APK в изданието + Изтеглянето не бе успешно с HTTP %1$d + Изтегленият файл с актуализация липсва. + Празно тяло на изтегленото + Грешка в GitHub releases API: %1$d + Все още не е публикувана актуализация. + Изданието няма таг или ime + Излъчва се %1$s + Излъчва се днес + Излъчва се утре + + Излъчва се след %1$d ден + Излъчва се след %1$d дни + + %1$s + Днес + Утре + + След %1$d ден + След %1$d дни + + Нов епизод + Нов сезон + Trakt списък %1$s + Android системен плейър + P2P потокинг + Този поток използва peer-to-peer (P2P) технология. С активирането на P2P вие признавате и се съгласявате, че:\n\n• Вашият IP адрес ще бъде видим за останалите участници в мрежата\n• Вие носите пълна отговорност за съдържанието, до което получавате достъп\n• Потвърждавате, че имате законно право да потоквате това съдържание във вашата юрисдикция\n• Nuvio не хоства, не разпространява и не контролира никакво P2P съдържание\n• Nuvio не носи отговорност за никакви правни последствия, произтичащи от вашето използване на P2P потокинг\n\nИзползвате тази функция изцяло на собствен риск. P2P може да бъде деактивиран по всяко време в Настройки. + Активирай P2P + Отказ + P2P потокинг + Разрешете peer-to-peer (торент) потоци + Скрий статистиките на торента + Скрий буфера, сийдовете, пиъровете и скоростта на изтегляне по време на зареждане и възпроизвеждане + %1$d сийда · %2$d пиъра + %1$s буферирано · %2$s · %3$s + %1$s · %2$s + %1$d пиъра · %2$d сийда · %3$d%% + Свързване с пиъри… + Стартиране на P2P двигател… + Неуспешно стартиране на торента: %1$s + Грешка в торента: %1$s + + Превключи + Пренесете библиотеката си в Nuvio с добавките, които използвате. + Добавете добавка, за да започнете да изграждате библиотеката си. + Настройте Nuvio по свой начин. + URL на добавка + Добавяне на добавка + Все още няма нищо тук + Текущите ви добавки все още нямат какво да покажат тук. + Сървър и поддръжка за този месец + Покрито. Допълнителната подкрепа вече отива за разработка. + След 100% допълнителната подкрепа отива за разработка. + Зареждане на напредъка на финансирането... + С регистрацията си се съгласявам с + Условията + Не са намерени редове със субтитри + Редовете със субтитри не могат да се заредят + Още действия + Неизвестна грешка в торента + Език на устройството + Добавяйте и управлявайте добавките, които използвате с Nuvio. + Изпращане на времеви маркери за интро и аутро + Предайте откритите времеви маркери за интро и аутро към външния плейър за автоматично прескачане. Работи само в плейъри, които го поддържат; останалите плейъри го игнорират. + Жестове с докосване + Позволява плъзгания и двойни докосвания върху повърхността на плейъра за превъртане, регулиране на яркостта или регулиране на силата на звука. + Оригинален език + Изисква обогатяването от TMDB да е включено + Енджин за възпроизвеждане + Първо използвай ExoPlayer и премини към libmpv, ако възпроизвеждането не успее. + Използвай ExoPlayer и декодерите Android Media3. + Използвай libmpv за възпроизвеждане в Android. + Рендерер libmpv + Рендерер libmpv + Хардуерно декодиране libmpv + Използвай хардуерно декодиране на mpv, когато е налично. + Съвместимост libmpv YUV420P + Принудително използване на YUV420P изход за устройства с проблеми с рендерера или цветовете. + Импортирането на значки не бе успешно. + Въведете URL към JSON със значки. + URL на значката трябва да започва с http:// или https://. + Можете да импортирате до %1$d URL адреса за значки. + Зареждане на сегменти за прескачане… + Упълномощаването на Trakt е изтекло + Списъкът в Trakt не е намерен + Достигнат е лимитът за списъци в Trakt + Достигнат е лимитът на заявките към Trakt + Заявката към Trakt не бе успешна + Добавянето към списъка за гледане в Trakt не бе успешно + Добавянето към списък в Trakt не бе успешно + Липсват съвместими Trakt идентификатори + Празно тяло на отговора + diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index 298527b0..039e9d5d 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -1,4 +1,5 @@ + Zdroje dat, poděkování a licence platformy Otevřené uznání a kredity projektu Zpět Zrušit @@ -12,28 +13,39 @@ Přehrát Předchozí Odstranit - Změnit pořadí + Přeuspořádat Obnovit výchozí Pokračovat - Opakovat + Zkusit znovu Uložit - Instaluje se + Ukládání… + Přepnout + Ověřit + Instalace Doplňky Aktivní %1$d katalogů - Nastavitelné - Obnovuje se + Konfigurovatelné + Vypnuto + Obnovování %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. + Přeneste svou knihovnu do Nuvia pomocí doplňků, které používáte. + Přidejte doplněk a začněte vytvářet svou knihovnu. + Přizpůsobte si Nuvio. + URL doplňku + Přidat doplněk + Zatím tu nic není + Vaše současné doplňky tu zatím nemají co zobrazit. + Přidejte URL manifestu, abyste mohli do Nuvio začít 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 + Instalovat doplněk Načítání podrobností manifestu... - Ověřování URL manifestu a načítání podrobností o doplňku před instalací. + Ověřování URL manifestu a načítání podrobností doplňku před instalací. Kontrola doplňku Instalace selhala Doplněk %1$s byl úspěšně ověřen a přidán. @@ -47,47 +59,47 @@ Přidat doplněk Nainstalované doplňky Přehled - %1$d pravidel id + %1$d ID pravidel 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 + %1$d sbírka(y), %2$d složka(y) + Smazat \"%1$s\"? Tuto akci nelze vrátit zpět. + Smazat sbírku + 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ů + Přidejte katalogy z nainstalovaných doplňků, abyste definovali, co tato složka zobrazuje. + Zatím žádné zdroje katalogu Vybrat Emoji URL obrázku Žádný Obal - Vytvořit kolekci + Vytvořit sbírku Hotovo - Upravit kolekci + Upravit sbírku 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. + Nastavte identitu složky, prezentaci a zdroje katalogu se stejnou strukturou jako v hlavním editoru sbírek. + Přidejte jednu pro začátek. Zatím žádné složky Složky Filtr žánrů - Zobrazit pouze obal + Zobrazit pouze obrázek obalu 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í. + Zobrazit tuto sbírku nad všemi běžnými katalogy na domovské obrazovce. Více připnutých sbírek se řídí pořadím jejich 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 + Název sbírky Uložit změny Uložit Vzhled Základy - Zdroje katalogů - Vyberte katalogy doplňků, které by měla tato složka sdružovat. + Zdroje katalogu + Vyberte katalogy doplňků, které by tato složka měla sdružovat. Vybrat katalogy Vybrat žánr %1$d vybráno @@ -95,11 +107,11 @@ %1$d vybráno Plakát Čtverec - Širokoúhlý + Široký 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ý + Přehrát nakonfigurovaný GIF místo statického obalu, je-li k dispozici. + Zobrazit GIF, když je nakonfigurován %1$d zdroj(ů) · %2$s Tvar dlaždice Řádky @@ -109,51 +121,51 @@ Veřejný seznam Produkce Síť - Kolekce + Sbírka Osoba Režisér Vlastní - Vyberte si předpřipravený zdroj. Po přidání ho můžete upravit nebo odstranit. + Vyberte si předpřipravený zdroj. Po přidání jej 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á. + Vyhledejte podle názvu studia nebo vložte ID/URL společnosti na TMDB a přidejte ji přímo. + Zadejte ID sítě. Běžné sítě jsou k dispozici v Předvolbách a rychlých filtrech. + Vyhledejte název filmové sbírky nebo vložte ID sbírky z TMDB. + Zadejte ID nebo URL osoby z TMDB a vytvořte řádek z jejích hereckých rolí. + Zadejte ID nebo URL osoby z TMDB a vytvořte řádek z jejích režisérských rolí. + Vytvořte živý řádek TMDB s použitím volitelných filtrů. Pokud filtr nepotřebujete, ponechte pole prázdná. Veřejný seznam TMDB ID sítě - ID kolekce + ID sbírky ID osoby Název, ID nebo URL produkční společnosti - TMDB ID nebo URL + ID nebo URL TMDB https://www.themoviedb.org/list/8504994 nebo 8504994 213 pro Netflix, 49 pro HBO, 2739 pro Disney+ - 10 pro Star Wars Collection + 10 pro sbírku Star Wars 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říklad: Star Wars Collection, Harry Potter Collection, nebo URL sbírky. 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 + Zobrazí se jako název řádku/karty. Pokud zůstane prázdné, Nuvio jej vytvoří ze zdroje. + Marvel filmy, Netflix Originals, Pixar + Filmy Toma Hankse, Oblíbení herci + Filmy Christophera Nolana, Oblíbení režiséři + Nejlepší akční filmy, Korejská dramata, Animáky 2024 Výsledky vyhledávání - Kolekce TMDB - Společnost TMDB %1$d - Kolekce TMDB %1$d + Sbírka TMDB + TMDB společnost %1$d + TMDB sbírka %1$d Typ Filmy Seriály Obojí - Seřadit + Řazení Filtry - Pokud filtr nepotřebujete, nechte pole prázdná. + Pokud filtr nepotřebujete, ponechte pole prázdná. Rychlé žánry Rychlé jazyky Rychlé země @@ -161,54 +173,70 @@ 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. + Použijte čísla žánrů z TMDB. Více položek oddělte čárkou pro A ZÁROVEŇ (AND), nebo svislítkem pro NEBO (OR). + 28,12 + 18,35 + Datum vydání/vysílání od + Datum vydání/vysílání do + Použijte RRRR-MM-DD, například 2024-01-01. + 2020-01-01 + 2024-12-31 Minimální hodnocení Maximální hodnocení Hodnocení TMDB od 0 do 10. Příklad: 7.0. + 7.0 + 10 Minimální počet hlasů - Slouží k vynechání neznámých titulů s malým počtem hlasů. Příklad: 100. + Použijte pro vyloučení obskurních titulů s malým počtem hlasů. Příklad: 100. + 100 Původní jazyk - Použijte dvoupísmenné kódy jazyků, například en, ko, ja, cs. + Použijte dvoupísmenné kódy jazyků, například cs, en, ko, ja, hi. + cs, en, ko, ja, hi Země původu - Použijte dvoupísmenné kódy zemí, například US, KR, JP, CZ. + Použijte dvoupísmenné kódy zemí, například CZ, US, KR, JP, IN. + CZ, US, KR, JP, IN ID klíčových slov - Použijte čísla klíčových slov TMDB. Rychlé volby vyplní běžné příklady. + Použijte čísla klíčových slov z TMDB. Rychlé čipy doplní běžné příklady. 9715 pro superhrdinu ID společností - Použijte ID studií/společností. Rychlé volby vyplní běžné příklady. + Použijte ID studií/společností. Rychlé čipy doplní 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. + 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. + 2024 Předvolby - Hledat + Vyhledat Přidat zdroj - Přidat seznam Trakt - Upravit seznam Trakt + Přidat Trakt seznam + Upravit Trakt seznam 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. + Hledat název, Trakt URL nebo ID seznamu + Použijte URL veřejného seznamu Trakt nebo číselné ID seznamu, případně vyhledejte 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í + Směr Vzestupně Sestupně - Pořadí v seznamu - Nedávno přidáno + Pořadí seznamu + Nedávno přidané Název - Datum vydání + Vydáno Délka Populární - Procenta + Procento Hlasy + Zadejte název, URL nebo ID seznamu Trakt + Zadejte ID nebo URL seznamu Trakt + Seznam Trakt se nepodařilo načíst + Nebyly nalezeny žádné seznamy Trakt + Rozpoznaný seznam Trakt + Trakt seznam %1$d Akční Dobrodružný Animovaný @@ -224,10 +252,10 @@ Hindština Španělština Spojené státy - Korea + Jižní Korea Japonsko Indie - Velká Británie + Spojené království Superhrdina Podle knižní předlohy Cestování v čase @@ -245,47 +273,62 @@ Původní Populární Nejlépe hodnocené - Nedávné + Nejnovější + Nejvíce hlasů + Region sledování + Kód země ISO 3166-1, kde je titul dostupný. Příklad: CZ, US, GB. + Rychlé regiony sledování + ID poskytovatelů + Použijte ID poskytovatelů (Watch providers) z TMDB. Více položek oddělte čárkou pro A ZÁROVEŇ, nebo svislítkem pro NEBO. + 8|337|350 + Rychlí poskytovatelé + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu Seznam TMDB - Filmová kolekce TMDB + Filmová sbírka TMDB Produkce Síť Osoba Režisér - Objevování TMDB - Vytvořte ji pro organizaci vašich katalogů. - Zatím žádné kolekce + Objevovat na TMDB (Discover) + Vytvořte ji a uspořádejte si své katalogy. + Zatím žádné sbírky %1$d složka(y) Nebyly nalezeny žádné položky Složka nebyla nalezena - Kolekce - Importovat kolekce + Sbírky + Importovat sbírky JSON - Vložte níže JSON vašich kolekcí. - Import - Nová kolekce + Níže vložte JSON svých sbírek. + Importovat + Nová sbírka Připnuté Vše - Vaše kolekce + Vaše sbírky Vytvořeno s ❤️ od Tapframe a přátel Verze %1$s (%2$s) - Vypnuto - Zapnuto + Vyp + Zap Pozastavit Znovu načíst - Už máte účet? + Již 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řihlaste se pro přístup ke své knihovně a postupu Přihlásit se Zaregistrujte se pro synchronizaci dat napříč zařízeními - Zaregistrovovat se + Zaregistrovat se Vaše data budou uložena pouze lokálně - Streamujte všechno, všude + Sledujte svou knihovnu kdekoliv + Registrací souhlasím s + Podmínkami Vítejte zpět Knihovna Knihovna Trakt @@ -295,64 +338,76 @@ Hledat Zvukové stopy Zvuk + Automatická synchronizace + Tučné Vestavěné Spodní odsazení + Zachytit Zavřít přehrávač Barva - Právě hraje + Nyní se přehrává E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s Epizody - Velikost písma + Velikost písma %1$dsp Uzamknout ovládání přehrávače + Načítání řádků titulků... + Nebyly nalezeny žádné řádky titulků + Nelze načíst řádky titulků Nejsou k dispozici žádné zvukové stopy Nejsou k dispozici žádné epizody Nebyly nalezeny žádné streamy - Žádné + Žádný Obrys + Barva obrysu Epizody Zdroje Streamy Chyba přehrávání Přehrávání Klepnutím načtěte titulky - Vrátit se zpět + Zpět + Znovu načíst + Reset Obnovit výchozí Vyplnit Přizpůsobit Přiblížit - Přetočit o 10 sekund zpět + Posunout zpět o 10 sekund -%1$ds +%1$ds -%1$ds +%1$ds - Přetočit o 10 sekund vpřed + Posunout vpřed o 10 sekund Zdroje Styl + Nejprve vyberte titulky doplňku Titulky + Zpoždění titulků Titulky + Průhlednost textu Jas %1$s Hlasitost %1$s Ztlumeno Staženo Vysílá se Bude oznámeno - Klepnutím odemknete + Klepnutím odemkněte Stopa %1$d Odemknout ovládání přehrávače - Právě sledujete + 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 + Nainstalované doplňky nevrátily platné výsledky vyhledávání. + Vyhledává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. + Pro tento dotaz nevrátily nainstalované prohledávatelné katalogy žádnou shodu. Nebyly nalezeny žádné výsledky - Vaše nainstalované doplňky neumožňují vyhledávání v katalozích. + Vaše nainstalované doplňky neumožňují vyhledávání v katalogu. Žádné prohledávatelné katalogy Hledat filmy, seriály... Nedávná hledání @@ -361,11 +416,14 @@ Obecné Účet Doplňky + Pokročilé Rozvržení - Obsah a objevování + Obsah & objevování Pokračovat ve sledování - Rozvržení domovské obrazovky + Připojené služby + Rozvržení domů Integrace + Licence & autorství Hodnocení MDBList Stránka podrobností Oznámení @@ -373,39 +431,78 @@ Pluginy Styl karty plakátu Nastavení + Streamy Podporovatelé a přispěvatelé Obohacení TMDB Trakt O APLIKACI - Účet a stav synchronizace + Stav účtu a synchronizace ÚČET - Struktura domovské obrazovky a styly plakátů + Chování při spuštění a chování profilu. + POKROČILÉ + Domů, streamy, sbírky a stránky podrobností Stáhnout nejnovější verzi Zkontrolovat aktualizace - Spravovat doplňky a zdroje objevování. - Spravovat vaše stažené filmy a epizody. - Stahování + Správa doplňků a zdrojů objevování. + Spravujte doplňky a vyberte, co se má v Nuviu zobrazovat. + Spravujte své stažené filmy a epizody. + Spravujte obsah uložený v tomto zařízení. + Stažené položky OBECNÉ Spravovat dostupné integrace - Spravovat upozornění na vydání epizod a odeslat zkušební oznámení. - Přepnout na jiný profil. + Správa upozornění na vydání epizod a odeslání zkušebního oznámení. + Zobrazení výsledků streamů a pravidla pro URL odznaků. + Změnit na jiný profil. Přepnout profil - Otevřít obrazovku připojení k Trakt + Otevřít obrazovku připojení Trakt Nebylo nalezeno žádné nastavení. Hledat v nastavení... VÝSLEDKY + SPUŠTĚNÍ + MEZIPAMĚŤ (CACHE) + Pamatovat si poslední profil + Pamatovat si naposledy vybraný profil při spuštění + Vymazat mezipaměť Pokračovat ve sledování + Odstranit data v mezipaměti u Pokračovat ve sledování a obnovit postup + Mezipaměť vymazána + LICENCE APLIKACE + DATA & SLUŽBY + LICENCE PŘEHRÁVÁNÍ + Nuvio Mobile + Zdrojový kód a licenční podmínky jsou dostupné v repozitáři projektu. + Licencováno pod GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio používá TMDB API pro metadata o filmech a TV pořadech, grafiku, trailery, herecké obsazení, produkční detaily, sbírky a doporučení. Tento produkt používá TMDB API, ale není schválen ani certifikován společností TMDB. + IMDb Non-Commercial Datasets + Nuvio používá nekomerční datové sady IMDb, včetně title.ratings.tsv.gz, pro hodnocení a počty hlasů na IMDb. Informace poskytl IMDb (https://www.imdb.com). Použito se svolením. Data IMDb jsou určena pro osobní a nekomerční použití podle podmínek IMDb. + Trakt + Nuvio se připojuje k Trakt pro ověřování účtu, historii sledování, synchronizaci postupu, data knihovny, hodnocení, seznamy a komentáře. Nuvio není přidruženo k Trakt a Trakt tuto aplikaci nijak neschválil. + Premiumize + Nuvio se připojuje k Premiumize pro ověřování účtu, přístup do cloudové knihovny, kontrolu mezipaměti a funkce cloudového přehrávání. Nuvio není přidruženo k Premiumize. + TorBox + Nuvio se připojuje k TorBox pro ověřování účtu, přístup do cloudové knihovny, kontrolu mezipaměti a funkce cloudového přehrávání. Nuvio není přidruženo k TorBox. + MDBList + Nuvio používá MDBList pro hodnocení a data externích poskytovatelů skóre. Nuvio není přidruženo k MDBList. + IntroDB + Nuvio používá IntroDB API pro komunitní časové značky intra, rekapitulací, závěrečných titulků a ukázek, které se používají při přeskakování. Nuvio není přidruženo k IntroDB. + MPVKit + Používá se pro přehrávání na iOS buildech. + Samotný zdrojový kód MPVKit je licencován pod LGPL v3.0. Balíčky MPVKit, včetně knihoven libmpv a FFmpeg, jsou rovněž licencovány pod LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Používá se pro přehrávání na Android buildech. + Licencováno pod Apache License, verze 2.0. Načítání vašich seznamů Trakt… - Vyberte, kam na Trakt chcete tento titul uložit + Vyberte, kam se má tento titul uložit na Trakt Přispět Přejít na podrobnosti Odstranit - Spustit od začátku + Přehrát 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í + Zatím nejsou k dispozici žádné recenze z Trakt. + %1$d to se mi líbí Tento komentář obsahuje spoilery. Tento komentář obsahuje spoilery a byl skryt. Komentáře @@ -413,8 +510,8 @@ %1$s (%2$d) Trailery Žádné dokončené epizody - Zatím žádná stahování - %1$d stažená epizoda (epizody) + Zatím žádné stažené položky + %1$d stažených epizod Aktivní Filmy Seriály @@ -426,13 +523,13 @@ Zhlédnuto Série %1$d Speciály - Pokračujte tam, kde jste přestali + Pokračovat tam, kde jste přestali Přidat do knihovny Označit jako nezhlédnuté Označit jako zhlédnuté - Odebrat z knihovny + Odstranit z knihovny Zobrazit vše - Přehrát ručně + Přehrát manuálně Logo %1$s Účet Smazat účet @@ -440,54 +537,57 @@ 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 + Nejste přihlášeni Odhlásit se - Budete vráceni na přihlašovací obrazovku. + Budete přesměrováni na přihlašovací obrazovku. Odhlásit se? Stav Anonymní Přihlášen - AMOLED Černá + AMOLED černá Použít čistě černé pozadí pro OLED obrazovky. Jazyk aplikace - Vyberte jazyk + Jazyk zařízení + Vybrat 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. + Liquid Glass + Použít nativní lištu panelů pro iPhone na iOS 26 a novějších. Vyladit šířku karty a poloměr rohů. ZOBRAZENÍ DOMŮ MOTIV - Kolekce • %1$s + Sbírka • %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) + Nainstalujte si doplněk s kompatibilními katalogy pro konfiguraci řádků na domovské obrazovce. + Žádné domovské katalogy + Zdroj Hero sekce 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 + Zaměření na Domů + %1$s • Limit dosažen (max %2$d) + Nejsou vybrány žádné zdroje Hero sekce + Není v Hero sekci + Chcete-li přesunout sbírku, odstraňte její připnutí na začátek Připnuto Připnuto nahoru - Změnit pořadí + Přeuspořádat 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. + KATALOGY A SBÍRKY + SBÍRKY + Rozvržení domů + Katalogy pro Hero + %1$d z %2$d vybráno + Zobrazit Hero sekci + Zobrazit hlavní karusel v horní části domovské stránky. 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 + Skrýt filmy a pořady, které ještě neměly premiéru. + Skrýt podtržení katalogu + Odstranit barevnou linii pod názvy katalogů a sbírek v celé aplikaci. + %1$d z %2$d katalogů viditelných • %3$d zdrojů Hero sekce vybráno 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ů + Poloměr rohů Styl karty plakátu Šířka Vlastní @@ -504,78 +604,167 @@ Zaoblený Ostrý Decentní - Vyvážená + Vyvážený Komfortní Kompaktní - Hustá - Velká + 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ě + Zobrazit výzvu k pokračování tam, kde jste přestali, při otevření aplikace po odchodu z přehrávače. + Výzva k pokračování při spuštění + Rozostřit miniatury dalších epizod v Pokračovat ve sledování pro zamezení spoilerů. + Rozostřit nezhlédnuté v Pokračovat ve sledování + Zahrnout nadcházející epizody do Pokračovat ve sledování ještě před jejich odvysíláním. + Zobrazit neodvysílané epizody + POŘADÍ ŘAZENÍ + Pořadí řazení + Výchozí + Seřadit všechny položky podle nejnovějších + Styl streamování + Nejprve vydané položky, nadcházející na konci Styl karty plakátu PŘI SPUŠTĚNÍ - CHOVÁNÍ DALŠÍ NA ŘADĚ + CHOVÁNÍ "DALŠÍ NA ŘADĚ" VIDITELNOST - Zobrazit lištu Pokračovat ve sledování na domovské obrazovce. + Zobrazit panel Pokračovat ve sledování na domovské obrazovce. Zobrazit Pokračovat ve sledování + Karta + Karta na šířku ve stylu TV Plakát - Karta plakátu zaměřená na grafiku + Karta s důrazem na obrázek obalu Š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í + Horizontální karta s detailními informacemi + Zobrazit další epizodu na základě nejdále zhlédnuté epizody. Vypněte pro opětovné zhlédnutí, abyste místo toho použili naposledy zhlédnutou epizodu. + Další na řadě podle nejdále zhlédnuté epizody + Upřednostňovat miniatury epizod, jsou-li k dispozici. + Upřednostňovat miniatury epizod 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. + Instalovat, odstraňovat, obnovovat a třídit vaše zdroje obsahu. + Přidávejte a spravujte doplňky, které používáte s Nuviem. + Instalovat repozitáře pro JavaScriptové scarpování a testovat poskytovatele interně. + Upravit rozvržení domovské obrazovky, viditelnost obsahu a chování plakátů. + Nastavení pro obrazovku detailu a epizody. + Vytvářet vlastní seskupení katalogů se složkami na domovské obrazovce. Integrace - Ovládací prvky pro obohacení metadat + Ovládání pro obohacování 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 + Připojit účty pro odkazy a přístup ke knihovně + Připojené služby + Tyto integrace jsou experimentální a mohou být zachovány, změněny nebo později odstraněny. + Cloudová knihovna + Procházet a přehrávat soubory, které už jsou ve vašich připojených účtech. + Rozpoznávat přehrávatelné odkazy + Požádat připojenou službu o přehrávatelné odkazy, pokud je výsledek potřebuje. Tím může být položka do této služby přidána. + Zpracovávat pomocí + Vyberte, který připojený účet bude zpracovávat přehrávatelné odkazy. + Nejprve připojte účet. + Účty + Připojte svůj účet na %1$s. + Propojte svůj %1$s účet v prohlížeči. + API klíč k %1$s + Zadejte svůj API klíč pro %1$s. + Zadejte API klíč k %1$s + Nenastaveno + Připojeno + Připojit %1$s + Odpojit %1$s + Odpojit + %1$s je připojeno na tomto zařízení. + Spouštění zabezpečeného přihlášení... + Otevřete odkaz a zadejte tento kód pro schválení Nuvio. + Kód byl zkopírován. + Otevřít odkaz + Čekání na schválení... + Nepodařilo se zahájit přihlášení. + Tato metoda přihlášení není v tomto buildu nakonfigurována. + Tento kód vypršel. Zkuste to znovu. + Příprava odkazů + Připravit odkazy + Zpracovat přehrávatelné odkazy dříve, než začne přehrávání. + Odkazy k přípravě + Kdykoli je to možné, použijte nižší počet. Připojené služby mohou omezovat, kolik odkazů lze vyhodnotit za určité časové období. Otevření filmu nebo epizody se může započítat do těchto limitů, i když nestisknete Přehrát, protože odkazy se připravují předem. + 1 odkaz + %1$d odkazů + Formátování + Šablona názvu + Ovládá, jak se budou názvy výsledků zobrazovat. Ponechte prázdné pro použití původního názvu výsledku. + Šablona popisu + Ovládá metadata zobrazená pod každým výsledkem. Ponechte prázdné pro použití původních detailů výsledku. + Obnovit formátování + Obnovit výchozí formátování výsledků. + Styl Fusion + Odznaky velikosti + Zobrazovat odznaky velikosti souboru ve výsledcích streamu a na panelech zdroje v přehrávači. + Logo doplňku + Zobrazovat logo a název doplňku vedle zdrojů streamů. + Zobrazení + Pozice odznaku + Vyberte si, zda se budou odznaky Fusion a velikosti zobrazovat nad nebo pod kartami streamu. + Pozice odznaku + Vyberte, kde se budou zobrazovat odznaky streamu na kartách streamu. + Nahoře + Dole + URL odznaků Fusion + Importujte až %1$d JSON URL odznaků streamu ve stylu Fusion. Každou URL lze aktualizovat nebo smazat samostatně. + Spravovat importované JSON URL odznaků streamu ve stylu Fusion. + Import odznaků selhal. + Zadejte JSON URL odznaku. + URL odznaku musí začínat na http:// nebo https://. + Můžete importovat až %1$d URL odznaků. + %1$d/%2$d URL, %3$d aktivních odznaků Fusion + Nejsou importována žádná URL odznaků Fusion. + JSON URL odznaku Fusion + Importováno %1$d/%2$d URL pro Fusion + Aktivní + Neaktivní + %1$s, %2$d povolených odznaků, %3$d skupin + Náhled + Náhled odznaku Fusion + %1$d odznaků ve stylu Fusion z tohoto URL + V tomto URL nejsou žádné obrázky odznaků ve stylu Fusion. + Skupina %1$d + Ostatní odznaky Fusion + API klíč byl ověřen. + Tento API klíč se nepodařilo ověřit. + Před zapnutím hodnocení přidejte níže svůj API klíč MDBList. + Vyžadováno k získání hodnocení z MDBList API klíč API klíč Povolit hodnocení MDBList - Načítat hodnocení od externích poskytovatelů na obrazovce s metadaty + Načítat hodnocení od externích poskytovatelů na obrazovce podrobností metadat API klíč Externí poskytovatelé hodnocení Hodnocení MDBList Akce - Ovládací prvky pro přehrávání a ukládání. + Ovládání přehrávání a ukládání. Obsazení - Hlavní seznam herců. + Hlavní herecké obsazení. Filmové pozadí - Rozmazané pozadí za obsahem, podobné obrazovce streamu. - Kolekce - Související kolekce nebo filmová série. + Rozmazané pozadí za obsahem, podobné jako na obrazovce streamů. + Sbírka + Související sbírka nebo franšízový pruh. Komentáře Recenze z Trakt Podrobnosti Délka, stav, vydání, jazyk a související informace. + Přehrávání traileru v Hero sekci + Přehrávat ukázky trailerů v hlavním zobrazení, pokud je trailer k dispozici. Karty epizod - Vyberte, jak se budou epizody vykreslovat na obrazovce s metadaty. + Vyberte, jak se budou vykreslovat epizody na obrazovce metadat. Horizontální Řádkové karty ve stylu pozadí Seznam - Skládané karty zaměřené na detaily + Skládané karty s důrazem na detail 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. + Série a seznam epizod pro seriály. + Rozostřit nezhlédnuté epizody + Rozostřit miniatury epizod do doby jejich zhlédnutí pro zamezení spoilerů. Skupina %1$d - Podobné - Doporučení TMDB na stránce podrobností - Žádné + Podobné tituly + Doporučení TMDB na stránce s podrobnostmi + Žádný Přehled Synopse, hodnocení, žánry a hlavní tvůrci. Produkce @@ -584,41 +773,45 @@ 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. + Seskupte sekce do karet jako v aplikaci TV. Každé skupině karet přiřaďte až 3 sekce. 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í. + Pruh s trailery a zástupci pro přehrávání. + Oznámení jsou momentálně v Nuvio zakázána. + Upozornění na vydání epizody + Naplánovat lokální upozornění, když bude k dispozici nová epizoda u uloženého pořadu. + Systémová oznámení jsou pro Nuvio vypnutá. Povolte je, abyste mohli dostávat upozornění a zkušební oznámení. + Momentálně je na tomto zařízení 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. + Abyste mohli otestovat oznámení, uložte si nejprve nějaký pořad 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. + Podívejte se na lidi, kteří tvoří a podporují Nuvio na Mobilech, TV a Webu. + Tento měsíc za servery a údržbu + Pokryto. Dodatečná podpora nyní půjde na vývoj. + Po dosažení 100% půjde další podpora na vývoj. + Načítání průběhu financování... + Supporters API 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. + Profil na GitHubu je nedostupný + Žádná připojená 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é. + Přispěvatele se nepodařilo načíst + Podporovatele se nepodařilo načíst + Nenalezeni žádní přispěvatelé. + Nenalezeni žá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 + Přispěvatele právě teď nelze načíst. + Podporovatele právě teď nelze načíst. + %1$d commitů celkem + Led Úno Bře Dub @@ -635,189 +828,245 @@ Všechny povolené pluginy Povolené doplňky Povolené pluginy - Anime Skip (přeskakování) + Anime Skip AnimeSkip Client ID - Zadejte své AnimeSkip API klientské ID. Získáte jej na anime-skip.com. + Zadejte své klientské ID pro AnimeSkip API. Získejte 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). + API klíč k IntroDB + Pro odeslání časových značek zadejte svůj API klíč IntroDB. Vyžadováno pro odeslání. + Hledat časové značky k přeskočení také na AnimeSkip (vyžaduje Client ID). Automaticky přehrát další epizodu - Po zobrazení výzvy automaticky spustit další epizodu. + Jakmile se zobrazí výzva, 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 + Klepnutím mimo oblast zavřete + Klepnutím mimo oblast uložíte a zavřete %1$d den %1$d dní %1$d hodina %1$d hodin - Použít libass pro titulky ASS/SSA + Použít libass pro ASS/SSA titulky Experimentální: pokročilé vykreslování ASS/SSA (styly, pozicování, animace) + Externí přehrávač + Aplikace externího přehrávače + Otevřít nové přehrávání pomocí výchozí video aplikace na Androidu nebo systémového výběru. + Otevřít nové přehrávání pomocí vybraného nainstalovaného přehrávače. + Přeposlat titulky do externího přehrávače + Stáhnout titulky z doplňků ve vašem preferovaném jazyce a předat je externímu přehrávači. + Odeslat časové značky pro intro a outro + Předat zpracované časové značky pro přeskočení intra a outra externímu přehrávači pro automatické přeskočení. Funguje pouze v přehrávačích, které to podporují; ostatní přehrávače to budou ignorovat. + Nejsou nainstalovány žádné podporované externí přehrávače + Přehrávač + Interní + Externí + Vyberte, který přehrávač má zpracovávat nové přehrávání. Rychlost při podržení - Podržet pro zrychlení + Podržením zrychlit 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. + Dotyková gesta + Povolit potáhnutí a dvojité klepnutí na ploše přehrávače pro posun, úpravu jasu nebo hlasitosti. + Neplatný regulární výraz (regex) + Doba uložení posledního odkazu do mezipaměti + Nouzový režim DV7 - HEVC + Mapovat profil Dolby Vision 7 na standardní HEVC u zařízení bez hardwarové podpory DV + Prahový limit (minuty) + Nouzový limit, pokud neexistuje žádná časová značka outra. %1$s min - Žádné položky nejsou k dispozici + Žádné dostupné položky Nenastaveno - Výchozí (soubor médií) + Výchozí (soubor média) Jazyk zařízení - Vynucené + Původní jazyk + Vyžaduje povolení obohacování z TMDB + Vynucené (Forced) Žá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í. + Všechny titulky + Načíst a zobrazit pro video všechny titulky z doplňků. + Rychlé spuštění + Přeskočit automatické načítání titulků z doplňků, dokud si to sami nevyžádáte v přehrávači. + Spouštění titulků doplňku + Pouze preferované + Stáhnout titulky z doplňků, ale zobrazit pouze shody v preferovaném jazyce. + Upřednostnit skupinu (Další epizoda) + Před uplatněním normálních pravidel automatického přehrávání zkusit nejdříve stejný profil zdroje (stejný doplněk/skupinu kvality). + Znovu použít skupinu pro další sledování (Binge Group) + Zapamatovat a znovu použít poslední skupinu zhlédnutí pro další relace (Pokračovat ve sledování, Detaily atd.). Preferovaný jazyk zvuku - Preferovaný jazyk titulků + Preferovaný jazyk 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 + Shoduje se s názvem streamu/názvem/popisem/doplňkem/url. Příklad: 4K|2160p|Remux + Vzor regulárního výrazu (Regex) Není nastaven žádný vzor. Příklad: 4K|2160p|Remux - Jakékoli 1080p+ + Jakékoli od 1080p AVC / x264 - BluRay kvalita + Kvalita BluRay Dolby Atmos / DTS Angličtina HDR / Dolby Vision HEVC / x265 Žádné CAM/TS Žádné REMUX/HDR - 1080p Standard + Standard 1080p 4K / Remux 720p / Menší - WEB zdroje + 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á. + Standardní ukazatele + Efektové plátno (Canvas) + Efektové OpenGL + Překryvné plátno (Canvas) + Překryvné OpenGL (Doporučeno) + Použít poslední odkaz znovu + Automaticky přehrát váš 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 + Modul přehrávání + Použije nejprve ExoPlayer a při selhání přehrávání přejde k libmpv. + Použije ExoPlayer a dekodéry Android Media3. + Použije libmpv pro přehrávání v systému Android. + Renderer pro libmpv + Renderer pro libmpv + Hardwarové dekódování pro libmpv + Použije hardwarové dekódování mpv, je-li k dispozici. + Kompatibilita YUV420P pro libmpv + Vynutí výstup v YUV420P na zařízeních s problémy s renderováním nebo barvami. DALŠÍ EPIZODA PŘEHRÁVAČ - PŘESKOČENÍ SEGMENTŮ + ÚSEKY PŘESKOČENÍ 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. + Vybráno %1$d + Překryvná obrazovka načítání + Zobrazovat obrazovku načítání do chvíle, než se objeví první snímek videa. + Barva pozadí + Tučné + Použije silnější řez písma pro titulky. + Průhledná + Obrys + Barva obrysu + Nakreslí okraj kolem textu titulků. + Zobrazovat pouze preferované jazyky + Zobrazit pouze titulky, které odpovídají vašemu nastavení preferovaných jazyků. + Velikost titulků + Barva textu + Použít vynucené (forced) titulky + Upřednostnit vynucené (forced) titulky při shodě s nastavením jazyka titulků. + Svislý posun Přeskočit intro - Použít introdb.app k detekci inter a shrnutí děje. - Rozsah zdrojů pro auto. přehrávání + Použije introdb.app pro detekci inter a rekapitulací. + Rozsah zdroje u automatického 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ů. + Automatické přehrávání zohledňuje pouze streamy, které pocházejí 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. + Automatické přehrávání může používat jak nainstalované doplňky, tak povolené pluginy. Pouze povolené pluginy - Automatické přehrávání zvažuje pouze streamy pocházející z povolených pluginů. + Automatické přehrávání zohledňuje pouze streamy, které pocházejí 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é přehrávání zohledňuje pouze streamy pocházející z nainstalovaných doplňků. Automatický výběr streamu Automaticky přehrát první zdroj - Automaticky přehrát první dostupný zdroj. + Přehrát automaticky 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. + Vždy zobrazovat seznam zdrojů a nechat mě vybrat. + Automaticky přehrát shodu podle regex + Přehrát první zdroj, jehož text odpovídá vašemu regulárnímu výrazu. + Časový limit pro výběr streamu + Doba č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% + Režim pro prahovou hodnotu další epizody + Minut do konce + Procento + Prahové procento + Nouzový limit, pokud neexistuje žádná č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íč. + Bez omezení + Tunelované přehrávání + Hardwarová synchronizace zvuku/videa. Může zlepšit přehrávání na některých zařízeních Android TV. + Před zapnutím obohacování metadat přidejte svůj vlastní API klíč TMDB. 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 + Použije TMDB jako zdroj metadat pro vylepšení dat z doplňku. + Zadejte svůj API klíč z TMDB v3. + Kód jazyka Grafika - Loga a obrázky na pozadí z TMDB + Obrázky log a 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 + Popis, žánry a hodnocení z TMDB. + Sbírky + Filmové sbírky TMDB v pořadí jejich vydání. + Tvůrci (Credits) + Obsazení s fotkami, režisér a scénárista z TMDB. Podrobnosti - Délka, stav, země a jazyk z TMDB + 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í + Názvy epizod, popisy, miniatury a délka z TMDB. + Podobné tituly + Doporučení TMDB (s obrázky na pozadí) na stránce s podrobnostmi. 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ů. + Sítě s logy z TMDB. + Produkční společnosti + Produkční společnosti z TMDB. + Plakáty pro série + V seznamu sérií u seriálů používá plakáty sérií z TMDB. Trailery - Kandidáti na trailery z videí na TMDB pro sekci detailů o trailerech + Návrhy trailerů z TMDB videí do sekce s trailery v detailech. Osobní API klíč Jazyk - Jazyk metadat z TMDB pro název, logo a povolená pole - PŘIHLAŠOVACÍ ÚDAJE + Jazyk metadat z TMDB pro titulek, logo a povolené pole. + POVĚŘENÍ LOKALIZACE MODULY Obohacení TMDB Po schválení budete automaticky přesměrováni zpět. - AUTENTIZACE + OVĚŘENÍ (AUTHENTICATION) Komentáře - Zobrazit recenze z Traktu na stránkách s metadaty + Zobrazovat recenze z Trakt na stránkách s metadaty. Připojit Trakt Připojeno jako %1$s - Uživatel Traktu + Uživatel Trakt Odpojit - Nepodařilo se otevřít prohlížeč + 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. + Dokončete přihlášení do Trakt ve svém prohlížeči. + Synchronizujte svůj seznam sledovaných (watchlist), postup sledování, scrobblování, pokračování ve sledování a osobní seznamy s Trakt. + Chybí pověření pro Trakt v local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Otevřít přihlášení k Trakt + Vaše akce Uložit teď mohou zacílit na seznam sledovaných (watchlist) a osobní seznamy na Trakt. + Přihlaste se přes Trakt, abyste povolili ukládání přes seznamy a režim knihovny Trakt. Zdroj knihovny - Vyberte, kterou knihovnu použít pro ukládání a prohlížení vaší kolekce + Vyberte, která knihovna se použije pro ukládání a zobrazení vaší sbírky. Zdroj knihovny - Vyberte, kam ukládat a spravovat položky vaší knihovny + Vyberte, kam se mají ukládat a spravovat položky vaší knihovny. Trakt Knihovna Nuvio - Vybrána knihovna Trakt - Vybrána knihovna Nuvio + Byla vybrána knihovna Trakt + Byla vybrána knihovna Nuvio Postup sledování - Vyberte, který zdroj postupu pohání obnovení a pokračování ve sledování + Vyberte, který zdroj postupu bude ovládat funkce Pokračovat ve sledování (resume a next up). 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í. + Vyberte, zda by panel Pokračovat ve sledování (resume a next up) měl používat Trakt nebo Synchronizaci Nuvio, 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í. + Synchronizace Nuvio + Zdroj postupu sledování byl nastaven na Trakt + Zdroj postupu sledování byl nastaven na Synchronizaci Nuvio + Zdroj Podobných titulů (More Like This) + Vyberte, odkud budou pocházet doporučení na stránkách s podrobnostmi. + Zdroj Podobných titulů + Vyberte zdroj doporučení zobrazených na stránkách s podrobnostmi. + Trakt + TMDB + Okno Pokračovat ve sledování + Historie Trakt braná v úvahu pro Pokračovat ve sledování + Okno Pokračovat ve sledování + Vyberte, nakolik by se aktivita Trakt měla zobrazovat v okně Pokračovat ve sledování. Celá historie %1$d dní Hodnocení diváků @@ -828,22 +1077,22 @@ TMDB Trakt Neznámý - Jantarový - Karmínový - Smaragdový + Jantar + Karmínová + Smaragd Oceán - Růžový - Fialový - Bílý + Růže + 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é + Miniatura další epizody + Neodvysíláno Přeskočit Přeskočit intro Přeskočit outro - Přeskočit shrnutí + Přeskočit rekapitulaci Nebyly nalezeny žádné titulky Afrikánština Albánština @@ -902,7 +1151,7 @@ Polština Portugalština (Portugalsko) Portugalština (Brazílie) - Paňdžábština + Pandžábština Rumunština Ruština Srbština @@ -931,18 +1180,21 @@ Ne Aktualizovat Ano - Opravdu chcete ukončit aplikaci? + Chcete ukončit aplikaci? Ukončit aplikaci Tento katalog nevrátil žádné položky. Nebyly nalezeny žádné tituly + Další akce 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é + Podobné tituly + Poháněno TMDB + Poháněno Trakt Série - Tento doplněk vrátil videa pro seriál, ale žádné neobsahovalo čísla série nebo epizody. + Tento doplněk vrátil videa pro seriál, ale žádné z nich neobsahovalo čísla sérií nebo epizod. Tento doplněk neposkytl metadata epizod pro tento seriál. - Epizody ještě nebyly tímto doplňkem zveřejněny. + Epizody zatím nebyly tímto doplňkem publikovány. Vaše zařízení je online, ale Nuvio se nemohlo spojit s požadovanými servery. Zobrazit méně Zobrazit více ▾ @@ -950,13 +1202,13 @@ Všechny žánry Katalog %1$s • %2$s - Vybranému katalogu se nepodařilo vrátit položky objevování. + Vybraný katalog nevrátil položky k objevování. Nepodařilo se načíst objevování - Nainstalované doplňky neposkytují katalogy kompatibilní s dlaždicemi pro objevování. + Nainstalované doplňky neobsahují kompatibilní katalogy pro funkci 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. + Nainstalujte a ověřte alespoň jeden doplněk před procházením objevovacích katalogů. Vybrat katalog Vybrat žánr Vybrat typ @@ -965,42 +1217,47 @@ Označit předchozí jako zhlédnuté Označit %1$s jako nezhlédnutou Označit %1$s jako zhlédnutou + Označit předchozí série jako zhlédnuté 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 + Zbývá %1$dh %2$dm + Zbývá %1$dm + Nainstalujte a ověřte alespoň jeden doplněk před načtením řádků katalogu na Domovské obrazovce. + Nainstalované doplňky v současné době nezpřístupňují kompatibilní katalogy bez požadovaných doplňků. + Nejsou k dispozici žádné domovské řádky Zobrazit podrobnosti - Ovládací prvky pro přehrávání a ukládání. + Ovládání přehrávání a ukládání. Akce - Hlavní seznam herců. - Související kolekce nebo filmová série. - Kolekce - Sekce komentářů Trakt. + Hlavní herecké obsazení. + Související sbírka nebo franšízový pruh. + Sbírka + Sekce s komentáři z Trakt. Délka, stav, vydání, jazyk a související informace. Podrobnosti - Sezóny a seznam epizod pro seriály. - Řádek doporučení. - Podobné + Série a seznam epizod pro seriály. + Pruh doporučení. + Podobné tituly Synopse, hodnocení, žánry a hlavní tvůrci. Přehled Studia a sítě. Produkce - Trailery a zástupci pro přehrávání. + Pruh s trailery a zástupci pro přehrávání. Zpět online - Nelze se spojit se servery + Nelze se připojit k serverům + Prázdné tělo odpovědi + Požadavek selhal s chybou HTTP %1$d Žá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 + Narozen/a %1$s%2$s + Zemřel/a %1$s + Známý/á pro: %1$s Nejnovější - Nepodařilo se načíst podrobnosti pro %1$s + Nelze načíst podrobnosti pro %1$s Populární Něco se pokazilo - Připravované + Nadcházející Zpět Zrušit Zadejte PIN @@ -1008,17 +1265,17 @@ 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. + Možnosti avatara se objeví zde po načtení katalogu. Avatar: %1$s - Zadejte platnou URL adresu obrázku s http:// nebo https://. - Vyberte avatara - Níže vyberte avatara. + Zadejte platné URL obrázku začínající http:// nebo https://. + Vyberte si avatara + Vyberte si avatara níže. Vytvořit profil - Vybrána vlastní URL avatara. + Vybráno vlastní URL avatara. Vlastní URL avatara - Vložte odkaz na obrázek, nebo ponechte prázdné pro použití vestavěného katalogu avatarů. + Vložte odkaz na obrázek, nebo nechte toto pole prázdné a použijte vestavěný katalog avatarů. https://example.com/avatar.png - Všechna data pro "%1$s" budou trvale smazána. + Všechna data pro \"%1$s\" budou trvale smazána. Smazat profil Přidat profil Upravit profil @@ -1032,34 +1289,38 @@ Primární doplňky vypnuty Primární doplňky zapnuty Odstranit PIN pro %1$s - Odstranit zámek PIN + Odstranit PIN zámek 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 + Přidejte si PIN, pokud chcete tento profil uzamknout před přepnutím do něj. + Tento profil je chráněn PINem. + Vyberte si avatara pro tento profil. + Nastavit PIN zámek Nepojmenovaný profil Použít primární doplňky - Sdílet nastavení doplňků hlavního profilu namísto správy samostatného seznamu. + Místo spravování odděleného seznamu sdílejte nastavení doplňků s hlavním profilem. Kdo se dívá? Staženo Pokračovat - Aktivní scrapery + Aktivní scrapers 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. + Otevřít v externím přehrávači + Otevřít v interním přehrávači + Nainstalované streamovací doplňky nevrátily platnou odpověď streamu. + Nelze načíst streamy + Nainstalujte si nejprve doplněk pro načtení streamů pro tento titul. Vaše nainstalované doplňky neposkytují streamy pro tento typ titulu. - Není k dispozici žádný doplněk pro streamování + Není k dispozici žádný streamovací doplněk Žá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í… + Získávání… Hledání zdroje… + Načítání titulků z doplňků… + Načítání úseků k přeskočení… Hledání streamů… Odkaz na stream zkopírován Není k dispozici žádný přímý odkaz na stream @@ -1069,35 +1330,44 @@ Pokračovat od %1$s VELIKOST %1$s Tento typ streamu není podporován + Připojte účet v Nastavení. + Není uloženo v mezipaměti na Torbox. + Tento odkaz vypršel. Obnovuji výsledky. + Nelze otevřít tento odkaz. + Nelze otevřít externí přehrávač + Nejprve vyberte externí přehrávač v nastavení + Žádný externí přehrávač není dostupný Zavřít trailer - Trailer nelze přehrát + Nelze přehrát trailer Nepodařilo se načíst seznamy Trakt Nepodařilo se aktualizovat seznamy Trakt %1$s • %2$s Kontrola aktualizací selhala Stahování selhalo + Prázdné tělo stahování + Stažený soubor aktualizace chybí. Stahování %1$d%% - Nelze zahájit instalaci + Nelze spustit instalaci Používáte nejnovější verzi. - Povolte instalace aplikací pro Nuvio, poté se vraťte a pokračujte. + Povolte instalace aplikací pro Nuvio, pak 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. + Aktualizace v aplikaci nejsou v tomto buildu dostupné. Příprava stahování Poznámky k vydání - Povolte instalace pro pokračování - Aktualizace k dispozici + Povolit instalace pro pokračování + Aktualizace je k dispozici Stav aktualizace Tento doplněk je již nainstalován. Zadejte platnou URL doplňku - Nelze načíst manifest + Nepodařilo se načíst manifest Nuvio Smazání účtu selhalo Přihlášení selhalo Odhlášení selhalo Registrace selhala - Nelze načíst položky katalogu. + Nepodařilo se načíst položky katalogu. Další na řadě Další na řadě • S%1$dE%2$d Logo %1$s @@ -1106,61 +1376,108 @@ 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í + Zobrazuje živý průběh a ovládání stahování. + Stažené položky 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? + Odstranit + Odstranit %1$s ze seznamu %2$s? + Odstranit %1$s z vaší knihovny? + Odstranit z knihovny? Film - Upozornění na vydání nové epizody uloženého seriálu. + Upozorňuje na vydání nové epizody uloženého pořadu. Náhled upozornění na vydání epizody. - Nepodařilo se odeslat zkušební oznámení. + Odeslání zkušebního oznámení selhalo. 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 stream se nepodařilo přehrát. + Modul přehrávače MPV není k dispozici. Prosím sestavte aplikaci znovu. + PIN k tomuto profilu se změnil. Pro aktualizaci zámku na tomto zařízení se jednou připojte. + PIN zámek se nepodařilo odstranit. Zkuste to znovu. + Pro odstranění PIN zámku se připojte k internetu. + Tento PIN zatím nelze ověřit offline na tomto zařízení. Jednou se připojte a nejdřív jej odemkněte online. + PIN se nepodařilo nastavit. Zkuste to znovu. + Pro nastavení PINu se připojte k internetu. Tento profil používá primární doplňky. - Nepodařilo se načíst %1$s + Nepodařilo se načíst scraper %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 + Vestavěné + Oprávnění odepřeno + Dokončete přihlášení do Trakt ve svém prohlížeči + Připojeno k Trakt + Odpojeno od Trakt + Neplatný callback Trakt + Neplatný stav callbacku Trakt + Neplatná odpověď s tokenem od 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í + Chybí pověření pro Trakt + Nepodařilo se načíst postup Trakt + Veřejný seznam Trakt + Zadejte platné ID nebo URL seznamu Trakt + %1$d položek + %1$d to se mi líbí + Chybí pověření pro Trakt v local.properties (TRAKT_CLIENT_ID). + Chybí ID seznamu Trakt + Seznam Trakt neobsahoval číselné ID + Seznam Trakt nenalezen nebo není veřejný + Dosažen limit počtu požadavků na Trakt + Požadavek na Trakt selhal + Nepodařilo se dokončit přihlášení do Trakt + Uživatel Trakt + Seznam sledovaných (Watchlist) + + Ověření k Trakt vypršelo + Seznam Trakt nebyl nalezen + Dosažen limit seznamů Trakt + Dosažen limit počtu požadavků na Trakt + Požadavek na Trakt selhal + Přidání do seznamu sledovaných na Trakt selhalo + Přidání do seznamu Trakt selhalo + Chybí kompatibilní Trakt ID + Prázdné tělo odpovědi + Přidejte API klíč TMDB v Nastavení, abyste mohli používat zdroje TMDB. + Sbírka TMDB %1$d + Sbírka TMDB nenalezena + Produkce TMDB %1$d + Společnost TMDB nenalezena + Režisér TMDB %1$d + Objevování (Discover) na TMDB nevrátilo žádná data + Objevovat na TMDB (Discover) + Zadejte platné ID nebo URL z TMDB. + Seznam TMDB %1$d + Seznam TMDB nenalezen + Zdroj TMDB se nepodařilo načíst + Chybí ID sbírky TMDB + Chybí ID seznamu TMDB + Chybí ID osoby TMDB + Síť TMDB %1$d + Síť TMDB nenalezena + Kredity osoby TMDB nenalezeny + Osoba TMDB %1$d + Osoba TMDB nenalezena Trailer - Neznámé + Neznámý Doplněk Uloženo Přehrát %1$s - Pokračovat %1$s + Pokračovat v %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. + Sbírka %1$d má prázdné ID. + ID sbírky '%1$s' je použito vícekrát. + Sbírka '%1$s' má prázdný název. + Složka %1$d ve sbírce '%2$s' má prázdné ID. + ID složky '%1$s' je ve sbírce '%2$s' použito vícekrát. + Složka '%1$s' ve sbírce '%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. + Zdroj %1$d ve složce '%2$s' postrádá ID seznamu Trakt. Neplatný JSON: %1$s - Doplněk nenalezen: %1$s + Doplněk nebyl nalezen: %1$s + Seznam filmů Trakt + Seznam seriálů Trakt Leden Únor Březen @@ -1200,28 +1517,63 @@ Délka Plakáty Text - Podrobnosti o seriálu + Podrobnosti o pořadu Stav Videa SOUBOR Není k dispozici žádný přímý odkaz na stream - Nahradilo předchozí stahování + Nahrazeno předchozí stahování Stahování zahájeno Nepodporovaný formát streamu pro stahování Prázdné tělo odpovědi + Stahovaný soubor se nepodařilo dokončit Požadavek selhal s chybou HTTP %1$d Systém stahování není inicializován + Nepodařilo se otevřít částečný soubor stahování + Částečný soubor stahování není otevřený Požadavek na stažení selhal + Nepodařilo se zapsat do částečného souboru stahování %1$s - %2$s - Uložené tituly se zde objeví poté, co klepnete na Uložit na obrazovce s podrobnostmi. + Uložené tituly se zde objeví poté, co na obrazovce podrobností klepnete na Uložit. Vaše knihovna je prázdná Knihovnu se nepodařilo načíst Ostatní + Cloud + Uložené Knihovna - Připojte Trakt a ukládejte tituly do svého seznamu ke zhlédnutí nebo do osobních seznamů. + Připojte se k Trakt a uložte tituly do seznamu sledovaných nebo osobních seznamů. Vaše knihovna Trakt je prázdná Knihovnu Trakt se nepodařilo načíst Knihovna Trakt + Připojit účet + Připojte účet v nastavení Připojené služby a procházejte přehrávatelné soubory z vaší cloudové knihovny. + Není připojen žádný cloudový účet + Otevřít Připojené služby + Zapněte cloudovou knihovnu v nastavení Připojené služby pro procházení souborů z připojených účtů. + Cloudová knihovna je vypnutá + Žádné přehrávatelné cloudové soubory neodpovídají aktuálním filtrům. + Zatím tu nic není + Vyberte soubor k přehrání + Nepodařilo se načíst cloudovou knihovnu %1$s + Tato položka nenabízí přehrávatelný video soubor. + Žádné přehrávatelné soubory + Žádné přehrávatelné soubory + Cloudová knihovna je vypnutá. + Tento cloudový soubor se nepodařilo přehrát. + Přehrát soubor + Cloudová služba není připojena. + Služba %1$s není připojena. + %1$d přehrávatelných souborů + Vše + Obnovit cloudovou knihovnu + Vybrat poskytovatele + Vybrat typ + Připraveno k přehrání + Vše + Torrenty + Usenet + Web + Soubory Anime Kanály Filmy @@ -1232,16 +1584,347 @@ Nová epizoda je nyní venku %1$s je nyní venku Vydání epizod + Alkohol/Drogy + Děsivé scény + Nahota + Vulgární jazyk + Mírné + Střední + Závažné + Násilí Tvůrce Režisér Scénárista Hodnocení diváků - Nebyl nalezen žádný přehratelný stream traileru. + Nenalezen žádný přehrávatelný stream s trailerem. Série %1$d - %2$s B KB MB GB - Nový díl - Nová sezóna + + Odeslat intro + Nastavení videa + + Cloudová knihovna není pro %1$s k dispozici. + + Video + Resetovat ladění + Předvolba výstupu + Detekce maximálního jasu HDR + Odhadnout maximální jas HDR, když jsou metadata chybná nebo chybí. + Mapování tónů + Odstranění pásů (Deband) + Snižuje pruhování barev za cenu malého poklesu výkonu. + Interpolace snímků + Plynulý pohyb, když může mpv čistě použít synchronizaci displeje. + Jas + Kontrast + Sytost + Gama + + Nativní EDR + Nejlepší pro iPhony a iPady s podporou HDR. + SDR mapované tóny + Předvídatelnější bílé a černé barvy u SDR výstupu. + Kompatibilita + Nejbližší staršímu chování iOS MPV. + Vlastní + Použije vaše pokročilé hodnoty níže. + Vyp + + Výstup videa pro iOS + Hardwarový dekodér + Rozšířený dynamický rozsah + Výchozí režim výstupu Metal pro nové relace přehrávání. + Pokyn pro barvy displeje + Nechte mpv ve výchozím stavu cílit na barevný prostor aktivního displeje. + Cílové základní barvy + Cílový přenos + + Výstup zvuku pro iOS + Výstup zvuku + Použít AudioUnit, dokud je výstup AVFoundation dočasně zakázán. + Experimentální podpora pro prostorový zvuk (Spatial Audio) a vícekanálový výstup. + Použít starší výstup AudioUnit. + + Správa výsledků + Max výsledků + Omezit, kolik výsledků se zobrazí. + Seřadit výsledky + Zvolit, jak budou výsledky seřazeny. + Limit na rozlišení + Omezení opakujících se výsledků (2160p, 1080p, 720p) po seřazení. + Limit na kvalitu + Omezení opakujících se výsledků (BluRay, WEB-DL, REMUX) po seřazení. + Rozsah velikosti + Filtrovat výsledky podle velikosti souboru. + + Zjistit více + Výchozí formát + Původní formát + Zadejte jednu skupinu na každý řádek. + + Původní pořadí + Nejlepší kvalita nejdřív + Největší nejdřív + Nejmenší nejdřív + Nejlepší zvuk nejdřív + Jazyk nejdřív + + Jakýkoliv + Vybráno %1$d + + Všechny výsledky + %1$d výsledků + + Až do %1$dGB + %1$dGB+ + %1$d-%2$dGB + + Preferovaná rozlišení + Třídit vybraná rozlišení jako první ve výchozím pořadí. + Požadovaná rozlišení + Zobrazit pouze vybraná rozlišení. + Vyloučená rozlišení + Skrýt vybraná rozlišení. + + Preferované kvality + Třídit vybrané kvality jako první ve výchozím pořadí. + Požadované kvality + Zobrazit pouze vybrané kvality. + Vyloučené kvality + Skrýt vybrané kvality. + + Preferované vizuální štítky + Třídit podle štítků DV, HDR, 10bit, IMAX a podobných. + Požadované vizuální štítky + Vyžadovat štítky DV, HDR, 10bit, IMAX, SDR a podobné. + Vyloučené vizuální štítky + Skrýt štítky DV, HDR, 10bit, 3D a podobné. + + Preferované zvukové štítky + Třídit podle štítků Atmos, TrueHD, DTS, AAC a podobných. + Požadované zvukové štítky + Vyžadovat štítky Atmos, TrueHD, DTS, AAC a podobné. + Vyloučené zvukové štítky + Skrýt vybrané zvukové štítky. + + Preferované kanály + Třídit preferované rozložení zvukových kanálů jako první. + Požadované kanály + Zobrazit pouze vybraná rozložení zvukových kanálů. + Vyloučené kanály + Skrýt vybraná rozložení zvukových kanálů. + + Preferované kódování + Třídit AV1, HEVC, AVC a podobná kódování. + Požadované kódování + Vyžadovat AV1, HEVC, AVC a podobná kódování. + Vyloučené kódování + Skrýt vybraná kódování. + + Preferované jazyky + Třídit preferované jazyky zvuku jako první. + Požadované jazyky + Zobrazit pouze výsledky s vybranými jazyky. + Vyloučené jazyky + Skrýt výsledky, u kterých je vyloučen každý z jazyků. + + Požadované release skupiny + Zobrazit pouze vybrané skupiny (release groups). + Vyloučené release skupiny + Skrýt vybrané skupiny (release groups). + + Odeslat časové značky + TYP ÚSEKU + Intro + Rekapitulace + Outro + ČAS ZAČÁTKU (MM:SS) + ČAS KONCE (MM:SS) + Odeslat + Zachytit + + Hardwarový dekodér + Výstup zvuku + Cílové základní barvy + Cílový přenos + + Rozpoznaný seznam Trakt + Objevovat na TMDB (Discover) + CZ, US, KR, JP, IN + + Zadejte název, URL nebo ID seznamu Trakt + Zadejte platné ID nebo URL z TMDB. + Zdroj TMDB se nepodařilo načíst + Zadejte ID nebo URL seznamu Trakt + Seznam Trakt se nepodařilo načíst + + Kanada + Austrálie + Německo + + Filmy + Seriály + + Cloudová knihovna je vypnutá. + + Neplatný API klíč nebo selhalo připojení + V manifestu chybí \"%1$s\" + Sbírka TMDB %1$s + Režisér TMDB %1$s + Objevovat na TMDB (Discover) + Zadejte platné ID nebo URL z TMDB. + Seznam TMDB %1$s + Síť TMDB %1$s + Osoba TMDB %1$s + Produkce TMDB %1$s + Zdroj TMDB se nepodařilo načíst + ID %1$s + Přidejte API klíč TMDB v Nastavení, abyste mohli používat zdroje TMDB. + Sbírka TMDB nenalezena + Společnost TMDB nenalezena + Objevování (Discover) na TMDB nevrátilo žádná data + Seznam TMDB nenalezen + Chybí ID sbírky TMDB + Chybí ID seznamu TMDB + Chybí ID osoby TMDB + Síť TMDB nenalezena + Kredity osoby TMDB nenalezeny + Osoba TMDB nenalezena + Chybí pověření pro Trakt. + %1$s (%2$d) + Zadejte platné ID nebo URL seznamu Trakt + %1$d položek + %1$d to se mi líbí + Seznam Trakt nenalezen nebo není veřejný + Chybí ID seznamu Trakt + Seznam Trakt neobsahoval číselné ID + Veřejný seznam Trakt + Dosažen limit počtu požadavků na Trakt + Požadavek na Trakt selhal + Nepodařilo se načíst komentáře z Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Stahovaný soubor se nepodařilo dokončit + Nepodařilo se otevřít částečný soubor stahování + Částečný soubor stahování není otevřený + Nepodařilo se zapsat do částečného souboru stahování + Knihovna Nuvio + Problém s připojením + Prázdné tělo odpovědi + Zkontrolujte prosím své připojení a zkuste to znovu. + Požadavek selhal s chybou HTTP %1$d + %1$s (%2$s) + Modul přehrávače MPV není k dispozici. Prosím sestavte aplikaci znovu. + Tento stream se nepodařilo přehrát. + Pluginy vypnuty + Pluginy zapnuty + %1$d poskytovatelů + Obnovování + %1$d repozitářů + Chybí API klíč TMDB + API klíč TMDB nastaven + Instalovat repozitář pluginů + Instalace… + Otestovat poskytovatele + Testování… + Smazat repozitář pluginů + Obnovit repozitář pluginů + Zatím nejsou k dispozici žádní poskytovatelé. + Přidejte URL repozitáře pro instalaci pluginů poskytovatelů za účelem objevování streamů. + Zatím nejsou nainstalovány žádné repozitáře pluginů. + Použít pluginy poskytovatelů během objevování streamů. + Povolit pluginy poskytovatelů globálně + Tento repozitář pluginů je již nainstalován. + Zadejte URL repozitáře pluginů. + Zadejte platnou URL pluginu. + Nepodařilo se nainstalovat repozitář pluginů + Poskytovatel nebyl nalezen + Nepodařilo se obnovit repozitář + Pluginy nejsou v tomto buildu dostupné. + Ve streamech zobrazovat jednoho poskytovatele na repozitář, namísto jednoho na zdroj. + Seskupovat pluginy poskytovatelů podle repozitáře + URL manifestu pluginu + V manifestu chybí název. + Manifest neobsahuje žádné poskytovatele. + V manifestu chybí verze. + V manifestu chybí název. + Manifest neobsahuje žádné poskytovatele. + V manifestu chybí verze. + Nainstalováno: %1$s. + Zakázáno repozitářem + Žádný popis + v%1$s + Repozitář pluginů + Verze %1$s + Tento repozitář pluginů je již nainstalován. + Nepodařilo se nainstalovat repozitář pluginů + Nepodařilo se obnovit repozitář + PŘIDAT REPOZITÁŘ + NAINSTALOVANÉ REPOZITÁŘE + PŘEHLED + POSKYTOVATELÉ + Chyba + Test poskytovatele selhal + Výsledky testu (%1$d) + Pluginy poskytovatelů vyžadují API klíč k TMDB. Nastavte jej na obrazovce TMDB, jinak nebudou pluginy správně fungovat. + Pro %1$s nebyly vráceny žádné výsledky vyhledávání. + Neplatný API klíč nebo selhalo připojení + Repozitář pluginů + Odeslat intro + Odeslat + Zachytit + ČAS KONCE (MM:SS) + TYP ÚSEKU + ČAS ZAČÁTKU (MM:SS) + Připojeno k Trakt + Odpojeno od Trakt + TB + Ve vydání nebyl nalezen žádný soubor APK + Stahování selhalo s chybou HTTP %1$d + Stažený soubor aktualizace chybí. + Prázdné tělo stahování + Chyba API GitHub vydání: %1$d + Zatím nebyla vydána žádná aktualizace. + Vydání nemá štítek ani název + + Vysílá se %1$s + Vysílá se dnes + Vysílá se zítra + + Vysílá se za %1$d den + Vysílá se za %1$d dní + + %1$s + Dnes + Zítra + + Za %1$d den + Za %1$d dní + + Nová epizoda + Nová série + + Seznam Trakt %1$s + Systémový přehrávač Android + P2P Streamování + Tento stream využívá technologii peer-to-peer (P2P). Povolením P2P potvrzujete a souhlasíte s tím, že:\n\n• Vaše IP adresa bude viditelná pro ostatní uživatele v síti (peery)\n• Jste výhradně zodpovědní za obsah, ke kterému přistupujete\n• Potvrzujete, že máte legální právo streamovat tento obsah ve vaší jurisdikci\n• Nuvio nehostuje, nedistribuuje ani nekontroluje žádný P2P obsah\n• Nuvio nenese žádnou odpovědnost za jakékoliv právní důsledky vyplývající z vašeho používání P2P streamování\n\nTuto funkci využíváte výhradně na vlastní nebezpečí. P2P lze kdykoli vypnout v Nastavení. + Povolit P2P + Zrušit + Neznámá chyba torrentu + P2P Streamování + Povolit peer-to-peer (torrent) streamy + Skrýt statistiky torrentu + Skrýt načítání (buffer), seedery, peery a rychlost stahování během načítání a přehrávání + %1$d seederů · %2$d peerů + %1$s uloženo do paměti (buffer) · %2$s · %3$s + %1$s · %2$s + %1$d peerů · %2$d seederů · %3$d%% + Připojování k peerům… + Spouštění enginu P2P… + Nepodařilo se spustit torrent: %1$s + Chyba torrentu: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 3afb1161..09a97502 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -27,6 +27,13 @@ Nicht verfügbar Addon konfigurieren Addon löschen + Bring deine Mediathek mit den Addons, die du nutzt, zu Nuvio. + Füge ein Addon hinzu, um deine Mediathek aufzubauen. + Richte Nuvio für dich ein. + Addon-URL + Addon hinzufügen + Hier gibt es noch nichts + Deine aktuellen Addons zeigen hier noch nichts an. Füge eine Manifest-URL hinzu, um Kataloge, Metadaten, Streams oder Untertitel in Nuvio zu laden. Noch keine Addons installiert. Gib eine Addon-URL ein. @@ -383,7 +390,9 @@ Suche nach neuen Versionen der App. Nach Updates suchen Verwalte Addons und Entdeckungsquellen. + Verwalte Addons und wähle aus, was in Nuvio angezeigt wird. Verwalte deine heruntergeladenen Filme und Episoden. + Verwalte, was du auf diesem Gerät gespeichert hast. Downloads ALLGEMEIN TMDB- und MDBList-Dienste verbinden. @@ -519,6 +528,7 @@ START QUELLEN Installiere, entferne, aktualisiere und sortiere deine Inhaltsquellen. + Füge die Addons hinzu, die du mit Nuvio nutzt, und verwalte sie. Installiere JavaScript-Scraper-Repositories und teste Anbieter intern. Lege fest, welche Kataloge auf der Startseite und in welcher Reihenfolge erscheinen. Detail-Abschnitte deaktivieren und alles unterhalb des Hero neu anordnen. diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index fcb41fc9..21a65b73 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -1,3 +1,4 @@ + Δημόσια αναγνώριση και συντελεστές του έργου Πίσω @@ -126,7 +127,7 @@ Συνέχεια χωρίς λογαριασμό Δημιουργία λογαριασμού Δεν έχετε λογαριασμό; - Email + Ηλεκτρονικό ταχυδρομείο ή Κωδικός Συνδεθείτε για να αποκτήσετε πρόσβαση στη βιβλιοθήκη και την πρόοδό σας @@ -232,7 +233,9 @@ Ελέγξτε για νέες εκδόσεις της εφαρμογής. Έλεγχος για ενημερώσεις Διαχείριση πρόσθετων και πηγών ανακάλυψης. + Διαχειριστείτε τα πρόσθετα και επιλέξτε τι εμφανίζεται στο Nuvio. Διαχειριστείτε τις κατεβασμένες ταινίες και επεισόδιά σας. + Διαχειριστείτε όσα έχετε αποθηκεύσει σε αυτή τη συσκευή. Λήψεις ΓΕΝΙΚΑ Σύνδεση υπηρεσιών TMDB και MDBList. @@ -249,7 +252,7 @@ Αναπαραγωγή %1$d/10 Κριτική - Spoiler + Σπόιλερ Δεν υπάρχουν ακόμα κριτικές Trakt. %1$d μου αρέσει Αυτό το σχόλιο περιέχει spoilers. @@ -261,6 +264,8 @@ Δεν υπάρχουν ολοκληρωμένα επεισόδια Δεν υπάρχουν λήψεις ακόμα %1$d ληφθέν(τα) επεισόδιο(α) + Άνοιγμα φακέλου λήψεων + Δεν ήταν δυνατό το άνοιγμα του φακέλου λήψεων Ενεργές Ταινίες Σειρές @@ -280,12 +285,25 @@ Προβολή όλων Χειροκίνητη αναπαραγωγή Λογότυπο %1$s + Μελλοντικά σφάλματα και τρέχοντα ANR από αυτή τη συσκευή δεν θα αναφέρονται. Αυτό μπορεί να δυσκολέψει σημαντικά την επίλυση σφαλμάτων που αφορούν συγκεκριμένες συσκευές. + Απενεργοποίηση αναφορών σφαλμάτων; + Οι αναφορές σφαλμάτων βοηθούν στον εντοπισμό προβλημάτων που αφορούν συγκεκριμένες συσκευές, χωρίς να χρειάζεται να αναπαράγετε το πρόβλημα με ADB logs. + Ενεργοποίηση αναφορών σφαλμάτων; + Οι αναφορές ομαδοποιούνται κατά έκδοση εφαρμογής, μοντέλο συσκευής, έκδοση Android και ίχνος στοίβας, ώστε να διορθώνονται πρώτα τα πιο συχνά πραγματικά σφάλματα. + Πώς βοηθάει + Διατήρηση ενεργού + Κωδικοί πρόσβασης, tokens πρόσβασης, tokens ανανέωσης, σώματα αιτημάτων, σώματα αποκρίσεων, κεφαλίδες, cookies, στιγμιότυπα οθόνης, ιεραρχία προβολής, επανάληψη περιόδου σύνδεσης, ακατέργαστα διαγνωστικά, καθώς και παράμετροι ή τμήματα URL ροής. + Δεν αποστέλλεται + Σφάλματα, τρέχοντα ANR, ίχνη στοίβας, έκδοση εφαρμογής, τύπος build, flavor, μεταδεδομένα συσκευής και λειτουργικού συστήματος, καθώς και ασφαλή στοιχεία δικτύου με μέθοδο, host, διαδρομή, κατάσταση, διάρκεια και τύπο εξαίρεσης. + Αποστέλλεται + Απενεργοποίηση + Ενεργοποίηση Λογαριασμός Διαγραφή λογαριασμού Αυτό θα διαγράψει μόνιμα τον λογαριασμό σας και όλα τα σχετικά δεδομένα. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Όλα τα δεδομένα, τα προφίλ και το ιστορικό συγχρονισμού σας θα αφαιρεθούν μόνιμα. Διαγραφή λογαριασμού; - Email + Ηλεκτρονικό ταχυδρομείο Δεν είστε συνδεδεμένοι Αποσύνδεση Θα επιστρέψετε στην οθόνη σύνδεσης. @@ -293,6 +311,9 @@ Κατάσταση Ανώνυμος Συνδεδεμένος + ΔΙΑΓΝΩΣΤΙΚΑ + Αναφορές σφαλμάτων Sentry + Αποστολή αναφορών σφαλμάτων και ANR με ασφαλές πλαίσιο. Ενεργό από προεπιλογή. AMOLED Μαύρο Χρήση καθαρού μαύρου φόντου για οθόνες OLED. Γλώσσα εφαρμογής @@ -328,6 +349,37 @@ Ανοίξτε έναν κατάλογο μόνο όταν χρειάζεστε να τον μετονομάσετε ή να τον αναδιατάξετε. Ορατό Αναπαραγωγή, υπότιτλοι και αυτόματη εκκίνηση + Εφέ βάθους κάρτας + Προσθέτει φωτεινό επάνω άκρο και απαλή λάμψη στις κάρτες εικόνας για αίσθηση βάθους. + Ενεργοποίηση εφέ βάθους + Λάμψη άκρου + Ήπια + Ισορροπημένη + Έντονη + Λάμψη επάνω μέρους + Ανενεργή + Απαλή + Φωτεινή + Εφαρμογή σε + Λεπτορύθμιση + Λεπτορύθμιση βάθους + Σύρετε την τελεία δεξιά για πιο φωτεινό άκρο, προς τα πάνω για περισσότερη λάμψη. Χρησιμοποιήστε το ρυθμιστικό για επέκταση της λάμψης γύρω από ολόκληρο το περίγραμμα. + Λάμψη άκρου → + ↑ Λάμψη + Λάμψη άκρου + Λάμψη + Κάλυψη άκρου + Μόνο επάνω + Μισή + Πλήρες περίγραμμα + Κάλυψη άκρου + Τίτλος επεισοδίου + S1 · E1 · 45 λ + Αφίσες + Συνέχεια παρακολούθησης + Κάρτες επεισοδίων + Καστ + Τρέιλερ Ακτίνα κάρτας ΣΤΥΛ ΚΑΡΤΑΣ ΑΦΙΣΑΣ Πλάτος κάρτας @@ -467,7 +519,7 @@ Όλα τα plugins Επιτρεπόμενα πρόσθετα Επιτρεπόμενα plugins - Anime Skip + Anime Παράλειψη Client ID AnimeSkip Εισάγετε το client ID API AnimeSkip. Αποκτήστε ένα στο anime-skip.com. Αναζήτηση επίσης στο AnimeSkip για χρονικές σημάνσεις παράλειψης (απαιτείται client ID). @@ -1056,4 +1108,862 @@ Νέο επεισόδιο Νέα σεζόν + Πηγές δεδομένων, αναγνωρίσεις και άδειες πλατφόρμας + Αποθήκευση… + Εναλλαγή + Επικύρωση + Φέρτε τη βιβλιοθήκη σας στο Nuvio με τα πρόσθετα που χρησιμοποιείτε. + Προσθέστε ένα πρόσθετο για να αρχίσετε να δημιουργείτε τη βιβλιοθήκη σας. + Κάντε το Nuvio δικό σας. + URL πρόσθετου + Προσθήκη πρόσθετου + Δεν υπάρχει τίποτα εδώ ακόμη + Τα πρόσθετα που χρησιμοποιείτε δεν έχουν ακόμη τίποτα να εμφανίσουν εδώ. + Απενεργοποιημένο + Το manifest είναι κενό "%1$s" + Σύνδεση λογαριασμού + Συνδέστε ένα λογαριασμό στις ρυθμίσεις Συνδεδεμένων Υπηρεσιών για να περιηγηθείτε σε αναπαιγαίμα αρχεία από τη βιβλιοθήκη cloud σας. + Δεν υπάρχει συνδεδεμένος λογαριασμός cloud + Άνοιγμα Συνδεδεμένων Υπηρεσιών + Ενεργοποιήστε τη βιβλιοθήκη Cloud στις ρυθμίσεις Συνδεδεμένων Υπηρεσιών για να περιηγηθείτε σε αρχεία από συνδεδεμένους λογαριασμούς. + Η βιβλιοθήκη Cloud είναι απενεργοποιημένη + Κανένα αναπαιγαίμα αρχείο cloud δεν ταιριάζει με τα τρέχοντα φίλτρα. + Τίποτα εδώ ακόμα + Επιλέξτε ένα αρχείο για αναπαραγωγή + Δεν ήταν δυνατή η φόρτωση της βιβλιοθήκης %1$s cloud + Αυτό το στοιχείο δεν εκθέτει ένα αναπαιγαίμο αρχείο βίντεο. + Δεν υπάρχουν αναπαιγαίμα αρχεία + Δεν υπάρχουν αναπαιγαίμα αρχεία + Η βιβλιοθήκη Cloud είναι απενεργοποιημένη. + Δεν ήταν δυνατή η αναπαραγωγή αυτού του αρχείου cloud. + Αναπαραγωγή αρχείου + Η υπηρεσία cloud δεν είναι συνδεδεμένη. + Το %1$s δεν είναι συνδεδεμένο. + %1$d αναπαιγαίμα αρχεία + Η βιβλιοθήκη Cloud είναι απενεργοποιημένη. + Όλα + Το cloud library δεν είναι διαθέσιμο για %1$s. + Ανανέωση cloud library + Επιλογή παρόχου + Επιλογή τύπου + Έτοιμο για αναπαραγωγή + Όλα + Αρχεία + Torrents + Usenet + Web + Προσθήκη πηγής + Προσθήκη Trakt List + %1$d κατάλογοι + %1$d επιλεγμένα + Επεξεργασία Trakt List + Ταινίες + Σειρές + Επιλυμένη λίστα Trakt + %1$d επιλεγμένα + Και τα δύο + Συλλογή TMDB + TMDB Collection %1$d + Παράδειγμα: Star Wars Collection, Harry Potter Collection, ή URL συλλογής. + ID συλλογής + Συλλογή + 10 για Star Wars Collection + TMDB Collection %1$s + ID εταιρειών + Χρησιμοποιήστε ID στούντιο/εταιρειών. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. + 420 για Marvel Studios + TMDB Company %1$d + Marvel Studios, 420, ή URL εταιρείας + Όνομα εταιρείας παραγωγής, ID ή URL + Χώρα προέλευσης + Αυστραλία + Καναδάς + Γερμανία + Χρησιμοποιήστε διγράμματους κωδικούς χώρας, για παράδειγμα US, KR, JP, IN. + Ινδία + Ιαπωνία + Κορέα + US, KR, JP, IN + Ηνωμένο Βασίλειο + Ηνωμένες Πολιτείες + Προσαρμοσμένο + Ημερομηνία κυκλοφορίας ή προβολής από + 2020-01-01 + Χρησιμοποιήστε YYYY-MM-DD, για παράδειγμα 2024-01-01. + Ημερομηνία κυκλοφορίας ή προβολής έως + 2024-12-31 + Σκηνοθέτης + TMDB Director %1$s + Ταινίες Christopher Nolan, Αγαπημένοι σκηνοθέτες + TMDB Discover + TMDB Discover + Καλύτερες ταινίες δράσης, Korean Dramas, 2024 Animation + Οθόνη title + Φίλτρα + Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. + Δράση + Περιπέτεια + Κινούμενα σχέδια + Κωμωδία + Εγκλημα + Δράμα + Τρόμου + Ριάλιτι + Επιστημονική φαντασία + Είδος IDs + Χρησιμοποιήστε αριθμούς ειδών TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR. + 28,12 + 18,35 + Αναζητήστε το όνομα μιας συλλογής ταινιών ή επικολλήστε το ID συλλογής από το TMDB. + Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τα έργα ενός σκηνοθέτη. + Δημιουργήστε μια ζωντανή σειρά TMDB χρησιμοποιώντας προαιρετικά φίλτρα. Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. + Επικολλήστε ένα δημόσιο URL λίστας TMDB ή μόνο τον αριθμό από το URL. + Εισαγάγετε ένα ID δικτύου. Κοινά δίκτυα είναι διαθέσιμα στα Προκαθορισμένα και στα γρήγορα φίλτρα. + Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τη συμμετοχή του ως ηθοποιός. + Επιλέξτε μια έτοιμη πηγή. Μπορείτε να την επεξεργαστείτε ή να την αφαιρέσετε αφού την προσθέσετε. + Αναζητήστε με το όνομα του στούντιο, ή επικολλήστε ένα ID/URL εταιρείας TMDB και προσθέστε το απευθείας. + TMDB ID or URL + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + Βασισμένο σε μυθιστόρημα + Space + Superhero + Ώρα Travel + ID λέξεων-κλειδιών + Χρησιμοποιήστε αριθμούς λέξεων-κλειδιών TMDB. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. + 9715 για υπερήρωες + Αρχική γλώσσα + Αγγλικά + Χρησιμοποιήστε διγράμματους κωδικούς γλώσσας, για παράδειγμα en, ko, ja, hi. + Χίντι + Ιαπωνικά + Κορεατικά + en, ko, ja, hi + Ισπανικά + Παράδειγμα: https://www.themoviedb.org/list/8504994 ή 8504994. + https://www.themoviedb.org/list/8504994 ή 8504994 + TMDB List %1$s + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Ταινίες + Disney+ + HBO + Παραδείγματα ID: Netflix 213, HBO 49, Disney+ 2739. + Hulu + ID δικτύου + Δίκτυο + Netflix + 213 for Netflix, 49 for HBO, 2739 for Disney+ + Prime Βίντεο + TMDB Network %1$s + ID δικτύων + Μόνο για σειρές. Χρησιμοποιήστε ID δικτύων όπως Netflix 213 ή HBO 49. + 213 for Netflix + Παράδειγμα: https://www.themoviedb.org/person/31-tom-hanks ή 31. + ID προσώπου + Πρόσωπο + 31 for Tom Hanks, or person URL + TMDB Person %1$s + Tom Hanks Ταινίες, Favorite Actors + Προκαθορισμένα + Παραγωγή + TMDB Production %1$s + Public TMDB list + Δημόσια λίστα + Γρήγορες χώρες + Γρήγορα είδη + Γρήγορες λέξεις-κλειδιά + Γρήγορες γλώσσες + Γρήγορα δίκτυα + Γρήγορα στούντιο + Γρήγοροι πάροχοι προβολής + Γρήγορες περιοχές προβολής + Βαθμολογία TMDB από 0 έως 10. Παράδειγμα: 7.0. + Μέγιστη βαθμολογία + 10 + Ελάχιστη βαθμολογία + 7.0 + Αναζήτηση + Παραδείγματα: Marvel Studios, 420, ή https://www.themoviedb.org/company/420. + Αποτελέσματα αναζήτησης + Σειρές + Ταξινόμηση + Αρχική σειρά + Δημοφιλή + Πρόσφατα + Καλύτερη βαθμολογία + Περισσότερες ψήφοι + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Πηγές TMDB + Disney + Lucasfilm + Marvel + Pixar + Warner Bros. + Σκηνοθέτης + TMDB Discover + TMDB List + Συλλογή ταινιών TMDB + Δίκτυο + Πρόσωπο + Παραγωγή + Εμφανίζεται ως το όνομα της σειράς/καρτέλας. Αν είναι κενό, το Nuvio δημιουργεί ένα από την πηγή. + Marvel Ταινίες, Netflix Originals, Pixar + Τύπος + Χρησιμοποιήστε το για να αποφύγετε άσημους τίτλους με λίγες ψήφους. Παράδειγμα: 100. + Ελάχιστες ψήφοι + 100 + Apple TV+ + Disney+ + Hulu + Netflix + Prime Βίντεο + ID παρόχων προβολής + Χρησιμοποιήστε ID παρόχων προβολής TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR. + 8|337|350 + Περιοχή προβολής + Κωδικός χώρας ISO 3166-1 όπου είναι διαθέσιμος ο τίτλος. Παράδειγμα: US, GB. + Έτος + Χρησιμοποιήστε τετραψήφιο έτος, για παράδειγμα 2024. + 2024 + Αύξουσα + Φθίνουσα + Κατεύθυνση + Εισαγάγετε ένα ID ή URL λίστας Trakt + Εισαγάγετε όνομα, URL ή ID λίστας Trakt + Trakt List %1$d + Εισαγάγετε ένα ID ή URL λίστας Trakt + Χρησιμοποιήστε ένα δημόσιο URL λίστας Trakt ή αριθμητικό ID λίστας, ή αναζητήστε με όνομα. + Αναζήτηση τίτλου, URL Trakt ή ID λίστας + Εισαγάγετε όνομα, URL ή ID λίστας Trakt + Λίστα Trakt + ID %1$s + Trakt List %1$s + Δεν ήταν δυνατή η φόρτωση της λίστας Trakt + Δεν ήταν δυνατή η φόρτωση της λίστας Trakt + Δεν βρέθηκαν λίστες Trakt + Δημοφιλείς λίστες + Επιλυμένη λίστα Trakt + Αποτελέσματα αναζήτησης + Σειρά λίστας + Ποσοστό + Δημοφιλή + Πρόσφατη προσθήκη + Ημερομηνία κυκλοφορίας + Διάρκεια + Τίτλος + Ψήφοι + Trakt Lists + Weekend Watch, Award Winners + Λίστες τάσεων + US, KR, JP, IN + Trakt Ταινία List + Trakt Σειρές List + Το id συλλογής '%1$s' χρησιμοποιείται περισσότερο από μία φορά. + Το ID φακέλου «%1$s» χρησιμοποιείται περισσότερες από μία φορά στο «%2$s». + Στην πηγή %1$d του φακέλου «%2$s» λείπει το ID λίστας Trakt. + Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. + Η συλλογή TMDB δεν βρέθηκε + Η εταιρεία TMDB δεν βρέθηκε + Το TMDB Discover δεν επέστρεψε δεδομένα + Η λίστα TMDB δεν βρέθηκε + Λείπει το ID συλλογής TMDB + Λείπει το ID λίστας TMDB + Λείπει το ID προσώπου TMDB + Το δίκτυο TMDB δεν βρέθηκε + Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB + Το πρόσωπο TMDB δεν βρέθηκε + Λείπουν τα διαπιστευτήρια Trakt. + %1$s (%2$d) + Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt + %1$d στοιχεία + %1$d μου αρέσει + Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια + Λείπει το ID λίστας Trakt + Η λίστα Trakt δεν περιείχε αριθμητικό ID + Δημόσια λίστα Trakt + Το όριο αιτημάτων του Trakt εξαντλήθηκε + Το αίτημα Trakt απέτυχε + Καλύφθηκε. Η επιπλέον υποστήριξη πηγαίνει πλέον στην ανάπτυξη. + Μετά το 100%%, η επιπλέον υποστήριξη πηγαίνει στην ανάπτυξη. + Διακομιστής & συντήρηση αυτού του μήνα + Φόρτωση funding progress... + Όρους + Με την εγγραφή, συμφωνώ με τους + Αυτόματο Sync + Έντονα + Λήψη + Φόρτωση γραμμών υποτίτλων... + Δεν βρέθηκαν γραμμές υποτίτλων + Χρώμα περιγράμματος + Επαναφόρτωση + Επαναφορά + Επιλέξτε πρώτα έναν υπότιτλο πρόσθετου + Καθυστέρηση υποτίτλων + Δεν ήταν δυνατή η φόρτωση των γραμμών υποτίτλων + Διαφάνεια κειμένου + Για προχωρημένους + Συνδεδεμένος Services + Άδειες και αποδόσεις + Ροές + Συμπεριφορά εκκίνησης και προφίλ. + ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ + Εμφάνιση αποτελεσμάτων ροής και κανόνες URL badge. + Συνδέστε έναν λογαριασμό στις Ρυθμίσεις. + Δεν είναι αποθηκευμένο στην προσωρινή μνήμη του Torbox. + Δεν ήταν δυνατό το άνοιγμα αυτού του συνδέσμου. + Αυτός ο σύνδεσμος έληξε. Ανανέωση αποτελεσμάτων. + Powered by TMDB + Powered by Trakt + Περισσότερα actions + Αποτυχία φόρτωσης σχολίων Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Αποτυχία ολοκλήρωσης αρχείου λήψης + Αποτυχία ολοκλήρωσης αρχείου λήψης + Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης + Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης + Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό + Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό + Αποτυχία εγγραφής ημιτελούς αρχείου λήψης + Αποτυχία εγγραφής ημιτελούς αρχείου λήψης + Σχετικά + Κατάλογος + Χώρα + Τύπος + Σήμανση προηγούμενων σεζόν ως προβληθέντων + Player συστήματος Android + Δεν ήταν δυνατό το άνοιγμα του εξωτερικού player + Επιλέξτε πρώτα έναν εξωτερικό player στις ρυθμίσεις + Δεν υπάρχει διαθέσιμος εξωτερικός player + απομένουν %1$dω %2$dλ + απομένουν %1$dλ + Απόκρυψη Unreleased Content + Απόκρυψη ταινιών και σειρών που δεν έχουν κυκλοφορήσει ακόμα. + Nuvio Βιβλιοθήκη + Κατάργηση %1$s από %2$s; + Cloud + Αποθηκεύτηκε + Πρόβλημα σύνδεσης + Άδειο response body + Άδειο response body + Το αίτημα απέτυχε με HTTP %1$d + Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. + Το αίτημα απέτυχε με HTTP %1$d + Αυτή η ροή χρησιμοποιεί τεχνολογία peer-to-peer (P2P). Ενεργοποιώντας το P2P, αναγνωρίζετε και συμφωνείτε ότι:\n\n• Η διεύθυνση IP σας θα είναι ορατή σε άλλους χρήστες του δικτύου\n• Είστε αποκλειστικά υπεύθυνοι για το περιεχόμενο στο οποίο έχετε πρόσβαση\n• Επιβεβαιώνετε ότι έχετε το νόμιμο δικαίωμα να παρακολουθήσετε αυτό το περιεχόμενο στη δικαιοδοσία σας\n• Το Nuvio δεν φιλοξενεί, διανέμει ή ελέγχει κανένα περιεχόμενο P2P\n• Το Nuvio δεν φέρει καμία ευθύνη για τυχόν νομικές συνέπειες που προκύπτουν από τη χρήση ροών P2P\n\nΧρησιμοποιείτε αυτή τη λειτουργία εξ ολοκλήρου με δική σας ευθύνη. Το P2P μπορεί να απενεργοποιηθεί ανά πάσα στιγμή από τις Ρυθμίσεις. + Ακύρωση + Ενεργοποίηση P2P + P2P Streaming + Unknown torrent Σφάλμα + Αλκοόλ/Ναρκωτικά + Τρομακτικό περιεχόμενο + Γυμνό + Χυδαία γλώσσα + Ήπιο + Μέτρια + Σοβαρό + Βία + Βιογραφία + Γέννηση + Έργα + Τόπος γέννησης + Υποβολή Intro + Video Ρυθμίσεις + %1$s (%2$s) + Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. + Αποτυχία εκκίνησης torrent: %1$s + Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. + Torrent Σφάλμα: %1$s + Δεν είναι δυνατή η αναπαραγωγή αυτής της ροής. + Λήψη υποτίτλων... + Φόρτωση υποτίτλων από πρόσθετα... + Ανενεργό + Πιο κοντά στην παλαιότερη συμπεριφορά MPV για iOS. + Συμβατότητα + Χρησιμοποιήστε τις παρακάτω προηγμένες τιμές σας. + Προσαρμοσμένο + Ιδανικό για iPhone και iPad με υποστήριξη HDR. + Εγγενές EDR + Πιο προβλέψιμα λευκά και μαύρα σε έξοδο τύπου SDR. + Χαρτογράφηση τόνου SDR + %1$s buffered · %2$s · %3$s + Σύνδεση με peers… + %1$d seeds · %2$d peers + Εκκίνηση μηχανισμού P2P… + %1$d peers · %2$d seeds · %3$d%% + %1$s · %2$s + Φωτεινότητα + Αντίθεση + Deband + Μειώνει τις λωρίδες χρώματος με μικρό κόστος απόδοσης. + Γάμμα + Ανίχνευση κορυφής HDR + Εκτίμηση της μέγιστης φωτεινότητας HDR όταν τα μεταδεδομένα είναι εσφαλμένα ή λείπουν. + Παρεμβολή καρέ + Ομαλή κίνηση όταν το mpv μπορεί να χρησιμοποιήσει καθαρό συγχρονισμό οθόνης. + Προκαθορισμένο εξόδου + Επαναφορά ρυθμίσεων + Κορεσμός + Βίντεο + Χαρτογραφία τόνου + Τα πρόσθετα είναι απενεργοποιημένα + Τα πρόσθετα είναι ενεργοποιημένα + %1$d providers + Ανανέωση + %1$d repos + Λείπει το κλειδί API TMDB + Το κλειδί API TMDB έχει οριστεί + Εγκατάσταση αποθετηρίου πρόσθετων + Γίνεται εγκατάσταση… + Δοκιμή παρόχου + Γίνεται δοκιμή… + Διαγραφή plugin repository + Ανανέωση plugin repository + Δεν υπάρχουν ακόμα διαθέσιμοι πάροχοι. + Προσθέστε ένα URL αποθετηρίου για να εγκαταστήσετε πρόσθετα παρόχων για ανακάλυψη ροών. + Δεν έχουν εγκατασταθεί ακόμα αποθετήρια πρόσθετων. + Χρήση παρόχων πρόσθετων κατά την ανακάλυψη ροών. + Καθολική ενεργοποίηση παρόχων πρόσθετων + Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. + Εισαγάγετε ένα URL αποθετηρίου πρόσθετων. + Εισαγάγετε ένα έγκυρο URL πρόσθετου. + Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων + Ο πάροχος δεν βρέθηκε + Αδυναμία ανανέωσης αποθετηρίου + Τα πρόσθετα δεν είναι διαθέσιμα σε αυτή την έκδοση. + Στις Ροές, εμφάνιση ενός παρόχου ανά αποθετήριο αντί για έναν ανά πηγή. + Ομαδοποίηση παρόχων πρόσθετων ανά αποθετήριο + URL manifest πρόσθετου + Λείπει το όνομα του manifest. + Το manifest δεν έχει πάροχους. + Λείπει η έκδοση του manifest. + Λείπει το όνομα του manifest. + Το manifest δεν έχει πάροχους. + Λείπει η έκδοση του manifest. + Το %1$s εγκαταστάθηκε. + Απενεργοποιημένο από το αποθετήριο + Χωρίς περιγραφή + v%1$s + Αποθετήριο πρόσθετων + Έκδοση %1$s + Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. + Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων + Αδυναμία ανανέωσης αποθετηρίου + Προσθήκη REPOSITORY + ΕΓΚΑΤΕΣΤΗΜΕΝΑ ΑΠΟΘΕΤΗΡΙΑ + ΕΠΙΣΚΟΠΗΣΗ + ΠΑΡΟΧΟΙ + Σφάλμα + Provider test Απέτυχε + Test results (%1$d) + Οι πάροχοι πρόσθετων απαιτούν κλειδί API TMDB. Ορίστε το στην οθόνη TMDB, διαφορετικά οι πάροχοι πρόσθετων δεν θα λειτουργούν σωστά. + Εισαγάγετε ένα έγκυρο URL εικόνας http:// ή https://. + Επιλέχθηκε προσαρμοσμένο URL avatar. + Προσαρμοσμένο URL avatar + Επικολλήστε έναν σύνδεσμο εικόνας, ή αφήστε το κενό για να χρησιμοποιήσετε τον ενσωματωμένο κατάλογο avatar. + https://example.com/avatar.png + Δεν επιστράφηκαν αποτελέσματα αναζήτησης για %1$s. + Εκκαθάριση cache Συνέχειας παρακολούθησης + Η προσωρινή μνήμη εκκαθαρίστηκε + Κατάργηση αποθηκευμένων δεδομένων Συνέχειας παρακολούθησης και ανανέωση προόδου παρακολούθησης + Απομνημόνευση τελευταίου προφίλ + Απομνημόνευση του τελευταίου επιλεγμένου προφίλ κατά την εκκίνηση + Προσωρινή μνήμη + ΕΚΚΙΝΗΣΗ + Γλώσσα συσκευής + Liquid Glass + Χρήση της εγγενούς γραμμής καρτελών iPhone σε iOS 26 και νεότερα. + Συνδέστε προσωπικές πηγές πολυμέσων και διαχειριστείτε την πρόσβαση στη δική σας βιβλιοθήκη. + Θόλωμα μικρογραφιών επόμενου επεισοδίου στη Συνέχεια παρακολούθησης για αποφυγή spoiler. + Θόλωμα μη προβληθέντων στη Συνέχεια παρακολούθησης + ΣΕΙΡΑ ΤΑΞΙΝΟΜΗΣΗΣ + Συμπερίληψη επερχόμενων επεισοδίων στη Συνέχεια παρακολούθησης πριν προβληθούν. + Εμφάνιση επερχόμενων επεισοδίων στο Επόμενο + Προεπιλογή + Ταξινόμηση όλων των στοιχείων κατά πρόσφατη δραστηριότητα + Στυλ streaming + Πρώτα τα κυκλοφορημένα, τα επερχόμενα στο τέλος + Σειρά ταξινόμησης + Κάρτα + Οριζόντια κάρτα σε στυλ τηλεόρασης + Προτίμηση μικρογραφιών επεισοδίου όταν είναι διαθέσιμες. + Προτίμηση μικρογραφιών επεισοδίου στη Συνέχεια παρακολούθησης + Συνδέστε πρώτα έναν λογαριασμό. + Cloud Βιβλιοθήκη + Περιήγηση και αναπαραγωγή αρχείων που υπάρχουν ήδη στους συνδεδεμένους λογαριασμούς σας. + Connect %1$s + Συνδεδεμένος + Περιγραφή template + Ελέγχει τα μεταδεδομένα που εμφανίζονται κάτω από κάθε αποτέλεσμα. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα λεπτομερειών αποτελέσματος. + Ο κωδικός αντιγράφηκε. + Το %1$s είναι συνδεδεμένο σε αυτή τη συσκευή. + This code expired. Προσπάθησε ξανά. + Δεν ήταν δυνατή η έναρξη σύνδεσης. + Ανοίξτε τον σύνδεσμο και εισαγάγετε αυτόν τον κωδικό για να εγκρίνετε το Nuvio. + Αυτή η μέθοδος σύνδεσης δεν έχει διαμορφωθεί σε αυτή την έκδοση. + Άνοιγμα link + Έναρξη ασφαλούς σύνδεσης... + Αναμονή έγκρισης... + Enter %1$s API key + Enter your %1$s API key. + %1$s API Key + Αποσύνδεση + Disconnect %1$s + Επίλυση αναπαράξιμων συνδέσμων + Ζητήστε από μια συνδεδεμένη υπηρεσία αναπαράξιμους συνδέσμους όταν χρειάζεται ένα αποτέλεσμα. Αυτό ενδέχεται να προσθέσει το στοιχείο σε αυτή την υπηρεσία. + Αυτές οι ενσωματώσεις είναι πειραματικές και ενδέχεται να διατηρηθούν, να αλλάξουν ή να αφαιρεθούν αργότερα. + Επαναφορά της προεπιλεγμένης μορφοποίησης αποτελεσμάτων. + Επαναφορά μορφοποίησης + Δεν ήταν δυνατή η επικύρωση αυτού του κλειδιού API. + Το κλειδί API επικυρώθηκε. + Learn Περισσότερα + Μέγιστα αποτελέσματα + Περιορίστε πόσα αποτελέσματα εμφανίζονται. + Πρότυπο ονόματος + Ελέγχει πώς εμφανίζονται τα ονόματα αποτελεσμάτων. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα αποτελέσματος. + Δεν έχει οριστεί + Per Ποιότητα limit + Περιορίστε τα επαναλαμβανόμενα αποτελέσματα BluRay, WEB-DL, REMUX μετά την ταξινόμηση. + Per Ανάλυση limit + Περιορίστε τα επαναλαμβανόμενα αποτελέσματα 2160p, 1080p, 720p μετά την ταξινόμηση. + %1$d links + 1 link + Προετοιμασία συνδέσμων + Επίλυση αναπαραγώσιμων συνδέσμων πριν ξεκινήσει η αναπαραγωγή. + Σύνδεσμοι προς προετοιμασία + Χρησιμοποιήστε μικρότερο αριθμό όταν είναι δυνατόν. Οι συνδεδεμένες υπηρεσίες ενδέχεται να θέτουν όριο ρυθμού στον αριθμό συνδέσμων που μπορούν να επιλυθούν σε μια χρονική περίοδο. Το άνοιγμα μιας ταινίας ή επεισοδίου μπορεί να προσμετρηθεί σε αυτά τα όρια ακόμα κι αν δεν πατήσετε Παρακολούθηση, επειδή οι σύνδεσμοι προετοιμάζονται εκ των προτέρων. + Connect your %1$s Λογαριασμός. + Συνδέστε τον λογαριασμό σας %1$s στο πρόγραμμα περιήγησης. + Εισαγάγετε μία ομάδα ανά γραμμή. + Επίλυση με + Επιλέξτε ποιος συνδεδεμένος λογαριασμός διαχειρίζεται τους αναπαραγώσιμους συνδέσμους. + Όλα results + %1$d results + Excluded Ήχος tags + Απόκρυψη επιλεγμένων ετικετών ήχου. + Excluded Κανάλια + Απόκρυψη επιλεγμένων διατάξεων καναλιών. + Εξαιρούμενα encodes + Απόκρυψη επιλεγμένων encodes. + Εξαιρούμενες γλώσσες + Απόκρυψη αποτελεσμάτων όπου κάθε γλώσσα είναι αποκλεισμένη. + Εξαιρούμενες ποιότητες + Απόκρυψη επιλεγμένων ποιοτήτων. + Εξαιρούμενες ομάδες κυκλοφορίας + Απόκρυψη επιλεγμένων release groups. + Εξαιρούμενες αναλύσεις + Απόκρυψη επιλεγμένων αναλύσεων. + Εξαιρούμενες οπτικές ετικέτες + Απόκρυψη ετικετών DV, HDR, 10bit, 3D και παρόμοιων. + Preferred Ήχος tags + Ταξινόμηση ετικετών Atmos, TrueHD, DTS, AAC και παρόμοιων. + Preferred Κανάλια + Ταξινόμηση προτιμώμενων διατάξεων καναλιών πρώτα. + Προτιμώμενα encodes + Ταξινόμηση encodes AV1, HEVC, AVC και παρόμοιων. + Προτιμώμενες γλώσσες + Ταξινόμηση προτιμώμενων γλωσσών ήχου πρώτα. + Προτιμώμενες ποιότητες + Ταξινόμηση επιλεγμένων ποιοτήτων πρώτα, με προεπιλεγμένη σειρά. + Προτιμώμενες αναλύσεις + Ταξινόμηση επιλεγμένων αναλύσεων πρώτα, με προεπιλεγμένη σειρά. + Προτιμώμενες οπτικές ετικέτες + Ταξινόμηση ετικετών DV, HDR, 10bit, IMAX και παρόμοιων. + Required Ήχος tags + Απαιτεί ετικέτες Atmos, TrueHD, DTS, AAC και παρόμοιες. + Required Κανάλια + Εμφάνιση μόνο επιλεγμένων διατάξεων καναλιών. + Απαιτούμενα encodes + Απαιτεί encodes AV1, HEVC, AVC και παρόμοια. + Απαιτούμενες γλώσσες + Εμφάνιση μόνο αποτελεσμάτων με τις επιλεγμένες γλώσσες. + Απαιτούμενες ποιότητες + Εμφάνιση μόνο επιλεγμένων ποιοτήτων. + Απαιτούμενες ομάδες κυκλοφορίας + Εμφάνιση μόνο επιλεγμένων release groups. + Απαιτούμενες αναλύσεις + Εμφάνιση μόνο των επιλεγμένων αναλύσεων. + Απαιτούμενες οπτικές ετικέτες + Απαιτεί ετικέτες DV, HDR, 10bit, IMAX, SDR και παρόμοιες. + Μορφοποίηση + Προετοιμασία συνδέσμων + Λογαριασμοί + Διαχείριση αποτελεσμάτων + Συνδεδεμένος Services + Οποιοδήποτε + %1$d επιλεγμένα + %1$dGB+ + Εύρος μεγέθους + Φιλτράρισμα αποτελεσμάτων ανά μέγεθος αρχείου. + %1$d-%2$dGB + Up to %1$dGB + Καλύτερος ήχος πρώτα + Καλύτερη ποιότητα πρώτα + Γλώσσα πρώτα + Πρώτα το μεγαλύτερο + Αρχική σειρά + Ταξινόμηση results + Επιλέξτε πώς ταξινομούνται τα αποτελέσματα. + Πρώτα το μικρότερο + Default Μορφή + Αρχική μορφή + Group %1$d + Other Fusion badges + Προεπισκόπηση + %1$d Fusion-style badges from this URL + Δεν υπάρχουν εικόνες badge τύπου Fusion σε αυτό το URL. + Προεπισκόπηση badge Fusion + Ενεργό + Ανενεργό + URL badge Fusion (JSON) + %1$s, %2$d enabled badges, %3$d groups + %1$d/%2$d Fusion URLs imported + Δεν έχουν εισαχθεί URL badge Fusion. + %1$d/%2$d URLs, %3$d active Fusion badges + Απόκρυψη value + Απόκρυψη Catalog Underline + Αφαίρεση της γραμμής τονισμού κάτω από τους τίτλους καταλόγων και συλλογών σε όλη την εφαρμογή. + Συνδέστε λογαριασμούς για συνδέσμους και πρόσβαση στη βιβλιοθήκη + Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις Android. + Licensed under the Apache License, Έκδοση 2.0. + AndroidX Media3 ExoPlayer 1.8.0 + Το Nuvio χρησιμοποιεί τα IMDb Non-Commercial Datasets, συμπεριλαμβανομένου του title.ratings.tsv.gz, για βαθμολογίες και αριθμό ψήφων IMDb. Πληροφορίες με ευγενική παραχώρηση του IMDb (https://www.imdb.com). Χρησιμοποιείται κατόπιν άδειας. Τα δεδομένα IMDb προορίζονται για προσωπική και μη εμπορική χρήση σύμφωνα με τους όρους του IMDb. + IMDb Non-Commercial Datasets + Το Nuvio χρησιμοποιεί το API του IntroDB για δεδομένα intro, ανακεφαλαίωσης, τίτλων τέλους και προεπισκόπησης χρονοσημάνσεων από την κοινότητα, τα οποία χρησιμοποιούνται από τα χειριστήρια παράλειψης. Το Nuvio δεν σχετίζεται με το IntroDB ούτε έχει την υποστήριξή του. + IntroDB + Το Nuvio χρησιμοποιεί το MDBList για βαθμολογίες και δεδομένα από εξωτερικούς παρόχους βαθμολόγησης. Το Nuvio δεν σχετίζεται με το MDBList ούτε έχει την υποστήριξή του. + MDBList + Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις iOS. + MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0. + MPVKit + Ο πηγαίος κώδικας και οι όροι αδειοδότησης είναι διαθέσιμοι στο αποθετήριο του έργου. + Licensed under the GNU Γενικά Public License v3.0. + Nuvio Mobile + Το Nuvio συνδέεται με το Premiumize για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το Premiumize ούτε έχει την υποστήριξή του. + Premiumize + ΑΔΕΙΑ ΕΦΑΡΜΟΓΗΣ + ΔΕΔΟΜΕΝΑ & ΥΠΗΡΕΣΙΕΣ + Αναπαραγωγή LICENSE + Το Nuvio χρησιμοποιεί το API του TMDB για μεταδεδομένα ταινιών και σειρών, εικαστικό υλικό, trailer, συμμετέχοντες, λεπτομέρειες παραγωγής, συλλογές και προτάσεις. Αυτό το προϊόν χρησιμοποιεί το API του TMDB αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB. + The Ταινία Database (TMDB) + Το Nuvio συνδέεται με το TorBox για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το TorBox ούτε έχει την υποστήριξή του. + TorBox + Το Nuvio συνδέεται με το Trakt για ταυτοποίηση λογαριασμού, ιστορικό παρακολούθησης, συγχρονισμό προόδου, δεδομένα βιβλιοθήκης, βαθμολογίες, λίστες και σχόλια. Το Nuvio δεν σχετίζεται με το Trakt ούτε έχει την υποστήριξή του. + Trakt + Λειτουργία φόντου + Κινηματογραφικό + Εμφάνιση ενός απαλού θολωμένου φόντου πίσω από τη σελίδα. + Επιλέξτε πώς εμφανίζεται το εικαστικό υλικό πίσω από τις σελίδες μεταδεδομένων. + Κυρίαρχο χρώμα + Αντιστοίχιση του φόντου σελίδας με το κύριο χρώμα από το φόντο. + Κανονικό + Χρήση του τυπικού φόντου εφαρμογής. + Θόλωμα μη προβληθέντων επεισοδίων + Θόλωμα μικρογραφιών επεισοδίων μέχρι να προβληθούν για αποφυγή spoiler. + Hero Trailer Αναπαραγωγή + Αναπαραγωγή προεπισκοπήσεων trailer στο κύριο πλαίσιο μεταδεδομένων όταν υπάρχει διαθέσιμο trailer. + Απόκρυψη buffer, seeds, peers και ταχύτητας λήψης κατά τη φόρτωση και την αναπαραγωγή + Απόκρυψη torrent stats + Επιτρέψτε ροές peer-to-peer (torrent) + Ροές P2P + Όλοι οι υπότιτλοι + Λήψη και εμφάνιση κάθε υπότιτλου πρόσθετου για το βίντεο. + Γρήγορη εκκίνηση + Παράλειψη αυτόματης λήψης υποτίτλων πρόσθετου έως ότου το ζητήσετε στον player. + Εκκίνηση υποτίτλων πρόσθετου + Μόνο προτιμώμενα + Λήψη υποτίτλων πρόσθετου, αλλά εμφάνιση μόνο των αντιστοιχιών προτιμώμενης γλώσσας. + Αναπαραγωγή Engine + Χρήση του ExoPlayer πρώτα και επιστροφή στο libmpv αν η αναπαραγωγή αποτύχει. + Χρήση ExoPlayer και αποκωδικοποιητών Android Media3. + Χρήση libmpv για αναπαραγωγή σε Android. + Εξωτερικός player + Εφαρμογή εξωτερικού player + Άνοιγμα νέας αναπαραγωγής με την προεπιλεγμένη εφαρμογή βίντεο του Android ή τον επιλογέα συστήματος. + Άνοιγμα νέας αναπαραγωγής με τον επιλεγμένο εγκατεστημένο player. + Προώθηση υποτίτλων στον εξωτερικό player + Λήψη υποτίτλων πρόσθετου στην προτιμώμενη γλώσσα σας και προώθησή τους στον εξωτερικό player. + Δεν έχουν εγκατασταθεί υποστηριζόμενοι εξωτερικοί player + Αποστολή Intro and Outro Timestamps + Προώθηση των επιλυμένων χρονοσημάνσεων intro και outro στον εξωτερικό player για αυτόματη παράλειψη. Λειτουργεί μόνο σε players που το υποστηρίζουν· άλλοι players το αγνοούν. + Ενεργοποίηση υποβολής intro + Εμφάνιση κουμπιού για υποβολή χρονοσημάνσεων intro/outro στη βάση δεδομένων της κοινότητας. + Κλειδί API IntroDB + Εισαγάγετε το κλειδί API του IntroDB για να υποβάλλετε χρονοσημάνσεις. Απαιτείται για υποβολή. + Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε + Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε + Ήχος output + Χρήση της παλαιότερης εξόδου AudioUnit. + Χρήση AudioUnit ενώ η έξοδος AVFoundation είναι προσωρινά απενεργοποιημένη. + Πειραματική υποστήριξη για Spatial Audio και πολυκάναλη έξοδο. + Ήχος output + iOS Ήχος output + Οθόνη color hint + Επιτρέψτε στο mpv να στοχεύει τον ενεργό χρωματικό χώρο της οθόνης από προεπιλογή. + Εκτεταμένο δυναμικό εύρος + Προεπιλεγμένη λειτουργία εξόδου Metal για νέες περιόδους αναπαραγωγής. + Αποκωδικοποιητής υλικού + Αποκωδικοποιητής υλικού + Target primaries + Target primaries + Target transfer + Target transfer + iOS Βίντεο output + libmpv Hardware Decoding + Χρήση αποκωδικοποίησης υλικού mpv όταν είναι διαθέσιμη. + Renderer libmpv + Renderer libmpv + libmpv YUV420P Compatibility + Επιβολή εξόδου YUV420P για συσκευές με προβλήματα renderer ή χρώματος. + Αρχική γλώσσα + Απαιτεί ενεργοποιημένο εμπλουτισμό TMDB + Player + Επιλέξτε ποιος player χειρίζεται τη νέα αναπαραγωγή. + Εξωτερικός + Εσωτερικός + Επαναχρησιμοποίηση ομάδας συνεχούς παρακολούθησης + Απομνημόνευση και επαναχρησιμοποίηση της τελευταίας ομάδας συνεχούς παρακολούθησης στις περιόδους σύνδεσης (Συνέχεια παρακολούθησης, Λεπτομέρειες, κ.λπ.). + Προειδοποιήσεις περιεχομένου + Εμφάνιση επικάλυψης γονικής καθοδήγησης κατά την έναρξη της αναπαραγωγής. + Χρώμα φόντου + Έντονα + Χρήση πιο έντονου βάρους γραμματοσειράς υποτίτλων. + Διαφανές + Περίγραμμα + Χρώμα περιγράμματος + Σχεδίαση περιγράμματος γύρω από το κείμενο υποτίτλων. + Εμφάνιση μόνο προτιμώμενων γλωσσών + Εμφάνιση μόνο υποτίτλων που ταιριάζουν με τις προτιμώμενες γλώσσες υποτίτλων σας. + Μέγεθος υποτίτλων + Χρώμα κειμένου + Χρήση αναγκαστικών υποτίτλων + Προτίμηση αναγκαστικών υποτίτλων όταν ταιριάζουν με τις ρυθμίσεις γλώσσας υποτίτλων σας. + Κατακόρυφη μετατόπιση + Χειρονομίες αφής + Επιτρέψτε σαρώσεις και διπλά πατήματα στην επιφάνεια του player για αναζήτηση, ρύθμιση φωτεινότητας ή ρύθμιση έντασης. + Δεν βρέθηκαν ρυθμίσεις. + Search Ρυθμίσεις... + ΑΠΟΤΕΛΕΣΜΑΤΑ + Εμφάνιση value + Εμφάνιση λογότυπου και ονόματος πρόσθετου δίπλα στις πηγές ροής. + Λογότυπο πρόσθετου + Εισαγάγετε ένα URL badge (JSON). + Η εισαγωγή badge απέτυχε. + Μπορείτε να εισαγάγετε έως %1$d URL badge. + Κάτω + Επιλέξτε αν τα badge Fusion και μεγέθους εμφανίζονται πάνω ή κάτω από τις κάρτες ροής. + Επιλέξτε πού εμφανίζονται τα badge ροής στις κάρτες ροής. + Θέση badge + Θέση badge + Πάνω + Το URL badge πρέπει να ξεκινά με http:// ή https://. + Εισαγωγή έως %1$d URL badge ροής τύπου Fusion σε μορφή JSON. Κάθε URL μπορεί να ενημερωθεί ή να διαγραφεί ξεχωριστά. + Διαχείριση εισηγμένων URL badge ροής τύπου Fusion (JSON). + URL badge Fusion + Στυλ Fusion + Οθόνη + Εμφάνιση badge μεγέθους αρχείου στα αποτελέσματα ροής και στα πλαίσια πηγών του player. + Badge μεγέθους + Loading Παράλειψη segments… + Φόρτωση υποτίτλων από πρόσθετα… + Άνοιγμα σε εξωτερικό player + Άνοιγμα σε εσωτερικό player + Plugin repository + Αυτός ο τύπος ροής δεν υποστηρίζεται + Υποβολή Intro + Υποβολή + Λήψη + Λήψη + END Ώρα (MM:SS) + END Ώρα (MM:SS) + Εισαγωγή + Τίτλοι τέλους + Ανακεφαλαίωση + ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ + ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ + START Ώρα (MM:SS) + START Ώρα (MM:SS) + Υποβολή + Υποβολή Timestamps + Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. + TMDB Collection %1$d + Η συλλογή TMDB δεν βρέθηκε + TMDB Production %1$d + Η εταιρεία TMDB δεν βρέθηκε + TMDB Director %1$d + Το TMDB Discover δεν επέστρεψε δεδομένα + TMDB Discover + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + TMDB List %1$d + Η λίστα TMDB δεν βρέθηκε + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Λείπει το ID συλλογής TMDB + Λείπει το ID λίστας TMDB + Λείπει το ID προσώπου TMDB + TMDB Network %1$d + Το δίκτυο TMDB δεν βρέθηκε + Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB + TMDB Person %1$d + Το πρόσωπο TMDB δεν βρέθηκε + Όλα history + Συνδεδεμένος με Trakt + Συνδεδεμένος με Trakt + Το ιστορικό Trakt λαμβάνεται υπόψη για τη Συνέχεια παρακολούθησης + Παράθυρο Συνέχειας παρακολούθησης + Επιλέξτε πόση δραστηριότητα Trakt θα εμφανίζεται στη Συνέχεια παρακολούθησης. + Παράθυρο Συνέχειας παρακολούθησης + %1$d days + Αποσυνδεδεμένος από Trakt + Αποσυνδεδεμένος από Trakt + Αποτυχία προσθήκης στη λίστα Trakt + Αποτυχία προσθήκης στη watchlist του Trakt + Η εξουσιοδότηση Trakt έληξε + Άδειο response body + Συμπληρώθηκε το όριο λιστών Trakt + Η λίστα Trakt δεν βρέθηκε + Λείπουν συμβατά ID Trakt + Το όριο αιτημάτων του Trakt εξαντλήθηκε + Το αίτημα Trakt απέτυχε + Επιλέξτε πού θα αποθηκεύετε και θα διαχειρίζεστε τα στοιχεία της βιβλιοθήκης σας + Πηγή βιβλιοθήκης + Nuvio Βιβλιοθήκη + Επιλέχθηκε η βιβλιοθήκη Nuvio + Επιλέξτε ποια βιβλιοθήκη θα χρησιμοποιείτε για αποθήκευση και προβολή της συλλογής σας + Πηγή βιβλιοθήκης + Trakt + Επιλέχθηκε η βιβλιοθήκη Trakt + Επιλέξτε την πηγή για τις προτάσεις που εμφανίζονται στις σελίδες λεπτομερειών. + Πηγή «Παρόμοια με αυτό» + Επιλέξτε από πού προέρχονται οι προτάσεις στις σελίδες λεπτομερειών + Πηγή «Παρόμοια με αυτό» + TMDB + Trakt + Trakt public list + Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt + %1$d στοιχεία + %1$d μου αρέσει + Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID). + Λείπει το ID λίστας Trakt + Η λίστα Trakt δεν περιείχε αριθμητικό ID + Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια + Το όριο αιτημάτων του Trakt εξαντλήθηκε + Το αίτημα Trakt απέτυχε + Επιλέξτε αν η συνέχιση και η Συνέχεια παρακολούθησης θα χρησιμοποιούν το Trakt ή τον Συγχρονισμό Nuvio, ενώ το scrobbling του Trakt παραμένει ενεργό. + Πρόοδος παρακολούθησης + Η πηγή προόδου παρακολούθησης ορίστηκε στον Συγχρονισμό Nuvio + Nuvio Συγχρονισμός + Trakt + Επιλέξτε ποια πηγή προόδου τροφοδοτεί τη συνέχιση και τη Συνέχεια παρακολούθησης + Πρόοδος παρακολούθησης + Η πηγή προόδου παρακολούθησης ορίστηκε στο Trakt + TB + Δεν βρέθηκε αρχείο APK στην έκδοση κυκλοφορίας + Κενό περιεχόμενο λήψης + Η λήψη απέτυχε με HTTP %1$d + Το αρχείο ενημέρωσης που λήφθηκε λείπει. + Το αρχείο ενημέρωσης που λήφθηκε λείπει. + Κενό περιεχόμενο λήψης + GitHub releases API Σφάλμα: %1$d + Δεν έχει δημοσιευθεί ακόμα κάποια ενημέρωση. + Η έκδοση κυκλοφορίας δεν έχει ετικέτα ή όνομα diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 1fe611f6..02427ae4 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -1,4 +1,5 @@ + Fuentes de datos, reconocimientos y licencias de plataforma Reconocimiento abierto y créditos del proyecto Atrás Cancelar @@ -17,16 +18,27 @@ Reanudar Reintentar Guardar + Ahorro… + Cambiar + Validar Instalando Complementos Activo %1$d catálogos Configurable + Desactivado Actualizando %1$d recursos No disponible Configurar complemento Eliminar complemento + Lleva tu biblioteca a Nuvio con los complementos que utilizas. + Agrega un complemento para empezar a crear tu biblioteca. + Haz tuyo Nuvio. + URL del complemento + Agregar complemento + Aún no hay nada aquí + Tus complementos actuales aún no tienen nada que mostrar aquí. Agrega una URL de manifiesto para empezar a cargar catálogos, metadatos, streams o subtítulos en Nuvio. Aún no hay complementos instalados. Introduce una URL de complemento. @@ -110,29 +122,38 @@ Producción Cadena Colección + Persona + Director Personalizado Elige una fuente preparada. Puedes editarla o quitarla después de añadirla. Pega una URL de lista pública de TMDB o solo el número de la URL. Busca por nombre de estudio, o pega un ID/URL de compañía de TMDB y añádelo directamente. Introduce un ID de cadena. Las cadenas comunes están disponibles en ajustes predefinidos y filtros rápidos. Busca el nombre de una colección de películas o pega el ID de colección de TMDB. + Ingrese una ID de persona o URL de TMDB para crear una fila a partir de los créditos emitidos. + Ingrese una ID de persona o URL de TMDB para crear una fila a partir de los créditos del director. Crea una fila dinámica de TMDB con filtros opcionales. Deja los campos vacíos cuando no necesites ese filtro. Lista pública de TMDB ID de cadena ID de colección + ID de persona Nombre, ID o URL de compañía de producción ID o URL de TMDB https://www.themoviedb.org/list/8504994 o 8504994 213 para Netflix, 49 para HBO, 2739 para Disney+ 10 para Star Wars Collection Marvel Studios, 420 o URL de compañía + 31 para Tom Hanks, o URL de persona Ejemplos: Marvel Studios, 420 o https://www.themoviedb.org/company/420. Ejemplo: Star Wars Collection, Harry Potter Collection o una URL de colección. IDs de ejemplo: Netflix 213, HBO 49, Disney+ 2739. Ejemplo: https://www.themoviedb.org/list/8504994 o 8504994. + Ejemplo: https://www.themoviedb.org/person/31-tom-hanks o 31. Título visible Se muestra como nombre de fila/pestaña. Si queda vacío, Nuvio crea uno desde la fuente. Películas de Marvel, Originales de Netflix, Pixar + Películas de Tom Hanks, Actores favoritos + Películas de Christopher Nolan, Directores favoritos Mejores películas de acción, dramas coreanos, animación 2024 Resultados de búsqueda Colección de TMDB @@ -153,18 +174,27 @@ Cadenas rápidas IDs de género Usa números de género de TMDB. Separa varios con comas para AND, o barras verticales para OR. + 28,12 + 18,35 Fecha de estreno o emisión desde Fecha de estreno o emisión hasta Usa YYYY-MM-DD, por ejemplo 2024-01-01. + 2020-01-01 + 2024-12-31 Calificación mínima Calificación máxima Calificación de TMDB de 0 a 10. Ejemplo: 7.0. + 7.0 + 10 Votos mínimos Úsalo para evitar títulos poco conocidos con pocos votos. Ejemplo: 100. + 100 Idioma original Usa códigos de idioma de dos letras, por ejemplo en, ko, ja, hi. + en, ko, ja, hola País de origen Usa códigos de país de dos letras, por ejemplo US, KR, JP, IN. + EE. UU., KR, JP, IN IDs de palabra clave Usa números de palabra clave de TMDB. Los chips rápidos rellenan ejemplos comunes. 9715 para superhéroes @@ -176,9 +206,37 @@ 213 para Netflix Año Usa un año de cuatro dígitos, por ejemplo 2024. + 2024 Predefinidos Buscar Añadir fuente + Agregar lista de rutas + Editar lista de rutas + Listas de rutas + Lista de tramos + Título de búsqueda, URL de Trakt o ID de lista + Utilice una URL de lista de Trakt pública o un ID de lista numérico, o busque por nombre. + Reloj de fin de semana, ganadores de premios + Resultados de la búsqueda + Listas de tendencias + Listas populares + Dirección + Ascendente + Descendente + Orden de lista + Agregado recientemente + Título + Liberado + Tiempo de ejecución + Popular + Porcentaje + Votos + Ingrese un nombre, URL o ID de la lista Trakt + Ingrese una ID o URL de la lista Trakt + No se pudo cargar la lista Trakt + No se encontraron listas de Trakt + Lista de rutas resueltas + Lista de rutas %1$d Acción Aventura Animación @@ -212,13 +270,29 @@ Disney+ Prime Video Hulu + Original Popular Mejor valoradas Reciente + Más votados + Ver región + Código de país ISO 3166-1 donde está disponible el título. Ejemplo: EE. UU., Gran Bretaña. + Regiones de visualización rápida + Ver ID de proveedores + Utilice los ID del proveedor de vigilancia TMDB. Separe varios con comas para AND o barras verticales para OR. + 8|337|350 + Proveedores de vigilancia rápida + netflix + Vídeo principal + Disney+ + AppleTV+ + Hulu Lista de TMDB Colección de películas de TMDB Producción Cadena + Persona + Director Descubrir de TMDB Crea una para organizar tus catálogos. Aún no hay colecciones @@ -253,6 +327,8 @@ Registrarse Tus datos solo se almacenarán localmente Transmite todo, en todas partes + Al registrarme, acepto las + Términos Bienvenido de nuevo Biblioteca Biblioteca de Trakt @@ -262,8 +338,11 @@ Buscar Pistas de audio Audio + Sincronización automática + Atrevido Integrado Desplazamiento inferior + Captura Cerrar reproductor Color Reproduciendo ahora @@ -274,11 +353,15 @@ Tamaño de fuente %1$dsp Bloquear controles del reproductor + Cargando líneas de subtítulos... + No se encontraron líneas de subtítulos + No se pueden cargar líneas de subtítulos No hay pistas de audio disponibles No hay episodios disponibles No se encontraron streams Ninguno Contorno + Color del contorno Episodios Fuentes Streams @@ -286,6 +369,8 @@ Reproduciendo Toca para buscar subtítulos Volver + Recargar + Reiniciar Restablecer valores predeterminados Rellenar Ajustar @@ -298,8 +383,11 @@ Avanzar 10 segundos Fuentes Estilo + Seleccione primero un subtítulo adicional Subtítulos + Retraso de subtítulos Subtítulos + Opacidad del texto Brillo %1$s Volumen %1$s Silenciado @@ -328,11 +416,14 @@ General Cuenta Complementos + Avanzado Apariencia Contenido y descubrimiento Seguir viendo + Servicios conectados Pantalla de inicio Integraciones + Licencias y atribución Calificaciones de MDBList Pantalla meta Notificaciones @@ -340,24 +431,66 @@ Plugins Personalización de póster Configuración + Corrientes Patrocinadores y colaboradores Enriquecimiento TMDB Trakt ACERCA DE Administra tu cuenta, cierra sesión o elimínala. CUENTA + Comportamiento de inicio y perfil. + AVANZADO Ajusta la presentación de inicio y las preferencias visuales. Buscar nuevas versiones de la app. Buscar actualizaciones Administra complementos y fuentes de descubrimiento. + Administra tus complementos y elige qué aparece en Nuvio. Administra tus películas y episodios descargados. + Administra lo que has guardado en este dispositivo. Descargas GENERAL Conecta los servicios TMDB y MDBList. Administra alertas de estreno de episodios y envía una notificación de prueba. + Transmitir reglas de URL de insignia y visualización de resultados. Cambiar a un perfil diferente. Cambiar perfil Conecta Trakt, sincroniza listas y guarda títulos directamente en Trakt. + No se encontraron configuraciones. + Configuración de búsqueda... + RESULTADOS + PUESTA EN MARCHA + CACHE + Recordar último perfil + Recordar el último perfil seleccionado al inicio + Borrar la caché de Continuar viendo + Elimine los datos de Continuar viendo almacenados en caché y actualice el progreso de la visualización + Caché borrado + LICENCIA DE APLICACIÓN + DATOS Y SERVICIOS + LICENCIA DE REPRODUCCIÓN + Nuvio móvil + El código fuente y los términos de la licencia están disponibles en el repositorio del proyecto. + Licenciado bajo la Licencia Pública General GNU v3.0. + La base de datos de películas (TMDB) + Nuvio utiliza la API TMDB para metadatos, ilustraciones, avances, reparto, detalles de producción, colecciones y recomendaciones de películas y TV. Este producto utiliza la API de TMDB pero no está respaldado ni certificado por TMDB. + Conjuntos de datos no comerciales de IMDb + Nuvio utiliza conjuntos de datos no comerciales de IMDb, incluido title.ratings.tsv.gz, para las calificaciones de IMDb y el recuento de votos. Información cortesía de IMDb (https://www.imdb.com). Usado con permiso. Los datos de IMDb son para uso personal y no comercial según los términos de IMDb. + Tracto + Nuvio se conecta a Trakt para autenticación de cuenta, historial de visualización, sincronización de progreso, datos de biblioteca, calificaciones, listas y comentarios. Nuvio no está afiliado ni respaldado por Trakt. + Premiumizar + Nuvio se conecta a Premiumize para autenticación de cuentas, acceso a la biblioteca en la nube, comprobaciones de caché y funciones de reproducción en la nube. Nuvio no está afiliado ni respaldado por Premiumize. + TorBox + Nuvio se conecta a TorBox para autenticación de cuentas, acceso a la biblioteca en la nube, comprobaciones de caché y funciones de reproducción en la nube. Nuvio no está afiliado ni respaldado por TorBox. + Lista MDBL + Nuvio utiliza MDBList para calificaciones y datos de proveedores de puntuaciones externos. Nuvio no está afiliado ni respaldado por MDBList. + IntroducciónDB + Nuvio utiliza la API IntroDB para la introducción, el resumen, los créditos y las marcas de tiempo de vista previa proporcionadas por la comunidad utilizadas por los controles de omisión. Nuvio no está afiliado ni respaldado por IntroDB. + MPVKit + Se utiliza para la reproducción en compilaciones de iOS. + La fuente MPVKit por sí sola tiene licencia LGPL v3.0. Los paquetes MPVKit, incluidas las bibliotecas libmpv y FFmpeg, también tienen licencia LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Se utiliza para la reproducción en compilaciones de Android. + Licenciado bajo la Licencia Apache, Versión 2.0. Cargando tus listas de Trakt… Elige dónde guardar este título en Trakt Donar @@ -414,8 +547,11 @@ Negro AMOLED Usa fondos negros puros para pantallas OLED. Idioma de la app + Idioma del dispositivo Elegir idioma Muestra, oculta y ajusta el estante de Seguir viendo. + Vidrio líquido + Utilice la barra de pestañas nativa del iPhone en iOS 26 y versiones posteriores. Ajusta el ancho compartido de las tarjetas de póster y los radios de esquina. PANTALLA INICIO @@ -442,9 +578,14 @@ %1$d de %2$d seleccionados Mostrar Destacado Mostrar un carrusel destacado en la parte superior del inicio. Elige hasta 2 catálogos de origen abajo. + Ocultar contenido inédito + Oculta películas y programas que aún no se han estrenado. + Ocultar subrayado del catálogo + Elimine la línea de acento debajo de los títulos de catálogos y colecciones en toda la aplicación. %1$d de %2$d catálogos visibles • %3$d fuentes de Destacado seleccionadas Abre un catálogo solo cuando necesites cambiarle el nombre o reordenarlo. Visible + Ocultar valor Reproductor, subtítulos y reproducción automática Radio de tarjeta ESTILO DE TARJETA DE PÓSTER @@ -469,23 +610,39 @@ Denso Grande Estándar + Mostrar valor Mostrar un aviso para continuar donde lo dejaste al abrir la app después de salir del reproductor. Aviso para reanudar al iniciar + Difumina las miniaturas del próximo episodio en Continuar viendo para evitar spoilers. + Desenfoque no visto en Continuar viendo + Incluya los próximos episodios en Continuar viendo antes de que salgan al aire. + Mostrar episodios siguientes no emitidos + ORDEN DE CLASIFICACIÓN + Orden de clasificación + Por defecto + Ordenar todos los elementos por actualidad + Estilo de transmisión + Artículos publicados primero, próximos al final. ESTILO DE TARJETA AL INICIAR COMPORTAMIENTO DE SIGUIENTE VISIBILIDAD Mostrar el estante de Seguir viendo en la pantalla de inicio. Mostrar Seguir viendo + Tarjeta + Tarjeta horizontal estilo TV Póster Tarjeta de póster centrada en la portada Panorámica Tarjeta horizontal rica en información Cuando está activado, Siguiente siempre continúa desde el episodio más avanzado visto. Cuando está desactivado, sigue el episodio visto más recientemente. Útil si vuelves a ver episodios anteriores. Siguiente desde el episodio más avanzado + Prefiere miniaturas de episodios cuando estén disponibles. + Prefiere miniaturas de episodios en Continuar viendo INICIO FUENTES Instala, elimina, actualiza y ordena tus fuentes de contenido. + Agrega y administra los complementos que utilizas con Nuvio. Instala repositorios de scrapers en JavaScript y prueba proveedores internamente. Controla qué catálogos aparecen en Inicio y en qué orden. Desactiva secciones de detalles y reordena todo debajo del Destacado. @@ -493,6 +650,84 @@ INTEGRACIONES Mejora las páginas de detalles con arte, créditos, metadatos de episodios y más desde TMDB. Añade calificaciones externas de IMDb, Rotten Tomatoes, Metacritic y otras a las páginas de detalles. + Conecte cuentas para enlaces y acceso a la biblioteca + Servicios conectados + Estas integraciones son experimentales y pueden conservarse, modificarse o eliminarse más adelante. + biblioteca en la nube + Explore y reproduzca archivos que ya estén en sus cuentas conectadas. + Resolver enlaces reproducibles + Solicite a un servicio conectado enlaces reproducibles cuando un resultado lo necesite. Esto puede agregar el artículo a ese servicio. + Resolver con + Elija qué cuenta conectada maneja los enlaces reproducibles. + Primero conecte una cuenta. + Cuentas + Conecta tu cuenta de %1$s. + Vincula tu cuenta de %1$s en el navegador. + %1$s clave API + Ingresa tu clave API de %1$s. + Ingresa la clave API de %1$s + No establecido + Conectado + Conectar %1$s + Desconectar %1$s + Desconectar + %1$s está conectado en este dispositivo. + Iniciando inicio de sesión seguro... + Abra el enlace e ingrese este código para aprobar Nuvio. + Código copiado. + Abrir enlace + Esperando aprobación... + No se pudo iniciar el inicio de sesión. + Este método de inicio de sesión no está configurado en esta compilación. + Este código expiró. Intentar otra vez. + Preparación de enlaces + Preparar enlaces + Resuelva los enlaces reproducibles antes de que comience la reproducción. + Enlaces para preparar + Utilice un recuento más bajo cuando sea posible. Los servicios conectados pueden limitar la cantidad de enlaces que se pueden resolver en un período de tiempo. Abrir una película o un episodio puede contar para esos límites incluso si no presiona Ver, porque los enlaces están preparados con anticipación. + 1 enlace + %1$d enlaces + Formato + Plantilla de nombre + Controla cómo aparecen los nombres de los resultados. Déjelo en blanco para utilizar el nombre del resultado original. + Plantilla de descripción + Controla los metadatos que se muestran debajo de cada resultado. Déjelo en blanco para utilizar los detalles del resultado original. + Restablecer formato + Restaurar el formato de resultados predeterminado. + Estilo de fusión + Insignias de tamaño + Muestre insignias de tamaño de archivo en los resultados de la transmisión y en los paneles de fuentes del reproductor. + Logotipo del complemento + Muestra el logotipo y el nombre del complemento junto a las fuentes de transmisión. + Mostrar + Posición de la insignia + Elija si las insignias de tamaño y Fusion aparecen encima o debajo de las tarjetas de transmisión. + Posición de la insignia + Seleccione dónde aparecen las insignias de transmisión en las tarjetas de transmisión. + Arriba + Abajo + URL de la insignia de Fusion + Importe hasta %1$d URL JSON de insignia de transmisión estilo Fusion. Cada URL se puede actualizar o eliminar por separado. + Administre las URL JSON de insignias de transmisión importadas de estilo Fusion. + Error al importar la insignia. + Introduzca una URL JSON de insignia. + La URL de la insignia debe comenzar con http:// o https://. + Puedes importar hasta %1$d URL de insignias. + %1$d/%2$d URL, %3$d insignias Fusion activas + No se importaron URL de insignias de Fusion. + URL JSON de la insignia de Fusion + %1$d/%2$d URL de Fusion importadas + Activo + Inactivo + %1$s, %2$d insignias habilitadas, %3$d grupos + Avance + Vista previa de la insignia Fusion + %1$d insignias de estilo Fusion de esta URL + No hay imágenes de insignias de estilo Fusion en esta URL. + Grupo %1$d + Otras insignias de Fusion + Clave API validada. + No se pudo validar esta clave API. Añade tu clave API de MDBList abajo antes de activar las calificaciones. Obtén una clave en https://mdblist.com/preferences y pégala aquí. Clave API @@ -514,6 +749,8 @@ Sección de comentarios de Trakt. Detalles Duración, estado, estreno, idioma e información relacionada. + Reproducción del tráiler del héroe + Reproduzca vistas previas de avances en el héroe de metadatos cuando haya un avance disponible. Tarjetas de episodios Elige cómo se muestran los episodios en la pantalla de metadatos. Horizontal @@ -522,6 +759,8 @@ Tarjetas apiladas centradas en detalles Episodios Temporadas y lista de episodios para series. + Desenfocar episodios no vistos + Desenfoque las miniaturas de los episodios hasta que se vean para evitar spoilers. Grupo %1$d Más como esto Carril de recomendaciones. @@ -551,6 +790,10 @@ Notificación de prueba Comunidad Mira a las personas que construyen y apoyan Nuvio en Mobile, TV y Web. + Servidor y mantenimiento de este mes + Cubierto. El apoyo adicional ahora se destina al desarrollo. + Después del 100%, el apoyo adicional se destina al desarrollo. + Cargando progreso de financiación... La API de patrocinadores no está configurada. Agrega DONATIONS_BASE_URL a local.properties. Colaboradores Patrocinadores @@ -588,6 +831,10 @@ Anime Skip ID de cliente de AnimeSkip Introduce tu ID de cliente API de AnimeSkip. Obtén uno en anime-skip.com. + Habilitar envío de introducción + Muestre un botón para enviar marcas de tiempo de introducción/final a la base de datos de la comunidad. + Clave API de introducción a DB + Ingrese su clave API de IntroDB para enviar marcas de tiempo. Requerido para la presentación. Buscar también marcas de salto en AnimeSkip (requiere ID de cliente). Reproducción automática del siguiente episodio Buscar y reproducir automáticamente el siguiente episodio cuando se alcance el umbral. @@ -603,9 +850,24 @@ %1$d horas Activar libass Usar libass para renderizar subtítulos ASS/SSA en lugar del renderizador predeterminado. + Reproductor externo + Aplicación de reproductor externo + Abra una nueva reproducción con la aplicación de video predeterminada de Android o el selector de sistema. + Abra una nueva reproducción con el reproductor instalado seleccionado. + Reenviar subtítulos a un reproductor externo + Obtenga subtítulos adicionales en su idioma preferido y páselos al reproductor externo. + Enviar marcas de tiempo de introducción y cierre + Pase las marcas de tiempo de omisión de introducción y final resueltas al reproductor externo para omitir automáticamente. Sólo funciona en reproductores que lo admitan; otros jugadores lo ignoran. + No hay reproductores externos compatibles instalados + Jugador + Interno + Externo + Elija qué reproductor maneja la nueva reproducción. Velocidad al mantener Mantener para acelerar Mantén pulsado en cualquier parte de la superficie del reproductor para aumentar temporalmente la velocidad. + Gestos táctiles + Permita deslizar y tocar dos veces la superficie del reproductor para buscar, ajustar el brillo o ajustar el volumen. Patrón regex no válido Duración de caché del último enlace Mapear DV7 a HEVC @@ -617,10 +879,21 @@ No establecido Predeterminado Idioma del dispositivo + Idioma original + Requiere que se habilite el enriquecimiento TMDB Forzado Ninguno + Todos los subtítulos + Busque y muestre todos los subtítulos adicionales del video. + Inicio rápido + Omita la búsqueda automática de subtítulos del complemento hasta que lo solicite en el reproductor. + Inicio de subtítulos adicionales + Preferido solamente + Obtenga subtítulos de complementos, pero solo muestre coincidencias de idiomas preferidos. Preferir grupo binge Al reproducir automáticamente, preferir un stream del mismo grupo binge que el actual. + Reutilizar grupo de atracones + Recuerde y reutilice el último grupo de atracones entre sesiones (Continuar viendo, Detalles, etc.). Idioma de audio preferido Idioma de subtítulos preferido Preajustes @@ -651,6 +924,16 @@ Idioma de audio secundario Idioma de subtítulos secundario DECODIFICADOR + Motor de reproducción + Utilice ExoPlayer primero y recurra a libmpv si falla la reproducción. + Utilice los decodificadores ExoPlayer y Android Media3. + Utilice libmpv para la reproducción de Android. + Renderizador libmpv + Renderizador libmpv + Decodificación de hardware libmpv + Utilice la decodificación de hardware mpv cuando esté disponible. + Compatibilidad con libmpv YUV420P + Fuerce la salida YUV420P para dispositivos con problemas de renderizado o color. SIGUIENTE EPISODIO REPRODUCTOR SALTAR SEGMENTOS @@ -661,6 +944,20 @@ %1$d seleccionados Mostrar superposición de carga Mostrar la superposición de carga inicial mientras empieza a reproducirse un stream. + Color de fondo + Atrevido + Utilice un peso de fuente de subtítulos más pesado. + Transparente + Describir + Color del contorno + Dibuja un borde alrededor del texto del subtítulo. + Mostrar solo idiomas preferidos + Muestra solo subtítulos que coincidan con tus idiomas de subtítulos preferidos. + Tamaño de subtítulo + Color del texto + Usar subtítulos forzados + Prefiere los subtítulos forzados cuando coincidan con la configuración de idioma de los subtítulos. + Desplazamiento vertical Saltar intro/outro/resumen Mostrar botón de salto durante segmentos detectados de intro, outro y resumen. Ámbito de fuentes @@ -744,6 +1041,34 @@ Abrir inicio de sesión de Trakt Tus acciones de Guardar ahora pueden apuntar a la watchlist de Trakt y a listas personales. Inicia sesión con Trakt para habilitar el guardado basado en listas y el modo biblioteca de Trakt. + Fuente de la biblioteca + Elija qué biblioteca usar para guardar y ver su colección + Fuente de la biblioteca + Elija dónde guardar y administrar los elementos de su biblioteca + Tracto + Biblioteca Nuvio + Biblioteca Trakt seleccionada + Biblioteca Nuvio seleccionada + Ver progreso + Elija qué fuente de progreso se reanudan los poderes y continúa mirando + Ver progreso + Elija si reanudar y continuar mirando debe usar Trakt o Nuvio Sync mientras la exploración de Trakt permanece activa. + Tracto + Sincronización Nuvio + Ver fuente de progreso configurada en Trakt + Ver fuente de progreso configurada en Nuvio Sync + Más como esta fuente + Elija de dónde provienen las recomendaciones en las páginas de detalles + Más como esta fuente + Seleccione la fuente de las recomendaciones que se muestran en las páginas de detalles. + Tracto + TMDB + Continuar viendo la ventana + Se considera el historial de Trakt para seguir viéndolo. + Continuar viendo la ventana + Elija cuánta actividad de Trakt debe aparecer al continuar viendo. + toda la historia + %1$d días Calificación del público IMDb Letterboxd @@ -859,10 +1184,13 @@ Salir de la app Este catálogo no devolvió ningún elemento. No se encontraron títulos + Más acciones Comprueba tu conexión Wi‑Fi o de datos móviles e inténtalo de nuevo. Director No se pudo cargar Más como esto + Desarrollado por TMDB + Desarrollado por Trakt Temporadas Este addon devolvió videos para la serie, pero ninguno incluía números de temporada o episodio. Este addon no proporcionó metadatos de episodios para esta serie. @@ -889,10 +1217,13 @@ Marcar anteriores como vistos Marcar %1$s como no vista Marcar %1$s como vista + Marcar temporadas anteriores como vistas Marcar como no visto Marcar como visto Siguiente %1$s visto + Falta %1$dh %2$dm + Faltan %1$dm Instala y valida al menos un addon antes de cargar filas de catálogo en Inicio. Los addons instalados no exponen actualmente catálogos compatibles con el tablero sin extras requeridos. No hay filas de inicio disponibles @@ -915,6 +1246,8 @@ Fila de tráilers y accesos rápidos de reproducción. De nuevo en línea No se puede acceder a los servidores + Cuerpo de respuesta vacío + La solicitud falló con HTTP %1$d Sin conexión a internet (edad %1$d) Nació %1$s%2$s @@ -934,9 +1267,14 @@ Bloqueado. Inténtalo de nuevo en %1$ds Las opciones de avatar aparecerán aquí cuando se cargue el catálogo. Avatar: %1$s + Introduzca una URL de imagen http:// o https:// válida. Elige un avatar Elige un avatar abajo. Crear perfil + URL de avatar personalizada seleccionada. + URL de avatar personalizada + Pegue el enlace de una imagen o déjelo vacío para usar el catálogo de avatar integrado. + https://ejemplo.com/avatar.png Todos los datos de "%1$s" se eliminarán permanentemente. Eliminar perfil Añadir perfil @@ -968,6 +1306,8 @@ Comprobando más addons… Copiar enlace del stream Descargar archivo + Abrir en reproductor externo + Abrir en reproductor interno Los addons de streams instalados no devolvieron una respuesta válida. No se pudieron cargar los streams Instala primero un addon para cargar streams de este título. @@ -979,6 +1319,8 @@ T%1$dE%2$d - %3$s Obteniendo… Buscando fuente… + Cargando subtítulos de complementos... + Cargando segmentos de salto... Buscando streams… Enlace del stream copiado No hay enlace directo del stream disponible @@ -987,6 +1329,14 @@ Reanudar desde %1$d% Reanudar desde %1$s TAMAÑO %1$s + Este tipo de transmisión no es compatible + Conecte una cuenta en Configuración. + No almacenado en caché en Torbox. + Este enlace expiró. Resultados refrescantes. + No se pudo abrir este enlace. + No se pudo abrir el reproductor externo + Elija primero un reproductor externo en la configuración + No hay ningún reproductor externo disponible Cerrar tráiler No se puede reproducir el tráiler No se pudieron cargar las listas de Trakt @@ -994,6 +1344,8 @@ %1$s • %2$s Falló la comprobación de actualizaciones La descarga falló + Cuerpo de descarga vacío + Falta el archivo de actualización descargado. Descargando %1$d% No se pudo iniciar la instalación Estás usando la versión más reciente. @@ -1032,6 +1384,7 @@ Descarga fallida En pausa %1$s Eliminar + ¿Quitar %1$s de %2$s? ¿Eliminar %1$s de tu biblioteca? ¿Eliminar de la biblioteca? Película @@ -1040,6 +1393,7 @@ No se pudo enviar una notificación de prueba. Notificación de prueba enviada para %1$s. No se puede reproducir este stream. + Motor de reproductor MPV no disponible. Reconstruya la aplicación. El PIN de este perfil cambió. Conéctate una vez para actualizar el bloqueo en este dispositivo. No se pudo quitar el bloqueo por PIN. Inténtalo de nuevo. Conéctate a internet para quitar el bloqueo por PIN. @@ -1052,6 +1406,8 @@ Integrado Autorización denegada Completa el inicio de sesión de Trakt en tu navegador + Conectado a Trakt + Desconectado de Trakt Callback de Trakt no válido Estado de callback de Trakt no válido Respuesta de token de Trakt no válida @@ -1060,9 +1416,49 @@ Trakt no devolvió un código de autorización Faltan credenciales de Trakt No se pudo cargar el progreso de Trakt + Lista pública de Trakt + Ingrese una ID o URL de lista de Trakt válida + %1$d artículos + %1$d me gusta + Faltan credenciales de Trakt en local.properties (TRAKT_CLIENT_ID). + Falta el ID de la lista Trakt + La lista de Trakt no incluía una identificación numérica. + Lista de canales no encontrada o no pública + Se alcanzó el límite de tarifa de Trakt + Error en la solicitud de seguimiento No se pudo completar el inicio de sesión de Trakt Usuario de Trakt Lista de seguimiento + + La autorización de Trakt expiró + Lista de rutas no encontrada + Se alcanzó el límite de la lista de seguimiento + Se alcanzó el límite de tarifa de Trakt + Error en la solicitud de seguimiento + No se pudo agregar a la lista de seguimiento de Trakt + No se pudo agregar a la lista Trakt + Faltan ID de Trakt compatibles + Cuerpo de respuesta vacío + Agregue una clave API de TMDB en Configuración para usar fuentes de TMDB. + Colección TMDB %1$d + Colección TMDB no encontrada + Producción de TMDB %1$d + Empresa TMDB no encontrada + Director de TMDB %1$d + TMDB Discover no devolvió datos + TMDB Descubrir + Introduzca un ID o URL de TMDB válido. + Lista TMDB %1$d + Lista TMDB no encontrada + No se pudo cargar la fuente TMDB + Falta el ID de la colección TMDB + Falta el ID de la lista TMDB + Falta el ID de persona de TMDB + Red TMDB %1$d + Red TMDB no encontrada + No se encontraron créditos personales de TMDB + Persona de TMDB %1$d + Persona TMDB no encontrada Tráiler Desconocido Complemento @@ -1071,12 +1467,17 @@ Reanudar %1$s El JSON está vacío. La colección %1$d tiene el id vacío. + El ID de colección \'%1$s\' se usa más de una vez. La colección '%1$s' tiene el título vacío. La carpeta %1$d en '%2$s' tiene el id vacío. + La identificación de carpeta \'%1$s\' se usa más de una vez en \'%2$s\'. La carpeta '%1$s' en '%2$s' tiene el título vacío. La fuente %1$d en la carpeta '%2$s' tiene campos vacíos. + A la fuente %1$d en la carpeta \'%2$s\' le falta un ID de lista de Trakt. JSON inválido: %1$s Complemento no encontrado: %1$s + Lista de películas de Trakt + Lista de series de Trakt Enero Febrero Marzo @@ -1125,19 +1526,54 @@ Descarga iniciada Formato de stream no compatible para descargas Cuerpo de respuesta vacío + No se pudo finalizar la descarga del archivo La solicitud falló con HTTP %1$d El sistema de descargas no está inicializado + No se pudo abrir el archivo de descarga parcial + El archivo de descarga parcial no está abierto La solicitud de descarga falló + No se pudo escribir el archivo de descarga parcial %1$s - %2$s Los títulos guardados aparecerán aquí después de tocar Guardar en una pantalla de detalles. Tu biblioteca está vacía No se pudo cargar la biblioteca Otro + Nube + Guardado Biblioteca Conecta Trakt y guarda títulos en tu lista de seguimiento o listas personales. Tu biblioteca de Trakt está vacía No se pudo cargar la biblioteca de Trakt Biblioteca de Trakt + Conectar cuenta + Conecte una cuenta en la configuración de Servicios conectados para buscar archivos reproducibles desde su biblioteca en la nube. + No hay ninguna cuenta en la nube conectada + Servicios conectados abiertos + Active la biblioteca en la nube en la configuración de Servicios conectados para buscar archivos de cuentas conectadas. + La biblioteca en la nube está desactivada + Ningún archivo reproducible en la nube coincide con los filtros actuales. + Nada aquí todavía + Elige un archivo para reproducir + No se pudo cargar la biblioteca en la nube de %1$s + Este elemento no expone un archivo de vídeo reproducible. + No hay archivos reproducibles + No hay archivos reproducibles + La biblioteca en la nube está desactivada. + No se pudo reproducir este archivo en la nube. + reproducir archivo + El servicio en la nube no está conectado. + %1$s no está conectado. + %1$d archivos reproducibles + Todo + Actualizar biblioteca en la nube + Seleccionar proveedor + Seleccionar tipo + Listo para jugar + Todo + Torrentes + Usenet + Web + Archivos Anime Canales Películas @@ -1148,6 +1584,14 @@ Ya hay un episodio nuevo disponible %1$s ya está disponible Estrenos de episodios + Alcohol/Drogas + Alarmante + Desnudez + Blasfemia + Leve + Moderado + Severo + Violencia Creador Director Guionista @@ -1158,12 +1602,300 @@ KB MB GB + + Enviar introducción + Configuración de vídeo + + La biblioteca en la nube no está disponible para %1$s. + + Video + Restablecer sintonización + Preajuste de salida + Detección de picos HDR + Calcule el brillo máximo de HDR cuando faltan metadatos o son incorrectos. + Mapeo de tonos + Debandar + Reduzca las bandas de color con un pequeño costo de rendimiento. + Interpolación de cuadros + Movimiento suave cuando el mpv puede utilizar la sincronización de pantalla de forma limpia. + Brillo + Contraste + Saturación + Gama + + EDR nativo + Lo mejor para iPhones y iPads con capacidad HDR. + Tono SDR mapeado + Blancos y negros más predecibles en salida estilo SDR. + Compatibilidad + Lo más cercano al comportamiento anterior de iOS MPV. + Costumbre + Utilice sus valores avanzados a continuación. + Apagado + + salida de vídeo iOS + Decodificador de hardware + Rango dinámico extendido + Modo de salida Metal predeterminado para nuevas sesiones de reproducción. + Indicación de color de la pantalla + Deje que mpv apunte al espacio de color de visualización activo de forma predeterminada. + Primarias objetivo + Transferencia de destino + + salida de audio de iOS + Salida de audio + Utilice AudioUnit mientras la salida de AVFoundation esté temporalmente desactivada. + Soporte experimental para audio espacial y salida multicanal. + Utilice la salida AudioUnit heredada. + + Gestión de resultados + Resultados máximos + Limite la cantidad de resultados que aparecen. + Ordenar resultados + Elija cómo se ordenan los resultados. + Por límite de resolución + Cap repitió resultados de 2160p, 1080p, 720p después de ordenar. + Por límite de calidad + Cap repitió los resultados de BluRay, WEB-DL, REMUX después de ordenar. + Rango de tallas + Filtre los resultados por tamaño de archivo. + + Más información + Formato predeterminado + Formato original + Introduzca un grupo por línea. + + orden original + La mejor calidad primero + El más grande primero + Lo más pequeño primero + Mejor audio primero + El idioma primero + + Cualquier + %1$d seleccionado + + Todos los resultados + %1$d resultados + + Hasta %1$dGB + %1$dGB+ + %1$d-%2$dGB + + Resoluciones preferidas + Ordene primero las resoluciones seleccionadas, en el orden predeterminado. + Resoluciones requeridas + Mostrar solo resoluciones seleccionadas. + Resoluciones excluidas + Ocultar resoluciones seleccionadas. + + Cualidades preferidas + Ordene primero las calidades seleccionadas, en el orden predeterminado. + Cualidades requeridas + Mostrar sólo calidades seleccionadas. + Calidades excluidas + Ocultar cualidades seleccionadas. + + Etiquetas visuales preferidas + Ordene etiquetas DV, HDR, 10 bits, IMAX y similares. + Etiquetas visuales requeridas + Requiere etiquetas DV, HDR, 10 bits, IMAX, SDR y similares. + Etiquetas visuales excluidas + Oculte etiquetas DV, HDR, 10 bits, 3D y similares. + + Etiquetas de audio preferidas + Ordene etiquetas Atmos, TrueHD, DTS, AAC y similares. + Etiquetas de audio requeridas + Requiere etiquetas Atmos, TrueHD, DTS, AAC y similares. + Etiquetas de audio excluidas + Ocultar etiquetas de audio seleccionadas. + + Canales preferidos + Ordene primero los diseños de canales preferidos. + Canales requeridos + Mostrar solo diseños de canales seleccionados. + Canales excluidos + Ocultar diseños de canales seleccionados. + + Codificaciones preferidas + Ordene AV1, HEVC, AVC y codificaciones similares. + Codificaciones requeridas + Requiere AV1, HEVC, AVC y códigos similares. + Codificaciones excluidas + Ocultar codificaciones seleccionadas. + + Idiomas preferidos + Ordene primero los idiomas de audio preferidos. + Idiomas requeridos + Mostrar solo resultados con idiomas seleccionados. + Idiomas excluidos + Ocultar resultados donde se excluyen todos los idiomas. + + Grupos de versión necesarios + Mostrar solo grupos de lanzamiento seleccionados. + Grupos de versiones excluidos + Ocultar grupos de lanzamiento seleccionados. + + Enviar marcas de tiempo + TIPO DE SEGMENTO + Introducción + Resumen + Outro + HORA DE INICIO (MM:SS) + HORA DE FINALIZACIÓN (MM:SS) + Entregar + Captura + + Decodificador de hardware + Salida de audio + Primarias objetivo + Transferencia de destino + + Lista de rutas resueltas + TMDB Descubrir + EE. UU., KR, JP, IN + + Ingrese un nombre, URL o ID de la lista Trakt + Introduzca un ID o URL de TMDB válido. + No se pudo cargar la fuente TMDB + Ingrese una ID o URL de la lista Trakt + No se pudo cargar la lista Trakt + + Canadá + Australia + Alemania + + Cine + Serie + + La biblioteca en la nube está deshabilitada. + + Clave API no válida o error de conexión + Falta el manifiesto \\"%1$s\\" + Colección TMDB %1$s + Director de TMDB %1$s + TMDB Descubrir + Introduzca un ID o URL de TMDB válido. + Lista TMDB %1$s + Red TMDB %1$s + Persona de TMDB %1$s + Producción de TMDB %1$s + No se pudo cargar la fuente TMDB + ID %1$s + Agregue una clave API de TMDB en Configuración para usar fuentes de TMDB. + Colección TMDB no encontrada + Empresa TMDB no encontrada + TMDB Discover no devolvió datos + Lista TMDB no encontrada + Falta el ID de la colección TMDB + Falta el ID de la lista TMDB + Falta el ID de persona de TMDB + Red TMDB no encontrada + No se encontraron créditos personales de TMDB + Persona TMDB no encontrada + Faltan credenciales de Trakt. + %1$s (%2$d) + Ingrese una ID o URL de lista de Trakt válida + %1$d artículos + %1$d me gusta + Lista de canales no encontrada o no pública + Falta el ID de la lista Trakt + La lista de Trakt no incluía una identificación numérica. + Lista pública de Trakt + Se alcanzó el límite de tarifa de Trakt + Error en la solicitud de seguimiento + No se pudieron cargar los comentarios de Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + No se pudo finalizar la descarga del archivo + No se pudo abrir el archivo de descarga parcial + El archivo de descarga parcial no está abierto + No se pudo escribir el archivo de descarga parcial + Biblioteca Nuvio + Problema de conexión + Cuerpo de respuesta vacío + Por favor verifique su conexión e inténtelo nuevamente. + La solicitud falló con HTTP %1$d + %1$s (%2$s) + Motor de reproductor MPV no disponible. Reconstruya la aplicación. + No se puede reproducir esta transmisión. + Complementos deshabilitados + Complementos habilitados + %1$d proveedores + Refrescante + %1$d repositorios + Falta la clave API de TMDB + Conjunto de claves API de TMDB + Instalar repositorio de complementos + Instalando… + Proveedor de pruebas + Pruebas… + Eliminar repositorio de complementos + Actualizar el repositorio de complementos + Aún no hay proveedores disponibles. + Agregue una URL del repositorio para instalar complementos del proveedor para el descubrimiento de transmisiones. + Aún no hay repositorios de complementos instalados. + Utilice proveedores de complementos durante el descubrimiento de transmisiones. + Habilitar proveedores de complementos a nivel mundial + Ese repositorio de complementos ya está instalado. + Ingrese la URL del repositorio de complementos. + Ingrese una URL de complemento válida. + No se puede instalar el repositorio de complementos + Proveedor no encontrado + No se puede actualizar el repositorio + Los complementos no están disponibles en esta compilación. + En Streams, muestra un proveedor por repositorio en lugar de uno por fuente. + Proveedores de complementos de grupo por repositorio + URL del manifiesto del complemento + Falta el nombre del manifiesto. + El manifiesto no tiene proveedores. + Falta la versión del manifiesto. + Falta el nombre del manifiesto. + El manifiesto no tiene proveedores. + Falta la versión del manifiesto. + Instalado %1$s. + Deshabilitado por repositorio + Sin descripción + %1$s + Repositorio de complementos + Versión %1$s + Ese repositorio de complementos ya está instalado. + No se puede instalar el repositorio de complementos + No se puede actualizar el repositorio + AÑADIR REPOSITORIO + REPOSITORIOS INSTALADOS + DESCRIPCIÓN GENERAL + PROVEEDORES + Error + La prueba del proveedor falló + Resultados de la prueba (%1$d) + Los proveedores de complementos requieren una clave API de TMDB. Configúrelo en la pantalla TMDB o los proveedores de complementos no funcionarán correctamente. + No se obtuvieron resultados de búsqueda para %1$s. + Clave API no válida o error de conexión + Repositorio de complementos + Enviar introducción + Entregar + Captura + HORA DE FINALIZACIÓN (MM:SS) + TIPO DE SEGMENTO + HORA DE INICIO (MM:SS) + Conectado a Trakt + Desconectado de Trakt + tuberculosis + No se encontró ningún recurso APK en la versión + La descarga falló con HTTP %1$d + Falta el archivo de actualización descargado. + Cuerpo de descarga vacío + GitHub publica error de API: %1$d + Aún no se ha publicado ninguna actualización. + El lanzamiento no tiene etiqueta ni nombre. + Se transmite %1$s Se transmite hoy Se transmite mañana Se transmite dentro de %1$d día - Se transmite dentro de %1$d días Se transmite dentro de %1$d días %1$s @@ -1171,9 +1903,28 @@ Mañana En %1$d día - En %1$d días En %1$d días Nuevo episodio Nueva temporada + + Lista de rutas %1$s + reproductor del sistema android + Transmisión P2P + Esta transmisión utiliza tecnología peer-to-peer (P2P). By enabling P2P, you acknowledge and agree that:\n\n• Your IP address will be visible to other peers in the network\n• You are solely responsible for the content you access\n• You confirm you have the legal right to stream this content in your jurisdiction\n• Nuvio does not host, distribute, or control any P2P content\n• Nuvio bears no liability for any legal consequences arising from your use of P2P streaming\n\nYou use this feature entirely at your own risk. P2P se puede desactivar en cualquier momento en Configuración. + Habilitar P2P + Cancelar + Error de torrent desconocido + Transmisión P2P + Permitir transmisiones de igual a igual (torrent) + Ocultar estadísticas de torrents + Ocultar buffer, semillas, pares y velocidad de descarga durante la carga y reproducción + %1$d semillas · %2$d pares + %1$s almacenado en búfer · %2$s · %3$s + %1$s · %2$s + %1$d pares · %2$d semillas · %3$d%% + Conectándose con sus compañeros... + Arrancando el motor P2P... + No se pudo iniciar el torrent: %1$s + Error de torrent: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 287403fa..a6a53665 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -20,6 +20,7 @@ Réessayer Enregistrer Enregistrement… + Changer Valider Installation en cours Addons @@ -32,6 +33,13 @@ Indisponible Configurer l’addon Supprimer l’addon + Retrouvez votre bibliothèque dans Nuvio avec les addons que vous utilisez. + Ajoutez un addon pour commencer à créer votre bibliothèque. + Personnalisez Nuvio. + URL de l’addon + Ajouter un addon + Rien à afficher pour le moment + Vos addons actuels n’ont encore rien à afficher ici. Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. Aucun addon installé. Saisissez une URL d’addon. @@ -311,6 +319,8 @@ S’inscrire Vos données seront uniquement stockées localement Regardez tout, partout + En m’inscrivant, j’accepte les + Conditions d’utilisation Bon retour Bibliothèque Bibliothèque Trakt @@ -426,7 +436,9 @@ Rechercher de nouvelles versions de l’application. Vérifier les mises à jour Gérez les addons et sources de découverte. + Gérez vos addons et choisissez ce qui apparaît dans Nuvio. Gérez vos films et épisodes téléchargés. + Gérez ce que vous avez enregistré sur cet appareil. Téléchargements GÉNÉRAL Connectez les services TMDB et MDBList. @@ -439,9 +451,25 @@ Rechercher dans les paramètres… RÉSULTATS DÉMARRAGE + DIAGNOSTICS CACHE Mémoriser le dernier profil Mémorise le dernier profil sélectionné au démarrage + Rapports de plantage Sentry + Envoyer les rapports de plantage et d’ANR avec un contexte sûr. Activé par défaut. + Activer les rapports de plantage ? + Les rapports de plantage aident à identifier les défaillances propres à un appareil sans vous demander de reproduire le problème avec les journaux ADB. + Désactiver les rapports de plantage ? + Les futurs plantages et les ANR en cours de cet appareil ne seront plus signalés. La correction des bugs propres à l’appareil peut en devenir bien plus difficile. + En quoi c’est utile + Les rapports sont regroupés par version de l’application, modèle d’appareil, version d’Android et pile d’appels, afin de corriger en priorité les plantages réels les plus fréquents. + Envoyé + Plantages, ANR en cours, piles d’appels, version de l’application, type de build, variante, métadonnées de l’appareil et du système, ainsi que des traces réseau sûres avec méthode, hôte, chemin, statut, durée et type d’exception. + Non envoyé + Mots de passe, jetons d’accès, jetons de rafraîchissement, corps de requête, corps de réponse, en-têtes, cookies, captures d’écran, hiérarchie des vues, relecture de session, diagnostics bruts, et valeurs de requête ou de fragment des URL de flux. + Garder activé + Désactiver + Activer Vider le cache de Continuer à regarder Supprime les miniatures, titres et données d’enrichissement en cache pour Continuer à regarder Cache vidé @@ -492,6 +520,8 @@ Aucun épisode terminé Aucun téléchargement %1$d épisode(s) téléchargé(s) + Ouvrir le dossier des téléchargements + Impossible d’ouvrir le dossier des téléchargements Actifs Films Séries @@ -527,6 +557,7 @@ Noir AMOLED Utilise des fonds noirs purs pour les écrans OLED. Langue de l’application + Langue de l’appareil Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. Liquid Glass @@ -621,6 +652,7 @@ ACCUEIL SOURCES Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. + Ajoutez et gérez les addons que vous utilisez avec Nuvio. Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne. Contrôle quels catalogues apparaissent à l’accueil et dans quel ordre. Désactivez des sections de détails et réorganisez tout sous le Hero. @@ -721,6 +753,14 @@ Liste principale du casting. Fond cinématographique Fond flou derrière le contenu, similaire à l’écran de streams. + Mode d’arrière-plan + Choisissez comment les visuels apparaissent derrière les pages de métadonnées. + Normal + Utiliser l’arrière-plan standard de l’application. + Cinématographique + Afficher un fond légèrement flou derrière la page. + Couleur dominante + Adapter l’arrière-plan de la page à la couleur principale du fond. Collection Rayon de collection ou de franchise associée. Commentaires @@ -768,6 +808,10 @@ Notification de test Communauté Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. + Serveur et maintenance de ce mois-ci + Couvert. Le soutien supplémentaire va désormais au développement. + Au-delà de 100 %, le soutien supplémentaire va au développement. + Chargement de la progression du financement… L’API des supporters n’est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters @@ -830,6 +874,8 @@ Ouvre les nouvelles lectures avec le lecteur installé sélectionné. Transmettre les sous-titres au lecteur externe Récupère les sous-titres d’addons dans votre langue préférée et les transmet au lecteur externe. + Envoyer les horodatages d’intro et d’outro + Transmet les horodatages de saut d’intro et d’outro résolus au lecteur externe pour le saut automatique. Ne fonctionne que dans les lecteurs compatibles ; les autres l’ignorent. Aucun lecteur externe compatible n’est installé Lecteur Interne @@ -851,6 +897,8 @@ Non défini Par défaut Langue de l’appareil + Langue originale + Nécessite l’activation de l’enrichissement TMDB Forcé Aucun Tous les sous-titres @@ -894,6 +942,16 @@ Langue audio secondaire Langue des sous-titres secondaire DÉCODEUR + Moteur de lecture + Utiliser d’abord ExoPlayer et basculer sur libmpv si la lecture échoue. + Utiliser ExoPlayer et les décodeurs Android Media3. + Utiliser libmpv pour la lecture Android. + Moteur de rendu libmpv + Moteur de rendu libmpv + Décodage matériel libmpv + Utiliser le décodage matériel mpv quand il est disponible. + Compatibilité YUV420P libmpv + Forcer la sortie YUV420P pour les appareils ayant des problèmes de rendu ou de couleur. ÉPISODE SUIVANT LECTEUR PASSER LES SEGMENTS @@ -1280,6 +1338,7 @@ Récupération… Recherche de la source… Chargement des sous-titres depuis les addons… + Chargement des segments à passer… Recherche des streams… Lien du stream copié Aucun lien direct du stream disponible @@ -1349,7 +1408,7 @@ Aperçu de l’alerte de sortie d’épisode. Impossible d’envoyer une notification de test. Notification de test envoyée pour %1$s. - Moteur de lecture MPV indisponible. Reconstruis l’app. + Moteur de lecture MPV indisponible. Reconstruisez l’app. Impossible de lire ce stream. Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil. Impossible de supprimer le verrouillage PIN. Réessayez. @@ -1422,8 +1481,10 @@ Reprendre %1$s Le JSON est vide. La collection %1$d a un ID vide. + L’identifiant de collection « %1$s » est utilisé plusieurs fois. La collection « %1$s » a un titre vide. Le dossier %1$d dans « %2$s » a un ID vide. + L’identifiant de dossier « %1$s » est utilisé plusieurs fois dans « %2$s ». Le dossier « %1$s » dans « %2$s » a un titre vide. La source %1$d dans le dossier « %2$s » a des champs vides. La source %1$d dans le dossier « %2$s » n’a pas d’ID de liste Trakt. @@ -1784,7 +1845,7 @@ Vérifiez votre connexion et réessayez. Échec de la requête (HTTP %1$d) %1$s (%2$s) - Moteur de lecture MPV indisponible. Reconstruis l’app. + Moteur de lecture MPV indisponible. Reconstruisez l’app. Impossible de lire ce stream. Plugins désactivés Plugins activés @@ -1895,4 +1956,19 @@ Démarrage du moteur P2P… Impossible de démarrer le torrent : %1$s Erreur de torrent : %1$s + + Naissance + Lieu de naissance + Crédits + Biographie + Pays + Type + Catalogue + À propos + Chargement des sous-titres depuis les addons… + Téléchargement des sous-titres… + + %d titre + %d titres + diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml new file mode 100644 index 00000000..3dda499a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml @@ -0,0 +1,1924 @@ + + Adatforrások, köszönetnyilvánítások és platform licencek + Támogatók elismerése és a projekt készítői + Vissza + Mégse + Bezárás + Törlés + Kész + Szerkesztés + Importálás + Következő + OK + Lejátszás + Előző + Eltávolítás + Átrendezés + Alapértelmezés visszaállítása + Folytatás + Újra + Mentés + Mentés… + Ellenőrzés + Telepítés… + Kiegészítők + Aktív + %1$d katalógus + Konfigurálható + Frissítés… + %1$d erőforrás + Nem érhető el + Kiegészítő konfigurálása + Kiegészítő törlése + Adj meg egy manifest URL-t, hogy elkezdhesd betölteni a katalógusokat, metaadatokat, streameket vagy feliratokat a Nuvio-ba. + Még nincsenek kiegészítők telepítve. + Adj meg egy kiegészítő URL-t. + Kiegészítő URL + Kiegészítő telepítése + Manifest részleteinek betöltése... + A manifest URL ellenőrzése és a kiegészítő részleteinek betöltése telepítés előtt. + Kiegészítő ellenőrzése + Sikertelen telepítés + A(z) %1$s ellenőrzése és hozzáadása sikeresen megtörtént. + Kiegészítő telepítve + Kiegészítő mozgatása lefelé + Kiegészítő mozgatása felfelé + Aktív + Kiegészítők + Katalógusok + Kiegészítő frissítése + Kiegészítő hozzáadása + Telepített kiegészítők + Áttekintés + %1$d azonosító szabály + Verzió: %1$s + Kiválasztva + JSON másolása + %1$d gyűjtemény, %2$d mappa + Biztosan törlöd a(z) \"%1$s\" elemet? Ez a művelet nem vonható vissza. + Gyűjtemény törlése + Katalógus hozzáadása + Mappa hozzáadása + Összes műfaj + Adj hozzá katalógusokat a telepített kiegészítőidből annak meghatározásához, hogy mi jelenjen meg ebben a mappában. + Még nincsenek katalógusforrások + Kiválasztás + Emoji + Kép URL + Nincs + Borító + Gyűjtemény létrehozása + Kész + Gyűjtemény szerkesztése + Mappa szerkesztése + Beállíthatod a mappa azonosítóját, megjelenését és katalógusforrásait a fő gyűjteményszerkesztővel megegyező struktúrában. + Adj hozzá egyet a kezdéshez. + Még nincsenek mappák + Mappák + Műfajszűrő + Csak a borítókép megjelenítése + Cím elrejtése + Új mappa + A gyűjtemény megjelenítése az összes normál kezdőképernyős katalógus felett. Több kitűzött gyűjtemény a létrehozási sorrendet követi. + Kitűzés a katalógusok felé + Háttérkép URL (opcionális) + Mappa neve + Animált GIF URL (csak kijelöléskor játszódik le) + Gyűjtemény neve + Módosítások mentése + Mentés + Megjelenés + Alapok + Katalógusforrások + Válaszd ki azokat a kiegészítő-katalógusokat, amelyeket ez a mappa összesítsen. + Katalógusok kiválasztása + Műfaj kiválasztása + %1$d kiválasztva + %1$d katalógus + %1$d kiválasztva + Poszter + Négyzet + Széles + Az összes katalógus kombinálása egyetlen fülre + \"Összes\" fül megjelenítése + A beállított GIF lejátszása a statikus borító helyett, ha elérhető. + GIF megjelenítése, ha be van állítva + %1$d forrás · %2$s + Csempe formája + Sorok + Fülek + Nézet mód + TMDB források + Nyilvános lista + Gyártó + Csatorna + Gyűjtemény + Személy + Rendező + Egyedi + Válassz egy kész forrást. Hozzáadás után szerkesztheted vagy eltávolíthatod. + Illessz be egy nyilvános TMDB lista URL-t, vagy csak a számot az URL-ből. + Keress stúdió név alapján, vagy illessz be egy TMDB gyártó ID-t/URL-t a közvetlen hozzáadáshoz. + Adj meg egy csatorna ID-t. A gyakori csatornák elérhetők az Előbeállításokban és a gyorsszűrőkben. + Keress egy filmgyűjtemény nevére, vagy illeszd be a gyűjtemény ID-ját a TMDB-ről. + Adj meg egy TMDB személy ID-t vagy URL-t, hogy egy sort építs a szereplései alapján. + Adj meg egy TMDB személy ID-t vagy URL-t, hogy egy sort építs a rendezései alapján. + Építs egy élő TMDB sort opcionális szűrők használatával. Hagyd üresen a mezőket, ha nincs szükséged az adott szűrőre. + Nyilvános TMDB lista + Csatorna ID + Gyűjtemény ID + Személy ID + Gyártó cég neve, ID-ja vagy URL-je + TMDB ID vagy URL + https://www.themoviedb.org/list/8504994 vagy 8504994 + 213 a Netflix, 49 az HBO, 2739 a Disney+ esetén + 10 a Star Wars gyűjtemény esetén + Marvel Studios, 420, vagy a cég URL-je + 31 Tom Hanks esetén, vagy a személy URL-je + Példák: Marvel Studios, 420, vagy https://www.themoviedb.org/company/420. + Példa: Star Wars Collection, Harry Potter Collection, vagy egy gyűjtemény URL. + Példa ID-k: Netflix 213, HBO 49, Disney+ 2739. + Példa: https://www.themoviedb.org/list/8504994 vagy 8504994. + Példa: https://www.themoviedb.org/person/31-tom-hanks vagy 31. + Megjelenítendő név + A sor vagy fül neveként jelenik meg. Ha üres, a Nuvio automatikusan létrehoz egyet a forrásból. + Marvel filmek, Netflix saját gyártás, Pixar + Tom Hanks filmek, Kedvenc színészek + Christopher Nolan filmek, Kedvenc rendezők + Legjobb akciófilmek, Koreai drámák, 2024-es animációk + Keresési eredmények + TMDB gyűjtemény + TMDB cég: %1$d + TMDB gyűjtemény: %1$d + Típus + Filmek + Sorozatok + Mindkettő + Rendezés + Szűrők + Hagyd üresen a mezőket, ha nincs szükséged az adott szűrőre. + Gyors műfajok + Gyors nyelvek + Gyors országok + Gyors kulcsszavak + Gyors stúdiók + Gyors csatornák + Műfaj ID-k + Használj TMDB műfajszámokat. Több megadásakor az ÉS kapcsolathoz használj vesszőt, a VAGY kapcsolathoz függőleges vonalat (|). + Megjelenés vagy adásba kerülés kezdete + Megjelenés vagy adásba kerülés vége + Használd az ÉÉÉÉ-HH-NN formátumot, például: 2024-01-01. + Minimum értékelés + Maximum értékelés + TMDB értékelés 0 és 10 között. Például: 7.0. + Minimum szavazatszám + Ezzel elkerülheted az ismeretlen, kevés szavazattal rendelkező címeket. Például: 100. + Eredeti nyelv + Használj kétbetűs nyelvkódokat, például: en, ko, ja, hi. + Származási ország + Használj kétbetűs országkódokat, például: US, KR, JP, IN. + Kulcsszó ID-k + Használj TMDB kulcsszőszámokat. A gyorsgombok feltöltik a gyakori példákat. + 9715 szuperhős esetén + Gyártó cég ID-k + Használj stúdió/gyártó cég ID-kat. A gyorsgombok feltöltik a gyakori példákat. + 420 a Marvel Studios esetén + Csatorna ID-k + Csak sorozatokhoz. Használj csatorna ID-kat, mint a Netflix 213 vagy az HBO 49. + 213 a Netflix esetén + Év + Használj négyjegyű évet, például: 2024. + Előbeállítások + Keresés + Forrás hozzáadása + Trakt lista hozzáadása + Trakt lista szerkesztése + Trakt listák + Trakt lista + Cím, Trakt URL vagy lista ID keresése + Használj egy nyilvános Trakt lista URL-t, numerikus lista ID-t, vagy keress név alapján. + Hétvégi filmezés, Díjnyertesek +Keresési eredmények + Felkapott listák + Népszerű listák + Rendezés iránya + Növekvő + Csökkenő + Lista sorrendje + Legutóbb hozzáadott + Cím + Megjelenés éve + Játékidő + Népszerűség + Százalékos értékelés + Szavazatok száma + Akció + Kaland + Animáció + Vígjáték + Horror + Sci-Fi + Dráma + Bűnügyi + Reality + Angol + Koreai + Japán + Hindi + Spanyol + Egyesült Államok + Korea + Japán + India + Egyesült Királyság + Szuperhős + Regény alapján + Időutazás + Űr + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Eredeti sorrend + Népszerű + Legjobbra értékelt + Legutóbbi + Legtöbb szavazat + Megtekintési régió + ISO 3166-1 országkód, ahol a tartalom elérhető. Például: HU, US, GB. + Gyors régióválasztás + Streaming szolgáltató ID-k + Használj TMDB szolgáltató ID-kat. Több megadásakor az ÉS kapcsolathoz használj vesszőt, a VAGY kapcsolathoz függőleges vonalat (|). + 8|337|350 + Gyors szolgáltatóválasztás + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + TMDB lista + TMDB filmgyűjtemény + Gyártó + Csatorna + Személy + Rendező + TMDB Felfedezés + Hozz létre egyet a katalógusaid rendszerezéséhez. + Még nincsenek gyűjtemények + %1$d mappa + Nem találhatók elemek + A mappa nem található + Gyűjtemények + Gyűjtemények importálása + JSON + Illeszd be a gyűjtemények JSON kódját alább. + Importálás + Új gyűjtemény + Kitűzött + Összes + Gyűjteményeid + Készítette ❤️-vel a Tapframe és barátai + Verzió: %1$s (%2$s) + Ki + Be + Szünet + Újratöltés + Már van fiókod? + Folytatás fiók nélkül + Fiók létrehozása + Nincs még fiókod? + E-mail + vagy + Jelszó + Jelentkezz be a könyvtárad és a megtekintési előzményeid eléréséhez + Bejelentkezés + Regisztrálj az adataid eszközök közötti szinkronizálásához + Regisztráció + Az adataid csak helyileg lesznek tárolva + Streamelj mindent, bárhol + Üdvözlünk újra! + Könyvtár + Trakt könyvtár + Kezdőlap + Könyvtár + Profil + Keresés + Hangsávok + Hang + Beépített + Alsó eltolás + Lejátszó bezárása + Szín + Most játszott tartalom + %1$d. rész + S%1$dE%2$d + S%1$dE%2$d • %3$s + Epizódok + Betűméret + %1$dsp + Vezérlők zárolása + Nem érhetők el hangsávok + Nem érhetők el epizódok + Nem találhatók streamek + Nincs + Körvonal + Epizódok + Források + Streamek + Lejátszási hiba + Lejátszás + Koppints a feliratok lekéréséhez + Visszalépés + Alapértelmezések visszaállítása + Kitöltés + Illesztés + Nagyítás + Visszatekerés 10 másodperccel + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Előretekerés 10 másodperccel + Források + Stílus + Feliratok + Feliratok + Fényerő: %1$s + Hangerő: %1$s + Némítva + Letöltve + Műsoron + Hamarosan + Koppints a feloldáshoz + %1$d. sáv + Vezérlők feloldása + Ezt nézed éppen + Profil hozzáadása + Keresés törlése + Felfedezés + A telepített kiegészítők nem tudtak érvényes keresési eredményt visszaadni. + Sikertelen keresés + A keresés előtt telepíts és ellenőrizz legalább egy kiegészítőt. + Nincsenek aktív kiegészítők + A telepített, kereshető katalógusok nem adtak találatot erre a lekérdezésre. + Nincs találat + A telepített kiegészítőid nem támogatják a katalógusokban való keresést. + Nincsenek kereshető katalógusok + Filmek, sorozatok keresése... + Előzmények + Keresési előzmény törlése + Névjegy + Általános + Fiók + Kiegészítők + Elrendezés + Tartalom és felfedezés + Nézés folytatása + Debrid + Kezdőlap elrendezése + Integrációk + Licencek és köszönetnyilvánítások + MDBList értékelések + Részletek oldal + Értesítések + Lejátszás + Beépülő modulok + Poszterkártya stílusa + Beállítások + Támogatók és közreműködők + TMDB adatbővítés + Trakt + NÉVJEGY + Fiók- és szinkronizálási állapot + FIÓK + Kezdőlap szerkezete és poszterstílusok + A legújabb verzió letöltése + Frissítések keresése + Kiegészítők és tartalomforrások kezelése + Kezeld a kiegészítőket, és válaszd ki, mi jelenjen meg a Nuvióban. + A letöltött filmek és epizódok kezelése + Kezeld az eszközre mentett tartalmakat. + Letöltések + ÁLTALÁNOS + Elérhető integrációk kezelése + Epizódok megjelenési értesítéseinek kezelése és teszt értesítés küldése + Váltás egy másik profilra + Profilváltás + A Trakt kapcsolat ablak megnyitása + Nem találhatók beállítások. + Beállítások keresése... + TALÁLATOK + ALKALMAZÁS LICENC + ADATOK & SZOLGÁLTATÁSOK + LEJÁTSZÁSI LICENC + Nuvio Mobile + A forráskód és a licencfeltételek elérhetők a projekt tárolóján (repository). + A GNU General Public License v3.0 feltételei szerint licencelve. + The Movie Database (TMDB) + A Nuvio a TMDB API-t használja a filmek és TV-műsorok metaadataihoz, borítóképeihez, előzeteseihez, stáblistáihoz, gyártási adataihoz, gyűjteményeihez és ajánlásaihoz. Ez a termék a TMDB API-t használja, de a TMDB nem támogatja és nem tanúsítja azt. + IMDb Nem Kereskedelmi Adatkészletek + A Nuvio az IMDb nem kereskedelmi adatkészleteit használja – beleértve a title.ratings.tsv.gz fájlt – az IMDb értékelésekhez és szavazatszámokhoz. Az információk az IMDb (https://www.imdb.com) jóvoltából állnak rendelkezésre, engedéllyel felhasználva. Az IMDb adatai kizárólag személyes és nem kereskedelmi célra használhatók fel az IMDb feltételei szerint. + Trakt + A Nuvio a Trakthoz kapcsolódik a fiókhitelesítés, a megtekintési előzmények, a folyamat-szinkronizálás, a könyvtáradatok, az értékelések, a listák és a hozzászólások kezeléséhez. A Nuvio nem áll kapcsolatban a Trakttal, és a Trakt nem támogatja azt. + MDBList + A Nuvio az MDBList-et használja az értékelésekhez és a külső pontszám-szolgáltatók adataihoz. A Nuvio nem áll kapcsolatban az MDBListtel, és az MDBList nem támogatja azt. + IntroDB + A Nuvio az IntroDB API-t használja a közösség által beküldött intró, összefoglaló (recap), stáblista és előzetes időbélyegekhez, amelyeket a kihagyás vezérlők használnak. A Nuvio nem áll kapcsolatban az IntroDB-vel, és az IntroDB nem támogatja azt. + MPVKit + Lejátszáshoz használatos az iOS buildjelemeken. + Az MPVKit forráskódja önmagában az LGPL v3.0 szerint licencelt. Az MPVKit csomagok, beleértve a libmpv és FFmpeg könyvtárakat, szintén az LGPL v3.0 szerint licenceltek. + AndroidX Media3 ExoPlayer 1.8.0 + Lejátszáshoz használatos az Android buildjelemeken. + Az Apache License 2.0-s verziója szerint licencelve. + A Trakt-listáid betöltése… + Válaszd ki, hová szeretnéd menteni ezt a címet a Trakton + Támogatás + Ugrás a részletekhez + Eltávolítás + Indítás az elejétől + Lejátszás + %1$d/10 + Kritika + Spoiler + Még nem érhetők el Trakt értékelések. + %1$d kedvelés + Ez a hozzászólás spoilert tartalmaz. + Ez a hozzászólás spoilert tartalmaz, ezért elrejtettük. + Hozzászólások + Előzetes + %1$s (%2$d) + Előzetesek + Nincsenek befejezett epizódok + Még nincsenek letöltések + %1$d letöltött epizód + Aktív + Filmek + Sorozatok + Letöltések megjelenítése + Befejezve • %1$s + Letöltés • %1$s + Sikertelen + Szüneteltetve • %1$s + Megtekintett + %1$d. évad + Különkiadások + Folytatás onnan, ahol abbahagytad + Hozzáadás a könyvtárhoz + Megjelölés nem megtekintettként + Megjelölés megtekintettként + Eltávolítás a könyvtárból + Összes megtekintése + Kézi lejátszás + %1$s logó + Fiók + Fiók törlése + Ez véglegesen törli a fiókodat és az összes kapcsolódó adatot. + Ez a művelet nem vonható vissza. Minden adatod, profilod és szinkronizálási előzményed véglegesen törlődik. + Törlöd a fiókot? + E-mail + Nincs bejelentkezve + Kijelentkezés + Visszakerülsz a bejelentkezési képernyőre. + Kijelentkezel? + Állapot + Névtelen + Bejelentkezve + AMOLED fekete + Tiszta fekete háttér használata OLED képernyőkhöz. + Alkalmazás nyelve + Nyelv választása + A Nézés folytatása sor beállításai. + Liquid Glass + A natív iPhone lapfülsáv használata iOS 26 és újabb verziókon. Ha ez be van kapcsolva, az azonnali profilváltás a lapfülsávról nem érhető el. + Kártyaszélesség és sarokkerekítés finomhangolása. + MEGJELENÍTÉS + KEZDŐLAP + TÉMA + Gyűjtemény • %1$s + Megjelenítendő név + A Kezdőlap sorainak konfigurálásához telepíts egy kiegészítőt, amely tartalmaz a kezdőlappal (board) kompatibilis katalógusokat. + Nincsenek kezdőlapi katalógusok + Kiemelt tartalom forrása + Rejtett + Kezdőlap fókuszban tartása + %1$s • Elérted a korlátot (max %2$d) + Nincs kiválasztva kiemelt forrás + Nincs a kiemeltek között + A mozgatáshoz távolítsd el a felülre rögzítést a gyűjteményből + Rögzített + Felülre rögzítve + Átrendezés + KATALÓGUSOK + KATALÓGUSOK & GYŰJTEMÉNYEK + GYŰJTEMÉNYEK + Kezdőlap elrendezése + Kiemelt katalógusok + %1$d / %2$d kiválasztva + Kiemelt rész megjelenítése + Kiemelt galéria (carousel) megjelenítése a kezdőlap tetején. + Meg nem jelent tartalmak elrejtése + A még meg nem jelent filmek és sorozatok elrejtése. + Katalógus aláhúzás elrejtése + A kiemelő vonal eltávolítása a katalógusok és gyűjtemények címei alól az alkalmazás egészében. + %1$d / %2$d katalógus látható • %3$d kiemelt forrás kiválasztva + Csak akkor nyiss meg egy katalógust, ha át szeretnéd nevezni vagy át szeretnéd rendezni. + Látható + Érték elrejtése + Lejátszó, feliratok és automatikus lejátszás + Sarokkerekítés + Poszterkártya stílusa + Szélesség + Egyéni + Kártyaszélesség és sarokkerekítés finomhangolása. + Feliratok elrejtése + Fekvő formátumú poszterek + Élő előnézet + %1$s (%2$s) + Sarokkerekítés: %1$ddp + Magasság: %1$ddp + Szélesség: %1$ddp + Klasszikus + Kapszula + Kerekített + Szögletes + Enyhe + Kiegyensúlyozott + Kényelmes + Kompakt + Sűrű + Nagy + Szabványos + Ha a lejátszóból való kilépés után nyitod meg újra az alkalmazást, egy felugró ablak jelenik meg, amellyel folytathatod a nézést onnan, ahol abbahagytad. + Folytatási felhívás indításkor + A következő epizód bélyegképeinek elhomályosítása a Nézés folytatása sorban a spoilerek elkerülése érdekében. + Nem látottak elhomályosítása a Nézés folytatásában + A még adásba nem került, közelgő epizódok megjelenítése a Nézés folytatása sorban. + Még le nem adott következő epizódok megjelenítése + RENDEZÉSI SORREND + Rendezési sorrend + Alapértelmezett + Minden elem rendezése a legutóbbi megtekintés alapján + Streaming stílus + A már megjelent elemek előre, a közelgők a sor végére kerülnek + Poszterkártya stílusa + INDÍTÁSKOR + KÖVETKEZŐ ELEM VISELKEDÉSE + LÁTHATÓSÁG + A Nézés folytatása sor megjelenítése a Kezdőlapon. + Nézés folytatása sor megjelenítése + Poszter + Elsősorban a borítóképre fókuszáló poszterkártya + Széles + Adatokban gazdag vízszintes kártya + A következő epizód megjelenítése a legutolsó megtekintett epizód alapján. Újranézés esetén kapcsold ki, hogy a legutóbb megtekintett epizód legyen használva helyette. + Következő a legutolsó epizódtól + Az epizódok saját bélyegképeinek előnyben részesítése, ha elérhetők. + Epizód-bélyegképek előnyben részesítése a Nézés folytatásában + KEZDŐLAP + FORRÁSOK + Tartalomforrások telepítése, eltávolítása, frissítése és rendezése. + JavaScript scraper tárolók telepítése és a szolgáltatók belső tesztelése. + Kezdőlap elrendezésének, a tartalom láthatóságának és a poszterek viselkedésének beállítása + A részletek és az epizódok képernyőinek beállításai. + Egyéni katalóguscsoportok létrehozása a Kezdőlapon megjelenő mappákkal. + Integrációk + Metaadat-bővítés vezérlői + Külső értékelési szolgáltatók + Kísérleti felhőalapú fiókforrások + Debrid + A Debrid támogatás kísérleti jellegű; a későbbiekben megtartható, módosítható vagy eltávolítható. + Források engedélyezése + Lejátszható találatok megjelenítése a csatlakoztatott fiókokból. + Először adj meg egy API-kulcsot. + Fiók + Torbox API-kulcs + Add meg a Torbox API-kulcsodat. + Írd be a Torbox API-kulcsot + Nincs beállítva + Azonnali lejátszás + Linkek előkészítése + Az elsődleges források feloldása a lejátszás megkezdése előtt. + Előkészítendő források száma + Lehetőleg alacsony értéket használj. A Debrid szolgáltatások korlátozhatják (rate-limit), hogy egy adott időszakon belül hány link oldható fel. Egy film vagy epizód adatlapjának megnyitása is beleszámíthat ezekbe a korlátokba – még akkor is, ha nem nyomsz a Lejátszás gombra –, mivel a linkek előre előkészítésre kerülnek. + 1 forrás + %1$d forrás + Formázás + Név sablon + A források nevének megjelenítését szabályozza. + Leírás sablon + Az egyes források alatt megjelenő metaadatokat szabályozza. + Formázás visszaállítása + A források alapértelmezett formázásának visszaállítása. + API-kulcs sikeresen ellenőrizve. + Az API-kulcs ellenőrzése sikertelen. + Az értékelések bekapcsolása előtt add meg az MDBList API-kulcsodat alább. + Szükséges az értékelések lekéréséhez az MDBList-ről + API-kulcs + API-kulcs + MDBList értékelések engedélyezése + Külső szolgáltatóktól származó értékelések megjelenítése a részletek képernyőn + API-kulcs + Külső értékelési szolgáltatók + MDBList értékelések + Műveletek + Lejátszás és mentés vezérlők. + Stáb + Főbb szereplők listája. + Mozi háttér + Elhomályosított háttérkép a tartalom mögött, a stream képernyőhöz hasonlóan. + Gyűjtemény + Kapcsolódó gyűjtemények vagy franchise-ok sora. + Hozzászólások + Értékelések és kritikák a Traktról + Részletek + Játékidő, állapot, megjelenés, nyelv és kapcsolódó információk. + Epizódkártyák + Válaszd ki, hogyan jelenjenek meg az epizódok a részletek képernyőn. + Vízszintes + Háttérkép stílusú kártyák sorban + Lista + Részletekre fókuszáló, egymás alatti kártyák + Epizódok + Évadok és epizódok listája a sorozatoknál. + Nem látott epizódok elhomályosítása + Az epizódok bélyegképeinek elhomályosítása a megtekintésig a spoilerek elkerülése érdekében. + %1$d. csoport + Hasonló tartalmak + TMDB által ajánlott tartalmak a részletek oldalon + Nincs + Áttekintés + Szinopszis, értékelések, műfajok és főbb készítők. + Gyártás + Stúdiók és csatornák. + MEGJELENÉS + SZEKCIÓK + %1$d. l csoport + Lapfüles elrendezés + Szekciók csoportosítása lapfülekre, a TV alkalmazáshoz hasonlóan. Lapfülcsoportonként legfeljebb 3 szekció rendelhető hozzá. + Előzetesek + Előzetesek sora és lejátszási parancsikonok. + Az értesítések jelenleg ki vannak kapcsolva a Nuvióban. + Epizódmegjelenési riasztások + Helyi értesítések ütemezése, amikor egy elmentett sorozathoz új epizód válik elérhetővé. + A rendszerértesítések le vannak tiltva a Nuvio számára. Engedélyezd őket a riasztások és tesztértesítések fogadásához. + Jelenleg %1$d megjelenési riasztás van ütemezve ezen az eszközön. + RIASZTÁSOK + TESZT + Teszt értesítés küldése + Teszt értesítés küldése... + Helyi teszt értesítés küldése ehhez: %1$s. + Az értesítések teszteléséhez előbb ments el egy sorozatot a könyvtáradba. + Teszt értesítés + Közösség + Ismerd meg azokat, akik a Nuviót építik és támogatják mobilon, TV-n és weben. + A támogatói API nincs konfigurálva. Add hozzá a DONATIONS_BASE_URL-t a local.properties fájlhoz. + Közreműködők + Támogatók + GitHub megnyitása + A GitHub-profil nem érhető el + Nincs hozzáfűzött üzenet. + Közreműködők betöltése... + Támogatók betöltése... + Nem sikerült betölteni a közreműködőket + Nem sikerült betölteni a támogatókat + Nem találhatók közreműködők. + Nem találhatók támogatók. + A közreműködők betöltése sikertelen. + A támogatók betöltése sikertelen. + A közreműködőket jelenleg nem sikerült betölteni. + A támogatókat jelenleg nem sikerült betölteni. + Összesen %1$d commit + Jan + Feb + Már + Ápr + Máj + Jún + Júl + Aug + Szep + Okt + Nov + Dec + %3$s. %1$s %2$s. + Minden telepített kiegészítő + Minden engedélyezett begépülő modul + Engedélyezett kiegészítők + Engedélyezett beépülő modulok + Anime Skip + AnimeSkip Client ID + Add meg az AnimeSkip API ügyfél-azonosítódat (Client ID). Az anime-skip.com oldalon igényelhetsz egyet. + Intró beküldés engedélyezése + Gomb megjelenítése az intró/outró időbélyegek közösségi adatbázisba történő beküldéséhez. + IntroDB API-kulcs + Add meg az IntroDB API-kulcsodat az időbélyegek beküldéséhez. A beküldéshez kötelező. + Keresés az AnimeSkip adatbázisában is a kihagyási időbélyegekért (ügyfél-azonosító szükséges). + Következő epizód automatikus lejátszása + A következő epizód automatikus indítása, amikor a felhívás megjelenik. + Csak eszközkódolók (hardveres) + Alkalmazásdekódolók előnyben részesítése (FFmpeg) + Eszközkódolók előnyben részesítése + Dekóder prioritása + Koppints kívülre a bezáráshoz + Koppints kívülre a mentéshez és bezáráshoz + %1$d nap + %1$d nap + %1$d óra + %1$d óra + A libass használata ASS/SSA feliratokhoz + Kísérleti: fejlett ASS/SSA felirat-megjelenítés (stílusok, pozicionálás, animációk) + Külső lejátszó + Külső lejátszó alkalmazás + A lejátszás megnyitása az Android alapértelmezett videólejátszójával vagy a rendszer választójával. + A lejátszás megnyitása a kiválasztott, telepített külső lejátszóval. + Nincs telepítve támogatott külső lejátszó + Gyorstekerési sebesség + Nyomva tartás a gyorsításhoz + Tartsd nyomva a kijelzőt bárhol a lejátszó felületén a lejátszási sebesség átmeneti növeléséhez. + Érvénytelen reguláris kifejezés (regex) minta + Utolsó link gyorsítótárának ideje + DV7 - HEVC visszalépés + A Dolby Vision Profile 7 leképzése szabványos HEVC formátumra a DV hardveres támogatással nem rendelkező eszközökön + Időkorlát (perc) + Visszalépési opció, ha nem létezik outró időbélyeg. + %1$s perc + Nincsenek elérhető elemek + Nincs beállítva + Alapértelmezett (médiafájl) + Eszköz nyelve + Kényszerített (forced) + Nincs + Sorozatnézési csoport előnyben részesítése + Próbálja meg először ugyanazt a forrásprofilt használni (azonos kiegészítő/minőségi csoport) a normál automatikus lejátszási szabályok előtt. + Előnyben részesített hangnyelv + Előnyben részesített feliratnyelv + Előbeállítások + A stream nevére, címére, leírására, kiegészítőjére vagy URL-jére illeszkedik. Például: 4K|2160p|Remux + Regex minta + Nincs minta beállítva. Például: 4K|2160p|Remux + Bármely 1080p+ + AVC / x264 + BluRay minőség + Dolby Atmos / DTS + Angol + HDR / Dolby Vision + HEVC / x265 + CAM/TS kizárása + REMUX/HDR kizárása + 1080p szabványos + 4K / Remux + 720p / Kisebb méret + WEB-es források + Libass renderelési mód + Szabványos Cues + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL (Ajánlott) + Utolsó link újrafelhasználása + Automatikusan játsza le az utoljára működő streamet ugyanahhoz a filmhez/epizódhoz, ha a gyorsítótár még érvényes + Másodlagos hangnyelv + Másodlagos feliratnyelv + DEKÓDER + KÖVETKEZŐ EPIZÓD + LEJÁTSZÓ + SZEGMENTUMOK KIHAGYÁSA + AUTOMATIKUS LEJÁTSZÁS + STREAM VÁLASZTÁS + FELIRAT ÉS HANG + FELIRAT MEGJELENÍTÉS + %1$d kiválasztva + Töltőképernyő megjelenítése + Töltőképernyő mutatása az első videokocka megjelenéséig. + Intró kihagyása + Az introdb.app használata az intrók és összefoglalók felismeréséhez. + Automatikus lejátszás forrásköre + Minden telepített kiegészítő + Az automatikus lejátszás csak a telepített kiegészítőkből származó streameket veszi figyelembe. + Minden forrás + Az automatikus lejátszás a telepített kiegészítőket és az engedélyezett beépülő modulokat is használhatja. + Csak engedélyezett beépülő modulok + Az automatikus lejátszás csak az engedélyezett beépülő modulokból származó streameket veszi figyelembe. + Csak telepített kiegészítők + Az automatikus lejátszás csak a telepített kiegészítőkből származó streameket veszi figyelembe. + Automatikus stream-választás + Első forrás automatikus lejátszása + Az első elérhető forrás automatikus elindítása. + Kézi (stream kiválasztása) + Mindig jelenjen meg a források listája a választáshoz. + Automatikus lejátszás regex egyezéssel + Az első olyan forrás lejátszása, amelynek szövege illeszkedik a regex mintára. + Stream-választási időtúllépés + A kiegészítők várakozási ideje a kiválasztás előtt. + Időkorlát (perc) + Következő epizód indítási módja + Perccel a vége előtt + Százalék + Indítási százalék + Visszalépési opció, ha nem létezik outró időbélyeg. + %1$s% + Azonnali + %1$ss + Korlátlan + Alagutalt lejátszás (Tunneled) + Hardverszintű hang- és videószinkronizáció. Javíthatja a lejátszást egyes Android TV eszközökön. + Az adatbővítés bekapcsolása előtt add meg a saját TMDB API-kulcsodat alább. + API-kulcs + TMDB adatbővítés engedélyezése + A TMDB használata metaadat-forrásként a kiegészítők adatainak felbővítéséhez + Add meg a TMDB v3 API-kulcsodat. + Nyelvkód + Borítóképek és grafikák + Logók és háttérképek a TMDB-ről + Alapinformációk + Leírás, műfajok és értékelések a TMDB-ről + Gyűjtemények + TMDB filmgyűjtemények megjelenési sorrendben + Közreműködők + Színészek fotókkal, rendezők és írók a TMDB-ről + Részletek + Játékidő, állapot, ország és nyelv a TMDB-ről + Epizódok + Epizódcímek, leírások, bélyegképek és játékidő a TMDB-ről + Hasonló tartalmak + TMDB által ajánlott tartalmak a részletek oldalon + Stúdiók / Csatornák + Televíziós hálózatok és csatornák logókkal a TMDB-ről + Gyártók + Gyártócégek a TMDB-ről + Évad poszterek + TMDB évadposzterek használata a sorozatok évadválasztó képernyőjén. + Előzetesek + Előzetesek a TMDB videóiból a részletek oldalon megjelenő szekcióhoz + Saját API-kulcs + Nyelv + A TMDB metaadatok (cím, logó és engedélyezett mezők) nyelve + HITELOSÍTÓ ADATOK + LOKALIZÁCIÓ + MODULOK + TMDB adatbővítés + A jóváhagyás után automatikusan visszairányítunk az alkalmazásba. + HITELOSÍTÁS + Hozzászólások + Trakt értékelések és kritikák megjelenítése a részletek oldalon + Trakt összekapcsolása + Összekapcsolva mint %1$s + Trakt felhasználó + Kapcsolat bontása + Nem sikerült megnyitni a böngészőt + FUNKCIÓK + Fejezd be a Trakt bejelentkezést a böngésződben + Szinkronizáld a megnézendők listáját, a megtekintési előrehaladást, a lejátszás folytatását, a scrobble-öket és a személyes listáidat a Trakttal. + Hiányzó Trakt hitelesítési adatok a local.properties fájlban (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Trakt bejelentkezés megnyitása + A mentési műveletek mostantól a Trakt megnézendők listáját és a személyes listákat is célozhatják. + Jelentkezz be a Trakt fiókodba a listaalapú mentés és a Trakt könyvtár mód engedélyezéséhez. + Könyvtár forrása + Válaszd ki, melyik könyvtárat szeretnéd használni a gyűjteményed mentéséhez és megtekintéséhez + Könyvtár forrása + Válaszd ki, hol szeretnéd menteni és kezelni a könyvtárad elemeit + Trakt + Nuvio Könyvtár + Trakt könyvtár kiválasztva + Nuvio könyvtár kiválasztva + Megtekintési előrehaladás + Válaszd ki, melyik forrás vezérelje a lejátszás folytatását + Megtekintési előrehaladás + Válaszd ki, hogy a lejátszás folytatása a Traktot vagy a Nuvio Sync-et használja, miközben a Trakt scrobbling (lejátszáskövetés) aktív marad. + Trakt + Nuvio Sync + Megtekintési előrehaladás forrása: Trakt + Megtekintési előrehaladás forrása: Nuvio Sync + Folyamatos megtekintési időablak + A lejátszás folytatásánál figyelembe vett Trakt előzmények + Folyamatos megtekintési időablak + Válaszd ki, mennyi Trakt aktivitás jelenjen meg a lejátszás folytatása sávban. + Teljes előzmény + %1$d nap + Közönség pontszám + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Ismeretlen + Borostyán + Bíbor + Smaragd + Óceán + Rózsa + Ibolya + Fehér + Következő epizód + Forrás keresése… + Lejátszás a(z) %1$s segítségével %2$d másodperc múlva… + Következő epizód bélyegképe + Még nem sugárzott + Kihagyás + Intró kihagyása + Outró kihagyása + Összefoglaló kihagyása + Nem találhatók feliratok + Afrikaans + Albán + Amhara + Arab + Örmény + Azeri + Baszk + Fehérorosz + Bengáli + Bosnyák + Bolgár + Burmai + Katalán + Kínai + Kínai (Egyszerűsített) + Kínai (Tradicionális) + Horvát + Cseh + Dán + Holland + Angol + Észt + Filippínó + Finn + Francia + Galíciai + Grúz + Német + Görög + Gudzsaráti + Héber + Hindi + Magyar + Izlandi + Indonéz + Ír + Olasz + Japán + Kannada + Kazah + Khmer + Koreai + Laoszi + Lett + Litván + Macedón + Maláj + Malajálam + Máltai + Maráthi + Mongol + Nepáli + Norvég + Perzsa + Lengyel + Portugál (Portugália) + Portugál (Brazília) + Pandzsábi + Román + Orosz + Szerb + Szingaléz + Szlovák + Szlovén + Spanyol + Spanyol (Latin-Amerika) + Szuahéli + Svéd + Tamil + Telugu + Thai + Török + Ukrán + Urdu + Üzbég + Vietnami + Walesi + Zulu + Törlés + Folytatás + Mellőzés + Telepítés + Később + Nem + Frissítés + Igen + Biztosan be akarod zárni az alkalmazást? + Kilépés az alkalmazásból + Ez a katalógus nem adott vissza semmilyen tartalmat. + Nem találhatók címek + Ellenőrizd a Wi-Fi- vagy a mobiladat-kapcsolatot, majd próbáld újra. + Rendező + Sikertelen betöltés + Hasonló tartalmak + Évadok + Ez a kiegészítő adott vissza videókat a sorozathoz, de egyik sem tartalmazott évad- vagy epizódszámot. + Ez a kiegészítő nem szolgáltatott epizód-metaadatokat ehhez a sorozathoz. + A kiegészítő még nem tette közzé ezeket az epizódokat. + Az eszközöd online, de a Nuvio nem érte el a szükséges szervereket. + Kevesebb + Több ▾ + Író + Minden műfaj + Katalógus + %1$s • %2$s + A kiválasztott katalógusból nem sikerült betölteni a felfedezésre váró tartalmakat. + Felfedezés betöltése sikertelen + A telepített kiegészítők nem tartalmaznak a felülettel kompatibilis katalógusokat a felfedezéshez. + Nincsenek felfedezési katalógusok + A kiválasztott katalógus és szűrők nem adtak vissza semmilyen tartalmat. + Nem találhatók címek + A felfedezési katalógusok böngészése előtt telepíts és hitelesíts legalább egy kiegészítőt. + Katalógus kiválasztása + Műfaj kiválasztása + Típus kiválasztása + Típus + Előzőek megjelölése lejátszatlanként + Előzőek megjelölése lejátszottként + %1$s megjelölése lejátszatlanként + %1$s megjelölése lejátszottként + Megjelölés lejátszatlanként + Megjelölés lejátszottként + Következő a sorban + %1$s megnézve + Telepíts és hitelesíts legalább egy kiegészítőt, hogy a katalógussorok betölthessenek a Kezdőlapon. + A telepített kiegészítők jelenleg nem tartalmaznak a felülettel kompatibilis katalógusokat a szükséges extrák nélkül. + Nem érhetők el sorok a kezdőlapon + Részletek megtekintése + Lejátszás és mentés vezérlői. + Műveletek + Főbb szereplők listája. + Kapcsolódó gyűjtemény vagy franchise sávja. + Gyűjtemény + Trakt hozzászólások szekció. + Játékidő, állapot, megjelenés, nyelv és kapcsolódó infók. + Részletek + Évadok és epizódok listája sorozatoknál. + Ajánlott tartalmak sávja. + Hasonló tartalmak + Szinopszis, értékelések, műfajok és főbb közreműködők. + Áttekintés + Stúdiók és csatornák. + Gyártás + Előzetesek sávja és lejátszási parancsikonok. + Ismét online + A szerverek nem érhetők el + Nincs internetkapcsolat + (életkor: %1$d év) + Született: %1$s%2$s + Meghalt: %1$s + Főbb művei: %1$s + Legutóbbi + Nem sikerült betölteni a részleteket a következőhöz: %1$s + Népszerű + Valami hiba történt + Közelgő + Törlés (Backspace) + Mégse + PIN-kód megadása + PIN-kód megadása a következőhöz: %1$s + Elfelejtetted a PIN-kódot? + Helytelen PIN-kód + Zárolva. Próbáld újra %1$ds múlva + Az profilkép-beállítások itt fognak megjelenni a katalógus betöltése után. + Profilkép: %1$s + Kérjük, érvényes http:// vagy https:// kép URL-t adj meg. + Válassz egy profilképet + Válassz egy profilképet az alábbiak közül. + Profil létrehozása + Egyedi profilkép URL kiválasztva. + Egyedi profilkép URL + Illessz be egy képlinket, vagy hagyd üresen a beépített profilkép-katalógus használatához. + https://pelda.hu/profilkep.png + A(z) "%1$s" profil minden adata véglegesen törlődni fog. + Profil törlése + Profil hozzáadása + Profil szerkesztése + Add meg a jelenlegi PIN-kódot + Add meg az új PIN-kódot + %1$d. profil + Profilképek betöltése... + Profilok kezelése + Profil neve + Új profil + Elsődleges kiegészítők kikapcsolva + Elsődleges kiegészítők bekapcsolva + PIN-kód eltávolítása a következőhöz: %1$s + PIN-kódos zárolás eltávolítása + Mentés... + Biztonság + Adj meg egy PIN-kódot, ha szeretnéd lezárni ezt a profilt a váltás előtt. + Ez a profil PIN-kóddal védett. + Válassz egy profilképet ehhez a profilhoz. + PIN-kódos zárolás beállítása + Névtelen profil + Elsődleges kiegészítők használata + A fő profil kiegészítő-konfigurációjának megosztása ahelyett, hogy külön listát kezelnél. + Ki nézi? + Letöltve + Folytatás + Aktív keresők (scraperek) + További kiegészítők ellenőrzése… + Stream link másolása + Fájl letöltése + Megnyitás külső lejátszóban + Megnyitás belső lejátszóban + A telepített stream kiegészítők nem adtak vissza érvényes stream választ. + Nem sikerült betölteni a streameket + A tartalomhoz tartozó streamek betöltéséhez előbb telepíts egy kiegészítőt. + A telepített kiegészítőid nem biztosítanak streameket az ilyen típusú tartalmakhoz. + Nem érhető el stream kiegészítő + Egyetlen telepített kiegészítő sem adott vissza streamet ehhez a tartalomhoz. + S%1$d E%2$d + Epizód + S%1$dE%2$d - %3$s + Lekérés… + Forrás keresése… + Streamek keresése… + Stream link lemásolva + Nem érhető el közvetlen stream link + Nem érhetők el metaadatok + Streamek frissítése + Folytatás innen: %1$d% + Folytatás innen: %1$s + MÉRET: %1$s + Ez a streamtípus nem támogatott + Adj meg egy Debrid API-kulcsot a Beállításokban. + Ez a Debrid találat lejárt. Streamek frissítése. + Nem sikerült feloldani ezt a Debrid streamet. + Nem sikerült megnyitni a külső lejátszót + Előbb válassz ki egy külső lejátszót a beállításokban + Nem érhető el külső lejátszó + Előzetes bezárása + Nem sikerült lejátszani az előzetest + Nem sikerült betölteni a Trakt listákat + Nem sikerült frissíteni a Trakt listákat + %1$s • %2$s + Frissítés ellenőrzése sikertelen + Letöltés sikertelen + Letöltés: %1$d% + Nem sikerült elindítani a telepítést + A legfrissebb verziót használod. + Engedélyezd az alkalmazások telepítését a Nuvio számára, majd térj vissza és folytasd. + Frissítés letöltése... + Nem találhatók frissítések. + Az új verzió készen áll a telepítésre. + Az alkalmazáson belüli frissítések nem érhetők el ebben a verzióban. + Letöltés előkészítése + Verzióinformációk + Telepítések engedélyezése a folytatáshoz + Frissítés elérhető + Frissítés állapota + Ez a kiegészítő már telepítve van. + Kérjük, érvényes kiegészítő URL-t adj meg + Nem sikerült betölteni a manifest fájlt + Nuvio + A fiók törlése sikertelen + Bejelentkezés sikertelen + Kijelentkezés sikertelen + Regisztráció sikertelen + Nem sikerült betölteni a katalóguselemeket. + Következő epizód + Következő • S%1$dE%2$d + %1$s logó + Nem sikerült betölteni a hozzászólásokat + Egyetlen kiegészítőből sem sikerült betölteni a részleteket. + Csatornák / Hálózatok + Egyetlen kiegészítő sem biztosít metaadatokat ehhez a tartalomhoz. + Letöltés sikertelen + Megjeleníti az élő letöltési folyamatot és a vezérlőket. + Letöltések + Letöltés befejeződött + Letöltés: %1$s • %2$s + Letöltés: %1$s • %2$s / %3$s + Letöltés sikertelen + Megszakítva: %1$s + Eltávolítás + Eltávolítod a következőt a listáról: %1$s (%2$s)? + Eltávolítod a következőt a könyvtáradból: %1$s? + Eltávolítás a könyvtárból? + Film + Értesítés, ha egy mentett sorozat új epizódja megjelenik. + Új epizód megjelenési értesítés előnézete. + Nem sikerült elküldeni a tesztértesítést. + Tesztértesítés elküldve a következőhöz: %1$s. + Nem sikerült lejátszani ezt a streamet. + A profil PIN-kódja megváltozott. Csatlakozz az internetre a zárolás frissítéséhez ezen az eszközön. + Nem sikerült eltávolítani a PIN-kódos zárolást. Próbáld újra. + Csatlakozz az internetre a PIN-kódos zárolás eltávolításához. + Ez a PIN-kód még nem ellenőrizhető offline ezen az eszközön. Előbb csatlakozz az internetre, és oldd fel online. + Nem sikerült beállítani a PIN-kódot. Próbáld újra. + Csatlakozz az internetre a PIN-kód beállításához. + Ez a profil az elsődleges kiegészítőket használja. + Nem sikerült betölteni a következőt: %1$s + Stream + Beágyazott + Engedélyezés megtagadva + Fejezd be a Trakt bejelentkezést a böngésződben + Érvénytelen Trakt visszahívás (callback) + Érvénytelen Trakt visszahívási állapot (state) + Érvénytelen Trakt token válasz + Nem sikerült betölteni a Trakt könyvtárat + %1$d. lista + A Trakt nem adott vissza hitelesítési kódot + Hiányzó Trakt hitelesítési adatok + Nem sikerült betölteni a Trakt előrehaladást + Nem sikerült befejezni a Trakt bejelentkezést + Trakt felhasználó + Megnézendők + Előzetes + Ismeretlen + Kiegészítő + Mentve + %1$s lejátszása + %1$s folytatása + A JSON üres. + A(z) %1$d. gyűjtemény azonosítója (ID) üres. + A(z) \'%1$s\' gyűjtemény címe üres. + A(z) %1$d. mappa a(z) \'%2$s\' helyen üres azonosítóval rendelkezik. + A(z) \'%1$s\' mappa a(z) \'%2$s\' helyen üres címmel rendelkezik. + A(z) %1$d. forrás a(z) \'%2$s\' mappában üres mezőket tartalmaz. + A(z) %1$d. forrásból a(z) \'%2$s\' mappában hiányzik a Trakt lista ID. + Érvénytelen JSON: %1$s + A kiegészítő nem található: %1$s + január + február + március + április + május + június + július + augusztus + szeptember + október + november + december + jan. + febr. + márc. + ápr. + máj. + jún. + júl. + aug. + szept. + okt. + nov. + dec. + Gyártócég + Csatorna / Hálózat + Nem sikerült betölteni a következőt: %1$s + Népszerű + Legutóbbi + %1$s • %2$s + Legjobbra értékelt + Korhatár-besorolás + Film részletei + Eredeti nyelv + Származási ország + Megjelenési információk + Játékidő + Poszterek + Szöveg + Műsor részletei + Állapot + Videók + FÁJL + Nem érhető el közvetlen stream link + Az előző letöltés lecserélve + Letöltés elindítva + Nem támogatott stream-formátum a letöltéshez + Üres válasz törzs (body) + A kérés meghiúsult: HTTP %1$d + A letöltőrendszer nincs inicializálva + A letöltési kérés meghiúsult + %1$s - %2$s + A mentett címek itt fognak megjelenni, miután a részletek képernyőn a Mentés gombra koppintasz. + A könyvtárad üres + Nem sikerült betölteni a könyvtárat + Egyéb + Könyvtár + Kapcsold össze a Traktot, és ments címeket a megnézendők listájára vagy a személyes listáidra. + A Trakt könyvtárad üres + Nem sikerült betölteni a Trakt könyvtárat + Trakt könyvtár + Anime + Csatornák + Filmek + Sorozatok + TV + Már megtekinthető: %1$s + Már megtekinthető: %1$s • %2$s + Egy új epizód már megtekinthető + Már megtekinthető: %1$s + Új epizódok megjelenése + Alkohol/Drogok + Félelemkeltő elemek + Meztelenség + Trágárság + Enyhe + Mérsékelt + Súlyos + Erőszak + Alkotó + Rendező + Író + Közönség pontszám + Nem található lejátszható előzetes stream. + %1$d. évad - %2$s + B + KB + MB + GB + Váltás + Kikapcsolva + Hozd el a könyvtáradat a Nuvióba a használt kiegészítőkkel. + Adj hozzá egy kiegészítőt a könyvtárad felépítéséhez. + Szabd személyre a Nuviót. + Kiegészítő URL-je + Kiegészítő hozzáadása + Itt még nincs semmi + A jelenlegi kiegészítőid még nem jelenítenek meg itt semmit. + 28,12 + 18,35 + 2020-01-01 + 2024-12-31 + 7.0 + 10 + 100 + en, ko, ja, hi + US, KR, JP, IN + 2024 + Adj meg egy Trakt lista nevet, URL-t vagy azonosítót (ID) + Adj meg egy Trakt lista azonosítót (ID) vagy URL-t + Nem sikerült betölteni a Trakt listát + Nem találhatók Trakt listák + Feloldott Trakt lista + %1$d. Trakt lista + A regisztrációval elfogadom a + Felhasználási feltételeket + Automatikus szinkronizálás + Félkövér + Rögzítés + Felirat sorok betöltése… + Nem találhatók felirat sorok + Nem sikerült betölteni a felirat sorokat + Körvonal színe + Újratöltés + Alaphelyzet + Először válassz ki egy kiegészítő feliratot + Felirat késleltetése + Szöveg átlátszósága + Haladó + Streamek + Indítási és profil működés + HALADÓ + Stream találatok megjelenítése és jelvény URL szabályok. + INDÍTÁS + GYORSÍTÓTÁR + Utolsó profil megjegyzése + Az utoljára kiválasztott profil megjegyzése indításkor + „Nézés folytatása” gyorsítótár törlése + A gyorsítótárazott „Nézés folytatása” adatok eltávolítása és a lejátszási folyamat frissítése + Gyorsítótár törölve + Premiumize + A Nuvio a Premiumize szolgáltatáshoz kapcsolódik a fiók hitelesítése, a felhő könyvtár elérése, a gyorsítótár ellenőrzése és a felhőalapú lejátszási funkciók érdekében. A Nuvio nem áll kapcsolatban a Premiumize-zal, és az nem támogatja azt. + TorBox + A Nuvio a TorBox szolgáltatáshoz kapcsolódik a fiók hitelesítése, a felhő könyvtár elérése, a gyorsítótár ellenőrzése és a felhőalapú lejátszási funkciók érdekében. A Nuvio nem áll kapcsolatban a TorBox-szal, és az nem támogatja azt. + Szinkronizációs háttérrendszer + Háttérrendszer váltása? + Átváltasz a következőre: %1$s, és kijelentkezel? A kiválasztott háttérrendszeren újra bejelentkezhetsz. + Eszköz nyelve + Érték megjelenítése + Kártya + TV-stílusú fekvő kártya + Add hozzá és kezeld a Nuvióval használt kiegészítőket. + Felhő könyvtár + A csatlakoztatott fiókjaidban már meglévő fájlok böngészése és lejátszása. + Feloldás ezzel + Válaszd ki, melyik csatlakoztatott fiók kezelje a lejátszható linkeket. + Csatlakoztasd a %1$s fiókodat. + Kapcsold össze a %1$s fiókodat a böngészőben. + Csatlakozva + %1$s csatlakoztatása + %1$s leválasztása + Leválasztás + A(z) %1$s csatlakoztatva van ezen az eszközön. + Biztonságos bejelentkezés indítása… + Nyisd meg a linket, és add meg ezt a kódot a Nuvio jóváhagyásához. + Kód másolva. + Link megnyitása + Jóváhagyásra várás… + Nem sikerült elindítani a bejelentkezést. + Ez a bejelentkezési mód nincs konfigurálva ebben a verzióban. + Ez a kód lejárt. Próbáld újra. + Fusion stílus + Méret jelvények + Fájlméret-jelvények megjelenítése a stream találatoknál és a lejátszó forrás-panelein. + Kiegészítő logója + A kiegészítő logójának és nevének megjelenítése a stream források mellett. + Megjelenítés + Jelvény pozíciója + Válaszd ki, hogy a Fusion és a méret jelvények a stream kártyák felett vagy alatt jelenjenek meg. + Jelvény pozíciója + Válaszd ki, hol jelenjenek meg a stream jelvények a stream kártyákon. + Fent + Lent + Fusion jelvény URL-ek + Importálj legfeljebb %1$d Fusion-stílusú stream jelvény JSON URL-t. Minden URL külön frissíthető vagy törölhető. + Importált Fusion-stílusú stream jelvény JSON URL-ek kezelése. + A jelvények importálása meghiúsult. + Adj meg egy jelvény JSON URL-t. + A jelvény URL-jének http:// vagy https:// sémával kell kezdődnie. + Legfeljebb %1$d jelvény URL-t importálhatsz. + %1$d/%2$d URL, %3$d aktív Fusion jelvény + Nincsenek importálva Fusion jelvény URL-ek. + Fusion jelvény JSON URL + %1$d/%2$d Fusion URL importálva + Aktív + Inaktív + %1$s, %2$d engedélyezett jelvény, %3$d csoport + Előnézet + Fusion jelvény előnézet + %1$d Fusion-stílusú jelvény erről az URL-ről + Ez az URL nem tartalmaz Fusion-stílusú jelvényképeket. + %1$d. csoport + Egyéb Fusion jelvények + Háttér mód + Válaszd ki, hogyan jelenjenek meg a borítóképek a metaadat-oldalak mögött. + Normál + Az alkalmazás szabványos hátterének használata. + Mozi hatás + Lágyan elmosott háttérkép megjelenítése az oldal mögött. + Domináns szín + Az oldal hátterének igazítása a háttérkép fő színéhez. + Kiemelt előzetes lejátszása + Előzetesek automatikus lejátszása a fejlécben, ha elérhető előzetes. + E havi szerver- és karbantartási költségek + Fedezve. A további támogatások mostantól a fejlesztésre fordítódnak. + 100%% felett a további támogatások a fejlesztésre fordítódnak. + Támogatási állapot betöltése… + Feliratok továbbítása a külső lejátszónak + Lekéri a kiegészítő feliratokat a preferált nyelveden, és átadja azokat a külső lejátszónak. + Intro és Outro időbélyegek küldése + Átadja a feloldott intro és outro átugrási időbélyegeket a külső lejátszónak az automatikus átugráshoz. Csak az ezt támogató lejátszókban működik; a többi lejátszó figyelmen kívül hagyja. + Lejátszó + Belső + Külső + Válaszd ki, melyik lejátszó kezelje az új lejátszásokat. + Érintési gesztusok + Csúsztatások és dupla koppintások engedélyezése a lejátszó felületén a tekeréshez, valamint a fényerő vagy a hangerő beállításához. + Eredeti nyelv + A TMDB adatbővítés engedélyezése szükséges hozzá + Összes felirat + Lekéri és megjeleníti a videóhoz tartozó összes kiegészítő feliratot. + Gyors indítás + Kihagyja a kiegészítő feliratok automatikus lekérését mindaddig, amíg nem kéred azt a lejátszóban. + Kiegészítő feliratok indítása + Csak a preferáltak + Lekéri a kiegészítő feliratokat, de csak a preferált nyelvi egyezéseket jeleníti meg. + Sorozatnézési csoport újrafelhasználása + Megjegyzi és újra felhasználja az utolsó sorozatnézési csoportot a munkamenetek között (Nézés folytatása, Részletek stb.). + Lejátszómotor + Elsőként az ExoPlayer-t használja, majd ha a lejátszás meghiúsul, visszavált a libmpv-re. + Az ExoPlayer és az Android Media3 dekóderek használata. + A libmpv használata az Androidos lejátszáshoz. + libmpv megjelenítő + libmpv megjelenítő + libmpv hardveres dekódolás + Az mpv hardveres dekódolás használata, ha elérhető. + libmpv YUV420P kompatibilitás + YUV420P kimenet kényszerítése olyan eszközökön, amelyeknél megjelenítési vagy színhibák lépnek fel. + Háttér színe + Félkövér + Vastagabb betűtípus használata a feliratokhoz. + Átlátszó + Körvonal + Körvonal színe + Körvonal rajzolása a felirat szövege köré. + Csak a preferált nyelvek megjelenítése + Csak a preferált feliratnyelveidnek megfelelő feliratok megjelenítése. + Felirat mérete + Szöveg színe + Kényszerített feliratok használata + A kényszerített (forced) feliratok előnyben részesítése, ha egyeznek a feliratnyelvi beállításaiddal. + Függőleges eltolás + „Hasonló tartalmak” forrása + Válaszd ki, honnan származzanak az ajánlások a részletek oldalakon + „Hasonló tartalmak” forrása + Válaszd ki a részletek oldalakon megjelenő ajánlások forrását. + Trakt + TMDB + További műveletek + Támogatja a TMDB + Támogatja a Trakt + Korábbi évadok megjelölése megtekintettként + %1$dó %2$dp van hátra + %1$dp van hátra + Üres válasz törzs (body) + A kérés meghiúsult: HTTP %1$d + Feliratok betöltése a kiegészítőkből… + Átugrandó szakaszok betöltése… + Nincs gyorsítótárazva a Torboxon. + Üres letöltési törzs (body) + A letöltött frissítési fájl hiányzik. + Az MPV lejátszómotor nem érhető el. Kérjük, építsd újra az alkalmazást. + Csatlakozva a Trakthoz + Lecsatlakozva a Traktról + Nyilvános Trakt lista + Adj meg egy érvényes Trakt lista azonosítót (ID) vagy URL-t + %1$d elem + %1$d kedvelés + Hiányzó Trakt hitelesítési adatok a local.properties fájlban (TRAKT_CLIENT_ID). + Hiányzó Trakt lista azonosító (ID) + A Trakt lista nem tartalmazott numerikus azonosítót + A Trakt lista nem található vagy nem nyilvános + Elérted a Trakt lekérdezési korlátját + A Trakt kérés meghiúsult + A Trakt engedélyezés lejárt + A Trakt lista nem található + Elérted a Trakt listák maximális korlátját + Elérted a Trakt lekérdezési korlátját + A Trakt kérés meghiúsult + Nem sikerült hozzáadni a Trakt megnézendők listájához + Nem sikerült hozzáadni a Trakt listához + Hiányzó kompatibilis Trakt azonosítók + Üres válasz törzs (body) + A TMDB források használatához adj meg egy TMDB API kulcsot a Beállításokban. + %1$d. TMDB gyűjtemény + A TMDB gyűjtemény nem található + %1$d. TMDB gyártó cég + A TMDB cég nem található + %1$d. TMDB rendező + A TMDB felfedezés nem adott vissza adatot + TMDB Felfedezés + Adj meg egy érvényes TMDB azonosítót (ID) vagy URL-t. + %1$d. TMDB lista + A TMDB lista nem található + Nem sikerült betölteni a TMDB forrást + Hiányzó TMDB gyűjtemény azonosító (ID) + Hiányzó TMDB lista azonosító (ID) + Hiányzó TMDB személy azonosító (ID) + %1$d. TMDB tévéhálózat + A TMDB tévéhálózat nem található + A TMDB személy közreműködései nem találhatók + %1$d. TMDB személy + A TMDB személy nem található + A(z) '%1$s' gyűjtemény azonosító egynél többször van használatban. + A(z) '%1$s' mappa azonosító egynél többször szerepel a(z) '%2$s' mappában. + Trakt filmlista + Trakt sorozatlista + Nem sikerült véglegesíteni a letöltött fájlt + Nem sikerült megnyitni a töredékes letöltési fájlt + A töredékes letöltési fájl nincs megnyitva + Nem sikerült írni a töredékes letöltési fájlba + Felhő + Mentett + Fiók összekapcsolása + Kapcsolj össze egy fiókot a Csatlakoztatott szolgáltatások beállításainál, hogy böngészhesd a lejátszható fájlokat a felhőalapú könyvtáradból. + Nincs felhőtárhely fiók csatlakoztatva + Csatlakoztatott szolgáltatások megnyitása + Kapcsold be a Felhő könyvtárat a Csatlakoztatott szolgáltatások beállításainál a fájlok böngészéséhez. + A Felhő könyvtár ki van kapcsolva + Nem találhatók a jelenlegi szűrőknek megfelelő lejátszható fájlok a felhőben. + Még nincs itt semmi + Válassz ki egy fájlt a lejátszáshoz + Nem sikerült betölteni a(z) %1$s felhő könyvtárat + Ez az elem nem tartalmaz lejátszható videofájlt. + Nincsenek lejátszható fájlok + Nincsenek lejátszható fájlok + A Felhő könyvtár ki van kapcsolva. + Nem sikerült lejátszani ezt a felhőben lévő fájlt. + Fájl lejátszása + A felhőszolgáltatás nincs csatlakoztatva. + A(z) %1$s nincs csatlakoztatva. + %1$d lejátszható fájl + Mindegyik + Felhő könyvtár frissítése + Szolgáltató kiválasztása + Típus kiválasztása + Lejátszásra kész + Mindegyik + Torrentek + Usenet + Web + Fájlok + Időbélyegek beküldése + Videó beállítások + A felhő könyvtár nem érhető el a(z) %1$s szolgáltatóhoz. + Videó + Finomhangolás visszaállítása + Kimeneti profil + HDR csúcsfényerő-észlelés + Megbecsüli a HDR csúcsfényerőt, ha a metaadatok hibásak vagy hiányoznak. + Tónusleképezés (Tone mapping) + Sávosodás csökkentése (Deband) + Csökkenti a színátmenetek sávosodását minimális teljesítményköltség mellett. + Képkocka-interpoláció + Simább mozgásmegjelenítés, amikor az mpv tisztán tudja használni a kijelző-szinkronizációt. + Fényerő + Kontraszt + Telítettség + Gamma + Natív EDR + A legjobb a HDR-képes iPhone és iPad eszközökhöz. + SDR tónusleképezett + Kiszámíthatóbb fehérek és feketék SDR-stílusú kimeneten. + Kompatibilitás + A legközelebb áll a régebbi iOS MPV viselkedéshez. + Egyéni + A lentebb megadott haladó értékeid használata. + Ki + iOS videó kimenet + Hardveres dekóder + Kiterjesztett dinamikatartomány (EDR) + Alapértelmezett Metal kimeneti mód az új lejátszásokhoz. + Kijelző színprofil javaslat + Hagyja, hogy az mpv alapértelmezés szerint az aktív kijelző színtartományát célozza meg. + Cél alapszínek (Primaries) + Cél átviteli karakterisztika + iOS hangkimenet + Hangkimenet + Az AudioUnit használata, amíg az AVFoundation kimenet átmenetileg le van tiltva. + Kísérleti támogatás a térbeli hangzáshoz (Spatial Audio) és a többcsatornás kimenethez. + A korábbi, örökölt AudioUnit kimenet használata. + Találatok kezelése + Maximális találatszám + Korlátozza a megjelenő találatok számát. + Találatok rendezése + Válaszd ki a találatok sorrendjét. + Felbontásonkénti korlát + Maximalizálja az ismétlődő 2160p, 1080p, 720p találatokat a rendezés után. + Minőségenkénti korlát + Maximalizálja az ismétlődő BluRay, WEB-DL, REMUX találatokat a rendezés után. + Mérettartomány + Találatok szűrése fájlméret alapján. + További információ + Alapértelmezett formátum + Eredeti formátum + Soronként egy csoportot adj meg. + Eredeti sorrend + Legjobb minőség elöl + Legnagyobb elöl + Legkisebb elöl + Legjobb hang elöl + Nyelv elöl + Bármelyik + %1$d kiválasztva + Összes találat + %1$d találat + Legfeljebb %1$d GB + %1$d GB felett + %1$d–%2$d GB + Preferált felbontások + A kiválasztott felbontásokat sorolja előre, az alapértelmezett sorrendben. + Kötelező felbontások + Csak a kiválasztott felbontásokat jeleníti meg. + Kizárt felbontások + Elrejti a kiválasztott felbontásokat. + Preferált minőségek + A kiválasztott minőségeket sorolja előre, az alapértelmezett sorrendben. + Kötelező minőségek + Csak a kiválasztott minőségeket jeleníti meg. + Kizárt minőségek + Elrejti a kiválasztott minőségeket. + Preferált képi címkék + Előre sorolja a DV, HDR, 10bit, IMAX és hasonló címkéket. + Kötelező képi címkék + Megköveteli a DV, HDR, 10bit, IMAX, SDR és hasonló címkéket. + Kizárt képi címkék + Elrejti a DV, HDR, 10bit, 3D és hasonló címkéket. + Preferált hangcímkék + Előre sorolja az Atmos, TrueHD, DTS, AAC és hasonló címkéket. + Kötelező hangcímkék + Megköveteli az Atmos, TrueHD, DTS, AAC és hasonló címkéket. + Kizárt hangcímkék + Elrejti a kiválasztott hangcímkéket. + Preferált csatornakiosztások + Előre sorolja a preferált hangcsatorna elrendezéseket. + Kötelező csatornakiosztások + Csak a kiválasztott csatornakiosztásokat jeleníti meg. + Kizárt csatornakiosztások + Elrejti a kiválasztott csatornakiosztásokat. + Preferált kódolások + Előre sorolja az AV1, HEVC, AVC és hasonló kódolásokat. + Kötelező kódolások + Megköveteli az AV1, HEVC, AVC és hasonló kódolásokat. + Kizárt kódolások + Elrejti a kiválasztott kódolásokat. + Preferált nyelvek + Előre sorolja a preferált hangnyelveket. + Kötelező nyelvek + Csak a kiválasztott nyelveket tartalmazó találatokat jeleníti meg. + Kizárt nyelvek + Elrejti azokat a találatokat, ahol az összes nyelv ki van zárva. + Kötelező kiadói csoportok (Release groups) + Csak a kiválasztott kiadói csoportokat jeleníti meg. + Kizárt kiadói csoportok (Release groups) + Elrejti a kiválasztott kiadói csoportokat. + Időbélyegek beküldése + SZAKASZ TÍPUSA + Intro (Főcím) + Recap (Összefoglaló) + Outro (Stáblista) + KEZDETI IDŐ (MM:SS) + VÉG_IDŐ (MM:SS) + Beküldés + Rögzítés + Hardveres dekóder + Hangkimenet + Cél alapszínek (Primaries) + Cél átviteli karakterisztika + Feloldott Trakt lista + TMDB Felfedezés + US, KR, JP, IN + Adj meg egy Trakt lista nevet, URL-t vagy azonosítót (ID) + Adj meg egy érvényes TMDB azonosítót (ID) vagy URL-t. + Nem sikerült betölteni a TMDB forrást + Adj meg egy Trakt lista azonosítót (ID) vagy URL-t + Nem sikerült betölteni a Trakt listát + Kanada + Ausztrália + Németország + Filmek + Sorozatok + A Felhő könyvtár le van tiltva. + Érvénytelen API kulcs vagy a kapcsolódás meghiúsult + A manifestből hiányzik a(z) „%1$s” mező + %1$s. TMDB gyűjtemény + %1$s. TMDB rendező + TMDB Felfedezés + Adj meg egy érvényes TMDB azonosítót (ID) vagy URL-t. + %1$s. TMDB lista + %1$s. TMDB tévéhálózat + %1$s. TMDB személy + %1$s. TMDB gyártó cég + Nem sikerült betölteni a TMDB forrást + ID: %1$s + A TMDB források használatához adj meg egy TMDB API kulcsot a Beállításokban. + A TMDB gyűjtemény nem található + A TMDB cég nem található + A TMDB felfedezés nem adott vissza adatot + A TMDB lista nem található + Hiányzó TMDB gyűjtemény azonosító (ID) + Hiányzó TMDB lista azonosító (ID) + Hiányzó TMDB személy azonosító (ID) + A TMDB tévéhálózat nem található + A TMDB személy közreműködései nem találhatók + A TMDB személy nem található + Hiányzó Trakt hitelesítési adatok. + %1$s (%2$d) + Adj meg egy érvényes Trakt lista azonosítót (ID) vagy URL-t + %1$d elem + %1$d kedvelés + A Trakt lista nem található vagy nem nyilvános + Hiányzó Trakt lista azonosító (ID) + A Trakt lista nem tartalmazott numerikus azonosítót + Nyilvános Trakt lista + Elérted a Trakt lekérdezési korlátját + A Trakt kérés meghiúsult + Nem sikerült betölteni a Trakt hozzászólásokat (%1$d) + %1$d óra %2$d perc + %1$d óra + %1$d perc + Nem sikerült véglegesíteni a letöltött fájlt + Nem sikerült megnyitni a töredékes letöltési fájlt + A töredékes letöltési fájl nincs megnyitva + Nem sikerült írni a töredékes letöltési fájlba + Nuvio Könyvtár + Kapcsolódási hiba + Üres válasz törzs (body) + Kérjük, ellenőrizd a kapcsolatot, majd próbáld újra. + A kérés meghiúsult: HTTP %1$d + %1$s (%2$s) + Az MPV lejátszómotor nem érhető el. Kérjük, építsd újra az alkalmazást. + Nem sikerült lejátszani ezt a streamet. + Bővítmények letiltva + Bővítmények engedélyezve + %1$d szolgáltató + Frissítés… + %1$d repó + Hiányzó TMDB API kulcs + TMDB API kulcs beállítva + Bővítmény-tároló (Repo) telepítése + Telepítés... + Szolgáltató tesztelése + Tesztelés… + Bővítmény-tároló törlése + Bővítmény-tároló frissítése + Még nem érhetők el szolgáltatók. + Adj meg egy tároló URL-t a streamek felkutatásához szükséges szolgáltatói bővítmények telepítéséhez. + Még nincsenek telepítve bővítmény-tárolók. + Szolgáltatói bővítmények használata a streamek felkutatása során. + Szolgáltatói bővítmények globális engedélyezése + Ez a bővítmény-tároló már telepítve van. + Adj meg egy bővítmény-tároló URL-t. + Adj meg egy érvényes bővítmény URL-t. + Nem sikerült telepíteni a bővítmény-tárolót + A szolgáltató nem található + Nem sikerült frissíteni a tárolót + A bővítmények nem érhetők el ebben a verzióban. + A streameknél tárolónként (repo) egy szolgáltató jelenjen meg, nem pedig forrásonként. + Bővítmények csoportosítása tároló szerint + Bővítmény manifest URL + A manifest neve hiányzik. + A manifest nem tartalmaz szolgáltatókat. + A manifest verziója hiányzik. + A manifest neve hiányzik. + A manifest nem tartalmaz szolgáltatókat. + A manifest verziója hiányzik. + %1$s sikeresen telepítve. + A repó által letiltva + Nincs leírás + v%1$s + Bővítmény-tároló + Verzió: %1$s + Ez a bővítmény-tároló már telepítve van. + Nem sikerült telepíteni a bővítmény-tárolót + Nem sikerült frissíteni a tárolót + TÁROLÓ HOZZÁADÁSA + TELEPÍTETT TÁROLÓK + ÁTTEKINTÉS + SZOLGÁLTATÓK + Hiba + A szolgáltató tesztelése meghiúsult + Teszt eredmények (%1$d) + A szolgáltatói bővítményekhez TMDB API kulcs szükséges. Állítsd be a TMDB képernyőn, különben a bővítmények nem fognak megfelelően működni. + Nincs találat a(z) %1$s katalógusban. + Érvénytelen API kulcs vagy a kapcsolódás meghiúsult + Bővítmény-tároló + Időbélyegek beküldése + Beküldés + Rögzítés + VÉG_IDŐ (MM:SS) + SZAKASZ TÍPUSA + KEZDETI IDŐ (MM:SS) + Csatlakozva a Trakthoz + Lecsatlakozva a Traktól + TB + Nem található APK fájl a kiadásban + A letöltés meghiúsult: HTTP %1$d + A letöltött frissítési fájl nem található. + Üres letöltési törzs (body) + GitHub kiadások API hiba: %1$d + Még nem jelent meg frissítés ehhez a csatornához. + A kiadás nem rendelkezik címkével vagy névvel + Adásban: %1$s + Ma kerül adásba + Holnap kerül adásba + + %1$d nap múlva kerül adásba + %1$d nap múlva kerül adásba + + %1$s + Ma + Holnap + + %1$d nap múlva + %1$d nap múlva + + Új epizód + Új évad + %1$s. Trakt lista + Android rendszerlejátszó + P2P Streaming + Ez a stream peer-to-peer (P2P) technológiát használ. A P2P engedélyezésével tudomásul veszed és elfogadod az alábbiakat:\n\n• Az IP-címed látható lesz a hálózat többi tagja (peer) számára\n• Kizárólag te felelsz az általad elért tartalmakért\n• Megerősíted, hogy a helyi jogszabályok szerint jogosult vagy a tartalom streamelésére\n• A Nuvio nem tárol, nem terjeszt és nem ellenőriz semmilyen P2P tartalmat\n• A Nuvio nem vállal felelősséget a P2P streamelés használatából eredő esetleges jogi következményekért\n\nEzt a funkciót teljesen a saját felelősségedre használod. A P2P funkció bármikor kikapcsolható a Beállításokban. + P2P engedélyezése + Mégse + Ismeretlen torrent hiba + P2P Streaming + Peer-to-peer (torrent) streamek engedélyezése + Torrent statisztikák elrejtése + Pufferelés, seederek, peerek és letöltési sebesség elrejtése a betöltés és lejátszás alatt + %1$d seeder · %2$d peer + %1$s pufferelve · %2$s · %3$s + %1$s · %2$s + %1$d peer · %2$d seeder · %3$d%% + Kapcsolódás a peerekhez… + P2P motor indítása… + Nem sikerült elindítani a torrentet: %1$s + Torrent hiba: %1$s + Született + Születési hely + Közreműködések + Életrajz + Ország + Típus + Katalógus + Névjegy + Feliratok betöltése a kiegészítőkből... + Feliratok letöltése... + + %d cím + %d cím + + diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 35a923d9..0c79d268 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -31,6 +31,13 @@ Tidak Tersedia Konfigurasi addon Hapus addon + Bawa pustaka Anda ke Nuvio dengan addon yang Anda gunakan. + Tambahkan addon untuk mulai membangun pustaka Anda. + Jadikan Nuvio milik Anda. + URL Addon + Tambah Addon + Belum ada apa pun di sini + Addon yang Anda gunakan belum memiliki apa pun untuk ditampilkan di sini. Tambahkan URL manifes untuk mulai memuat katalog, metadata, streaming, atau subtitle ke Nuvio. Belum ada addon yang terpasang. Masukkan URL addon. @@ -412,7 +419,9 @@ Unduh versi terbaru Periksa pembaruan Kelola addon dan sumber penemuan. + Kelola addon dan pilih apa yang tampil di Nuvio. Kelola film dan episode yang telah diunduh. + Kelola yang Anda simpan di perangkat ini. Unduhan UMUM Kelola integrasi yang tersedia @@ -597,6 +606,7 @@ BERANDA SUMBER Pasang, hapus, perbarui, dan urutkan sumber konten Anda. + Tambahkan dan kelola addon yang Anda gunakan dengan Nuvio. Pasang repositori scraper JavaScript dan uji penyedia secara internal. Sesuaikan tata letak beranda, visibilitas konten, dan poster Pengaturan untuk layar detail dan episode. diff --git a/composeApp/src/commonMain/composeResources/values-in/strings.xml b/composeApp/src/commonMain/composeResources/values-in/strings.xml index e442e971..b56c75ce 100644 --- a/composeApp/src/commonMain/composeResources/values-in/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-in/strings.xml @@ -31,6 +31,13 @@ Tidak Tersedia Konfigurasi addon Hapus addon + Bawa pustaka Anda ke Nuvio dengan addon yang Anda gunakan. + Tambahkan addon untuk mulai membangun pustaka Anda. + Jadikan Nuvio milik Anda. + URL Addon + Tambah Addon + Belum ada apa pun di sini + Addon yang Anda gunakan belum memiliki apa pun untuk ditampilkan di sini. Tambahkan URL manifes untuk mulai memuat katalog, metadata, streaming, atau subtitle ke Nuvio. Belum ada addon yang terpasang. Masukkan URL addon. @@ -412,7 +419,9 @@ Unduh versi terbaru Periksa pembaruan Kelola addon dan sumber penemuan. + Kelola addon dan pilih apa yang tampil di Nuvio. Kelola film dan episode yang telah diunduh. + Kelola yang Anda simpan di perangkat ini. Unduhan UMUM Kelola integrasi yang tersedia @@ -597,6 +606,7 @@ BERANDA SUMBER Pasang, hapus, perbarui, dan urutkan sumber konten Anda. + Tambahkan dan kelola addon yang Anda gunakan dengan Nuvio. Pasang repositori scraper JavaScript dan uji penyedia secara internal. Sesuaikan tata letak beranda, visibilitas konten, dan poster Pengaturan untuk layar detail dan episode. diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index d96e8195..17b0e5b5 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -27,6 +27,13 @@ Non Disponibile Configura addon Cancella addon + Porta la tua libreria su Nuvio con gli addon che usi. + Aggiungi un addon per iniziare a creare la tua libreria. + Fai tuo Nuvio. + URL addon + Aggiungi addon + Non c’è ancora niente qui + Gli addon che usi non hanno ancora nulla da mostrare qui. Aggiungi un manifest URL per iniziare a caricare cataloghi , metadata, flussi o sottotitoli dentro Nuvio. Nessun addon installato ancora. Inserisci l'URL dell'addon. @@ -144,8 +151,11 @@ Cerca Tracce audio Audio + Auto Sync + Bold Integrato Offset inferiore + Capture Chiudi player Colore In riproduzione @@ -156,11 +166,14 @@ Dimensione carattere %1$dsp Blocca comandi player + Caricamento linee sottotitoli... Nessuna traccia audio disponibile Nessun episodio disponibile + Nessuna linea di sottotitoli trovata Nessun flusso trovato Nessuno Bordo + Colore bordo Episodi Sorgenti Flussi @@ -168,6 +181,8 @@ In riproduzione Tocca per scaricare i sottotitoli Torna indietro + Ricarica + Reset Ripristina predefiniti Riempi Adatta @@ -180,8 +195,11 @@ Avanti di 10 secondi Sorgenti Stile + Seleziona prima un sottotitolo addon Sub + Ritardo sottotitoli Sottotitoli + Opacità testo Luminosità %1$s Volume %1$s Muto @@ -210,6 +228,7 @@ Generali Account Addon + Avanzate Aspetto Contenuti e Scoperta Continua a guardare @@ -228,15 +247,19 @@ INFORMAZIONI Gestisci il tuo account, disconnettiti o eliminalo. ACCOUNT + AVANZATE Regola la presentazione della home e le preferenze visive. Controlla se ci sono nuove versioni dell'app. Verifica aggiornamenti Gestisci gli addon e le sorgenti di scoperta. + Gestisci gli addon e scegli cosa mostrare in Nuvio. Gestisci i film e gli episodi scaricati. + Gestisci ciò che hai salvato su questo dispositivo. Download GENERALI Collega i servizi TMDB e MDBList. Gestisci gli avvisi per l'uscita di nuovi episodi e invia una notifica di test. + Gestisci risultati streaming e badge URL. Passa a un profilo diverso. Cambia profilo Collega Trakt, sincronizza la lista dei desideri e salva i titoli direttamente su Trakt. @@ -368,6 +391,7 @@ HOME SORGENTI Installa, rimuovi, aggiorna e ordina le tue sorgenti di contenuto. + Aggiungi e gestisci gli addon che usi con Nuvio. Installa repository di scraper JavaScript e testa i provider internamente. Controlla quali cataloghi appaiono in Home e in quale ordine. Disabilita le sezioni dei dettagli e riordina tutto ciò che sta sotto l'elemento Hero. @@ -501,6 +525,13 @@ Lingua del dispositivo Forzati Nessuno + Tutti i sottotitoli + Recupera e mostra ogni sottotitolo degli addon per il video. + Avvio veloce + Salta la ricerca automatica dei sottotitoli se non lo richiedi manualmente nel player. + Avvio Sottotitoli degli Addons + Solo preferiti + Trova sottotitoli degli addon, ma mostra solo se nelle lingue preferite. Preferisci Binge Group Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale. Lingua audio preferita @@ -535,6 +566,10 @@ DECODER PROSSIMO EPISODIO PLAYER + Interno + Esterno + Gesture Touch + Consenti swipe e e doppi tocchi sulla superficie del player per andare avanti o indietro, modificare la luminosità e il volume. SALTA SEGMENTI RIPRODUZIONE AUTOMATICA FLUSSO SELEZIONE FLUSSO @@ -543,6 +578,20 @@ %1$d selezionati Mostra overlay di caricamento Mostra una schermata di caricamento all'avvio della riproduzione di un flusso. + Colore di Sfondo + Grassetto + Usa il grassetto per i sottotitoli. + Trasparente + Bordo testo + Colore Bordo + Aggiunge un bordo al testo dei sottotitoli per renderlo più leggibile. + Mostra solo sottotitoli nelle Lingue Preferite + Mostra solo i sottotitoli che corrispondono alle tue lingue preferite per i sottotitoli. + Dimensione Sottotitoli + Colore del Testo + Usa Sottotitoli Forzati + Preferisci sottotitoli forzati quando disponibili secondo le tue impostazioni della lingua sottotitoli. + Offset Verticale Salta Intro/Outro/Recap Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto. Ambito sorgente @@ -575,6 +624,16 @@ Illimitato Riproduzione Tunneled Abilita la riproduzione tunneled per una minore latenza nella sincronizzazione audio/video. + Uscita Audio iOS + Prova prima AVFoundation, altrimenti passa ad AudioUnit. + Usa l'uscita legacy AudioUnit. + Decodifica hardware + Range dinamico esteso + Usa la modalità default di Metal per le nuove sessioni di riproduzione. + Suggerimento colore display + Lascia scegliere a mpv lo spazio colore del display per impostazione predefinita. + Target primario display + Target trasferimento colore Aggiungi la tua chiave API TMDB qui sotto prima di attivare l'arricchimento. Chiave API TMDB Abilita arricchimento TMDB @@ -641,6 +700,13 @@ Rose Violet White + Cremisi + Oceano + Violetto + Smeraldo + Ambra + Rosa + Bianco Prossimo episodio Ricerca sorgente… In riproduzione tramite %1$s tra %2$d… @@ -1213,6 +1279,11 @@ Nessuna impostazione trovata. Cerca impostazioni... RISULTATI + AVVIO + Ricorda Ultimo Profilo + Ricorda l'ultimo profilo usato all'avvio + Pulisci la cache del Continua a Guardare + Pulisci i dati in cache del Continua a Guardare e aggiorna i progressi di visione LICENZA APP DATI E SERVIZI LICENZA RIPRODUZIONE @@ -1303,6 +1374,33 @@ Controlla i metadati mostrati sotto ogni risultato. Ripristina formattazione Ripristina la formattazione predefinita dei risultati. + Stile Fusion + Badge della dimensione + Mostra il badge della dimensione nei risultati stream e nei pannelli origine del player. + Logo Addon + Mostra il logo e il nome dell'addon accanto alle sorgenti degli stream. + Display + Posizione badge + Scegli se i badge Fusion e quelli della dimensione appaiono sopra o sotto le schede stream. + Posizione badge + Seleziona dove appariranno i badge stream sulle schede stream. + In Alto + In Basso + URL badge Fusion + Importa fino a %1$d URL JSON per i badge stream in stile Fusion. Ogni URL può essere aggiornato o eliminato separatamente. + Puoi importare fino a %1$d URL di badge. + %1$d/%2$d URL, %3$d badge Fusion attivi + URL JSON per i badge Fusion + %1$d/%2$d URL Fusion importati + Attivo + Inattivo + %1$s, %2$d badge abilitati, %3$d gruppi + Anteprima + Anteprima badge Fusion + %1$d badge in stile Fusion presenti in questo URL + Nessuna immagine badge in stile Fusion presente in questo URL. + Gruppo %1$d + Altri badge Fusion Chiave API convalidata. Impossibile convalidare questa chiave API. Sfoca episodi non visti @@ -1401,18 +1499,452 @@ In onda oggi In onda domani - In onda tra %1$d giorno - In onda tra %1$d giorni - In onda tra %1$d giorni + In onda tra %1$d giorno + In onda tra %1$d giorni + In onda tra %1$d giorni %1$s Oggi Domani - Tra %1$d giorno - Tra %1$d giorni - Tra %1$d giorni + Tra %1$d giorno + Tra %1$d giorni + Tra %1$d giorni Nuovo episodio Nuova stagione + Riproduci Trailer in Hero + Riproduci l'anteprima dei trailer nella sezione hero quando un trailer è disponibile. + Nessun URL di badge Fusion importato. + Abilita i Plugin + Usa i plugin durante la ricerca di stream. + Raggruppa i provider dei plugin per repository + Mostra negli stream un solo provider per repository invece che uno per sorgente. + Streaming P2P + Consenti lo streaming peer-to-peer (torrent) + Nascondi le statistiche torrent + Nascondi buffer, seeds, peers e velocità download durante il caricamento e la riproduzione + Avvio e comportamento profili. + Costi di manutenzione e server + Coperto. Qualsiasi supporto ulteriore andrà allo sviluppo. + Dopo il 100%, eventuale supporto in più andrà allo sviluppo. + Dona a Nuvio + Cambia + 28,12 + 18,35 + 2020-01-01 + 2024-12-31 + 7.0 + 10 + 100 + en, ko, ja, hi + US, KR, JP, IN + 2024 + Inserisci il nome, l’URL o l’ID di una lista Trakt + Inserisci l’ID o l’URL di una lista Trakt + Impossibile caricare la lista Trakt + Nessuna lista Trakt trovata + Lista Trakt identificata + Lista Trakt %1$d + Registrandomi, accetto i + Termini + Impossibile caricare le righe dei sottotitoli + Flussi + DIAGNOSTICA + CACHE + Report arresti anomali Sentry + Invia report su arresti anomali e ANR con un contesto sicuro. Attivi per impostazione predefinita. + Attivare i report sugli arresti anomali? + I report sugli arresti anomali aiutano a identificare i problemi specifici dei dispositivi senza dover riprodurre l’errore tramite i log ADB. + Disattivare i report sugli arresti anomali? + I futuri arresti anomali e gli ANR attuali di questo dispositivo non verranno segnalati. Questo può rendere molto più difficile correggere i bug specifici del dispositivo. + Come aiuta + I report vengono raggruppati per versione dell’app, modello del dispositivo, versione di Android e stack trace, così da poter correggere prima gli arresti anomali reali più comuni. + Inviato + Arresti anomali, ANR attuali, stack trace, versione dell’app, tipo di build, flavor, metadati del dispositivo e del sistema operativo, oltre a breadcrumb di rete sicuri con metodo, host, percorso, stato, durata e tipo di eccezione. + Non inviato + Password, token di accesso, token di aggiornamento, corpi delle richieste, corpi delle risposte, header, cookie, screenshot, gerarchia delle viste, Session Replay, diagnostica grezza e valori della query o del frammento degli URL dei flussi. + Lascia attivo + Disattiva + Attiva + Cache cancellata + Apri cartella dei download + Impossibile aprire la cartella dei download + Lingua del dispositivo + Scheda + Scheda orizzontale in stile TV + Gestisci gli URL JSON importati dei badge dei flussi in stile Fusion. + Importazione dei badge non riuscita. + Inserisci un URL JSON dei badge. + L’URL dei badge deve iniziare con http:// o https://. + Modalità sfondo + Scegli come mostrare le immagini dietro le pagine dei metadati. + Normale + Usa lo sfondo standard dell’app. + Cinematografico + Mostra uno sfondo leggermente sfocato dietro la pagina. + Colore dominante + Adatta lo sfondo della pagina al colore principale dell’immagine di sfondo. + Server e manutenzione di questo mese + Coperti. Il supporto aggiuntivo sarà destinato allo sviluppo. + Dopo il 100%, il supporto aggiuntivo sarà destinato allo sviluppo. + Caricamento progresso dei finanziamenti... + Invia i sottotitoli al player esterno + Recupera i sottotitoli degli addon nella lingua preferita e li passa al player esterno. + Invia i timestamp di intro e outro + Passa al player esterno i timestamp risolti di intro e outro per saltarli automaticamente. Funziona solo nei player che lo supportano; gli altri li ignorano. + Player + Scegli quale player gestisce le nuove riproduzioni. + Lingua originale + Richiede che l’arricchimento TMDB sia abilitato + Motore di riproduzione + Usa prima ExoPlayer e passa a libmpv se la riproduzione non riesce. + Usa ExoPlayer e i decoder Android Media3. + Usa libmpv per la riproduzione su Android. + Renderer libmpv + Renderer libmpv + Decodifica hardware libmpv + Usa la decodifica hardware di mpv quando disponibile. + Compatibilità YUV420P di libmpv + Forza l’uscita YUV420P sui dispositivi con problemi di rendering o colore. + Avvisi sui contenuti + Mostra una sovrimpressione con le indicazioni per i genitori all’avvio della riproduzione. + Sorgente di Altri titoli simili + Scegli da dove provengono i consigli nelle pagine dei dettagli + Sorgente di Altri titoli simili + Seleziona la sorgente dei consigli mostrati nelle pagine dei dettagli. + Trakt + TMDB + Altre azioni + Offerto da TMDB + Offerto da Trakt + %1$dh %2$dm rimanenti + %1$dm rimanenti + Corpo della risposta vuoto + Richiesta non riuscita con HTTP %1$d + Caricamento sottotitoli dagli addon… + Caricamento segmenti da saltare… + Corpo del download vuoto + Il file dell’aggiornamento scaricato non è presente. + Il motore del player MPV non è disponibile. Ricompila l’app. + Connesso a Trakt + Disconnesso da Trakt + Lista pubblica Trakt + Inserisci un ID o un URL valido di una lista Trakt + %1$d elementi + %1$d Mi piace + Credenziali Trakt mancanti in local.properties (TRAKT_CLIENT_ID). + ID della lista Trakt mancante + La lista Trakt non include un ID numerico + Lista Trakt non trovata o non pubblica + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Autorizzazione Trakt scaduta + Lista Trakt non trovata + Limite di liste Trakt raggiunto + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Impossibile aggiungere alla watchlist Trakt + Impossibile aggiungere alla lista Trakt + ID Trakt compatibili mancanti + Corpo della risposta vuoto + Aggiungi una chiave API TMDB nelle Impostazioni per usare le sorgenti TMDB. + Collezione TMDB %1$d + Collezione TMDB non trovata + Produzione TMDB %1$d + Azienda TMDB non trovata + Regista TMDB %1$d + La ricerca TMDB non ha restituito dati + Scopri TMDB + Inserisci un ID o un URL TMDB valido. + Lista TMDB %1$d + Lista TMDB non trovata + Impossibile caricare la sorgente TMDB + ID collezione TMDB mancante + ID lista TMDB mancante + ID persona TMDB mancante + Network TMDB %1$d + Network TMDB non trovato + Crediti della persona TMDB non trovati + Persona TMDB %1$d + Persona TMDB non trovata + L’ID collezione '%1$s' è usato più di una volta. + L’ID cartella '%1$s' è usato più di una volta in '%2$s'. + Lista film Trakt + Lista serie Trakt + Impossibile finalizzare il file scaricato + Impossibile aprire il file parziale del download + Il file parziale del download non è aperto + Impossibile scrivere il file parziale del download + Invia intro + Impostazioni video + La libreria cloud non è disponibile per %1$s. + Video + Ripristina regolazioni + Preset di uscita + Rilevamento picco HDR + Stima la luminosità di picco HDR quando i metadati sono errati o mancanti. + Mappatura dei toni + Riduzione banding + Riduce il banding dei colori con un lieve costo in termini di prestazioni. + Interpolazione dei fotogrammi + Rende il movimento più fluido quando mpv può usare correttamente la sincronizzazione del display. + Luminosità + Contrasto + Saturazione + Gamma + EDR nativo + Ideale per iPhone e iPad compatibili con HDR. + SDR con tone mapping + Bianchi e neri più prevedibili con un’uscita in stile SDR. + Compatibilità + Il comportamento più simile al precedente MPV su iOS. + Personalizzato + Usa i valori avanzati riportati sotto. + Disattivato + Uscita video iOS + Uscita audio + Supporto sperimentale per Audio Spaziale e uscita multicanale. + Gestione risultati + Risultati massimi + Limita il numero di risultati visualizzati. + Ordina risultati + Scegli come ordinare i risultati. + Limite per risoluzione + Limita i risultati ripetuti 2160p, 1080p e 720p dopo l’ordinamento. + Limite per qualità + Limita i risultati ripetuti BluRay, WEB-DL e REMUX dopo l’ordinamento. + Intervallo dimensioni + Filtra i risultati in base alla dimensione del file. + Scopri di più + Formato predefinito + Formato originale + Inserisci un gruppo per riga. + Ordine originale + Prima la qualità migliore + Prima i più grandi + Prima i più piccoli + Prima l’audio migliore + Prima la lingua + Qualsiasi + %1$d selezionati + Tutti i risultati + %1$d risultati + Fino a %1$d GB + %1$d GB+ + %1$d-%2$d GB + Risoluzioni preferite + Ordina prima le risoluzioni selezionate, nell’ordine predefinito. + Risoluzioni richieste + Mostra solo le risoluzioni selezionate. + Risoluzioni escluse + Nascondi le risoluzioni selezionate. + Qualità preferite + Ordina prima le qualità selezionate, nell’ordine predefinito. + Qualità richieste + Mostra solo le qualità selezionate. + Qualità escluse + Nascondi le qualità selezionate. + Tag video preferiti + Ordina prima DV, HDR, 10bit, IMAX e tag simili. + Tag video richiesti + Richiede DV, HDR, 10bit, IMAX, SDR e tag simili. + Tag video esclusi + Nascondi DV, HDR, 10bit, 3D e tag simili. + Tag audio preferiti + Ordina prima Atmos, TrueHD, DTS, AAC e tag simili. + Tag audio richiesti + Richiede Atmos, TrueHD, DTS, AAC e tag simili. + Tag audio esclusi + Nascondi i tag audio selezionati. + Canali preferiti + Ordina prima le configurazioni di canali preferite. + Canali richiesti + Mostra solo le configurazioni di canali selezionate. + Canali esclusi + Nascondi le configurazioni di canali selezionate. + Codifiche preferite + Ordina prima AV1, HEVC, AVC e codifiche simili. + Codifiche richieste + Richiede AV1, HEVC, AVC e codifiche simili. + Codifiche escluse + Nascondi le codifiche selezionate. + Lingue preferite + Ordina prima le lingue audio preferite. + Lingue richieste + Mostra solo i risultati con le lingue selezionate. + Lingue escluse + Nascondi i risultati in cui tutte le lingue sono escluse. + Gruppi di rilascio richiesti + Mostra solo i gruppi di rilascio selezionati. + Gruppi di rilascio esclusi + Nascondi i gruppi di rilascio selezionati. + Invia timestamp + TIPO DI SEGMENTO + Intro + Riassunto + Outro + TEMPO INIZIALE (MM:SS) + TEMPO FINALE (MM:SS) + Invia + Acquisisci + Decoder hardware + Uscita audio + Primarie di destinazione + Trasferimento di destinazione + Lista Trakt identificata + Scopri TMDB + US, KR, JP, IN + Inserisci il nome, l’URL o l’ID di una lista Trakt + Inserisci un ID o un URL TMDB valido. + Impossibile caricare la sorgente TMDB + Inserisci l’ID o l’URL di una lista Trakt + Impossibile caricare la lista Trakt + Canada + Australia + Germania + Film + Serie + La libreria cloud è disabilitata. + Chiave API non valida o connessione non riuscita + Nel manifest manca "%1$s" + Collezione TMDB %1$s + Regista TMDB %1$s + Scopri TMDB + Inserisci un ID o un URL TMDB valido. + Lista TMDB %1$s + Network TMDB %1$s + Persona TMDB %1$s + Produzione TMDB %1$s + Impossibile caricare la sorgente TMDB + ID %1$s + Add a TMDB API key in Impostazioni to use TMDB sorgentes. + Collezione TMDB non trovata + Azienda TMDB non trovata + La ricerca TMDB non ha restituito dati + Lista TMDB non trovata + ID collezione TMDB mancante + ID lista TMDB mancante + ID persona TMDB mancante + Network TMDB non trovato + Crediti della persona TMDB non trovati + Persona TMDB non trovata + Credenziali Trakt mancanti. + %1$s (%2$d) + Inserisci un ID o un URL valido di una lista Trakt + %1$d elementi + %1$d Mi piace + Lista Trakt non trovata o non pubblica + ID della lista Trakt mancante + La lista Trakt non include un ID numerico + Lista pubblica Trakt + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Impossibile caricare i commenti Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Impossibile finalizzare il file scaricato + Impossibile aprire il file parziale del download + Il file parziale del download non è aperto + Impossibile scrivere il file parziale del download + Libreria Nuvio + Problema di connessione + Corpo della risposta vuoto + Controlla la connessione e riprova. + Richiesta non riuscita con HTTP %1$d + %1$s (%2$s) + Il motore del player MPV non è disponibile. Ricompila l’app. + Impossibile riprodurre questo flusso. + Plugin disabilitati + Plugin abilitati + %1$d provider + Aggiornamento + %1$d repository + Chiave API TMDB mancante + Chiave API TMDB impostata + Installa repository di plugin + Installazione… + Testa provider + Test in corso… + Elimina repository di plugin + Aggiorna repository di plugin + Nessun provider ancora disponibile. + Aggiungi l’URL di un repository per installare plugin provider per la ricerca dei flussi. + Nessun repository di plugin ancora installato. + Questo repository di plugin è già installato. + Inserisci l’URL di un repository di plugin. + Inserisci un URL di plugin valido. + Impossibile installare il repository di plugin + Provider non trovato + Impossibile aggiornare il repository + I plugin non sono disponibili in questa build. + URL del manifest del plugin + Nome del manifest mancante. + Il manifest non contiene provider. + Versione del manifest mancante. + Nome del manifest mancante. + Il manifest non contiene provider. + Versione del manifest mancante. + %1$s installato. + Disabilitato dal repository + Nessuna descrizione + v%1$s + Repository di plugin + Versione %1$s + Questo repository di plugin è già installato. + Impossibile installare il repository di plugin + Impossibile aggiornare il repository + AGGIUNGI REPOSITORY + REPOSITORY INSTALLATI + PANORAMICA + PROVIDER + Errore + Test del provider non riuscito + Risultati del test (%1$d) + I provider dei plugin richiedono una chiave API TMDB. Impostala nella schermata TMDB, altrimenti i provider non funzioneranno correttamente. + Nessun risultato di ricerca restituito per %1$s. + Chiave API non valida o connessione non riuscita + Repository di plugin + Invia intro + Invia + Acquisisci + TEMPO FINALE (MM:SS) + TIPO DI SEGMENTO + TEMPO INIZIALE (MM:SS) + Connesso a Trakt + Disconnesso da Trakt + TB + Nessun file APK trovato nella release + Download non riuscito con HTTP %1$d + Il file dell’aggiornamento scaricato non è presente. + Corpo del download vuoto + Errore API delle release GitHub: %1$d + Non è stato ancora pubblicato alcun aggiornamento. + La release non ha un tag o un nome + Trakt Lista %1$s + Player di sistema Android + Streaming P2P + uesto flusso utilizza la tecnologia peer-to-peer (P2P). Abilitando il P2P, riconosci e accetti che:\n\n• Il tuo indirizzo IP sarà visibile agli altri peer della rete\n• Sei l’unico responsabile dei contenuti a cui accedi\n• Confermi di avere il diritto legale di riprodurre questi contenuti nella tua giurisdizione\n• Nuvio non ospita, distribuisce né controlla alcun contenuto P2P\n• Nuvio non si assume alcuna responsabilità per eventuali conseguenze legali derivanti dall’uso dello streaming P2P\n\nUtilizzi questa funzione interamente a tuo rischio. Il P2P può essere disabilitato in qualsiasi momento dalle Impostazioni. + Abilita P2P + Annulla + Errore torrent sconosciuto + %1$d seed · %2$d peer + %1$s nel buffer · %2$s · %3$s + %1$s · %2$s + %1$d peer · %2$d seed · %3$d%% + Connessione ai peer… + Avvio del motore P2P… + Impossibile avviare il torrent: %1$s + Errore torrent: %1$s + Nascita + Luogo di nascita + Crediti + Biografia + Paese + Tipo + Catalogo + Informazioni + Caricamento sottotitoli dagli addon... + Download dei sottotitoli... diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index ca0a8855..885e1c81 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -19,6 +19,7 @@ 再試行 保存 保存中… + 切り替え 検証 インストール中 アドオン @@ -31,6 +32,13 @@ 利用不可 アドオンを設定 アドオンを削除 + いつものアドオンで、ライブラリをNuvioにまとめましょう。 + アドオンを追加して、ライブラリを作り始めましょう。 + Nuvioを自分好みに。 + アドオンURL + アドオンを追加 + まだ何もありません + 現在のアドオンには、ここに表示できるものがまだありません。 マニフェストURLを追加して、カタログ・メタデータ・ストリーム・字幕をNuvioに読み込みましょう。 アドオンがありません アドオンのURLを入力してください。 @@ -319,6 +327,8 @@ サインアップ データはこのデバイスにのみ保存されます いつでも、どこでも、すべてをストリーム + サインアップにより、 + 利用規約 おかえりなさい ライブラリ Traktライブラリ @@ -434,7 +444,9 @@ 最新リリースをダウンロード アップデートを確認 アドオンと探索ソースを管理 + アドオンを管理して、Nuvioに表示する内容を選べます。 ダウンロードした映画とエピソードを管理 + このデバイスに保存した項目を管理します。 ダウンロード 一般 利用可能な連携を管理 @@ -532,7 +544,6 @@ ステータス 匿名 サインイン済み - 同期バックエンド AMOLED ブラック OLEDスクリーン向けに純粋な黒背景を使用します。 アプリの言語 @@ -540,7 +551,7 @@ 言語を選択 「視聴を続ける」セクションの設定 Liquid Glass - iOS 26以降でネイティブのiPhoneタブバーを使用します。オン中はタブバーからのプロフィール即時切り替えが利用できません。 + iOS 26以降でネイティブのiPhoneタブバーを使用します。 カードの幅と角の丸みを調整します。 ディスプレイ ホーム @@ -631,6 +642,7 @@ ホーム ソース コンテンツソースのインストール・削除・更新・並べ替え + Nuvioで使うアドオンを追加・管理します。 JavaScriptスクレイパーリポジトリのインストールと内部テスト ホームレイアウト・コンテンツ表示・ポスターの動作を調整 詳細画面とエピソード画面の設定 @@ -780,7 +792,7 @@ Mobile・TV・Webを通じてNuvioを構築・支援している人々をご覧ください。 今月のサーバー・メンテナンス費用 達成済み。追加のサポートは開発費に充てられます。 - 100%%を超えた追加サポートは開発費に充てられます。 + 100%を超えた追加サポートは開発費に充てられます。 資金調達状況を読み込み中... サポーターAPIが設定されていません。local.propertiesにDONATIONS_BASE_URLを追加してください。 コントリビューター @@ -910,6 +922,16 @@ 第2音声言語 第2優先言語 デコーダー + 再生エンジン + ExoPlayerを優先し、再生に失敗した場合はlibmpvにフォールバックします。 + ExoPlayerとAndroid Media3デコーダーを使用します。 + Android再生にlibmpvを使用します。 + libmpvレンダラー + libmpvレンダラー + libmpvハードウェアデコード + 利用可能な場合、mpvハードウェアデコードを使用します。 + libmpv YUV420P互換 + レンダラーや色の問題があるデバイス向けにYUV420P出力を強制します。 次のエピソード プレーヤー スキップセグメント @@ -1301,9 +1323,9 @@ 直接のストリームリンクがありません メタデータがありません ストリームを更新 - %1$d%% から再開 + %1$d% から再開 %1$s から再開 - サイズ %1$s + %1$s このストリームタイプはサポートされていません 設定でアカウントを連携してください。 TorBoxにキャッシュされていません。 @@ -1321,7 +1343,7 @@ ダウンロードに失敗しました ダウンロードボディが空です ダウンロードした更新ファイルが見つかりません。 - ダウンロード中 %1$d%% + ダウンロード中 %1$d% インストールを開始できません 最新バージョンを使用しています。 Nuvioのアプリインストールを有効にして、戻って続けてください。 @@ -1404,6 +1426,7 @@ Traktのサインイン完了に失敗しました Traktユーザー ウォッチリスト + Traktの認証が期限切れです Traktリストが見つかりません Traktリストの上限に達しました @@ -1574,9 +1597,12 @@ KB MB GB + イントロを投稿 映像設定 + %1$s ではクラウドライブラリが利用できません。 + 映像 調整をリセット 出力プリセット @@ -1591,6 +1617,7 @@ コントラスト 彩度 ガンマ + ネイティブEDR HDR対応のiPhoneとiPadに最適。 SDRトーンマッピング @@ -1600,6 +1627,7 @@ カスタム 下の詳細設定値を使用します。 オフ + iOS映像出力 ハードウェアデコーダー 拡張ダイナミックレンジ @@ -1608,11 +1636,13 @@ mpvがデフォルトでアクティブなディスプレイの色空間をターゲットにするようにします。 ターゲット原色 ターゲット転送 + iOS音声出力 音声出力 AVFoundation出力が一時的に無効な間はAudioUnitを使用します。 空間オーディオとマルチチャンネル出力の試験的サポート。 レガシーのAudioUnit出力を使用します。 + 結果の管理 最大結果数 表示する結果数を制限します。 @@ -1624,69 +1654,83 @@ 並び替え後のBluRay・WEB-DL・REMUX の重複結果を制限します。 サイズ範囲 ファイルサイズで結果をフィルターします。 + 詳しく見る デフォルト形式 オリジナル形式 1行に1グループずつ入力してください。 + オリジナル順 最高品質優先 ファイルサイズ大優先 ファイルサイズ小優先 最高音質優先 言語優先 + すべて %1$d 件選択中 + すべての結果 %1$d 件の結果 + 最大 %1$dGB %1$dGB 以上 %1$d〜%2$dGB + 優先解像度 選択した解像度をデフォルト順で先頭に並べます。 必須解像度 選択した解像度のみ表示します。 除外する解像度 選択した解像度を非表示にします。 + 優先品質 選択した品質をデフォルト順で先頭に並べます。 必須品質 選択した品質のみ表示します。 除外する品質 選択した品質を非表示にします。 + 優先映像タグ DV・HDR・10bit・IMAXなどのタグを並べ替えます。 必須映像タグ DV・HDR・10bit・IMAX・SDRなどのタグを必須にします。 除外する映像タグ DV・HDR・10bit・3Dなどのタグを非表示にします。 + 優先音声タグ Atmos・TrueHD・DTS・AACなどのタグを並べ替えます。 必須音声タグ Atmos・TrueHD・DTS・AACなどのタグを必須にします。 除外する音声タグ 選択した音声タグを非表示にします。 + 優先チャンネル 優先するチャンネルレイアウトを先頭に並べます。 必須チャンネル 選択したチャンネルレイアウトのみ表示します。 除外するチャンネル 選択したチャンネルレイアウトを非表示にします。 + 優先エンコード AV1・HEVC・AVCなどのエンコードを並べ替えます。 必須エンコード AV1・HEVC・AVCなどのエンコードを必須にします。 除外するエンコード 選択したエンコードを非表示にします。 + 優先言語 優先する音声言語を先頭に並べます。 必須言語 選択した言語を含む結果のみ表示します。 除外する言語 すべての言語が除外対象の結果を非表示にします。 + 必須リリースグループ 選択したリリースグループのみ表示します。 除外するリリースグループ 選択したリリースグループを非表示にします。 + タイムスタンプを投稿 セグメントタイプ イントロ @@ -1696,24 +1740,31 @@ 終了時刻 (MM:SS) 投稿 キャプチャ + ハードウェアデコーダー 音声出力 ターゲット原色 ターゲット転送 + 解決済みTraktリスト TMDBディスカバー US, KR, JP, IN + Traktリスト名、URL、またはIDを入力してください 有効なTMDB IDまたはURLを入力してください。 TMDBソースを読み込めませんでした TraktリストIDまたはURLを入力してください Traktリストを読み込めませんでした + カナダ オーストラリア ドイツ + 映画 シリーズ + クラウドライブラリが無効です。 + APIキーが無効か接続に失敗しました マニフェストに「%1$s」がありません TMDBコレクション %1$s @@ -1834,20 +1885,24 @@ GitHub releases APIエラー:%1$d まだアップデートが公開されていません。 リリースにタグまたは名前がありません + %1$s に放送 本日放送 明日放送 + %1$d 日後に放送 %1$d 日後に放送 %1$s 今日 明日 + %1$d 日後 %1$d 日後 新しいエピソード 新シーズン + Traktリスト %1$s Androidシステムプレーヤー P2Pストリーミング diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index ad7cea8c..9957c3db 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -29,6 +29,13 @@ Utilgjengelig Konfigurer tillegg Slett tillegg + Ta med biblioteket ditt til Nuvio med tilleggene du bruker. + Legg til et tillegg for å begynne å bygge biblioteket ditt. + Gjør Nuvio til ditt. + URL til tillegg + Legg til tillegg + Det er ingenting her ennå + Tilleggene du bruker, har ingenting å vise her ennå. Legg til en manifest-URL for å starte lasting av kataloger, metadata, strømmer eller undertekster i Nuvio. Ingen tillegg installert ennå. Skriv inn en tilleggs-URL. @@ -400,7 +407,9 @@ Last ned nyeste versjon Se etter oppdateringer Administrer tillegg og oppdagelseskilder. + Administrer tillegg og velg hva som vises i Nuvio. Administrer nedlastede filmer og episoder. + Administrer det du har lagret på denne enheten. Nedlastinger GENERELT Administrer tilgjengelige integrasjoner @@ -581,6 +590,7 @@ HJEM KILDER Installer, fjern, oppdater og sorter innholdskilder. + Legg til og administrer tilleggene du bruker med Nuvio. Installer JavaScript-scraper-repositories og test providere internt. Juster hjemmeoppsett, synlighet og plakat-oppførsel. Innstillinger for detalj- og episodeskjermer. diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml new file mode 100644 index 00000000..c2fdc267 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -0,0 +1,1971 @@ + + Databronnen, dankbetuigingen en platformlicenties + Open erkenning en projectcredits + Terug + Annuleren + Sluiten + Verwijderen + Gereed + Bewerken + Importeren + Volgende + OK + Afspelen + Vorige + Verwijderen + Opnieuw ordenen + Standaardinstellingen herstellen + Hervatten + Opnieuw proberen + Opslaan + Bezig met opslaan… + Wisselen + Valideren + Installeren + Add-ons + Actief + %1$d catalogi + Configurabel + Uitgeschakeld + Vernieuwen + %1$d bronnen + Niet beschikbaar + Addon configureren + Addon verwijderen + Breng je bibliotheek naar Nuvio met de add-ons die je gebruikt. + Voeg een add-on toe om je bibliotheek op te bouwen. + Maak Nuvio helemaal van jou. + Add-on-URL + Add-on toevoegen + Hier is nog niets + Je huidige add-ons hebben hier nog niets om te tonen. + Voeg een manifest-URL toe om catalogi, metadata, streams of ondertitels in Nuvio te laden. + Nog geen addons geïnstalleerd. + Voer een addon-URL in. + Addon-URL + Addon installeren + Manifestgegevens laden... + De manifest-URL wordt gevalideerd en addongegevens worden geladen vóór installatie. + Addon controleren + Installatie mislukt + %1$s is gevalideerd en succesvol toegevoegd. + Addon geïnstalleerd + Addon omlaag verplaatsen + Addon omhoog verplaatsen + Actief + Addons + Catalogi + Addon verversen + Addon toevoegen + Geïnstalleerde Addons + Overzicht + %1$d id-regels + Versie %1$s + Geselecteerd + JSON kopiëren + %1$d collectie(s), %2$d map(pen) + "%1$s" verwijderen? Dit kan niet ongedaan worden gemaakt. + Collectie verwijderen + Catalogus toevoegen + Map toevoegen + Alle genres + Voeg catalogi toe vanuit je geïnstalleerde addons om te bepalen wat deze map toont. + Nog geen catalogusbronnen + Kiezen + Emoji + Afbeeldings-URL + Geen + Omslag + Collectie maken + Gereed + Collectie bewerken + Map bewerken + Stel de identiteit, presentatie en catalogusbronnen van de map in met dezelfde structuur als de hoofdeditor voor collecties. + Voeg er één toe om te beginnen. + Nog geen mappen + Mappen + Genrefilter + Toon alleen de omslagafbeelding + Titel verbergen + Nieuwe map + Toon deze collectie boven alle gewone startcatalogi. Meerdere vastgezette collecties volgen de aanmaakvolgorde van de collectie. + Boven catalogi vastzetten + URL van achtergrondafbeelding (optioneel) + Mapnaam + URL van geanimeerde GIF (speelt alleen af wanneer geselecteerd) + Collectienaam + Wijzigingen opslaan + Opslaan + Weergave + Basis + Catalogusbronnen + Kies welke addoncatalogi deze map moet samenvoegen. + Catalogi selecteren + Genre selecteren + %1$d geselecteerd + %1$d catalogi + %1$d geselecteerd + Poster + Vierkant + Breed + Combineer alle catalogi in één tabblad + Toon tabblad \"Alles\" + Speel de ingestelde GIF af in plaats van de statische omslag wanneer beschikbaar. + Toon GIF wanneer ingesteld + %1$d bron(nen) · %2$s + Tegelvorm + Rijen + Tabbladen + Weergavemodus + TMDB-bronnen + Openbare lijst + Productie + Netwerk + Collectie + Persoon + Regisseur + Aangepast + Kies een kant-en-klare bron. Je kunt die na het toevoegen bewerken of verwijderen. + Plak een openbare TMDB-lijst-URL of alleen het nummer uit de URL. + Zoek op studionaam, of plak een TMDB-bedrijfs-ID/URL en voeg deze direct toe. + Voer een netwerk-ID in. Veelgebruikte netwerken zijn beschikbaar in Voorinstellingen en snelle filters. + Zoek een filmcollectienaam of plak de collectie-ID van TMDB. + Voer een TMDB-persoons-ID of URL in om een rij op te bouwen uit castcredits. + Voer een TMDB-persoons-ID of URL in om een rij op te bouwen uit regisseurscredits. + Bouw een live TMDB-rij met optionele filters. Laat velden leeg als je dat filter niet nodig hebt. + Openbare TMDB-lijst + Netwerk-ID + Collectie-ID + Persoons-ID + Naam, ID of URL van productiebedrijf + TMDB-ID of URL + https://www.themoviedb.org/list/8504994 of 8504994 + 213 voor Netflix, 49 voor HBO, 2739 voor Disney+ + 10 voor Star Wars Collection + Marvel Studios, 420, of bedrijfs-URL + 31 voor Tom Hanks, of persoons-URL + Voorbeelden: Marvel Studios, 420, of [https://www.themoviedb.org/company/420](https://www.themoviedb.org/company/420). + Voorbeeld: Star Wars Collection, Harry Potter Collection, of een collectie-URL. + Voorbeeld-ID's: Netflix 213, HBO 49, Disney+ 2739. + Voorbeeld: [https://www.themoviedb.org/list/8504994](https://www.themoviedb.org/list/8504994) of 8504994. + Voorbeeld: [https://www.themoviedb.org/person/31-tom-hanks](https://www.themoviedb.org/person/31-tom-hanks) of 31. + Weergavetitel + Wordt getoond als de naam van de rij/het tabblad. Als dit leeg is, maakt Nuvio er een op basis van de bron. + Marvel Movies, Netflix Originals, Pixar + Tom Hanks Movies, Favoriete Acteurs + Christopher Nolan Movies, Favoriete Regisseurs + Beste Actiefilms, Koreaanse Drama's, Animatie 2024 + Zoekresultaten + TMDB-collectie + TMDB-bedrijf %1$d + TMDB-collectie %1$d + Type + Films + Series + Beide + Sorteren + Filters + Laat velden leeg als je dat filter niet nodig hebt. + Snelle genres + Snelle talen + Snelle landen + Snelle trefwoorden + Snelle studio's + Snelle netwerken + Genre-ID's + Gebruik TMDB-genrenummers. Scheid meerdere met komma's voor EN, of pipes voor OF. + 28,12 + 18,35 + Release- of uitzenddatum vanaf + Release- of uitzenddatum tot + Gebruik JJJJ-MM-DD, bijvoorbeeld 2024-01-01. + 2020-01-01 + 2024-12-31 + Minimale beoordeling + Maximale beoordeling + TMDB-beoordeling van 0 tot 10. Voorbeeld: 7.0. + 7.0 + 10 + Minimumaantal stemmen + Gebruik dit om obscure titels met weinig stemmen te vermijden. Voorbeeld: 100. + 100 + Originele taal + Gebruik taalcodes van twee letters, bijvoorbeeld en, ko, ja, hi. + en, ko, ja, hi + Land van herkomst + Gebruik landcodes van twee letters, bijvoorbeeld US, KR, JP, IN. + US, KR, JP, IN + Trefwoord-ID's + Gebruik TMDB-trefwoordnummers. Snelle chips vullen veelvoorkomende voorbeelden in. + 9715 voor superheld + Bedrijfs-ID's + Gebruik studio-/bedrijfs-ID's. Snelle chips vullen veelvoorkomende voorbeelden in. + 420 voor Marvel Studios + Netwerk-ID's + Alleen voor series. Gebruik netwerk-ID's zoals Netflix 213 of HBO 49. + 213 voor Netflix + Jaar + Gebruik een jaartal van vier cijfers, bijvoorbeeld 2024. + 2024 + Voorinstellingen + Zoeken + Bron toevoegen + Trakt-lijst toevoegen + Trakt-lijst bewerken + Trakt-lijsten + Trakt-lijst + Zoek titel, Trakt-URL, of lijst-ID + Gebruik een openbare Trakt-lijst-URL of numerieke lijst-ID, of zoek op naam. + Weekend Watch, Award Winners + Zoekresultaten + Trending lijsten + Populaire lijsten + Richting + Oplopend + Aflopend + Lijstvolgorde + Recent toegevoegd + Titel + Uitgebracht + Speelduur + Populair + Percentage + Stemmen + Voer een Trakt-lijstnaam, URL of ID in + Voer een Trakt-lijst-ID of URL in + Kan Trakt-lijst niet laden + Geen Trakt-lijsten gevonden + Herkende Trakt-lijst + Trakt-lijst %1$d + Actie + Avontuur + Animatie + Komedie + Horror + Sci-fi + Drama + Misdaad + Reality + Engels + Koreaans + Japans + Hindi + Spaans + Verenigde Staten + Korea + Japan + India + Verenigd Koninkrijk + Superheld + Gebaseerd op roman + Tijdreizen + Ruimte + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Origineel + Populair + Best beoordeeld + Recent + Meest gestemd + Kijkregio + ISO 3166-1-landcode waar de titel beschikbaar is. Voorbeeld: US, GB. + Snelle kijkregio's + ID's van kijkaanbieders + Gebruik TMDB-ID's van kijkaanbieders. Scheid meerdere met komma's voor EN, of pipes voor OF. + 8|337|350 + Snelle kijkaanbieders + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + TMDB-lijst + TMDB-filmcollectie + Productie + Netwerk + Persoon + Regisseur + TMDB Ontdekken + Maak er een aan om je catalogi te organiseren. + Nog geen collecties + %1$d map(pen) + Geen items gevonden + Map niet gevonden + Collecties + Collecties importeren + JSON + Plak hieronder je collecties-JSON. + Importeren + Nieuwe collectie + Vastgezet + Alles + Jouw collecties + Gemaakt met ❤️ door Tapframe en vrienden + Versie %1$s (%2$s) + Uit + Aan + Pauze + Vernieuwen + Heb je al een account? + Doorgaan zonder account + Account aanmaken + Heb je nog geen account? + E-mail + of + Wachtwoord + Log in om toegang te krijgen tot je bibliotheek en voortgang + Inloggen + Registreer om je gegevens te synchroniseren tussen apparaten + Registreren + Je gegevens worden alleen lokaal opgeslagen + Bekijk je bibliotheek, overal + Door je te registreren ga je akkoord met de + Voorwaarden + Welkom terug + Bibliotheek + Trakt-bibliotheek + Home + Bibliotheek + Profiel + Zoeken + Audiosporen + Audio + Automatische synchronisatie + Vet + Ingebouwd + Verticale offset onder + Vastleggen + Speler sluiten + Kleur + Nu aan het afspelen + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Afleveringen + Lettergrootte + %1$dsp + Spelerbediening vergrendelen + Ondertitelregels laden... + Geen ondertitelregels gevonden + Kan ondertitelregels niet laden + Geen audiosporen beschikbaar + Geen afleveringen beschikbaar + Geen streams gevonden + Geen + Omlijning + Omlijningskleur + Afleveringen + Bronnen + Streams + Afspeelfout + Wordt afgespeeld + Tik om ondertitels op te halen + Ga terug + Vernieuwen + Resetten + Standaardinstellingen resetten + Vullen + Passend + Zoom + 10 seconden terugspoelen + -%1$ds + +%1$ds + -%1$ds + +%1$ds + 10 seconden vooruitspoelen + Bronnen + Stijl + Selecteer eerst een addon-ondertitel + Ondertitels + Ondertitelvertraging + Ondertitels + Tekstdekking + Helderheid %1$s + Volume %1$s + Gedempt + Gedownload + Uitzending + Nog te bepalen + Tik om te ontgrendelen + Spoor %1$d + Spelerbediening ontgrendelen + Je kijkt naar + Profiel toevoegen + Zoekopdracht wissen + Ontdekken + Geïnstalleerde addons gaven geen geldige zoekresultaten terug. + Zoeken mislukt + Installeer en valideer minstens één addon voordat je zoekt. + Geen actieve addons + Geïnstalleerde doorzoekbare catalogi gaven geen resultaten voor deze zoekopdracht. + Geen resultaten gevonden + Je geïnstalleerde addons bieden geen catalogus zoekfunctie. + Geen doorzoekbare catalogi + Zoek films, series... + Recente zoekopdrachten + Recente zoekopdracht verwijderen + Over + Algemeen + Account + Addons + Geavanceerd + Indeling + Inhoud en ontdekking + Verdergaan met kijken + Verbonden diensten + Startschermindeling + Integraties + Licenties en attributie + MDBList-beoordelingen + Detailpagina + Meldingen + Afspelen + Plug-ins + Posterkaartstijl + Instellingen + Streams + Ondersteuners en bijdragers + TMDB-verrijking + Trakt + OVER + Account- en synchronisatiestatus + ACCOUNT + Opstart- en profielgedrag. + GEAVANCEERD + Home, streams, collecties en detailpagina's + Download de laatste versie + Controleren op updates + Beheer addons en ontdekkingsbronnen. + Beheer add-ons en kies wat er in Nuvio verschijnt. + Beheer je gedownloade films en afleveringen. + Beheer wat je op dit apparaat hebt opgeslagen. + Downloads + ALGEMEEN + Beheer beschikbare integraties + Beheer meldingen voor nieuwe afleveringen en verstuur een testmelding. + Weergave van streamresultaten en badge-URL-regels. + Wissel naar een ander profiel. + Profiel wisselen + Open het Trakt-verbindingsscherm + Geen instellingen gevonden. + Instellingen zoeken... + RESULTATEN + OPSTARTEN + DIAGNOSTIEK + CACHE + Laatste profiel onthouden + Onthoud het laatst geselecteerde profiel bij opstarten + Crashrapporten (Sentry) + Verstuur crash- en ANR-rapporten met veilige context. Standaard ingeschakeld. + Crashrapporten inschakelen? + Crashrapporten helpen apparaatspecifieke fouten te identificeren zonder dat je het probleem met ADB-logs hoeft te reproduceren. + Crashrapporten uitschakelen? + Toekomstige crashes en huidige ANR's van dit apparaat worden niet gerapporteerd. Dit kan het oplossen van apparaatspecifieke bugs veel lastiger maken. + Hoe dit helpt + Rapporten worden gegroepeerd op app-versie, apparaatmodel, Android-versie en stacktrace, zodat de meest voorkomende echte crashes als eerst kunnen worden opgelost. + Verzonden + Crashes, huidige ANR's, stacktraces, app-versie, buildtype, variant, apparaat- en OS-metadata, plus veilige netwerk-breadcrumbs met methode, host, pad, status, duur en type uitzondering. + Niet verzonden + Wachtwoorden, toegangstokens, refreshtokens, request-bodies, response-bodies, headers, cookies, screenshots, view-hiërarchie, sessie-replay, ruwe diagnostiek, en query- of fragmentwaarden van stream-URL's. + Aan houden + Uitschakelen + Inschakelen + Cache 'Verdergaan met kijken' wissen + Verwijder gecachte gegevens van 'Verdergaan met kijken' en vernieuw kijkvoortgang + Cache gewist + APP-LICENTIE + GEGEVENS EN DIENSTEN + AFSPEELLICENTIE + Nuvio Mobile + Broncode en licentievoorwaarden zijn beschikbaar in de projectrepository. + Uitgebracht onder de GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio gebruikt de TMDB API voor film- en tv-metadata, artwork, trailers, cast, productiedetails, collecties en aanbevelingen. Dit product gebruikt de TMDB API maar wordt niet goedgekeurd of gecertificeerd door TMDB. + IMDb Non-Commercial Datasets + Nuvio gebruikt IMDb Non-Commercial Datasets, waaronder title.ratings.tsv.gz, voor IMDb-beoordelingen en stemaantallen. Informatie met dank aan IMDb (https://www.imdb.com). Gebruikt met toestemming. IMDb-gegevens zijn voor persoonlijk en niet-commercieel gebruik onder de voorwaarden van IMDb. + Trakt + Nuvio maakt verbinding met Trakt voor accountauthenticatie, kijkgeschiedenis, voortgangssynchronisatie, bibliotheekgegevens, beoordelingen, lijsten en reacties. Nuvio is niet gelieerd aan of goedgekeurd door Trakt. + Premiumize + Nuvio maakt verbinding met Premiumize voor accountauthenticatie, toegang tot cloudbibliotheek, cachecontroles en cloud-afspeelfuncties. Nuvio is niet gelieerd aan of goedgekeurd door Premiumize. + TorBox + Nuvio maakt verbinding met TorBox voor accountauthenticatie, toegang tot cloudbibliotheek, cachecontroles en cloud-afspeelfuncties. Nuvio is niet gelieerd aan of goedgekeurd door TorBox. + MDBList + Nuvio gebruikt MDBList voor beoordelingen en gegevens van externe scoreproviders. Nuvio is niet gelieerd aan of goedgekeurd door MDBList. + IntroDB + Nuvio gebruikt de IntroDB API voor door de community aangeleverde tijdstempels van intro, samenvatting, aftiteling en preview die worden gebruikt door de overslaanbediening. Nuvio is niet gelieerd aan of goedgekeurd door IntroDB. + MPVKit + Gebruikt voor afspelen op iOS-builds. + De MPVKit-broncode zelf is gelicentieerd onder LGPL v3.0. MPVKit-bundels, inclusief libmpv- en FFmpeg-bibliotheken, zijn eveneens gelicentieerd onder LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Gebruikt voor afspelen op Android-builds. + Gelicentieerd onder de Apache License, versie 2.0. + Je Trakt-lijsten worden geladen… + Kies waar je deze titel wilt opslaan op Trakt + Doneren + Ga naar details + Verwijderen + Vanaf het begin starten + Afspelen + %1$d/10 + Recensie + Spoiler + Nog geen Trakt-recensies beschikbaar. + %1$d likes + Deze reactie bevat spoilers. + Deze reactie bevat spoilers en is verborgen. + Reacties + Trailer + %1$s (%2$d) + Trailers + Geen voltooide afleveringen + Nog geen downloads + %1$d gedownloade aflevering(en) + Downloadsmap openen + Kan de downloadsmap niet openen + Actief + Films + Series + Downloads tonen + Voltooid • %1$s + Downloaden • %1$s + Mislukt + Gepauzeerd • %1$s + Bekeken + Seizoen %1$d + Specials + Ga verder waar je gebleven was + Toevoegen aan bibliotheek + Markeren als onbekeken + Markeren als bekeken + Verwijderen uit bibliotheek + Alles bekijken + Handmatig afspelen + %1$s-logo + Account + Account verwijderen + Hiermee wordt je account en alle bijbehorende gegevens permanent verwijderd. + Deze actie kan niet ongedaan worden gemaakt. Al je gegevens, profielen en synchronisatiegeschiedenis worden permanent verwijderd. + Account verwijderen? + E-mail + Niet ingelogd + Uitloggen + Je wordt teruggebracht naar het inlogscherm. + Uitloggen? + Status + Anoniem + Ingelogd + AMOLED-zwart + Gebruik puur zwarte achtergronden voor OLED-schermen. + App-taal + Apparaattaal + Kies taal + Instellingen voor de sectie Verdergaan met kijken. + Liquid Glass + Gebruik de systeemeigen iPhone-tabbalk op iOS 26 en later. + Pas kaartbreedte en hoekradius aan. + WEERGAVE + HOME + THEMA + Collectie • %1$s + Weergavenaam + Installeer een addon met board-compatibele catalogi om startschermrijen te configureren. + Geen startschermcatalogi + Herobron + Verborgen + Home gefocust houden + %1$s • Limiet bereikt (max %2$d) + Geen herobronnen geselecteerd + Niet in hero + Verwijder vastzetten bovenaan van collectie om te verplaatsen + Vastgezet + Bovenaan vastgezet + Herordenen + CATALOGI + CATALOGI EN COLLECTIES + COLLECTIES + Startschermindeling + Hero-catalogi + %1$d van %2$d geselecteerd + Herosectie tonen + Toon herocarrousel bovenaan de startpagina. + Niet-uitgebrachte inhoud verbergen + Verberg films en series die nog niet zijn uitgebracht. + Catalogusonderstreping verbergen + Verwijder de accentlijn onder catalogus- en collectietitels in de hele app. + %1$d van %2$d catalogi zichtbaar • %3$d herobronnen geselecteerd + Open een catalogus alleen als je deze wilt hernoemen of herordenen. + Zichtbaar + Waarde verbergen + Speler, ondertitels en automatisch afspelen + Hoekradius + Posterkaartstijl + Breedte + Aangepast + Pas kaartbreedte en hoekradius aan. + Labels verbergen + Liggende posters + Live voorbeeld + %1$s (%2$s) + Hoekradius: %1$ddp + Hoogte: %1$ddp + Breedte: %1$ddp + Klassiek + Pil + Afgerond + Scherp + Subtiel + Gebalanceerd + Comfortabel + Compact + Dicht + Groot + Standaard + Waarde tonen + Toon een pop-up om verder te gaan waar je gebleven was bij het openen van de app na het verlaten van de speler. + Hervatprompt bij opstarten + Vervaag miniaturen van de volgende aflevering in Verdergaan met kijken om spoilers te vermijden. + Onbekeken vervagen in Verdergaan met kijken + Toon aankomende afleveringen in Verdergaan met kijken voordat ze worden uitgezonden. + Nog niet uitgezonden volgende afleveringen tonen + SORTEERVOLGORDE + Sorteervolgorde + Standaard + Sorteer alle items op recentheid + Streamingstijl + Uitgebrachte items eerst, aankomende items achteraan + Posterkaartstijl + BIJ OPSTARTEN + GEDRAG VOLGENDE AFLEVERING + ZICHTBAARHEID + Toon de sectie Verdergaan met kijken op het startscherm. + Verdergaan met kijken tonen + Kaart + Liggende kaart in tv-stijl + Poster + Posterkaart met artwork centraal + Breed + Informatiedichte horizontale kaart + Toon de volgende aflevering op basis van de verst bekeken aflevering. Schakel uit bij herbekijken om in plaats daarvan de laatst bekeken aflevering te gebruiken. + Volgende vanaf verst bekeken aflevering + Geef de voorkeur aan afleveringsminiaturen indien beschikbaar. + Voorkeur voor afleveringsminiaturen in Verdergaan met kijken + HOME + BRONNEN + Installeer, verwijder, vernieuw en sorteer je contentbronnen. + Voeg de add-ons toe die je met Nuvio gebruikt en beheer ze. + Installeer JavaScript-scraperrepository's en test providers intern. + Pas startschermindeling, zichtbaarheid van inhoud en postergedrag aan + Instellingen voor de detail- en afleveringsschermen. + Maak aangepaste catalogusgroepen met mappen die op Home worden getoond. + Integraties + Bediening voor metadataverrijking + Externe beoordelingsproviders + Verbind accounts voor links en bibliotheektoegang + Verbonden diensten + Deze integraties zijn experimenteel en kunnen later behouden, gewijzigd of verwijderd worden. + Cloudbibliotheek + Blader door en speel bestanden die al in je verbonden accounts staan. + Afspeelbare links oplossen + Vraag een verbonden dienst om afspeelbare links wanneer een resultaat dit nodig heeft. Dit kan het item toevoegen aan die dienst. + Oplossen met + Kies welk verbonden account afspeelbare links afhandelt. + Verbind eerst een account. + Accounts + Verbind je %1$s-account. + Koppel je %1$s-account in de browser. + %1$s API-sleutel + Voer je %1$s API-sleutel in. + Voer %1$s API-sleutel in + Niet ingesteld + Verbonden + %1$s verbinden + %1$s loskoppelen + Loskoppelen + %1$s is verbonden op dit apparaat. + Beveiligd inloggen starten... + Open de link en voer deze code in om Nuvio goed te keuren. + Code gekopieerd. + Link openen + Wachten op goedkeuring... + Kan inloggen niet starten. + Deze inlogmethode is niet geconfigureerd in deze build. + Deze code is verlopen. Probeer het opnieuw. + Voorbereiding van links + Links voorbereiden + Los afspeelbare links op voordat het afspelen begint. + Voor te bereiden links + Gebruik indien mogelijk een lager aantal. Verbonden diensten kunnen het aantal links dat binnen een bepaalde periode kan worden opgelost, beperken. Het openen van een film of aflevering kan meetellen voor die limieten, zelfs als je niet op Afspelen drukt, omdat de links van tevoren worden voorbereid. + 1 link + %1$d links + Opmaak + Naamsjabloon + Bepaalt hoe resultaatnamen worden weergegeven. Laat leeg om de originele resultaatnaam te gebruiken. + Beschrijvingssjabloon + Bepaalt de metadata die onder elk resultaat wordt getoond. Laat leeg om de originele resultaatdetails te gebruiken. + Opmaak resetten + Herstel de standaard resultaatopmaak. + Fusion-stijl + Bestandsgrootte-badges + Toon badges met bestandsgrootte in streamresultaten en spelerbronpanelen. + Addon-logo + Toon addon-logo en -naam naast streambronnen. + Weergave + Badgepositie + Kies of Fusion- en groottebadges boven of onder streamkaarten verschijnen. + Badgepositie + Selecteer waar streambadges op streamkaarten verschijnen. + Boven + Onder + Fusion-badge-URL's + Importeer tot %1$d JSON-URL's voor streambadges in Fusion-stijl. Elke URL kan afzonderlijk worden bijgewerkt of verwijderd. + Beheer geïmporteerde JSON-URL's voor streambadges in Fusion-stijl. + Importeren van badge is mislukt. + Voer een badge-JSON-URL in. + Badge-URL moet beginnen met http:// of https://. + Je kunt tot %1$d badge-URL's importeren. + %1$d/%2$d URL's, %3$d actieve Fusion-badges + Geen Fusion-badge-URL's geïmporteerd. + Fusion-badge-JSON-URL + %1$d/%2$d Fusion-URL's geïmporteerd + Actief + Inactief + %1$s, %2$d ingeschakelde badges, %3$d groepen + Voorbeeld + Voorbeeld van Fusion-badge + %1$d badges in Fusion-stijl van deze URL + Geen badge-afbeeldingen in Fusion-stijl in deze URL. + Groep %1$d + Overige Fusion-badges + API-sleutel gevalideerd. + Kon deze API-sleutel niet valideren. + Voeg hieronder je MDBList API-sleutel toe voordat je beoordelingen inschakelt. + Vereist om beoordelingen van MDBList op te halen + API-sleutel + API-sleutel + MDBList-beoordelingen inschakelen + Haal beoordelingen op van externe providers op het detailscherm van metadata + API-sleutel + Externe beoordelingsproviders + MDBList-beoordelingen + Acties + Bedieningselementen voor afspelen en opslaan. + Cast + Lijst met hoofdrolspelers. + Filmische achtergrond + Vervaagde achtergrond achter de inhoud, vergelijkbaar met het streamscherm. + Achtergrondmodus + Kies hoe artwork achter metadatapagina's wordt weergegeven. + Normaal + Gebruik de standaard app-achtergrond. + Filmisch + Toon een zacht vervaagde achtergrond achter de pagina. + Dominante kleur + Pas de paginaachtergrond aan op de hoofdkleur van de achtergrondafbeelding. + Collectie + Gerelateerde collectie- of franchisebalk. + Reacties + Recensies van Trakt + Details + Speelduur, status, releasedatum, taal en gerelateerde info. + Hero-trailerweergave + Speel trailervoorbeelden af in de metadata-hero wanneer een trailer beschikbaar is. + Afleveringskaarten + Kies hoe afleveringen worden weergegeven op het metadatascherm. + Horizontaal + Rijkaarten in achtergrondstijl + Lijst + Gestapelde kaarten met details vooraan + Afleveringen + Seizoenen en afleveringenlijst voor series. + Onbekeken afleveringen vervagen + Vervaag afleveringsminiaturen totdat ze bekeken zijn om spoilers te vermijden. + Groep %1$d + Meer zoals dit + TMDB-aanbevelingsachtergronden op de detailpagina + Geen + Overzicht + Synopsis, beoordelingen, genres en belangrijkste credits. + Productie + Studio's en netwerken. + WEERGAVE + SECTIES + Tabbladgroep %1$d + Tabbladindeling + Groepeer secties in tabbladen zoals de tv-app. Wijs maximaal 3 secties per tabbladgroep toe. + Trailers + Trailerbalk en snelkoppelingen voor afspelen. + Meldingen zijn momenteel uitgeschakeld in Nuvio. + Meldingen bij nieuwe afleveringen + Plan lokale meldingen wanneer een nieuwe aflevering van een opgeslagen serie beschikbaar komt. + Systeemmeldingen zijn uitgeschakeld voor Nuvio. Schakel ze in om meldingen en testmeldingen te ontvangen. + %1$d releasemeldingen staan momenteel gepland op dit apparaat. + MELDINGEN + TEST + Testmelding versturen + Testmelding wordt verstuurd... + Verstuur een lokale testmelding voor %1$s. + Sla eerst een serie op in je bibliotheek om meldingen te testen. + Testmelding + Community + Bekijk de mensen die Nuvio bouwen en ondersteunen op Mobiel, TV en Web. + Server- en onderhoudskosten van deze maand + Gedekt. Extra steun gaat nu naar ontwikkeling. + Na 100%% gaat extra steun naar ontwikkeling. + Financieringsvoortgang wordt geladen... + Supporters-API is niet geconfigureerd. Voeg DONATIONS_BASE_URL toe aan local.properties. + Bijdragers + Ondersteuners + GitHub openen + GitHub-profiel niet beschikbaar + Geen bericht bijgevoegd. + Bijdragers worden geladen... + Ondersteuners worden geladen... + Kon bijdragers niet laden + Kon ondersteuners niet laden + Geen bijdragers gevonden. + Geen ondersteuners gevonden. + Kan bijdragers niet laden. + Kan ondersteuners niet laden. + Kon bijdragers nu niet laden. + Kon ondersteuners nu niet laden. + %1$d commits in totaal + jan + feb + mrt + apr + mei + jun + jul + aug + sep + okt + nov + dec + %1$s %2$s, %3$s + Alle geïnstalleerde addons + Alle ingeschakelde plug-ins + Toegestane addons + Toegestane plug-ins + Anime Skip + AnimeSkip-clientID + Voer je AnimeSkip API-clientID in. Verkrijg er een op anime-skip.com. + Intro-inzending inschakelen + Toon een knop om intro-/outrotijdstempels naar de communitydatabase te versturen. + IntroDB API-sleutel + Voer je IntroDB API-sleutel in om tijdstempels in te dienen. Vereist voor inzending. + Zoek ook op AnimeSkip naar overslaantijdstempels (vereist clientID). + Volgende aflevering automatisch afspelen + Start de volgende aflevering automatisch wanneer de prompt verschijnt. + Alleen apparaatdecoders + Voorkeur voor app-decoders (FFmpeg) + Voorkeur voor apparaatdecoders + Decoderprioriteit + Tik buiten om te sluiten + Tik buiten om op te slaan en te sluiten + %1$d dag + %1$d dagen + %1$d uur + %1$d uur + Libass gebruiken voor ASS/SSA-ondertitels + Experimenteel: geavanceerde ASS/SSA-weergave (stijlen, positionering, animaties) + Externe speler + Externe spelerapp + Open nieuwe weergave met Android's standaard video-app of systeemkiezer. + Open nieuwe weergave met de geselecteerde geïnstalleerde speler. + Ondertitels doorsturen naar externe speler + Haal addon-ondertitels op in je voorkeurstaal en geef ze door aan de externe speler. + Intro- en outrotijdstempels versturen + Geef opgeloste intro- en outro-overslaantijdstempels door aan de externe speler voor automatisch overslaan. Werkt alleen in spelers die dit ondersteunen; andere spelers negeren dit. + Geen ondersteunde externe spelers geïnstalleerd + Speler + Intern + Extern + Kies welke speler nieuwe weergave afhandelt. + Ingedrukt houden voor snelheid + Ingedrukt houden voor snelheid + Houd overal op het spelervlak lang ingedrukt om de afspeelsnelheid tijdelijk te verhogen. + Aanraakgebaren + Sta vegen en dubbeltikken op het spelervlak toe om te spoelen, helderheid aan te passen of volume aan te passen. + Ongeldig regex-patroon + Duur van cache voor laatste link + DV7 - HEVC-fallback + Zet Dolby Vision Profile 7 om naar standaard HEVC voor apparaten zonder DV-hardwareondersteuning + Drempelminuten + Fallback wanneer er geen outro-tijdstempel bestaat. + %1$s min + Geen items beschikbaar + Niet ingesteld + Standaard (mediabestand) + Apparaattaal + Originele taal + Vereist dat TMDB-verrijking is ingeschakeld + Geforceerd + Geen + Alle ondertitels + Haal elke addon-ondertitel voor de video op en toon deze. + Snel opstarten + Sla het automatisch ophalen van addon-ondertitels over totdat je dit in de speler aanvraagt. + Opstartmodus addon-ondertitels + Alleen voorkeur + Haal addon-ondertitels op, maar toon alleen overeenkomsten in de voorkeurstaal. + Voorkeur voor bingegroep (volgende aflevering) + Probeer eerst hetzelfde bronprofiel (dezelfde addon-/kwaliteitsgroep) vóór de normale regels voor automatisch afspelen. + Bingegroep hergebruiken + Onthoud en hergebruik de laatste bingegroep tussen sessies (Verdergaan met kijken, Details, enz.). + Voorkeurstaal audio + Voorkeurstaal + Voorinstellingen + Komt overeen met streamnaam/titel/beschrijving/addon/URL. Voorbeeld: 4K|2160p|Remux + Regex-patroon + Geen patroon ingesteld. Voorbeeld: 4K|2160p|Remux + Elke 1080p+ + AVC / x264 + BluRay-kwaliteit + Dolby Atmos / DTS + Engels + HDR / Dolby Vision + HEVC / x265 + Geen CAM/TS + Geen REMUX/HDR + 1080p Standaard + 4K / Remux + 720p / Kleiner + WEB-bronnen + Libass-rendermodus + Standaard cues + Effecten Canvas + Effecten OpenGL + Overlay Canvas + Overlay OpenGL (aanbevolen) + Laatste link hergebruiken + Speel automatisch je laatst werkende stream af voor dezelfde film/aflevering als de cache nog geldig is + Secundaire audiotaal + Secundaire voorkeurstaal + DECODER + Afspeelengine + Gebruik eerst ExoPlayer en val terug op libmpv als het afspelen mislukt. + Gebruik ExoPlayer en Android Media3-decoders. + Gebruik libmpv voor Android-weergave. + Libmpv-renderer + Libmpv-renderer + Libmpv-hardwaredecodering + Gebruik mpv-hardwaredecodering indien beschikbaar. + Libmpv YUV420P-compatibiliteit + Forceer YUV420P-uitvoer voor apparaten met rendering- of kleurproblemen. + VOLGENDE AFLEVERING + SPELER + OVERSLAANSEGMENTEN + AUTOMATISCH AFSPELEN VAN STREAM + STREAMSELECTIE + ONDERTITELING EN AUDIO + ONDERTITELWEERGAVE + %1$d geselecteerd + Laadoverlay + Toon laadscherm totdat het eerste videobeeld verschijnt. + Achtergrondkleur + Vet + Gebruik een zwaarder lettergewicht voor ondertitels. + Transparant + Omlijning + Omlijningskleur + Teken een rand rond de ondertiteltekst. + Alleen voorkeurstalen tonen + Toon alleen ondertitels die overeenkomen met je voorkeurstalen voor ondertiteling. + Ondertitelgrootte + Tekstkleur + Geforceerde ondertitels gebruiken + Geef de voorkeur aan geforceerde ondertitels bij het matchen van je taalinstellingen voor ondertiteling. + Verticale offset + Intro overslaan + Gebruik introdb.app om intro's en samenvattingen te detecteren. + Bronbereik voor automatisch afspelen + Alle geïnstalleerde addons + Automatisch afspelen houdt alleen rekening met streams van je geïnstalleerde addons. + Alle bronnen + Automatisch afspelen kan zowel geïnstalleerde addons als ingeschakelde plug-ins gebruiken. + Alleen ingeschakelde plug-ins + Automatisch afspelen houdt alleen rekening met streams van ingeschakelde plug-ins. + Alleen geïnstalleerde addons + Automatisch afspelen houdt alleen rekening met streams van je geïnstalleerde addons. + Automatische streamselectie + Eerste bron automatisch afspelen + Speel automatisch de eerste beschikbare bron af. + Handmatig (stream kiezen) + Toon altijd de bronlijst en laat mij kiezen. + Automatisch afspelen bij regex-match + Speel de eerste bron af waarvan de tekst overeenkomt met je regex-patroon. + Time-out voor streamselectie + Wachttijd voor addons voordat er geselecteerd wordt. + Drempelminuten + Drempelmodus volgende aflevering + Minuten voor het einde + Percentage + Drempelpercentage + Fallback wanneer er geen outro-tijdstempel bestaat. + %1$s% + Direct + %1$ss + Onbeperkt + Tunneled Playback + Audio-/videosynchronisatie op hardwareniveau. Kan de weergave op sommige Android TV-apparaten verbeteren + Voeg hieronder je eigen TMDB API-sleutel toe voordat je verrijking inschakelt. + API-sleutel + TMDB-verrijking inschakelen + Gebruik TMDB als metadatabron om addon-gegevens aan te vullen + Voer je TMDB v3 API-sleutel in. + Taalcode + Artwork + Logo- en achtergrondafbeeldingen van TMDB + Basisinformatie + Beschrijving, genres en beoordeling van TMDB + Collecties + TMDB-filmcollecties in releasevolgorde + Credits + Cast met foto's, regisseur en schrijver van TMDB + Details + Speelduur, status, land en taal van TMDB + Afleveringen + Afleveringstitels, overzichten, miniaturen en speelduur van TMDB + Meer zoals dit + TMDB-aanbevelingsachtergronden op de detailpagina + Netwerken + Netwerken met logo's van TMDB + Producties + Productiebedrijven van TMDB + Seizoensposters + Gebruik TMDB-seizoensposters in de seizoenskiezer op het metadatascherm voor series. + Trailers + Trailerkandidaten uit TMDB-video's voor de trailersectie op de detailpagina + Persoonlijke API-sleutel + Taal + TMDB-metadatataal voor titel, logo en ingeschakelde velden + INLOGGEGEVENS + LOKALISATIE + MODULES + TMDB-verrijking + Na goedkeuring word je automatisch teruggeleid. + AUTHENTICATIE + Reacties + Toon Trakt-recensies op metadatapagina's + Trakt verbinden + Verbonden als %1$s + Trakt-gebruiker + Loskoppelen + Kon browser niet openen + FUNCTIES + Voltooi het inloggen bij Trakt in je browser + Synchroniseer je kijklijst, kijkvoortgang, verdergaan met kijken, scrobbles en persoonlijke lijsten met Trakt. + Trakt-inloggegevens ontbreken in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Trakt-inlog openen + Je opslaacties kunnen nu de Trakt-kijklijst en persoonlijke lijsten als doel hebben. + Log in bij Trakt om opslaan op basis van lijsten en de Trakt-bibliotheekmodus in te schakelen. + Bibliotheekbron + Kies welke bibliotheek je wilt gebruiken om je collectie op te slaan en te bekijken + Bibliotheekbron + Kies waar je je bibliotheekitems opslaat en beheert + Trakt + Nuvio-bibliotheek + Trakt-bibliotheek geselecteerd + Nuvio-bibliotheek geselecteerd + Kijkvoortgang + Kies welke voortgangsbron hervatten en verdergaan met kijken aandrijft + Kijkvoortgang + Kies of hervatten en verdergaan met kijken gebruik moeten maken van Trakt of Nuvio Sync, terwijl Trakt-scrobbling actief blijft. + Trakt + Nuvio Sync + Kijkvoortgangsbron ingesteld op Trakt + Kijkvoortgangsbron ingesteld op Nuvio Sync + Bron voor Meer zoals dit + Kies waar aanbevelingen op detailpagina's vandaan komen + Bron voor Meer zoals dit + Selecteer de bron voor aanbevelingen die op detailpagina's worden getoond. + Trakt + TMDB + Venster voor Verdergaan met kijken + Trakt-geschiedenis die meetelt voor verdergaan met kijken + Venster voor Verdergaan met kijken + Kies hoeveel Trakt-activiteit moet verschijnen in verdergaan met kijken. + Alle geschiedenis + %1$d dagen + Publieksscore + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Onbekend + Amber + Karmozijn + Smaragd + Oceaan + Roze + Violet + Wit + Volgende aflevering + Bron zoeken… + Afspelen via %1$s over %2$d… + Miniatuur volgende aflevering + Nog niet uitgezonden + Overslaan + Intro overslaan + Outro overslaan + Samenvatting overslaan + Geen ondertitels gevonden + Afrikaans + Albanees + Amhaars + Arabisch + Armeens + Azerbeidzjaans + Baskisch + Wit-Russisch + Bengaals + Bosnisch + Bulgaars + Birmaans + Catalaans + Chinees + Chinees (vereenvoudigd) + Chinees (traditioneel) + Kroatisch + Tsjechisch + Deens + Nederlands + Engels + Ests + Filipijns + Fins + Frans + Galicisch + Georgisch + Duits + Grieks + Gujarati + Hebreeuws + Hindi + Hongaars + IJslands + Indonesisch + Iers + Italiaans + Japans + Kannada + Kazachs + Khmer + Koreaans + Laotiaans + Lets + Litouws + Macedonisch + Maleis + Malayalam + Maltees + Marathi + Mongools + Nepalees + Noors + Perzisch + Pools + Portugees (Portugal) + Portugees (Brazilië) + Punjabi + Roemeens + Russisch + Servisch + Singalees + Slowaaks + Sloveens + Spaans + Spaans (Latijns-Amerika) + Swahili + Zweeds + Tamil + Telugu + Thai + Turks + Oekraïens + Urdu + Oezbeeks + Vietnamees + Welsh + Zoeloe + Wissen + Doorgaan + Negeren + Installeren + Later + Nee + Bijwerken + Ja + Wil je de app afsluiten? + App afsluiten + Deze catalogus heeft geen items teruggegeven. + Geen titels gevonden + Meer acties + Controleer je wifi- of mobiele dataverbinding en probeer het opnieuw. + Regisseur + Laden mislukt + Meer zoals dit + Mogelijk gemaakt door TMDB + Mogelijk gemaakt door Trakt + Seizoenen + Deze addon heeft video's voor de serie teruggegeven, maar geen daarvan bevatte seizoen- of afleveringsnummers. + Deze addon heeft geen afleveringsmetadata voor deze serie geleverd. + Afleveringen zijn nog niet gepubliceerd door deze addon. + Je apparaat is online, maar Nuvio kon de vereiste servers niet bereiken. + Minder tonen + Meer tonen ▾ + Schrijver + Alle genres + Catalogus + %1$s • %2$s + De geselecteerde catalogus kon geen ontdekitems teruggeven. + Kon ontdekken niet laden + Geïnstalleerde addons bieden geen board-compatibele catalogi voor ontdekken. + Geen ontdekcatalogi + De geselecteerde catalogus en filters gaven geen items terug. + Geen titels gevonden + Installeer en valideer minstens één addon voordat je ontdekcatalogi doorbladert. + Catalogus selecteren + Genre selecteren + Type selecteren + Type + Markeer vorige als onbekeken + Markeer vorige als bekeken + Markeer %1$s als onbekeken + Markeer %1$s als bekeken + Markeer vorige seizoenen als bekeken + Markeren als onbekeken + Markeren als bekeken + Volgende + %1$s bekeken + Nog %1$du %2$dm + Nog %1$dm + Installeer en valideer minstens één addon voordat je catalogusrijen op Home laadt. + Geïnstalleerde addons bieden momenteel geen board-compatibele catalogi zonder vereiste extra's. + Geen startschermrijen beschikbaar + Details bekijken + Bedieningselementen voor afspelen en opslaan. + Acties + Lijst met hoofdrolspelers. + Gerelateerde collectie- of franchisebalk. + Collectie + Sectie met Trakt-reacties. + Speelduur, status, releasedatum, taal en gerelateerde info. + Details + Seizoenen en afleveringenlijst voor series. + Aanbevelingsbalk. + Meer zoals dit + Synopsis, beoordelingen, genres en belangrijkste credits. + Overzicht + Studio's en netwerken. + Productie + Trailerbalk en snelkoppelingen voor afspelen. + Weer online + Kan servers niet bereiken + Lege responsebody + Verzoek mislukt met HTTP %1$d + Geen internetverbinding + (leeftijd %1$d) + Geboren op %1$s%2$s + Overleden op %1$s + Bekend van: %1$s + Nieuwste + Kon details voor %1$s niet laden + Populair + Er is iets misgegaan + Binnenkort + Backspace + Annuleren + Pincode invoeren + Voer pincode in voor %1$s + Pincode vergeten? + Onjuiste pincode + Vergrendeld. Probeer het opnieuw over %1$ds + Avatar-opties verschijnen hier zodra de catalogus is geladen. + Avatar: %1$s + Voer een geldige http:// of https:// afbeeldings-URL in. + Kies een avatar + Kies hieronder een avatar. + Profiel aanmaken + Aangepaste avatar-URL geselecteerd. + Aangepaste avatar-URL + Plak een afbeeldingslink, of laat dit leeg om de ingebouwde avatarcatalogus te gebruiken. + https://example.com/avatar.png + Alle gegevens voor "%1$s" worden permanent verwijderd. + Profiel verwijderen + Profiel toevoegen + Profiel bewerken + Voer huidige pincode in + Voer nieuwe pincode in + Profiel %1$d + Avatars laden... + Profielen beheren + Profielnaam + Nieuw profiel + Primaire addons uit + Primaire addons aan + Pincode verwijderen voor %1$s + Pincodevergrendeling verwijderen + Opslaan... + Beveiliging + Voeg een pincode toe als je wilt dat dit profiel vergrendeld is voordat je erin wisselt. + Dit profiel is beveiligd met een pincode. + Selecteer een avatar voor dit profiel. + Pincodevergrendeling instellen + Naamloos profiel + Primaire addons gebruiken + Deel de addon-configuratie van het hoofdprofiel in plaats van een aparte lijst te beheren. + Wie kijkt er? + Gedownload + Hervatten + Actieve scrapers + Meer addons controleren… + Streamlink kopiëren + Bestand downloaden + Openen in externe speler + Openen in interne speler + De geïnstalleerde streamaddons konden geen geldige streamrespons teruggeven. + Kon streams niet laden + Installeer eerst een addon om streams voor deze titel te laden. + Je geïnstalleerde addons bieden geen streams voor dit type titel. + Geen streamaddon beschikbaar + Geen van je geïnstalleerde addons gaf streams terug voor deze titel. + S%1$d E%2$d + Aflevering + S%1$dE%2$d - %3$s + Ophalen… + Bron zoeken… + Ondertitels van addons laden… + Overslaansegmenten laden… + Streams zoeken… + Streamlink gekopieerd + Geen directe streamlink beschikbaar + Geen metadata beschikbaar + Streams vernieuwen + Hervatten vanaf %1$d% + Hervatten vanaf %1$s + GROOTTE %1$s + Dit streamtype wordt niet ondersteund + Verbind een account in Instellingen. + Niet gecachet op Torbox. + Deze link is verlopen. Resultaten worden vernieuwd. + Kon deze link niet openen. + Kon externe speler niet openen + Kies eerst een externe speler in de instellingen + Geen externe speler beschikbaar + Trailer sluiten + Kan trailer niet afspelen + Laden van Trakt-lijsten mislukt + Bijwerken van Trakt-lijsten mislukt + %1$s • %2$s + Controle op updates mislukt + Download mislukt + Lege downloadbody + Gedownload updatebestand ontbreekt. + %1$d% downloaden + Kan installatie niet starten + Je gebruikt de nieuwste versie. + Schakel app-installaties in voor Nuvio en kom daarna terug om door te gaan. + Update wordt gedownload... + Geen updates gevonden. + Een nieuwe versie staat klaar om te installeren. + In-app updates zijn niet beschikbaar in deze build. + Download wordt voorbereid + Release notes + Installaties toestaan om door te gaan + Update beschikbaar + Updatestatus + Die addon is al geïnstalleerd. + Voer een geldige addon-URL in + Kan manifest niet laden + Nuvio + Verwijderen van account mislukt + Inloggen mislukt + Uitloggen mislukt + Registreren mislukt + Kan catalogusitems niet laden. + Volgende + Volgende • S%1$dE%2$d + %1$s-logo + Laden van reacties mislukt + Kon details van geen enkele addon laden. + Netwerken + Geen addon levert meta voor deze inhoud. + Download mislukt + Toont live downloadvoortgang en bediening. + Downloads + Download voltooid + Downloaden %1$s • %2$s + Downloaden %1$s • %2$s / %3$s + Download mislukt + Gepauzeerd %1$s + Verwijderen + %1$s verwijderen uit %2$s? + %1$s verwijderen uit je bibliotheek? + Verwijderen uit bibliotheek? + Film + Meldingen wanneer een nieuwe aflevering van een opgeslagen serie wordt uitgebracht. + Voorbeeld van melding bij nieuwe aflevering. + Versturen van testmelding mislukt. + Testmelding verstuurd voor %1$s. + Kan deze stream niet afspelen. + MPV-spelerengine niet beschikbaar. Bouw de app opnieuw. + De pincode van dit profiel is gewijzigd. Maak eenmalig verbinding om de vergrendeling op dit apparaat te vernieuwen. + Kon pincodevergrendeling niet verwijderen. Probeer het opnieuw. + Maak verbinding met internet om de pincodevergrendeling te verwijderen. + Deze pincode kan op dit apparaat nog niet offline worden geverifieerd. Maak eenmalig verbinding en ontgrendel deze eerst online. + Kon pincode niet instellen. Probeer het opnieuw. + Maak verbinding met internet om een pincode in te stellen. + Dit profiel gebruikt primaire addons. + Laden van %1$s mislukt + Stream + Ingesloten + Autorisatie geweigerd + Voltooi het inloggen bij Trakt in je browser + Verbonden met Trakt + Verbinding met Trakt verbroken + Ongeldige Trakt-callback + Ongeldige Trakt-callbackstatus + Ongeldig Trakt-tokenantwoord + Laden van Trakt-bibliotheek mislukt + Lijst %1$d + Trakt heeft geen autorisatiecode teruggegeven + Trakt-inloggegevens ontbreken + Laden van Trakt-voortgang mislukt + Openbare Trakt-lijst + Voer een geldige Trakt-lijst-ID of URL in + %1$d items + %1$d likes + Trakt-inloggegevens ontbreken in local.properties (TRAKT_CLIENT_ID). + Trakt-lijst-ID ontbreekt + Trakt-lijst bevatte geen numerieke ID + Trakt-lijst niet gevonden of niet openbaar + Trakt-snelheidslimiet bereikt + Trakt-verzoek mislukt + Voltooien van Trakt-inlog mislukt + Trakt-gebruiker + Kijklijst + + Trakt-autorisatie verlopen + Trakt-lijst niet gevonden + Trakt-lijstlimiet bereikt + Trakt-snelheidslimiet bereikt + Trakt-verzoek mislukt + Toevoegen aan Trakt-kijklijst mislukt + Toevoegen aan Trakt-lijst mislukt + Compatibele Trakt-ID's ontbreken + Lege responsebody + Voeg een TMDB API-sleutel toe in Instellingen om TMDB-bronnen te gebruiken. + TMDB-collectie %1$d + TMDB-collectie niet gevonden + TMDB-productie %1$d + TMDB-bedrijf niet gevonden + TMDB-regisseur %1$d + TMDB Ontdekken gaf geen gegevens terug + TMDB Ontdekken + Voer een geldige TMDB-ID of URL in. + TMDB-lijst %1$d + TMDB-lijst niet gevonden + Kon TMDB-bron niet laden + TMDB-collectie-ID ontbreekt + TMDB-lijst-ID ontbreekt + TMDB-persoons-ID ontbreekt + TMDB-netwerk %1$d + TMDB-netwerk niet gevonden + TMDB-persoonscredits niet gevonden + TMDB-persoon %1$d + TMDB-persoon niet gevonden + Trailer + Onbekend + Addon + Opgeslagen + %1$s afspelen + %1$s hervatten + JSON is leeg. + Collectie %1$d heeft een lege ID. + Collectie-ID '%1$s' wordt meer dan één keer gebruikt. + Collectie '%1$s' heeft een lege titel. + Map %1$d in '%2$s' heeft een lege ID. + Map-ID '%1$s' wordt meer dan één keer gebruikt in '%2$s'. + Map '%1$s' in '%2$s' heeft een lege titel. + Bron %1$d in map '%2$s' heeft lege velden. + Bron %1$d in map '%2$s' mist een Trakt-lijst-ID. + Ongeldige JSON: %1$s + Addon niet gevonden: %1$s + Trakt-filmlijst + Trakt-serielijst + januari + februari + maart + april + mei + juni + juli + augustus + september + oktober + november + december + jan + feb + mrt + apr + mei + jun + jul + aug + sep + okt + nov + dec + Productiebedrijf + Netwerk + Kon %1$s niet laden + Populair + Recent + %1$s • %2$s + Best beoordeeld + Certificering + Filmdetails + Originele taal + Land van herkomst + Release-informatie + Speelduur + Posters + Tekst + Details tonen + Status + Video's + BESTAND + Geen directe streamlink beschikbaar + Vorige download vervangen + Download gestart + Niet-ondersteund streamformaat voor downloads + Lege responsebody + Voltooien van downloadbestand mislukt + Verzoek mislukt met HTTP %1$d + Downloadsysteem is niet geïnitialiseerd + Openen van gedeeltelijk downloadbestand mislukt + Gedeeltelijk downloadbestand is niet geopend + Downloadverzoek mislukt + Schrijven naar gedeeltelijk downloadbestand mislukt + %1$s - %2$s + Opgeslagen titels verschijnen hier nadat je op Opslaan hebt getikt op een detailscherm. + Je bibliotheek is leeg + Kon bibliotheek niet laden + Overig + Cloud + Opgeslagen + Bibliotheek + Verbind Trakt en sla titels op in je kijklijst of persoonlijke lijsten. + Je Trakt-bibliotheek is leeg + Kon Trakt-bibliotheek niet laden + Trakt-bibliotheek + Account verbinden + Verbind een account in de instellingen van Verbonden diensten om afspeelbare bestanden uit je cloudbibliotheek te bekijken. + Geen cloudaccount verbonden + Verbonden diensten openen + Schakel Cloudbibliotheek in bij de instellingen van Verbonden diensten om bestanden van verbonden accounts te bekijken. + Cloudbibliotheek is uit + Geen afspeelbare cloudbestanden komen overeen met de huidige filters. + Nog niets hier + Kies een bestand om af te spelen + Kon %1$s-cloudbibliotheek niet laden + Dit item bevat geen afspeelbaar videobestand. + Geen afspeelbare bestanden + Geen afspeelbare bestanden + Cloudbibliotheek is uit. + Kon dit cloudbestand niet afspelen. + Bestand afspelen + Clouddienst is niet verbonden. + %1$s is niet verbonden. + %1$d afspeelbare bestanden + Alle + Cloudbibliotheek vernieuwen + Provider selecteren + Type selecteren + Klaar om af te spelen + Alle + Torrents + Usenet + Web + Bestanden + Anime + Kanalen + Films + Series + TV + %1$s is nu beschikbaar + %1$s • %2$s is nu beschikbaar + Er is een nieuwe aflevering beschikbaar + %1$s is nu beschikbaar + Nieuwe afleveringen + Alcohol/drugs + Beangstigend + Naaktheid + Grof taalgebruik + Licht + Gemiddeld + Ernstig + Geweld + Maker + Regisseur + Schrijver + Publieksscore + Geen afspeelbare trailerstream gevonden. + Seizoen %1$d - %2$s + B + KB + MB + GB + + Intro indienen + Video-instellingen + + Cloudbibliotheek is niet beschikbaar voor %1$s. + + Video + Afstemming resetten + Uitvoervoorinstelling + HDR-piekdetectie + Schat de HDR-piekhelderheid wanneer metadata slecht of ontbrekend is. + Toonmapping + Deband + Vermindert kleurbanden tegen een kleine prestatiekost. + Frame-interpolatie + Vloeiende beweging wanneer mpv display sync goed kan gebruiken. + Helderheid + Contrast + Verzadiging + Gamma + + Native EDR + Het beste voor HDR-geschikte iPhones en iPads. + SDR toonmapping + Voorspelbaardere witten en zwarten bij SDR-achtige uitvoer. + Compatibiliteit + Het dichtst bij het oudere iOS MPV-gedrag. + Aangepast + Gebruik je geavanceerde waarden hieronder. + Uit + + iOS-video-uitvoer + Hardwaredecoder + Uitgebreid dynamisch bereik + Standaard Metal-uitvoermodus voor nieuwe afspeelsessies. + Kleurhint van scherm + Laat mpv standaard de kleurruimte van het actieve scherm nastreven. + Doelprimairen + Doeloverdracht + + iOS-audio-uitvoer + Audio-uitvoer + Gebruik AudioUnit terwijl AVFoundation-uitvoer tijdelijk is uitgeschakeld. + Experimentele ondersteuning voor Spatial Audio en meerkanaals uitvoer. + Gebruik de verouderde AudioUnit-uitvoer. + + Resultaatbeheer + Max. resultaten + Beperk het aantal weergegeven resultaten. + Resultaten sorteren + Kies hoe resultaten worden gesorteerd. + Limiet per resolutie + Beperk herhaalde 2160p-, 1080p-, 720p-resultaten na sortering. + Limiet per kwaliteit + Beperk herhaalde BluRay-, WEB-DL-, REMUX-resultaten na sortering. + Groottebereik + Filter resultaten op bestandsgrootte. + + Meer informatie + Standaardformaat + Oorspronkelijk formaat + Voer één groep per regel in. + + Oorspronkelijke volgorde + Beste kwaliteit eerst + Grootste eerst + Kleinste eerst + Beste audio eerst + Taal eerst + + Elke + %1$d geselecteerd + + Alle resultaten + %1$d resultaten + + Tot %1$dGB + %1$dGB+ + %1$d-%2$dGB + + Voorkeursresoluties + Sorteer geselecteerde resoluties eerst, in standaardvolgorde. + Vereiste resoluties + Toon alleen geselecteerde resoluties. + Uitgesloten resoluties + Verberg geselecteerde resoluties. + + Voorkeurskwaliteiten + Sorteer geselecteerde kwaliteiten eerst, in standaardvolgorde. + Vereiste kwaliteiten + Toon alleen geselecteerde kwaliteiten. + Uitgesloten kwaliteiten + Verberg geselecteerde kwaliteiten. + + Voorkeurs-visuele tags + Sorteer DV, HDR, 10bit, IMAX en vergelijkbare tags. + Vereiste visuele tags + Vereis DV, HDR, 10bit, IMAX, SDR en vergelijkbare tags. + Uitgesloten visuele tags + Verberg DV, HDR, 10bit, 3D en vergelijkbare tags. + + Voorkeurs-audiotags + Sorteer Atmos, TrueHD, DTS, AAC en vergelijkbare tags. + Vereiste audiotags + Vereis Atmos, TrueHD, DTS, AAC en vergelijkbare tags. + Uitgesloten audiotags + Verberg geselecteerde audiotags. + + Voorkeurskanalen + Sorteer voorkeurskanaalindelingen eerst. + Vereiste kanalen + Toon alleen geselecteerde kanaalindelingen. + Uitgesloten kanalen + Verberg geselecteerde kanaalindelingen. + + Voorkeurscoderingen + Sorteer AV1, HEVC, AVC en vergelijkbare coderingen. + Vereiste coderingen + Vereis AV1, HEVC, AVC en vergelijkbare coderingen. + Uitgesloten coderingen + Verberg geselecteerde coderingen. + + Voorkeurstalen + Sorteer voorkeurs-audiotalen eerst. + Vereiste talen + Toon alleen resultaten met geselecteerde talen. + Uitgesloten talen + Verberg resultaten waarbij elke taal is uitgesloten. + + Vereiste releasegroepen + Toon alleen geselecteerde releasegroepen. + Uitgesloten releasegroepen + Verberg geselecteerde releasegroepen. + + Tijdstempels indienen + SEGMENTTYPE + Intro + Samenvatting + Outro + STARTTIJD (MM:SS) + EINDTIJD (MM:SS) + Indienen + Vastleggen + + Hardwaredecoder + Audio-uitvoer + Doelprimairen + Doeloverdracht + + Opgeloste Trakt-lijst + TMDB Ontdekken + US, KR, JP, IN + + Voer een Trakt-lijstnaam, URL of ID in + Voer een geldige TMDB-ID of URL in. + Kon TMDB-bron niet laden + Voer een Trakt-lijst-ID of URL in + Kon Trakt-lijst niet laden + + Canada + Australië + Duitsland + + Films + Series + + Cloudbibliotheek is uitgeschakeld. + + Ongeldige API-sleutel of verbinding mislukt + Manifest mist \"%1$s\" + TMDB-collectie %1$s + TMDB-regisseur %1$s + TMDB Ontdekken + Voer een geldige TMDB-ID of URL in. + TMDB-lijst %1$s + TMDB-netwerk %1$s + TMDB-persoon %1$s + TMDB-productie %1$s + Kon TMDB-bron niet laden + ID %1$s + Voeg een TMDB API-sleutel toe in Instellingen om TMDB-bronnen te gebruiken. + TMDB-collectie niet gevonden + TMDB-bedrijf niet gevonden + TMDB Ontdekken gaf geen gegevens terug + TMDB-lijst niet gevonden + TMDB-collectie-ID ontbreekt + TMDB-lijst-ID ontbreekt + TMDB-persoons-ID ontbreekt + TMDB-netwerk niet gevonden + TMDB-persoonscredits niet gevonden + TMDB-persoon niet gevonden + Trakt-inloggegevens ontbreken. + %1$s (%2$d) + Voer een geldige Trakt-lijst-ID of URL in + %1$d items + %1$d likes + Trakt-lijst niet gevonden of niet openbaar + Trakt-lijst-ID ontbreekt + Trakt-lijst bevatte geen numerieke ID + Openbare Trakt-lijst + Trakt-snelheidslimiet bereikt + Trakt-verzoek mislukt + Laden van Trakt-reacties mislukt (%1$d) + %1$du %2$dm + %1$du + %1$dm + Voltooien van downloadbestand mislukt + Openen van gedeeltelijk downloadbestand mislukt + Gedeeltelijk downloadbestand is niet geopend + Schrijven naar gedeeltelijk downloadbestand mislukt + Nuvio-bibliotheek + Verbindingsprobleem + Lege responsebody + Controleer je verbinding en probeer het opnieuw. + Verzoek mislukt met HTTP %1$d + %1$s (%2$s) + MPV-spelerengine niet beschikbaar. Bouw de app opnieuw. + Kan deze stream niet afspelen. + Plug-ins uitgeschakeld + Plug-ins ingeschakeld + %1$d providers + Vernieuwen + %1$d repo's + TMDB API-sleutel ontbreekt + TMDB API-sleutel ingesteld + Plug-inrepository installeren + Installeren… + Provider testen + Testen… + Plug-inrepository verwijderen + Plug-inrepository vernieuwen + Nog geen providers beschikbaar. + Voeg een repository-URL toe om providerplug-ins te installeren voor het vinden van streams. + Nog geen plug-inrepository's geïnstalleerd. + Gebruik providerplug-ins bij het zoeken naar streams. + Providerplug-ins globaal inschakelen + Die plug-inrepository is al geïnstalleerd. + Voer een plug-inrepository-URL in. + Voer een geldige plug-in-URL in. + Kan plug-inrepository niet installeren + Provider niet gevonden + Kan repository niet vernieuwen + Plug-ins zijn niet beschikbaar in deze build. + Toon in Streams één provider per repository in plaats van één per bron. + Providerplug-ins groeperen op repository + Plug-inmanifest-URL + Manifestnaam ontbreekt. + Manifest heeft geen providers. + Manifestversie ontbreekt. + Manifestnaam ontbreekt. + Manifest heeft geen providers. + Manifestversie ontbreekt. + %1$s geïnstalleerd. + Uitgeschakeld door repo + Geen beschrijving + v%1$s + Plug-inrepository + Versie %1$s + Die plug-inrepository is al geïnstalleerd. + Kan plug-inrepository niet installeren + Kan repository niet vernieuwen + REPOSITORY TOEVOEGEN + GEÏNSTALLEERDE REPOSITORY'S + OVERZICHT + PROVIDERS + Fout + Providertest mislukt + Testresultaten (%1$d) + Providerplug-ins vereisen een TMDB API-sleutel. Stel deze in op het TMDB-scherm, anders werken providerplug-ins niet correct. + Geen zoekresultaten voor %1$s. + Ongeldige API-sleutel of verbinding mislukt + Plug-inrepository + Intro indienen + Indienen + Vastleggen + EINDTIJD (MM:SS) + SEGMENTTYPE + STARTTIJD (MM:SS) + Verbonden met Trakt + Verbinding met Trakt verbroken + TB + Geen APK-asset gevonden in de release + Download mislukt met HTTP %1$d + Gedownload updatebestand ontbreekt. + Lege downloadbody + GitHub releases-API-fout: %1$d + Er is nog geen update gepubliceerd. + Release heeft geen tag of naam + + Uitzending op %1$s + Vandaag uitzending + Morgen uitzending + + Uitzending over %1$d dag + Uitzending over %1$d dagen + + %1$s + Vandaag + Morgen + + Over %1$d dag + Over %1$d dagen + + Nieuwe aflevering + Nieuw seizoen + + Trakt-lijst %1$s + Android-systeemspeler + P2P-streaming + Deze stream maakt gebruik van peer-to-peer (P2P) technologie. Door P2P in te schakelen, erken en ga je ermee akkoord dat:\n\n• Je IP-adres zichtbaar is voor andere peers in het netwerk\n• Jij als enige verantwoordelijk bent voor de inhoud die je bekijkt\n• Je bevestigt dat je het wettelijke recht hebt om deze inhoud in jouw rechtsgebied te streamen\n• Nuvio geen P2P-inhoud host, verspreidt of controleert\n• Nuvio geen aansprakelijkheid draagt voor juridische gevolgen die voortvloeien uit je gebruik van P2P-streaming\n\nJe gebruikt deze functie volledig op eigen risico. P2P kan op elk moment worden uitgeschakeld in Instellingen. + P2P inschakelen + Annuleren + Onbekende torrentfout + P2P-streaming + Peer-to-peer (torrent) streams toestaan + Torrentstatistieken verbergen + Verberg buffer, seeds, peers en downloadsnelheid tijdens laden en afspelen + %1$d seeds · %2$d peers + %1$s gebufferd · %2$s · %3$s + %1$s · %2$s + %1$d peers · %2$d seeds · %3$d%% + Verbinden met peers… + P2P-engine starten… + Starten van torrent mislukt: %1$s + Torrentfout: %1$s + + Geboren + Geboorteplaats + Credits + Biografie + Land + Type + Catalogus + Over + Ondertitels van addons laden... + Ondertitels downloaden... + + %d titel + %d titels + + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 4c7e450d..473e6a21 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -31,6 +31,13 @@ Niedostępny Konfiguruj dodatek Usuń dodatek + Przenieś swoją bibliotekę do Nuvio za pomocą używanych dodatków. + Dodaj dodatek, aby zacząć tworzyć swoją bibliotekę. + Dostosuj Nuvio do siebie. + URL dodatku + Dodaj dodatek + Na razie nic tu nie ma + Używane dodatki nie mają jeszcze nic do wyświetlenia. Dodaj URL manifestu, aby zacząć ładować katalogi, metadane, strumienie lub napisy do Nuvio. Brak zainstalowanych dodatków. Wprowadź URL dodatku. @@ -412,7 +419,9 @@ Sprawdź dostępność nowych wersji aplikacji. Sprawdź aktualizacje Zarządzaj dodatkami i źródłami odkrywania. + Zarządzaj dodatkami i wybierz, co ma być widoczne w Nuvio. Zarządzaj pobranymi filmami i odcinkami. + Zarządzaj zawartością zapisaną na tym urządzeniu. Pobrane OGÓLNE Połącz usługi TMDB i MDBList. @@ -597,6 +606,7 @@ EKRAN GŁÓWNY ŹRÓDŁA Instaluj, usuwaj, odświeżaj i sortuj źródła treści. + Dodawaj używane w Nuvio dodatki i zarządzaj nimi. Instaluj repozytoria scraperów JavaScript i testuj dostawców wewnętrznie. Kontroluj, które katalogi pojawiają się na ekranie głównym i w jakiej kolejności. Wyłącz sekcje szczegółów i zmień kolejność wszystkiego poniżej Hero. @@ -1655,7 +1665,6 @@ Wyniki testu (%1$d) Dostawcy wtyczek wymagają klucza API TMDB. Ustaw go na ekranie TMDB, w przeciwnym razie dostawcy nie będą działać poprawnie. Brak wyników wyszukiwania dla %1$s. - Backend synchronizacji Wyczyść pamięć Kontynuuj oglądanie Pamięć wyczyszczona Usuń dane Kontynuuj oglądanie i odśwież postęp diff --git a/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml b/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml index 49fb2960..97b5fde2 100644 --- a/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -30,6 +30,13 @@ Indisponível Configurar addon Excluir addon + Leve sua biblioteca para o Nuvio com os addons que você usa. + Adicione um addon para começar a montar sua biblioteca. + Deixe o Nuvio do seu jeito. + URL do addon + Adicionar addon + Ainda não há nada aqui + Os addons que você usa ainda não têm nada para mostrar aqui. Adicione uma URL de manifesto para começar a carregar catálogos, metadados, streams ou legendas no Nuvio. Nenhum addon instalado ainda. Insira uma URL de addon. @@ -401,7 +408,9 @@ Baixar a versão mais recente Verificar atualizações Gerenciar addons e fontes de descoberta. + Gerencie addons e escolha o que aparece no Nuvio. Gerencie seus filmes e episódios baixados. + Gerencie o que você salvou neste dispositivo. Downloads GERAL Gerenciar integrações disponíveis @@ -582,6 +591,7 @@ PÁGINA INICIAL FONTES Instale, remova, atualize e ordene suas fontes de conteúdo. + Adicione e gerencie os addons que você usa com o Nuvio. Instale repositórios de scraper JavaScript e teste provedores internamente. Ajuste layout da página inicial, visibilidade de conteúdo e comportamento de pôsteres Configurações para as telas de detalhes e episódios. diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 51a4a704..e6719a5f 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -31,6 +31,13 @@ Indisponível Configurar addon Eliminar addon + Leva a tua biblioteca para o Nuvio com os addons que usas. + Adiciona um addon para começares a criar a tua biblioteca. + Deixa o Nuvio à tua maneira. + URL do addon + Adicionar addon + Ainda não há nada aqui + Os addons que usas ainda não têm nada para mostrar aqui. Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio. Ainda não tens addons instalados. Introduz o URL de um addon. @@ -432,7 +439,9 @@ Transferir a versão mais recente Procurar atualizações Gere addons e fontes de descoberta. + Gere os addons e escolhe o que aparece no Nuvio. Gere os teus filmes e episódios transferidos. + Gere o que guardaste neste dispositivo. Transferências GERAL Gere as integrações disponíveis @@ -627,6 +636,7 @@ PÁGINA INICIAL FONTES Instala, remove, atualiza e ordena as tuas fontes de conteúdo. + Adiciona e gere os addons que usas com o Nuvio. Instala repositórios de scrapers JavaScript e testa fornecedores internamente. Ajusta o esquema da página inicial, a visibilidade do conteúdo e o comportamento dos cartazes. Definições para as páginas de detalhes e de episódios. diff --git a/composeApp/src/commonMain/composeResources/values-ro/strings.xml b/composeApp/src/commonMain/composeResources/values-ro/strings.xml new file mode 100644 index 00000000..f002ef42 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ro/strings.xml @@ -0,0 +1,1939 @@ + + Surse de date, mulțumiri și licențe de platformă + Recunoaștere publică și credite proiect + Înapoi + Anulează + Închide + Șterge + Gata + Editează + Importă + Înainte + OK + Redă + Anterior + Elimină + Rearanjează + Resetează la valorile implicite + Reia + Reîncearcă + Salvează + Se salvează… + Schimbă + Validează + Se instalează + Extensii + Activ + %1$d cataloage + Configurabil + Dezactivat + Se reîmprospătează + %1$d resurse + Indisponibil + Configurează extensia + Șterge extensia + Folosește extensiile preferate pentru a-ți aduce biblioteca în Nuvio. + Adaugă o extensie pentru a începe să-ți construiești biblioteca. + Personalizează Nuvio. + URL extensie + Adaugă extensie + Nu este nimic aici încă + Extensiile pe care le folosești nu au încă nimic de afișat aici. + Adaugă un URL de manifest pentru a începe încărcarea de cataloage, metadate, fluxuri sau subtitrări în Nuvio. + Nu există extensii instalate încă. + Introdu un URL pentru extensie. + URL Extensie + Instalează extensia + Se încarcă detaliile manifestului... + Se validează URL-ul manifestului și se încarcă detaliile extensiei înainte de instalare. + Se verifică extensia + Instalarea a eșuat + %1$s a fost validat și adăugat cu succes. + Extensie instalată + Mută extensia mai jos + Mută extensia mai sus + Active + Extensii + Cataloage + Reîmprospătează extensia + Adaugă extensie + Extensii instalate + Prezentare generală + %1$d reguli de ID + Versiunea %1$s + Selectat + Copiază JSON + %1$d colecție(i), %2$d dosar(e) + Ștergi „%1$s”? Această acțiune este ireversibilă. + Șterge colecția + Adaugă catalog + Adaugă dosar + Toate genurile + Adaugă cataloage din extensiile tale instalate pentru a defini ce afișează acest dosar. + Nu există încă surse de catalog + Alege + Emoji + URL Imagine + Fără + Copertă + Creează colecție + Gata + Editează colecția + Editează dosarul + Setează identitatea dosarului, prezentarea și sursele de catalog folosind aceeași structură ca în editorul principal de colecții. + Adaugă unul pentru a începe. + Nu există încă dosare + Dosare + Filtru de gen + Afișează doar imaginea de copertă + Ascunde titlul + Dosar nou + Afișează această colecție deasupra tuturor cataloagelor normale de pe ecranul principal. Mai multe colecții fixate vor respecta ordinea creării lor. + Fixează deasupra cataloagelor + URL imagine de fundal (opțional) + Nume dosar + URL GIF animat (rulează doar când este focalizat) + Nume colecție + Salvează modificările + Salvează + Aspect + De bază + Surse de catalog + Alege cataloagele din extensii pe care acest dosar ar trebui să le cumuleze. + Selectează cataloagele + Selectează genul + %1$d selectate + %1$d cataloage + %1$d selectate + Poster + Pătrat + Lat + Combină toate cataloagele într-o singură filă + Afișează fila „Toate” + Redă GIF-ul configurat în locul copertei statice, atunci când este disponibil. + Afișează GIF-ul când este configurat + %1$d sursă(e) · %2$s + Formă casetă + Rânduri + File + Mod de vizualizare + Surse TMDB + Listă publică + Producție + Rețea + Colecție + Persoană + Regizor + Personalizat + Alege o sursă predefinită. O poți edita sau elimina după adăugare. + Lipește un URL de listă publică TMDB sau doar numărul din URL. + Caută după numele studioului sau lipește un ID/URL de companie TMDB și adaugă-l direct. + Introdu un ID de rețea. Rețelele comune sunt disponibile în Predefiniri și filtre rapide. + Caută un nume de colecție de filme sau lipește ID-ul colecției de pe TMDB. + Introdu un ID sau URL de persoană TMDB pentru a crea un rând pe baza listei de distribuție. + Introdu un ID sau URL de persoană TMDB pentru a crea un rând pe baza filmografiei ca regizor. + Creează un rând TMDB live folosind filtre opționale. Lasă câmpurile goale dacă nu ai nevoie de un filtru anume. + Listă publică TMDB + ID rețea + ID colecție + ID persoană + Nume, ID sau URL companie de producție + ID sau URL TMDB + https://www.themoviedb.org/list/8504994 sau 8504994 + 213 pentru Netflix, 49 pentru HBO, 2739 pentru Disney+ + 10 pentru Star Wars Collection + Marvel Studios, 420 sau URL companie + 31 pentru Tom Hanks sau URL persoană + Exemple: Marvel Studios, 420 sau https://www.themoviedb.org/company/420. + Exemplu: Star Wars Collection, Harry Potter Collection sau un URL de colecție. + Exemple de ID-uri: Netflix 213, HBO 49, Disney+ 2739. + Exemplu: https://www.themoviedb.org/list/8504994 sau 8504994. + Exemplu: https://www.themoviedb.org/person/31-tom-hanks sau 31. + Titlu afișat + Apare ca nume de rând/filă. Dacă rămâne gol, Nuvio creează unul din sursă. + Filme Marvel, Originale Netflix, Pixar + Filme cu Tom Hanks, Actori preferați + Filme regizate de Christopher Nolan, Regizori preferați + Cele mai bune filme de acțiune, Drame coreene, Animații din 2024 + Rezultatele căutării + Colecție TMDB + Companie TMDB %1$d + Colecție TMDB %1$d + Tip + Filme + Seriale + Ambele + Ordonează + Filtre + Lasă câmpurile goale dacă nu ai nevoie de un filtru anume. + Genuri rapide + Limbi rapide + Țări rapide + Cuvinte cheie rapide + Studiouri rapide + Rețele rapide + ID-uri genuri + Folosește numerele de gen TMDB. Separă-le prin virgulă pentru ȘI, sau prin bare verticale (|) pentru SAU. + 28,12 + 18,35 + Data lansării/difuzării de la + Data lansării/difuzării până la + Folosește formatul AAAA-LL-ZZ, de exemplu 2024-01-01. + 2020-01-01 + 2024-12-31 + Evaluare minimă + Evaluare maximă + Evaluare TMDB de la 0 la 10. Exemplu: 7.0. + 7.0 + 10 + Voturi minime + Folosește acest filtru pentru a evita titlurile obscure cu puține voturi. Exemplu: 100. + 100 + Limba originală + Folosește coduri de limbă din două litere, de exemplu en, ko, ja, hi. + en, ko, ja, hi + Țara de origine + Folosește coduri de țară din două litere, de exemplu US, KR, JP, IN. + US, KR, JP, IN + ID-uri cuvinte cheie + Folosește numerele de cuvinte cheie TMDB. Cipurile rapide completează exemple comune. + 9715 pentru supererou + ID-uri companii + Folosește ID-uri de studio/companie. Cipurile rapide completează exemple comune. + 420 pentru Marvel Studios + ID-uri rețele + Doar pentru seriale. Folosește ID-uri de rețea precum Netflix 213 sau HBO 49. + 213 pentru Netflix + An + Folosește un an format din patru cifre, de exemplu 2024. + 2024 + Predefiniri + Caută + Adaugă sursă + Adaugă listă Trakt + Editează listă Trakt + Liste Trakt + Listă Trakt + Caută titlu, URL Trakt sau ID listă + Folosește un URL de listă publică Trakt sau un ID numeric de listă, ori caută după nume. + Vizionare de weekend, Câștigători de premii + Rezultatele căutării + Liste în tendințe + Liste populare + Direcție + Ascendent + Descendent + Ordine listă + Adăugate recent + Titlu + Lansat + Durată de rulare + Popular + Procentaj + Voturi + Introdu un nume, URL sau ID de listă Trakt + Introdu un ID sau URL de listă Trakt + Nu s-a putut încărca lista Trakt + Nu s-au găsit liste Trakt + Listă Trakt identificată + Listă Trakt %1$d + Acțiune + Aventură + Animație + Comedie + Horror + Sci-Fi + Dramă + Crimă + Reality-show + Engleză + Coreeană + Japoneză + Hindi + Spaniolă + Statele Unite + Coreea + Japonia + India + Regatul Unit + Supererou + Bazat pe roman + Călătorie în timp + Spațiu + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Originală + Populare + Cele mai apreciate + Recente + Cele mai votate + Regiune de vizionare + Cod de țară ISO 3166-1 unde titlul este disponibil. Exemplu: US, GB. + Regiuni rapide de vizionare + ID-uri furnizori de vizionare + Folosește ID-urile de furnizori TMDB. Separă-le prin virgulă pentru ȘI, sau prin bare verticale (|) pentru SAU. + 8|337|350 + Furnizori rapizi de vizionare + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + Listă TMDB + Colecție de filme TMDB + Producție + Rețea + Persoană + Regizor + Descoperire TMDB + Creează una pentru a-ți organiza cataloagele. + Nu există încă colecții + %1$d dosar(e) + Nu s-au găsit elemente + Dosarul nu a fost găsit + Colecții + Importă colecții + JSON + Lipește mai jos codul JSON al colecțiilor tale. + Importă + Colecție nouă + Fixate + Toate + Colecțiile tale + Creat cu ❤️ de Tapframe și prieteni + Versiunea %1$s (%2$s) + Oprit + Pornit + Pauză + Reîncarcă + Ai deja un cont? + Continuă fără cont + Creează cont + Nu ai un cont? + E-mail + sau + Parolă + Autentifică-te pentru a-ți accesa biblioteca și progresul + Autentificare + Înregistrează-te pentru a-ți sincroniza datele pe toate dispozitivele + Înregistrare + Datele tale vor fi stocate doar local + Urmărește-ți biblioteca, oriunde + Prin înregistrare, sunt de acord cu + Termenii + Bine ai revenit + Bibliotecă + Bibliotecă Trakt + Acasă + Bibliotecă + Profil + Caută + Piste audio + Audio + Sincronizare automată + Aldin + Integrat + Decalaj inferior + Captează + Închide playerul + Culoare + Rulează în prezent + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episoade + Dimensiune font + %1$dsp + Blochează comenzile playerului + Se încarcă liniile de subtitrare... + Nu s-au găsit linii de subtitrare + Nu s-au putut încărca liniile de subtitrare + Nicio pistă audio disponibilă + Niciun episod disponibil + Nu s-au găsit fluxuri + Fără + Contur + Culoare contur + Episoade + Surse + Fluxuri + Eroare de redare + Redare + Apasă pentru a prelua subtitrările + Înapoi + Reîncarcă + Resetează + Resetează la valorile implicite + Umple + Potrivește + Zoom + Sari înapoi 10 secunde + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Sari înainte 10 secunde + Surse + Stil + Selectează mai întâi o subtitrare din extensii + Subtitrări + Decalaj subtitrare + Subtitrări + Opacitate text + Luminozitate %1$s + Volum %1$s + Mut + Descărcat + Se difuzează + Urmează să fie anunțat + Apasă pentru a debloca + Pista %1$d + Deblochează comenzile playerului + Vizionezi + Adaugă profil + Șterge căutarea + Descoperă + Extensiile instalate nu au returnat rezultate de căutare valide. + Căutarea a eșuat + Instalează și validează cel puțin o extensie înainte de a căuta. + Nicio extensie activă + Cataloagele în care s-a căutat nu au returnat nicio potrivire pentru această interogare. + Nu s-au găsit rezultate + Extensiile tale instalate nu permit căutarea în cataloage. + Niciun catalog căutabil + Caută filme, seriale... + Căutări recente + Elimină căutarea recentă + Despre + General + Cont + Extensii + Avansat + Aspect ecran + Conținut și descoperire + Continuă vizionarea + Servicii conectate + Aspect ecran principal + Integrări + Licențe și atribuiri + Evaluări MDBList + Pagina de detalii + Notificări + Redare + Module (Plugins) + Stil card poster + Setări + Fluxuri + Susținători și colaboratori + Îmbunătățire TMDB + Trakt + DESPRE + Cont și starea sincronizării + CONT + Comportament pornire și profil. + AVANSAT + Ecran principal, fluxuri, colecții și pagini de detalii + Descarcă cea mai recentă versiune + Caută actualizări + Gestionează extensiile și sursele de descoperire. + Gestionează extensiile și alege ce apare în Nuvio. + Gestionează filmele și episoadele descărcate. + Gestionează ce ai salvat pe acest dispozitiv. + Descărcări + GENERAL + Gestionează integrările disponibile + Gestionează alertele de lansare a episoadelor și trimite o notificare de test. + Afișarea rezultatelor fluxurilor și reguli pentru URL insignă. + Comută la un alt profil. + Schimbă profilul + Deschide ecranul de conectare Trakt + Nu s-au găsit setări. + Caută în setări... + REZULTATE + PORNIRE + DIAGNOSTIC + CACHE + Reține ultimul profil + Reține ultimul profil selectat la pornire + Rapoarte de eroare Sentry + Trimite rapoarte de blocare și ANR în condiții de siguranță. Activat implicit. + Activezi rapoartele de eroare? + Rapoartele de eroare ajută la identificarea problemelor specifice dispozitivului fără a fi nevoie să reproduci problema folosind logurile ADB. + Dezactivezi rapoartele de eroare? + Erorile viitoare și problemele de tip ANR de pe acest dispozitiv nu vor mai fi raportate. Acest lucru poate face erorile specifice dispozitivului mult mai greu de remediat. + Cum ajută acest lucru + Rapoartele sunt grupate după versiunea aplicației, modelul dispozitivului, versiunea de Android și stack trace, astfel încât cele mai frecvente erori reale să poată fi reparate primele. + Trimise + Blocări, erori ANR curente, stack trace-uri, versiunea aplicației, tipul de build, flavor, metadate despre dispozitiv și sistemul de operare, plus repere de rețea sigure care conțin metoda, gazda, calea, starea, durata și tipul excepției. + Netrimise + Parole, tokenuri de acces, tokenuri de reîmprospătare, corpul cererilor sau al răspunsurilor, headere, cookie-uri, capturi de ecran, ierarhia vizuală, înregistrări de sesiune, diagnostice brute și valori de interogare sau fragmente din URL-ul fluxului. + Păstrează activat + Dezactivează + Activează + Șterge cache-ul pentru Continuă vizionarea + Elimină datele stocate în cache pentru Continuă vizionarea și reîmprospătează progresul vizionării + Cache golit + LICENȚĂ APLICAȚIE + DATE ȘI SERVICII + LICENȚĂ REDARE + Nuvio Mobile + Codul sursă și termenii licenței sunt disponibili în depozitul proiectului. + Licențiat sub GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio utilizează API-ul TMDB pentru metadate de filme și seriale, imagini, trailere, distribuție, detalii de producție, colecții și recomandări. Acest produs folosește API-ul TMDB, dar nu este aprobat sau certificat de către TMDB. + Seturi de date IMDb non-comerciale + Nuvio utilizează seturi de date IMDb non-comerciale, inclusiv title.ratings.tsv.gz, pentru evaluări și numărul de voturi IMDb. Informații oferite prin amabilitatea IMDb (https://www.imdb.com). Utilizate cu permisiune. Datele IMDb sunt destinate exclusiv utilizării personale și non-comerciale, conform termenilor IMDb. + Trakt + Nuvio se conectează la Trakt pentru autentificarea contului, istoricul de vizionare, sincronizarea progresului, datele din bibliotecă, evaluări, liste și comentarii. Nuvio nu este afiliat cu și nici aprobat de Trakt. + Premiumize + Nuvio se conectează la Premiumize pentru autentificarea contului, accesul la biblioteca din cloud, verificarea cache-ului și funcții de redare în cloud. Nuvio nu este afiliat cu și nici aprobat de Premiumize. + TorBox + Nuvio se conectează la TorBox pentru autentificarea contului, accesul la biblioteca din cloud, verificarea cache-ului și funcții de redare în cloud. Nuvio nu este afiliat cu și nici aprobat de TorBox. + MDBList + Nuvio utilizează MDBList pentru evaluări și date de la furnizori externi de scoruri. Nuvio nu este afiliat cu și nici aprobat de MDBList. + IntroDB + Nuvio folosește API-ul IntroDB pentru marcajele de timp (intro, recapitulare, credite și previzualizări) oferite de comunitate și utilizate de comenzile de salt. Nuvio nu este afiliat cu și nici aprobat de IntroDB. + MPVKit + Utilizat pentru redare pe build-urile de iOS. + Codul sursă MPVKit în sine este licențiat sub LGPL v3.0. Pachetele MPVKit, inclusiv bibliotecile libmpv și FFmpeg, sunt de asemenea licențiate sub LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Utilizat pentru redare pe build-urile de Android. + Licențiat sub Apache License, Versiunea 2.0. + Se încarcă listele tale Trakt… + Alege unde să salvezi acest titlu pe Trakt + Donează + Mergi la detalii + Elimină + Ia-o de la început + Redă + %1$d/10 + Recenzie + Spoiler + Nu există încă recenzii Trakt disponibile. + %1$d aprecieri + Acest comentariu conține spoilere. + Acest comentariu conține spoilere și a fost ascuns. + Comentarii + Trailer + %1$s (%2$d) + Trailere + Niciun episod finalizat + Nicio descărcare încă + %1$d episod(e) descărcat(e) + Deschide dosarul de descărcări + Nu s-a putut deschide dosarul de descărcări + Active + Filme + Seriale + Afișează descărcările + Finalizat • %1$s + Se descarcă • %1$s + Eșuat + Pus pe pauză • %1$s + Vizionat + Sezonul %1$d + Ediții speciale + Continuă de unde ai rămas + Adaugă în bibliotecă + Marchează ca nevizionat + Marchează ca vizionat + Elimină din bibliotecă + Vezi tot + Redă manual + Siglă %1$s + Cont + Șterge contul + Această acțiune va șterge definitiv contul tău și toate datele asociate. + Această acțiune este ireversibilă. Toate datele, profilurile și istoricul de sincronizare vor fi eliminate definitiv. + Ștergi contul? + E-mail + Neautentificat + Deconectare + Vei fi redirecționat către ecranul de autentificare. + Te deconectezi? + Stare + Anonim + Autentificat + Negru AMOLED + Folosește fundaluri complet negre pentru ecranele OLED. + Limba aplicației + Limba dispozitivului + Alege limba + Setări pentru secțiunea Continuă vizionarea. + Liquid Glass + Folosește bara nativă de file iPhone pe iOS 26 și versiunile ulterioare. + Ajustează lățimea cardului și raza colțurilor. + AFIȘARE + ECRAN PRINCIPAL + TEMĂ + Colecție • %1$s + Nume afișat + Instalează o extensie cu cataloage compatibile pentru a configura rândurile de pe ecranul principal. + Nu există cataloage pentru ecranul principal + Sursă secțiune principală (Hero) + Ascuns + Păstrează ecranul principal în focalizare + %1$s • Limită atinsă (max %2$d) + Nicio sursă selectată pentru secțiunea principală + Nu este în secțiunea principală + Elimină fixarea de sus a colecției pentru a o putea muta + Fixat + Fixat în partea de sus + Rearanjează + CATALOAGE + CATALOAGE ȘI COLECȚII + COLECȚII + Aspect ecran principal + Cataloage secțiune principală (Hero) + %1$d din %2$d selectate + Afișează secțiunea principală (Hero) + Afișează un carusel în partea de sus a ecranului principal. + Ascunde conținutul nelansat + Ascunde filmele și serialele care nu au fost lansate încă. + Ascunde sublinierea catalogului + Elimină linia de accent de sub titlurile cataloagelor și colecțiilor din întreaga aplicație. + %1$d din %2$d cataloage vizibile • %3$d surse hero selectate + Deschide un catalog doar când trebuie să îl redenumești sau să îl rearanjezi. + Vizibil + Ascunde valoarea + Player, subtitrări și redare automată + Raza colțurilor + Stil card poster + Lățime + Personalizat + Ajustează lățimea cardului și raza colțurilor. + Ascunde etichetele + Postere în mod peisaj + Previzualizare live + %1$s (%2$s) + Raza colțurilor: %1$ddp + Înălțime: %1$ddp + Lățime: %1$ddp + Clasic + Pastilă + Rotunjit + Ascuțit + Subtil + Echilibrat + Confortabil + Compact + Dens + Mare + Standard + Afișează valoarea + Afișează o fereastră pop-up pentru a continua de unde ai rămas atunci când deschizi aplicația după ce ai ieșit din player. + Mesaj de reluare la pornire + Estompează miniaturile episoadelor următoare din Continuă vizionarea pentru a evita spoilerele. + Estompează episoadele nevizionate în Continuă vizionarea + Include episoadele viitoare în Continuă vizionarea înainte ca acestea să fie difuzate. + Afișează episoadele viitoare nedifuzate + ORDINE DE SORTARE + Ordine de sortare + Implicită + Sortează toate elementele după recență + Stil platformă de streaming + Elementele lansate primele, cele viitoare la sfârșit + Stil card poster + LA PORNIRE + COMPORTAMENT EPISODUL URMĂTOR + VIZIBILITATE + Afișează rândul Continuă vizionarea pe ecranul principal. + Afișează Continuă vizionarea + Card peisaj + Card în mod peisaj în stil TV + Poster + Card de tip poster axat pe imagine + Card lat + Card orizontal dens în informații + Afișează următorul episod bazat pe cel mai avansat episod vizionat. Dezactivează pentru revizionări pentru a folosi în schimb cel mai recent episod vizionat. + Următorul episod de la cel mai avansat vizionat + Preferă miniaturile episoadelor atunci când sunt disponibile. + Preferă miniaturile de episod în Continuă vizionarea + ECRAN PRINCIPAL + SURSE + Instalează, elimină, reîmprospătează și sortează sursele de conținut. + Adaugă și gestionează extensiile pe care le folosești cu Nuvio. + Instalează depozite de scraper JavaScript și testează furnizorii intern. + Ajustează aspectul ecranului principal, vizibilitatea conținutului și comportamentul posterului + Setări pentru ecranele de detalii și de episoade. + Creează grupări personalizate de cataloage cu dosare afișate pe ecranul principal. + Integrări + Comenzi pentru îmbunătățirea metadatelor + Furnizori externi de evaluări + Conectează conturi pentru linkuri și acces la bibliotecă + Servicii conectate + Aceste integrări sunt experimentale și pot fi păstrate, modificate sau eliminate ulterior. + Bibliotecă în cloud + Răsfoiește și redă fișierele existente deja în conturile tale conectate. + Rezolvă linkurile redate + Cere unui serviciu conectat linkuri redabile atunci când un rezultat are nevoie de ele. Acest lucru poate adăuga elementul în serviciul respectiv. + Rezolvă cu + Alege ce cont conectat se ocupă de linkurile redabile. + Conectează mai întâi un cont. + Conturi + Conectează contul tău %1$s. + Asociază contul tău %1$s în browser. + Cheie API %1$s + Introdu cheia API pentru %1$s. + Introdu cheia API %1$s + Nesetată + Conectat + Conectează %1$s + Deconectează %1$s + Deconectează + %1$s este conectat pe acest dispozitiv. + Se pornește autentificarea securizată... + Deschide linkul și introdu acest cod pentru a aproba Nuvio. + Cod copiat. + Deschide linkul + Se așteaptă aprobarea... + Nu s-a putut iniția autentificarea. + Această metodă de autentificare nu este configurată în acest build. + Acest cod a expirat. Încearcă din nou. + Pregătire linkuri + Pregătește linkurile + Rezolvă linkurile redabile înainte ca redarea să înceapă. + Număr de linkuri de pregătit + Folosește un număr cât mai mic posibil. Serviciile conectate pot limita rata de rezolvare a linkurilor într-o anumită perioadă de timp. Deschiderea unui film sau episod poate conta la aceste limite chiar dacă nu apeși pe Vizionare, deoarece linkurile sunt pregătite în avans. + 1 link + %1$d linkuri + Formatare + Șablon nume + Controlează modul în care apar numele rezultatelor. Lasă gol pentru a folosi numele original al rezultatului. + Șablon descriere + Controlează metadatele afișate sub fiecare rezultat. Lasă gol pentru a folosi detaliile originale ale rezultatului. + Resetează formatarea + Restaurează formatarea implicită a rezultatelor. + Stil Fusion + Insigne de dimensiune + Afișează insigne cu dimensiunea fișierului în rezultatele fluxului și în panourile cu surse ale playerului. + Siglă extensie + Afișează sigla și numele extensiei lângă sursele fluxului. + Afișare + Poziție insignă + Alege dacă insignele Fusion și de dimensiune apar deasupra sau dedesubtul cardurilor de flux. + Poziție insignă + Selectează unde apar insignele pe cardurile de flux. + Sus + Jos + URL-uri insigne Fusion + Importă până la %1$d URL-uri JSON de insigne de flux în stil Fusion. Fiecare URL poate fi actualizat sau șters separat. + Gestionează URL-urile JSON importate pentru insignele de flux în stil Fusion. + Importul insignelor a eșuat. + Introdu un URL JSON pentru insigne. + URL-ul insignelor trebuie să înceapă cu http:// sau https://. + Poți importa maximum %1$d URL-uri de insigne. + %1$d/%2$d URL-uri, %3$d insigne Fusion active + Nu s-a importat niciun URL de insigne Fusion. + URL JSON insigne Fusion + %1$d/%2$d URL-uri Fusion importate + Activ + Inactiv + %1$s, %2$d insigne activate, %3$d grupuri + Previzualizare + Previzualizare insigne Fusion + %1$d insigne în stil Fusion de la acest URL + Nu există imagini de insigne în stil Fusion la acest URL. + Grupul %1$d + Alte insigne Fusion + Cheia API a fost validată. + Nu s-a putut valida această cheie API. + Adaugă cheia API MDBList mai jos înainte de a activa evaluările. + Necesară pentru a prelua evaluările de pe MDBList + Cheie API + Cheie API + Activează evaluările MDBList + Preia evaluările de la furnizorii externi în ecranul cu detalii despre metadate + Cheie API + Furnizori externi de evaluări + Evaluări MDBList + Acțiuni + Comenzi de redare și salvare. + Distribuție + Lista cu actorii principali. + Fundal cinematografic + Fundal estompat în spatele conținutului, similar cu ecranul de flux. + Mod fundal + Alege cum apare imaginea de fundal în paginile de metadate. + Normal + Folosește fundalul standard al aplicației. + Cinematografic + Afișează un fundal estompat subtil în spatele paginii. + Culoare dominantă + Potrivește fundalul paginii cu culoarea principală din imaginea de fundal. + Colecție + Rând cu colecția înrudită sau franciza respectivă. + Comentarii + Recenzii de pe Trakt + Detalii + Durată, stare, lansare, limbă și informații înrudite. + Redare trailer în secțiunea principală + Redă previzualizări ale trailerelor în zona principală de metadate atunci când este disponibil un trailer. + Carduri episoade + Alege modul în care sunt afișate episoadele în ecranul de metadate. + Orizontal + Carduri tip rând, în stil imagine de fundal + Listă + Carduri suprapuse, cu detaliile pe primul loc + Episoade + Sezoane și lista de episoade pentru seriale. + Estompează episoadele nevizionate + Estompează miniaturile episoadelor până când sunt vizionate pentru a evita spoilerele. + Grupul %1$d + Mai multe de acest gen + Imagini de fundal pentru recomandări TMDB pe pagina de detalii + Fără + Prezentare generală + Sinopsis, evaluări, genuri și echipa de bază. + Producție + Studiouri și rețele. + ASPECT + SECȚIUNI + Grup de file %1$d + Aspect file + Grupează secțiunile în file ca în aplicația TV. Alocă până la 3 secțiuni per grup de file. + Trailere + Rând cu trailere și scurtături de redare. + Notificările sunt în prezent dezactivate în Nuvio. + Alerte de lansare episoade + Programează notificări locale atunci când un nou episod dintr-un serial salvat devine disponibil. + Notificările de sistem sunt dezactivate pentru Nuvio. Activează-le pentru a primi alerte și notificări de test. + %1$d alerte de lansare sunt programate în prezent pe acest dispozitiv. + ALERTE + TEST + Trimite notificare de test + Se trimite notificarea de test... + Trimite o notificare locală de test pentru %1$s. + Salvează mai întâi un serial în bibliotecă pentru a testa notificările. + Notificare de test + Comunitate + Vezi persoanele care construiesc și susțin Nuvio pe Mobile, TV și Web. + Serverul și întreținerea din această lună + Acoperit. Sprijinul suplimentar merge acum către dezvoltare. + După 100%, sprijinul suplimentar merge către dezvoltare. + Se încarcă progresul finanțării... + API-ul pentru susținători nu este configurat. Adaugă DONATIONS_BASE_URL în local.properties. + Colaboratori + Susținători + Deschide GitHub + Profilul GitHub nu este disponibil + Niciun mesaj atașat. + Se încarcă colaboratorii... + Se încarcă susținătorii... + Nu s-au putut încărca colaboratorii + Nu s-au putut încărca susținătorii + Nu s-au găsit colaboratori. + Nu s-au găsit susținători. + Nu s-au putut încărca colaboratorii. + Nu s-au putut încărca susținătorii. + Nu s-au putut încărca colaboratorii în acest moment. + Nu s-au putut încărca susținătorii în acest moment. + %1$d commit-uri în total + Ian + Feb + Mar + Apr + Mai + Iun + Iul + Aug + Sep + Oct + Noi + Dec + %1$s %2$s, %3$s + Toate extensiile instalate + Toate modulele activate + Extensii permise + Module permise + Salt Anime + Client ID AnimeSkip + Introdu ID-ul tău de client API AnimeSkip. Poți obține unul pe anime-skip.com. + Activează trimiterea de intro-uri + Afișează un buton pentru a trimite marcajele de timp pentru intro/outro în baza de date a comunității. + Cheie API IntroDB + Introdu cheia ta API IntroDB pentru a trimite marcaje de timp. Este necesară pentru trimitere. + Caută marcaje de timp de salt și pe AnimeSkip (necesită ID de client). + Redă automat episodul următor + Pornește automat episodul următor când apare mesajul. + Doar decodoarele dispozitivului + Preferă decodoarele aplicației (FFmpeg) + Preferă decodoarele dispozitivului + Prioritate decodor + Apasă în afară pentru a închide + Apasă în afară pentru a salva și închide + %1$d zi + %1$d zile + %1$d oră + %1$d ore + Folosește libass pentru subtitrări ASS/SSA + Experimental: randare avansată ASS/SSA (stiluri, poziționare, animații) + Player extern + Aplicație player extern + Deschide redările noi cu aplicația video implicită din Android sau prin selectorul de sistem. + Deschide redările noi cu playerul instalat selectat. + Redirecționează subtitrările către playerul extern + Preia subtitrările din extensii în limba preferată și trimite-le către playerul extern. + Trimite marcajele de timp pentru intro și outro + Trimite marcajele de timp identificate pentru salt intro și outro către playerul extern pentru salt automat. Funcționează doar în playerele care suportă această funcție; celelalte o vor ignora. + Nu există playere externe compatibile instalate + Player + Intern + Extern + Alege ce player se ocupă de redările noi. + Viteză la menținere + Menține pentru viteză crescută + Apasă lung oriunde pe suprafața playerului pentru a mări temporar viteza de redare. + Gesturi tactile + Permite glisări și atingeri duble pe suprafața playerului pentru a derula, a ajusta luminozitatea sau a modifica volumul. + Model regex nevalid + Durata cache-ului pentru ultimul link + Fallback DV7 - HEVC + Convertește Dolby Vision Profil 7 în HEVC standard pentru dispozitivele fără suport hardware Dolby Vision + Minute limită + Soluție de rezervă când nu există un marcaj de timp outro. + %1$s min + Nu există elemente disponibile + Nesetată + Implicit (fișier media) + Limba dispozitivului + Limba originală + Necesită activarea îmbunătățirii TMDB + Forțată + Fără + Toate subtitrările + Preia și afișează fiecare subtitrare disponibilă din extensii pentru videoclip. + Pornire rapidă + Omite preluarea automată a subtitrărilor din extensii până când o soliciți în player. + Mod pornire subtitrări din extensii + Doar cele preferate + Preia subtitrările din extensii, dar le afișează doar pe cele care se potrivesc cu limba preferată. + Preferă grupul de Binge (Episodul următor) + Încearcă mai întâi același profil de sursă (aceeași extensie/grup de calitate) înainte de regulile normale de redare automată. + Reutilizează grupul de Binge + Reține și reutilizează ultimul grup de binge în cadrul sesiunilor (Continuă vizionarea, Detalii etc.). + Limba audio preferată + Limba preferată + Predefiniri + Se compară cu numele/titlul/descrierea fluxului, numele extensiei sau URL-ul. Exemplu: 4K|2160p|Remux + Model Regex Pattern + Niciun model setat. Exemplu: 4K|2160p|Remux + Orice format peste 1080p+ + AVC / x264 + Calitate BluRay + Dolby Atmos / DTS + Engleză + HDR / Dolby Vision + HEVC / x265 + Fără CAM/TS + Fără REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Mai mic + Surse WEB + Mod randare Libass + Standard Cues + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL (Recomandat) + Reutilizează ultimul link + Redă automat ultimul flux funcțional pentru același film/episod atunci când memoria cache este încă valabilă + Limba audio secundară + Limba secundară preferată + DECODOR + Motor de redare + Folosește ExoPlayer mai întâi și revine la libmpv dacă redarea eșuează. + Folosește ExoPlayer și decodoarele Android Media3. + Folosește libmpv pentru redarea pe Android. + Randator libmpv + Randator libmpv + Decodare hardware libmpv + Folosește decodarea hardware mpv când este disponibilă. + Compatibilitate libmpv YUV420P + Forțează ieșirea YUV420P pentru dispozitivele cu probleme de culoare sau de randare. + EPISODUL URMĂTOR + PLAYER + SALT PESTE SEGMENTE + REDARE AUTOMATĂ FLUX + SELECȚIE FLUX + SUBTITRARE ȘI AUDIO + RANDARE SUBTITRARE + %1$d selectate + Suprapunere ecran de încărcare + Afișează ecranul de încărcare până când apare primul cadru video. + Culoare fundal + Aldin (Bold) + Folosește un font mai gros pentru subtitrare. + Transparent + Contur + Culoare contur + Desenează un contur în jurul textului subtitrării. + Afișează doar limbile preferate + Afișează doar subtitrările care corespund setărilor limbilor tale preferate. + Dimensiune subtitrare + Culoare text + Folosește subtitrări forțate + Preferă subtitrările forțate atunci când corespund setărilor tale de limbă. + Decalaj vertical + Sari peste intro + Folosește introdb.app pentru a detecta intro-urile și recapitulările. + Domeniu redare automată surse + Toate extensiile instalate + Redarea automată ia în considerare doar fluxurile care provin din extensiile tale instalate. + Toate sursele + Redarea automată poate folosi atât extensiile instalate, cât și modulele activate. + Doar modulele activate + Redarea automată ia în considerare doar fluxurile care provin din modulele activate. + Doar extensiile instalate + Redarea automată ia în considerare doar fluxurile care provin din extensiile tale instalate. + Selecție automată a fluxului + Redă automat prima sursă + Redă automat prima sursă disponibilă. + Manual (alegere flux) + Afișează întotdeauna lista de surse și mă lasă să aleg. + Redare automată prin potrivire regex + Redă automat prima sursă al cărei text se potrivește cu modelul tău regex. + Timp expirare selecție flux + Timpul de așteptare pentru extensii înainte de selectare. + Minute limită + Mod limită episodul următor + Minute înainte de final + Procentaj + Procentaj limită + Soluție de rezervă când nu există un marcaj de timp outro. + %1$s%% + Instant + %1$ss + Nelimitat + Redare tunelată + Sincronizare audio/video la nivel hardware. Poate îmbunătăți redarea pe unele dispozitive Android TV + Adaugă propria cheie API TMDB mai jos înainte de a activa îmbunătățirea. + Cheie API + Activează îmbunătățirea TMDB + Folosește TMDB ca sursă de metadate pentru a îmbunătăți datele din extensii + Introdu cheia API TMDB v3. + Cod limbă + Imagini și elemente vizuale + Logouri și imagini de fundal de pe TMDB + Informații de bază + Descriere, genuri și evaluare de pe TMDB + Colecții + Colecții de filme TMDB în ordinea lansării + Credite echipă + Distribuție cu fotografii, regizor și scenarist de pe TMDB + Detalii + Durată, stare, țară și limbă de pe TMDB + Episoade + Titluri de episoade, prezentări generale, miniaturi și durată de pe TMDB + Mai multe de acest gen + Imagini de fundal pentru recomandări TMDB pe pagina de detalii + Rețele + Rețele de televiziune cu logouri de pe TMDB + Producții + Companii de producție de pe TMDB + Postere sezoane + Folosește posterele de sezoane TMDB în selectorul de sezoane pentru seriale. + Trailere + Trailere potențiale din videoclipurile TMDB pentru secțiunea dedicată + Cheie API personală + Limbă + Limba metadatelor TMDB pentru titlu, siglă și câmpurile activate + CREDENTIALE + LOCALIZARE + MODULE + Îmbunătățire TMDB + După aprobare, vei fi redirecționat înapoi în mod automat. + AUTENTIFICARE + Comentarii + Afișează recenziile Trakt pe paginile de metadate + Conectează Trakt + Conectat ca %1$s + Utilizator Trakt + Deconectează + Nu s-a putut deschide browserul + CARACTERISTICI + Finalizează autentificarea Trakt în browser + Sincronizează-ți lista de urmărire, progresul, istoricul, scrobble-urile și listele personale cu Trakt. + Lipsesc credențialele Trakt în local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Deschide autentificarea Trakt + Acțiunile tale de salvare pot viza acum lista de urmărire Trakt (watchlist) și listele personale. + Autentifică-te cu Trakt pentru a activa salvarea bazată pe liste și modul de bibliotecă Trakt. + Sursă bibliotecă + Alege ce bibliotecă să folosești pentru salvarea și vizualizarea colecției tale + Sursă bibliotecă + Alege unde să salvezi și să gestionezi elementele din bibliotecă + Trakt + Bibliotecă Nuvio + Biblioteca Trakt a fost selectată + Biblioteca Nuvio a fost selectată + Progres vizionare + Alege sursa de progres care alimentează reluarea și continuarea vizionării + Progres vizionare + Alege dacă reluarea și continuarea vizionării ar trebui să folosească Trakt sau Nuvio Sync în timp ce scrobbling-ul Trakt rămâne activ. + Trakt + Nuvio Sync + Sursa progresului vizionării a fost setată la Trakt + Sursa progresului vizionării a fost setată la Nuvio Sync + Sursă pentru „Mai multe de acest gen” + Alege de unde provin recomandările de pe paginile de detalii + Sursă recomandări + Selectează sursa pentru recomandările afișate pe paginile de detalii. + Trakt + TMDB + Fereastră Continuă vizionarea + Istoricul Trakt luat în considerare pentru continuarea vizionării + Fereastră Continuă vizionarea + Alege cât de mult din activitatea Trakt ar trebui să apară în secțiunea Continuă vizionarea. + Tot istoricul + %1$d zile + Scor audiență + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Necunoscută + Chihlimbar + Caciu (Crimson) + Smarald + Ocean + Roz (Rose) + Violet + Alb + Episodul următor + Se caută sursa… + Redare prin %1$s în %2$d… + Miniatură episodul următor + Nedifuzat + Sari peste + Sari peste Intro + Sari peste Outro + Sari peste Recapitulare + Nu s-au găsit subtitrări + Afrikaans + Albaneză + Amharică + Arabă + Armeană + Azeră + Bască + Belarusă + Bengaleză + Bosniacă + Bulgară + Birmană + Catalană + Chineză + Chineză (Simplificată) + Chineză (Tradițională) + Croată + Cehă + Daneză + Olandeză + Engleză + Estonă + Filipineză + Finlandeză + Franceză + Galiciană + Georgiană + Germană + Greacă + Gujarati + Ebraică + Hindi + Maghiară + Islandeză + Indoneziană + Irlandeză + Italiană + Japoneză + Kannada + Kazahă + Khmeră + Coreeană + Laosiană + Letonă + Lituaniană + Macedoneană + Malayeză + Malayalam + Malteză + Marathi + Mongolă + Nepaleză + Norvegiană + Persană + Poloneză + Portugheză (Portugalia) + Portugheză (Brazilia) + Punjabi + Română + Rusă + Sârbă + Sinhaleză + Slovacă + Slovenă + Spaniolă + Spaniolă (America Latină) + Swahili + Suedeză + Tamilă + Telugu + Thailandeză + Turcă + Ucraineană + Urdu + Uzbekă + Vietnameză + Galeză + Zulu + Șterge + Continuă + Ignoră + Instalează + Mai târziu + Nu + Actualizează + Da + Vrei să ieși din aplicație? + Ieșire aplicație + Acest catalog nu a returnat niciun element. + Nu s-au găsit titluri + Mai multe acțiuni + Verifică conexiunea Wi-Fi sau de date mobile și încearcă din nou. + Regizor + Încărcarea a eșuat + Mai multe de acest gen + Oferit de TMDB + Oferit de Trakt + Sezoane + Această extensie a returnat videoclipuri pentru serial, dar niciunul nu a inclus numărul sezonului sau al episodului. + Această extensie nu a furnizat metadate despre episoade pentru acest serial. + Episoadele nu au fost încă publicate de această extensie. + Dispozitivul tău este online, dar Nuvio nu a putut accesa serverele necesare. + Arată mai puțin + Arată mai mult ▾ + Scenarist + Toate genurile + Catalog + %1$s • %2$s + Catalogul selectat nu a putut returna elemente pentru descoperire. + Nu s-a putut încărca secțiunea Descoperă + Extensiile instalate nu oferă cataloage compatibile pentru secțiunea Descoperă. + Nu există cataloage pentru descoperire + Catalogul și filtrele selectate nu au returnat niciun element. + Nu s-au găsit titluri + Instalează și validează cel puțin o extensie înainte de a răsfoi cataloagele de descoperire. + Selectează catalogul + Selectează genul + Selectează tipul + Tip + Marchează episoadele anterioare ca nevizionate + Marchează episoadele anterioare ca vizionate + Marchează %1$s ca nevizionat + Marchează %1$s ca vizionat + Marchează sezoanele anterioare ca vizionate + Marchează ca nevizionat + Marchează ca vizionat + Următorul la rând + %1$s vizionat + %1$dh %2$dm rămase + %1$dm rămase + Instalează și validează cel puțin o extensie înainte de a încărca rândurile de cataloage pe ecranul principal. + Extensiile instalate nu oferă în prezent cataloage compatibile fără cerințe suplimentare (extras). + Nu există rânduri disponibile pe ecranul principal + Vezi detalii + Comenzi de redare și salvare. + Acțiuni + Lista cu actorii principali. + Rând cu colecția înrudită sau franciza respectivă. + Colecție + Secțiunea de comentarii de pe Trakt. + Durată, stare, lansare, limbă și informații înrudite. + Detalii + Sezoane și lista de episoade pentru seriale. + Rândul cu recomandări. + Mai multe de acest gen + Sinopsis, evaluări, genuri și echipa de bază. + Prezentare generală + Studiouri și rețele. + Producție + Rând cu trailere și scurtături de redare. + Înapoi online + Serverele nu pot fi accesate + Corpul răspunsului este gol + Cererea a eșuat cu HTTP %1$d + Nicio conexiune la internet + (vârsta %1$d) + Născut pe %1$s%2$s + Decedat pe %1$s + Cunoscut pentru: %1$s + Cele mai recente + Nu s-au putut încărca detaliile pentru %1$s + Populare + Ceva nu a mers bine + Viitoare + Șterge + Anulează + Introdu codul PIN + Introdu codul PIN pentru %1$s + Ai uitat codul PIN? + Cod PIN incorect + Blocat. Încearcă din nou în %1$ds + Opțiunile de avatar vor apărea aici după ce catalogul se încarcă. + Avatar: %1$s + Introdu un URL valid de imagine (http:// sau https://). + Alege un avatar + Alege un avatar de mai jos. + Creează profil + A fost selectat un URL de avatar personalizat. + URL avatar personalizat + Lipește un link de imagine sau lasă gol pentru a folosi catalogul integrat de avatare. + https://exemplu.com/avatar.png + Toate datele pentru „%1$s” vor fi șterse definitiv. + Șterge profilul + Adaugă profil + Editează profilul + Introdu codul PIN curent + Introdu noul cod PIN + Profilul %1$d + Se încarcă avatarele... + Gestionează profilurile + Nume profil + Profil nou + Extensii principale oprite + Extensii principale pornite + Elimină codul PIN pentru %1$s + Elimină blocarea prin PIN + Se salvează... + Securitate + Adaugă un cod PIN dacă vrei ca acest profil să fie blocat înainte de a comuta pe el. + Acest profil este protejat cu un cod PIN. + Selectează un avatar pentru acest profil. + Setează blocarea prin PIN + Profil fără nume + Folosește extensiile principale + Partajează configurația de extensii a profilului principal în loc să gestionezi o listă separată. + Cine vizionează? + Descărcat + Reia + Scrapere active + Se verifică și alte extensii… + Copiază linkul fluxului + Descarcă fișierul + Deschide în player extern + Deschide în player intern + Extensiile de flux instalate nu au returnat un răspuns de flux valid. + Nu s-au putut încărca fluxurile + Instalează mai întâi o extensie pentru a încărca fluxurile pentru acest titlu. + Extensiile tale instalate nu oferă fluxuri pentru acest tip de titlu. + Nicio extensie de flux disponibilă + Nicio extensie instalată nu a returnat fluxuri pentru acest titlu. + S%1$d E%2$d + Episod + S%1$dE%2$d - %3$s + Se preia… + Se caută sursa… + Se încarcă subtitrările din extensii… + Se încarcă segmentele de salt… + Se caută fluxuri… + Linkul fluxului a fost copiat + Niciun link direct de flux disponibil + Nicio metadată disponibilă + Reîmprospătează fluxurile + Reia de la %1$d%% + Reia de la %1$s + DIMENSIUNE %1$s + Acest tip de flux nu este acceptat + Conectează un cont în Setări. + Nu este stocat în cache pe Torbox. + Acest link a expirat. Se reîmprospătează rezultatele. + Nu s-a putut deschide acest link. + Nu s-a putut deschide playerul extern + Alege mai întâi un player extern din setări + Nu este disponibil niciun player extern + Închide trailerul + Nu se poate reda trailerul + Nu s-au putut încărca listele Trakt + Nu s-au putut actualiza listele Trakt + %1$s • %2$s + Verificarea actualizărilor a eșuat + Descărcarea a eșuat + Corpul descărcării este gol + Fișierul de actualizare descărcat lipsește. + Se descarcă %1$d%% + Nu s-a putut porni instalarea + Utilizezi cea mai recentă versiune. + Activează instalarea aplicațiilor pentru Nuvio, apoi revino și continuă. + Se descarcă actualizarea... + Nu s-au găsit actualizări. + O nouă versiune este gata de instalare. + Actualizările în aplicație nu sunt disponibile în acest build. + Se pregătește descărcarea + Note de lansare + Permite instalările pentru a continua + Actualizare disponibilă + Stare actualizare + Această extensie este deja instalată. + Introdu un URL valid pentru extensie + Nu s-a putut încărca manifestul + Nuvio + Ștergerea contului a eșuat + Autentificarea a eșuat + Deconectarea a eșuat + Înregistrarea a eșuat + Nu s-au putut încărca elementele din catalog. + Următorul la rând + Următorul • S%1$dE%2$d + Siglă %1$s + Nu s-au putut încărca comentariile + Nu s-au putut încărca detaliile din nicio extensie. + Rețele + Nicio extensie nu oferă metadate pentru acest conținut. + Descărcarea a eșuat + Afișează progresul live și comenzile pentru descărcări. + Descărcări + Descărcare finalizată + Se descarcă %1$s • %2$s + Se descarcă %1$s • %2$s / %3$s + Descărcarea a eșuat + Pus pe pauză %1$s + Elimină + Elimini %1$s din %2$s? + Elimini %1$s din biblioteca ta? + Elimini din bibliotecă? + Film + Alerte când apare un nou episod dintr-un serial salvat. + Previzualizare alertă lansare episod. + Nu s-a putut trimite notificarea de test. + Notificarea de test a fost trimisă pentru %1$s. + Acest flux nu poate fi redat. + Motorul de redare MPV nu este disponibil. Te rugăm să reconstruiești aplicația. + Codul PIN al acestui profil s-a schimbat. Conectează-te o dată pentru a actualiza blocarea pe acest dispozitiv. + Nu s-a putut elimina blocarea prin PIN. Încearcă din nou. + Conectează-te la internet pentru a elimina blocarea prin PIN. + Acest PIN nu poate fi verificat offline pe acest dispozitiv încă. Conectează-te și deblochează-l mai întâi online. + Nu s-a putut seta codul PIN. Încearcă din nou. + Conectează-te la internet pentru a seta un cod PIN. + Acest profil folosește extensiile principale. + Nu s-a putut încărca %1$s + Flux + Integrat (Embedded) + Autorizare refuzată + Finalizează autentificarea Trakt în browser + Conectat la Trakt + Deconectat de la Trakt + Callback Trakt nevalid + Stare callback Trakt nevalidă + Răspuns nevalid pentru tokenul Trakt + Nu s-a putut încărca biblioteca Trakt + Lista %1$d + Trakt nu a returnat un cod de autorizare + Lipsesc credențialele Trakt + Nu s-a putut încărca progresul Trakt + Listă publică Trakt + Introdu un ID sau URL valid de listă Trakt + %1$d elemente + %1$d aprecieri + Lipsesc credențialele Trakt în local.properties (TRAKT_CLIENT_ID). + Lipsește ID-ul listei Trakt + Lista Trakt nu include un ID numeric + Lista Trakt nu a fost găsită sau nu este publică + Limita de cereri Trakt a fost atinsă + Cererea către Trakt a eșuat + Nu s-a putut finaliza autentificarea Trakt + Utilizator Trakt + Listă de urmărire (Watchlist) + Autorizarea Trakt a expirat + Lista Trakt nu a fost găsită + Limita de liste Trakt a fost atinsă + Limita de cereri Trakt a fost atinsă + Cererea către Trakt a eșuat + Nu s-a putut adăuga în lista de urmărire Trakt + Nu s-a putut adăuga în lista Trakt + Lipsesc ID-uri Trakt compatibile + Corpul răspunsului este gol + Adaugă o cheie API TMDB în Setări pentru a folosi sursele TMDB. + Colecție TMDB %1$d + Colecția TMDB nu a fost găsită + Producție TMDB %1$d + Compania TMDB nu a fost găsită + Regizor TMDB %1$d + Descoperirea TMDB nu a returnat date + Descoperire TMDB + Introdu un ID sau URL TMDB valid. + Listă TMDB %1$d + Lista TMDB nu a fost găsită + Sursa TMDB nu a putut fi încărcată + Lipsește ID-ul colecției TMDB + Lipsește ID-ul listei TMDB + Lipsește ID-ul persoanei TMDB + Rețea TMDB %1$d + Rețeaua TMDB nu a fost găsită + Creditele persoanei pe TMDB nu au fost găsite + Persoană TMDB %1$d + Persoana TMDB nu a fost găsită + Trailer + Necunoscut + Extensie + Salvat + Redă %1$s + Reia %1$s + Codul JSON este gol. + Colecția %1$d are ID-ul gol. + ID-ul de colecție „%1$s” este utilizat de mai multe ori. + Colecția „%1$s” are titlul gol. + Dosarul %1$d din „%2$s” are ID-ul gol. + ID-ul de dosar „%1$s” este utilizat de mai multe ori în „%2$s”. + Dosarul „%1$s” din „%2$s” are titlul gol. + Sursa %1$d din dosarul „%2$s” are câmpuri goale. + Sursa %1$d din dosarul „%2$s” nu are un ID de listă Trakt. + JSON nevalid: %1$s + Extensia nu a fost găsită: %1$s + Listă de filme Trakt + Listă de seriale Trakt + Ianuarie + Februarie + Martie + Aprilie + Mai + Iunie + Iulie + August + Septembrie + Octombrie + Noiembrie + Decembrie + Ian + Feb + Mar + Apr + Mai + Iun + Iul + Aug + Sep + Oct + Noi + Dec + Companie de producție + Rețea + Nu s-a putut încărca %1$s + Populare + Recente + %1$s • %2$s + Cele mai apreciate + Certificare (Rating vârstă) + Detalii film + Limba originală + Țara de origine + Informații despre lansare + Durată de rulare + Postere + Text + Detalii serial + Stare + Videoclipuri + FIȘIER + Niciun link direct de flux disponibil + S-a înlocuit descărcarea anterioară + Descărcarea a început + Format de flux neacceptat pentru descărcări + Corpul răspunsului este gol + Nu s-a putut finaliza fișierul descărcat + Cererea a eșuat cu HTTP %1$d + Sistemul de descărcare nu este inițializat + Nu s-a putut deschide fișierul de descărcare parțială + Fișierul de descărcare parțială nu este deschis + Cererea de descărcare a eșuat + Nu s-a putut scrie în fișierul de descărcare parțială + %1$s - %2$s + Titlurile salvate vor apărea aici după ce apeși pe Salvează în ecranul cu detalii. + Biblioteca ta este goală + Nu s-a putut încărca biblioteca + Altele + Cloud + Salvate + Bibliotecă + Conectează Trakt și salvează titluri în lista de urmărire sau în listele personale. + Biblioteca ta Trakt este goală + Nu s-a putut încărca biblioteca Trakt + Bibliotecă Trakt + Conectează contul + Conectează un cont în setările de Servicii Conectate pentru a răsfoi fișierele redabile din biblioteca ta din cloud. + Niciun cont de cloud conectat + Deschide Servicii Conectate + Activează Biblioteca în cloud din setările de Servicii Conectate pentru a răsfoi fișierele din conturile conectate. + Biblioteca în cloud este oprită + Nu există fișiere redabile în cloud care să se potrivească cu filtrele actuale. + Nimic aici momentan + Alege un fișier pentru redare + Nu s-a putut încărca biblioteca în cloud pentru %1$s + Acest element nu oferă un fișier video redabil. + Niciun fișier redabil + Niciun fișier redabil + Biblioteca în cloud este dezactivată. + Nu s-a putut reda acest fișier din cloud. + Redă fișierul + Serviciul de cloud nu este conectat. + %1$s nu este conectat. + %1$d fișiere redabile + Toți + Reîmprospătează biblioteca în cloud + Selectează furnizorul + Selectează tipul + Pregătit de redare + Toate + Torrente + Usenet + Web + Fișiere + Anime + Canale + Filme + Seriale + TV + %1$s este disponibil acum + %1$s • %2$s este disponibil acum + Un nou episod este disponibil acum + %1$s este disponibil acum + Lansări de episoade + Alcool/Droguri + Înfricoșător + Nuditate + Limbaj licențios + Ușor + Moderat + Sever + Violență + Creator + Regizor + Scenarist + Scor audiență + Nu s-a găsit un flux redabil pentru trailer. + Sezonul %1$d - %2$s + B + KB + MB + GB + Trimite Intro + Setări video + Biblioteca în cloud nu este disponibilă pentru %1$s. + Video + Resetează ajustările + Profil predefinit de ieșire + Detectare vârf HDR + Estimează luminozitatea maximă HDR atunci când metadatele sunt eronate sau lipsesc. + Tone mapping + Deband (Reducere benzi culori) + Reduce efectul de fragmentare a culorilor (color banding) cu un cost mic de performanță. + Interpolare cadre + Fluidizează mișcarea când mpv poate folosi sincronizarea ecranului fără probleme. + Luminozitate + Contrast + Saturație + Gama + EDR Nativ + Cel mai bun pentru iPhone-uri și iPad-uri compatibile cu HDR. + SDR tone mapped + Niveluri mai previzibile de alb și negru pe ieșirile de tip SDR. + Compatibilitate + Cel mai apropiat comportament de versiunile mai vechi de MPV pe iOS. + Personalizat + Folosește valorile tale avansate de mai jos. + Dezactivat + Ieșire video iOS + Decodor hardware + Interval dinamic extins (EDR) + Modul de ieșire Metal implicit pentru noile sesiuni de redare. + Indiciu spațiu culoare ecran + Permite mpv să vizeze spațiul de culoare activ al ecranului în mod implicit. + Culori primare țintă + Funcție de transfer țintă + Ieșire audio iOS + Ieșire audio + Se folosește AudioUnit în timp ce ieșirea AVFoundation este dezactivată temporar. + Suport experimental pentru Spatial Audio și ieșire multicanal. + Folosește ieșirea moștenită (legacy) AudioUnit. + Gestionare rezultate + Număr maxim de rezultate + Limitează numărul de rezultate afișate. + Ordonează rezultatele + Alege cum sunt ordonate rezultatele. + Limită per rezoluție + Limitează rezultatele repetate de 2160p, 1080p, 720p după sortare. + Limită per calitate + Limitează rezultatele repetate de BluRay, WEB-DL, REMUX după sortare. + Interval dimensiune + Filtrează rezultatele după dimensiunea fișierului. + Află mai multe + Format implicit + Format original + Introdu câte un grup pe rând. + Ordinea originală + Cea mai bună calitate mai întâi + Cele mai mari mai întâi + Cele mai mici mai întâi + Cel mai bun audio mai întâi + Limba mai întâi + Oricare + %1$d selectate + Toate rezultatele + %1$d rezultate + Până la %1$dGB + %1$dGB+ + %1$d-%2$dGB + Rezoluții preferate + Sortează rezoluțiile selectate mai întâi, în ordinea implicită. + Rezoluții obligatorii + Afișează doar rezoluțiile selectate. + Rezoluții excluse + Ascunde rezoluțiile selectate. + Calități preferate + Sortează calitățile selectate mai întâi, în ordinea implicită. + Calități obligatorii + Afișează doar calitățile selectate. + Calități excluse + Ascunde calitățile selectate. + Etichete vizuale preferate + Sortează etichetele de tip DV, HDR, 10bit, IMAX și cele similare. + Etichete vizuale obligatorii + Solicită etichete de tip DV, HDR, 10bit, IMAX, SDR și altele similare. + Etichete vizuale excluse + Ascunde etichetele de tip DV, HDR, 10bit, 3D și altele similare. + Etichete audio preferate + Sortează etichetele de tip Atmos, TrueHD, DTS, AAC și cele similare. + Etichete audio obligatorii + Solicită etichete de tip Atmos, TrueHD, DTS, AAC și altele similare. + Etichete audio excluse + Ascunde etichetele audio selectate. + Canale preferate + Sortează mai întâi configurațiile de canale preferate. + Canale obligatorii + Afișează doar configurațiile de canale selectate. + Canale excluse + Ascunde configurațiile de canale selectate. + Codificări (Encodes) preferate + Sortează codificările de tip AV1, HEVC, AVC și cele similare. + Codificări obligatorii + Solicită codificări de tip AV1, HEVC, AVC și altele similare. + Codificări excluse + Ascunde codificările selectate. + Limbi preferate + Sortează mai întâi limbile audio preferate. + Limbi obligatorii + Afișează doar rezultatele care conțin limbile selectate. + Limbi excluse + Ascunde rezultatele în care toate limbile sunt excluse. + Grupuri de lansare obligatorii + Afișează doar grupurile de lansare selectate. + Grupuri de lansare excluse + Ascunde grupurile de lansare selectate. + Trimite marcajele de timp + TIP SEGMENT + Intro + Recapitulare + Outro + TIMP PORNIRE (MM:SS) + TIMP FINAL (MM:SS) + Trimite + Captează + Decodor hardware + Ieșire audio + Culori primare țintă + Funcție de transfer țintă + Listă Trakt identificată + Descoperire TMDB + US, KR, JP, IN + Introdu un nume, URL sau ID de listă Trakt + Introdu un ID sau URL TMDB valid. + Sursa TMDB nu a putut fi încărcată + Introdu un ID sau URL de listă Trakt + Lista Trakt nu a fost găsită sau nu a putut fi încărcată + Canada + Australia + Germania + Filme + Seriale + Biblioteca în cloud este dezactivată. + Cheie API nevalidă sau conexiunea a eșuat + Din manifest lipsește câmpul „%1$s” + Colecție TMDB %1$s + Regizor TMDB %1$s + Descoperire TMDB + Introdu un ID sau URL TMDB valid. + Listă TMDB %1$s + Rețea TMDB %1$s + Persoană TMDB %1$s + Producție TMDB %1$s + Sursa TMDB nu a putut fi încărcată + ID %1$s + Adaugă o cheie API TMDB în Setări pentru a folosi sursele TMDB. + Colecția TMDB nu a fost găsită + Compania TMDB nu a fost găsită + Descoperirea TMDB nu a returnat date + Lista TMDB nu a fost găsită + Lipsește ID-ul colecției TMDB + Lipsește ID-ul listei TMDB + Lipsește ID-ul persoanei TMDB + Rețeaua TMDB nu a fost găsită + Creditele persoanei pe TMDB nu au fost găsite + Persoana TMDB nu a fost găsită + Lipsesc credențialele Trakt. + %1$s (%2$d) + Introdu un ID sau URL valid de listă Trakt + %1$d elemente + %1$d aprecieri + Lista Trakt nu a fost găsită sau nu este publică + Lipsește ID-ul listei Trakt + Lista Trakt nu include un ID numeric + Listă publică Trakt + Limita de cereri Trakt a fost atinsă + Cererea către Trakt a eșuat + Nu s-au putut încărca comentariile Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Nu s-a putut finaliza fișierul descărcat + Nu s-a putut deschide fișierul de descărcare parțială + Fișierul de descărcare parțială nu este deschis + Nu s-a putut scrie în fișierul de descărcare parțială + Bibliotecă Nuvio + Problemă de conexiune + Corpul răspunsului este gol + Te rugăm să verifici conexiunea și să încerci din nou. + Cererea a eșuat cu HTTP %1$d + %1$s (%2$s) + Motorul de redare MPV nu este disponibil. Te rugăm să reconstruiești aplicația. + Acest flux nu poate fi redat. + Module dezactivate + Module activate + %1$d furnizori + Se reîmprospătează + %1$d depozite (repos) + Lipsește cheia API TMDB + Cheia API TMDB este configurată + Instalează depozitul de module + Se instalează… + Testează furnizorul + Se testează… + Șterge depozitul de module + Reîmprospătează depozitul de module + Nu există încă furnizori disponibili. + Adaugă un URL de depozit pentru a instala module de furnizori destinate descoperirii de fluxuri. + Nu există încă depozite de module instalate. + Folosește furnizorii de module în timpul descoperirii fluxurilor. + Activează furnizorii de module la nivel global + Acel depozit de module este deja instalat. + Introdu URL-ul unui depozit de module. + Introdu un URL de modul valid. + Nu s-a putut instala depozitul de module + Furnizorul nu a fost găsit + Nu s-a putut reîmprospăta depozitul + Modulele nu sunt disponibile în acest build. + În secțiunea Fluxuri, afișează un singur furnizor per depozit în loc de unul per sursă. + Grupează furnizorii de module după depozit + URL manifest modul + Numele manifestului lipsește. + Manifestul nu are furnizori. + Versiunea manifestului lipsește. + Numele manifestului lipsește. + Manifestul nu are furnizori. + Versiunea manifestului lipsește. + S-a instalat %1$s. + Dezactivat de depozit + Fără descriere + v%1$s + Depozit de module + Versiunea %1$s + Acel depozit de module este deja instalat. + Nu s-a putut instala depozitul de module + Nu s-a putut reîmprospăta depozitul + ADĂUGĂ DEPOZIT + DEPOZITE INSTALATE + PREZENTARE GENERALĂ + FURNIZORI + Eroare + Testul furnizorului a eșuat + Rezultate test (%1$d) + Furnizorii de module necesită o cheie API TMDB. Seteaz-o în ecranul TMDB, altfel modulele furnizorilor nu vor funcționa corect. + Nu s-au returnat rezultate de căutare pentru %1$s. + Cheie API nevalidă sau conexiunea a eșuat + Depozit de module + Trimite Intro + Trimite + Captează + TIMP FINAL (MM:SS) + TIP SEGMENT + TIMP PORNIRE (MM:SS) + Conectat la Trakt + Deconectat de la Trakt + TB + Nu s-a găsit niciun fișier APK în această versiune lansată + Descărcarea a eșuat cu HTTP %1$d + Fișierul de actualizare descărcat lipsește. + Corpul descărcării este gol + Eroare API GitHub releases: %1$d + Nu a fost publicată nicio actualizare încă. + Versiunea lansată nu are etichetă (tag) sau nume + Se difuzează pe %1$s + Se difuzează azi + Se difuzează mâine + + Se difuzează într-o zi + Se difuzează în %1$d zile + + %1$s + Azi + Mâine + + Într-o zi + În %1$d zile + + Episod nou + Sezon nou + Listă Trakt %1$s + Playerul de sistem Android + Streaming P2P + Acest flux folosește tehnologia peer-to-peer (P2P). Prin activarea P2P, confirmi și ești de acord că:\n\n• Adresa ta IP va fi vizibilă pentru ceilalți utilizatori din rețea\n• Ești singurul responsabil pentru conținutul pe care îl accesezi\n• Confirmi că ai dreptul legal de a reda acest conținut în jurisdicția ta\n• Nuvio nu găzduiește, nu distribuie și nu controlează conținutul P2P\n• Nuvio nu își asumă nicio răspundere pentru eventualele consecințe legale ce decurg din utilizarea streamingului P2P\n\nFolosești această funcție complet pe propriul risc. Tehnologia P2P poate fi dezactivată oricând din Setări. + Activează P2P + Anulează + Eroare torrent necunoscută + Streaming P2P + Permite fluxurile de tip peer-to-peer (torrent) + Ascunde statisticile torrentului + Ascunde datele despre buffer, seeders, peers și viteza de descărcare în timpul încărcării și redării + %1$d seeds · %2$d peers + %1$s stocați în buffer · %2$s · %3$s + %1$s · %2$s + %1$d peers · %2$d seeds · %3$d%% + Se conectează la peers… + Se pornește motorul P2P… + Nu s-a putut porni torrentul: %1$s + Eroare torrent: %1$s + Născut + Locul nașterii + Credite (Roluri) + Biografie + Țară + Tip + Catalog + Despre + Se încarcă subtitrările din extensii... + Se descarcă subtitrările... + + %d titlu + %d titluri + + diff --git a/composeApp/src/commonMain/composeResources/values-sk/strings.xml b/composeApp/src/commonMain/composeResources/values-sk/strings.xml new file mode 100644 index 00000000..9f1c7563 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-sk/strings.xml @@ -0,0 +1,1929 @@ + + Zdroje dát, poďakovania a licencie platformy + Otvorené uznanie a kredity projektu + Späť + Zrušiť + Zavrieť + Odstrániť + Hotovo + Upraviť + Importovať + Ďalej + OK + Prehrať + Predchádzajúce + Odstrániť + Zmeniť poradie + Obnoviť predvolené + Pokračovať + Skúsiť znova + Uložiť + Ukladanie… + Overiť + Inštalácia + Doplnky + Aktívny + %1$d katalógov + Konfigurovateľný + Vypnutý + Obnovuje sa + %1$d zdrojov + Nedostupný + Konfigurovať doplnok + Odstrániť doplnok + Pridajte URL manifestu a začnite načítavať katalógy, metadáta, streamy alebo titulky do Nuvio. + Zatiaľ nie sú nainštalované žiadne doplnky. + Zadajte URL doplnku. + URL doplnku + Inštalovať doplnok + Načítavanie podrobností manifestu... + Overovanie URL manifestu a načítavanie podrobností doplnku pred inštaláciou. + Kontrola doplnku + Inštalácia zlyhala + %1$s bol úspešne overený a pridaný. + Doplnok nainštalovaný + Presunúť doplnok nadol + Presunúť doplnok nahor + Aktívny + Doplnky + Katalógy + Obnoviť doplnok + Pridať doplnok + Nainštalované doplnky + Prehľad + %1$d pravidiel ID + Verzia %1$s + Vybrané + Kopírovať JSON + %1$d kolekcia(-í), %2$d priečinok(-kov) + Odstrániť „%1$s"? Túto akciu nie je možné vrátiť späť. + Odstrániť kolekciu + Pridať katalóg + Pridať priečinok + Všetky žánre + Pridajte katalógy z nainštalovaných doplnkov a definujte, čo tento priečinok zobrazuje. + Zatiaľ žiadne zdroje katalógov + Vybrať + Emoji + URL obrázka + Žiadne + Obal + Vytvoriť kolekciu + Hotovo + Upraviť kolekciu + Upraviť priečinok + Nastavte identitu priečinka, prezentáciu a zdroje katalógov s rovnakou štruktúrou ako hlavný editor kolekcií. + Pridajte jeden a začnite. + Zatiaľ žiadne priečinky + Priečinky + Filter žánrov + Zobraziť iba obrázok obalu + Skryť názov + Nový priečinok + Zobraziť túto kolekciu nad všetkými bežnými katalógmi domovskej obrazovky. Viaceré pripnuté kolekcie sa riadia poradím ich vytvorenia. + Pripnúť nad katalógy + URL obrázka pozadia (voliteľné) + Názov priečinka + URL animovaného GIF (prehráva sa iba pri zameraní) + Názov kolekcie + Uložiť zmeny + Uložiť + Vzhľad + Základy + Zdroje katalógov + Vyberte katalógy doplnkov, ktoré má tento priečinok agregovať. + Vybrať katalógy + Vybrať žáner + %1$d vybraných + %1$d katalógov + %1$d vybraných + Plagát + Štvorec + Široký + Spojiť všetky katalógy do jednej záložky + Zobraziť záložku „Všetko" + Prehrávať nakonfigurovaný GIF namiesto statického obalu, ak je dostupný. + Zobraziť GIF, ak je nakonfigurovaný + %1$d zdroj(-ov) · %2$s + Tvar dlaždice + Riadky + Záložky + Režim zobrazenia + Zdroje TMDB + Verejný zoznam + Produkcia + Sieť + Kolekcia + Osoba + Režisér + Vlastné + Vyberte hotový zdroj. Po pridaní ho môžete upraviť alebo odstrániť. + Vložte URL verejného zoznamu TMDB alebo iba číslo z URL. + Vyhľadajte podľa názvu štúdia alebo vložte ID/URL spoločnosti TMDB a pridajte ju priamo. + Zadajte ID siete. Bežné siete sú dostupné v predvoľbách a rýchlych filtroch. + Vyhľadajte názov filmovej kolekcie alebo vložte ID kolekcie z TMDB. + Zadajte ID osoby alebo URL z TMDB a vytvorte riadok z kreditov obsadenia. + Zadajte ID osoby alebo URL z TMDB a vytvorte riadok z kreditov režiséra. + Vytvorte živý riadok TMDB pomocou voliteľných filtrov. Polia nechajte prázdne, ak daný filter nepotrebujete. + Verejný zoznam TMDB + ID siete + ID kolekcie + ID osoby + Názov produkčnej spoločnosti, ID alebo URL + ID alebo URL TMDB + https://www.themoviedb.org/list/8504994 alebo 8504994 + 213 pre Netflix, 49 pre HBO, 2739 pre Disney+ + 10 pre kolekciu Star Wars + Marvel Studios, 420 alebo URL spoločnosti + 31 pre Toma Hanksa alebo URL osoby + Príklady: Marvel Studios, 420 alebo https://www.themoviedb.org/company/420. + Príklad: Kolekcia Star Wars, Kolekcia Harry Potter alebo URL kolekcie. + Príklady ID: Netflix 213, HBO 49, Disney+ 2739. + Príklad: https://www.themoviedb.org/list/8504994 alebo 8504994. + Príklad: https://www.themoviedb.org/person/31-tom-hanks alebo 31. + Zobrazovaný názov + Zobrazuje sa ako názov riadku/záložky. Ak je prázdne, Nuvio ho vytvorí zo zdroja. + Filmy Marvel, Netflix Originals, Pixar + Filmy Toma Hanksa, Obľúbení herci + Filmy Christophera Nolana, Obľúbení režiséri + Najlepšie akčné filmy, Kórejské drámy, Animácie 2024 + Výsledky vyhľadávania + Kolekcia TMDB + Spoločnosť TMDB %1$d + Kolekcia TMDB %1$d + Typ + Filmy + Seriály + Oboje + Zoradiť + Filtre + Polia nechajte prázdne, ak daný filter nepotrebujete. + Rýchle žánre + Rýchle jazyky + Rýchle krajiny + Rýchle kľúčové slová + Rýchle štúdiá + Rýchle siete + ID žánrov + Použite čísla žánrov TMDB. Viacero oddeľte čiarkami pre AND alebo zvislou čiarou pre OR. + 28,12 + 18,35 + Dátum vydania alebo vysielania od + Dátum vydania alebo vysielania do + Použite formát RRRR-MM-DD, napríklad 2024-01-01. + 2020-01-01 + 2024-12-31 + Minimálne hodnotenie + Maximálne hodnotenie + Hodnotenie TMDB od 0 do 10. Príklad: 7.0. + 7.0 + 10 + Minimálny počet hlasov + Použite toto na vylúčenie titulov s nízkym počtom hlasov. Príklad: 100. + 100 + Pôvodný jazyk + Použite dvojpísmenové kódy jazykov, napríklad en, ko, ja, hi. + en, ko, ja, hi + Krajina pôvodu + Použite dvojpísmenové kódy krajín, napríklad US, KR, JP, IN. + US, KR, JP, IN + ID kľúčových slov + Použite čísla kľúčových slov TMDB. Rýchle čipy vyplnia bežné príklady. + 9715 pre superhrdinu + ID spoločností + Použite ID štúdií/spoločností. Rýchle čipy vyplnia bežné príklady. + 420 pre Marvel Studios + ID sietí + Iba pre seriály. Použite ID sietí ako Netflix 213 alebo HBO 49. + 213 pre Netflix + Rok + Použite štvorcifernú hodnotu roku, napríklad 2024. + 2024 + Predvoľby + Vyhľadať + Pridať zdroj + Pridať zoznam Trakt + Upraviť zoznam Trakt + Zoznamy Trakt + Zoznam Trakt + Vyhľadajte názov, URL Trakt alebo ID zoznamu + Použite URL verejného zoznamu Trakt alebo číselné ID zoznamu, alebo vyhľadajte podľa názvu. + Víkendové sledovanie, Ocenené filmy + Výsledky vyhľadávania + Trendy zoznamy + Populárne zoznamy + Smer + Vzostupne + Zostupne + Poradie zoznamu + Nedávno pridané + Názov + Vydané + Dĺžka + Populárne + Percentuálne + Hlasy + Zadajte názov, URL alebo ID zoznamu Trakt + Zadajte ID alebo URL zoznamu Trakt + Nepodarilo sa načítať zoznam Trakt + Nenašli sa žiadne zoznamy Trakt + Vyriešený zoznam Trakt + Zoznam Trakt %1$d + Akčný + Dobrodružný + Animovaný + Komédia + Horor + Sci-Fi + Dráma + Krimi + Reality + Angličtina + Kórejčina + Japončina + Hindčina + Španielčina + Spojené štáty + Kórea + Japonsko + India + Spojené kráľovstvo + Superhrdina + Podľa románu + Cestovanie v čase + Vesmír + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Pôvodné + Populárne + Najlepšie hodnotené + Nedávne + Najviac hlasovaných + Región sledovania + Kód krajiny ISO 3166-1, kde je titul dostupný. Príklad: US, GB. + Rýchle regióny sledovania + ID poskytovateľov sledovania + Použite ID poskytovateľov sledovania TMDB. Viacero oddeľte čiarkami pre AND alebo zvislou čiarou pre OR. + 8|337|350 + Rýchli poskytovatelia sledovania + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + Zoznam TMDB + Filmová kolekcia TMDB + Produkcia + Sieť + Osoba + Režisér + Objavovanie TMDB + Vytvorte jednu na usporiadanie katalógov. + Zatiaľ žiadne kolekcie + %1$d priečinok(-kov) + Nenašli sa žiadne položky + Priečinok sa nenašiel + Kolekcie + Importovať kolekcie + JSON + Vložte JSON kolekcií nižšie. + Importovať + Nová kolekcia + Pripnuté + Všetko + Vaše kolekcie + Vytvorené s ❤️ od Tapframe a priateľov + Verzia %1$s (%2$s) + Vypnuté + Zapnuté + Pozastaviť + Znovu načítať + Už máte účet? + Pokračovať bez účtu + Vytvoriť účet + Nemáte účet? + E-mail + alebo + Heslo + Prihláste sa pre prístup k vašej knižnici a postupu + Prihlásiť sa + Zaregistrujte sa a synchronizujte dáta naprieč zariadeniami + Zaregistrovať sa + Vaše dáta budú uložené iba lokálne + Streamujte všetko, všade + Vitajte späť + Knižnica + Knižnica Trakt + Domov + Knižnica + Profil + Hľadať + Zvukové stopy + Zvuk + Automatická synchronizácia + Tučné + Vstavaný + Spodný posun + Zachytiť + Zavrieť prehrávač + Farba + Práve sa prehráva + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Epizódy + Veľkosť písma + %1$dsp + Zamknúť ovládacie prvky prehrávača + Načítavanie riadkov titulkov... + Nenašli sa žiadne riadky titulkov + Nepodarilo sa načítať riadky titulkov + Nie sú dostupné žiadne zvukové stopy + Nie sú dostupné žiadne epizódy + Nenašli sa žiadne streamy + Žiadne + Obrys + Farba obrysu + Epizódy + Zdroje + Streamy + Chyba prehrávania + Prehráva sa + Klepnutím načítate titulky + Späť + Znovu načítať + Resetovať + Obnoviť predvolené + Vyplniť + Prispôsobiť + Priblížiť + Posunúť späť o 10 sekúnd + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Posunúť vpred o 10 sekúnd + Zdroje + Štýl + Najprv vyberte titulok doplnku + Titulky + Oneskorenie titulkov + Titulky + Priehľadnosť textu + Jas %1$s + Hlasitosť %1$s + Stlmené + Stiahnuté + Vysiela sa + TBA + Klepnutím odomknete + Stopa %1$d + Odomknúť ovládacie prvky prehrávača + Pozeráte + Pridať profil + Vymazať vyhľadávanie + Objavovať + Nainštalované doplnky nevrátili platné výsledky vyhľadávania. + Vyhľadávanie zlyhalo + Pred vyhľadávaním nainštalujte a overte aspoň jeden doplnok. + Žiadne aktívne doplnky + Nainštalované prehľadávateľné katalógy nevrátili žiadne zhody pre tento dotaz. + Nenašli sa žiadne výsledky + Vaše nainštalované doplnky neposkytujú vyhľadávanie v katalógu. + Žiadne prehľadávateľné katalógy + Hľadajte filmy, seriály... + Nedávne vyhľadávania + Odstrániť nedávne vyhľadávanie + O aplikácii + Všeobecné + Účet + Doplnky + Pokročilé + Rozloženie + Obsah a objavovanie + Pokračovať v sledovaní + Prepojené služby + Rozloženie domovskej obrazovky + Integrácie + Licencie a atribúcie + Hodnotenia MDBList + Stránka s podrobnosťami + Oznámenia + Prehrávanie + Pluginy + Štýl karty plagátu + Nastavenia + Streamy + Podporovatelia a prispievatelia + Obohatenie TMDB + Trakt + O APLIKÁCII + Stav účtu a synchronizácie + ÚČET + Správanie pri spustení a profiloch. + POKROČILÉ + Štruktúra domovskej obrazovky a štýly plagátov + Stiahnuť najnovšie vydanie + Skontrolovať aktualizácie + Spravovať doplnky a zdroje objavovania. + Spravujte doplnky a vyberte, čo sa má zobrazovať v Nuviu. + Spravovať stiahnuté filmy a epizódy. + Spravujte obsah uložený v tomto zariadení. + Stiahnuté + VŠEOBECNÉ + Spravovať dostupné integrácie + Spravovať upozornenia na vydanie epizód a odoslať testovacie oznámenie. + Zobrazenie výsledkov streamov a pravidlá URL odznakov. + Prepnúť na iný profil. + Prepnúť profil + Otvoriť obrazovku pripojenia Trakt + Nenašli sa žiadne nastavenia. + Hľadať nastavenia... + VÝSLEDKY + SPUSTENIE + VYROVNÁVACIA PAMÄŤ + Zapamätať posledný profil + Zapamätať naposledy vybraný profil pri spustení + Vymazať vyrovnávaciu pamäť Pokračovať v sledovaní + Odstrániť uložené dáta Pokračovať v sledovaní a obnoviť postup sledovania + Vyrovnávacia pamäť vymazaná + LICENCIA APLIKÁCIE + DÁTA A SLUŽBY + LICENCIA PREHRÁVANIA + Nuvio Mobile + Zdrojový kód a licenčné podmienky sú dostupné v repozitári projektu. + Licencované pod GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio používa API TMDB pre metadáta filmov a TV, umelecké diela, trailery, obsadenie, produkčné detaily, kolekcie a odporúčania. Tento produkt používa API TMDB, ale nie je schválený ani certifikovaný spoločnosťou TMDB. + Nekomerčné datasety IMDb + Nuvio používa nekomerčné datasety IMDb vrátane title.ratings.tsv.gz pre hodnotenia IMDb a počty hlasov. Informácie poskytnuté IMDb (https://www.imdb.com). Použité so súhlasom. Dáta IMDb sú určené na osobné a nekomerčné použitie podľa podmienok IMDb. + Trakt + Nuvio sa pripája k Trakt pre autentifikáciu účtu, históriu sledovania, synchronizáciu postupu, dáta knižnice, hodnotenia, zoznamy a komentáre. Nuvio nie je pridružený k Trakt ani ním schválený. + Premiumize + Nuvio sa pripája k Premiumize pre autentifikáciu účtu, prístup ku cloudovej knižnici, kontroly vyrovnávacej pamäte a funkcie cloudového prehrávania. Nuvio nie je pridružený k Premiumize ani ním schválený. + TorBox + Nuvio sa pripája k TorBox pre autentifikáciu účtu, prístup ku cloudovej knižnici, kontroly vyrovnávacej pamäte a funkcie cloudového prehrávania. Nuvio nie je pridružený k TorBox ani ním schválený. + MDBList + Nuvio používa MDBList pre hodnotenia a dáta externých poskytovateľov skóre. Nuvio nie je pridružený k MDBList ani ním schválený. + IntroDB + Nuvio používa API IntroDB pre komunitou poskytované časové pečiatky intro, rekapitulácie, kreditov a náhľadov používaných ovládacími prvkami preskočenia. Nuvio nie je pridružený k IntroDB ani ním schválený. + MPVKit + Používa sa na prehrávanie v zostavách iOS. + Samotný zdrojový kód MPVKit je licencovaný pod LGPL v3.0. Balíky MPVKit vrátane knižníc libmpv a FFmpeg sú tiež licencované pod LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Používa sa na prehrávanie v zostavách Android. + Licencované pod Apache License, Version 2.0. + Načítavanie vašich zoznamov Trakt… + Vyberte, kam uložiť tento titul na Trakt + Darovať + Prejsť na podrobnosti + Odstrániť + Začať od začiatku + Prehrať + %1$d/10 + Recenzia + Spoiler + Zatiaľ nie sú dostupné žiadne recenzie Trakt. + %1$d páči sa + Tento komentár obsahuje spoilery. + Tento komentár obsahuje spoilery a bol skrytý. + Komentáre + Trailer + %1$s (%2$d) + Trailery + Žiadne dokončené epizódy + Zatiaľ žiadne stiahnuté súbory + %1$d stiahnutá(-é) epizóda(-y) + Aktívne + Filmy + Seriály + Zobraziť stiahnuté + Dokončené • %1$s + Sťahovanie • %1$s + Zlyhalo + Pozastavené • %1$s + Pozreté + Séria %1$d + Špeciály + Pokračovať tam, kde ste skončili + Pridať do knižnice + Označiť ako nepozreté + Označiť ako pozreté + Odstrániť z knižnice + Zobraziť všetko + Prehrať manuálne + Logo %1$s + Účet + Odstrániť účet + Toto natrvalo odstráni váš účet a všetky súvisiace dáta. + Túto akciu nie je možné vrátiť späť. Všetky vaše dáta, profily a história synchronizácie budú natrvalo odstránené. + Odstrániť účet? + E-mail + Nie ste prihlásený + Odhlásiť sa + Budete presmerovaný na prihlasovaciu obrazovku. + Odhlásiť sa? + Stav + Anonymný + Prihlásený + AMOLED čierna + Použiť čisto čierne pozadie pre OLED obrazovky. + Jazyk aplikácie + Vybrať jazyk + Nastavenia sekcie Pokračovať v sledovaní. + Liquid Glass + Použiť natívny panel záložiek iPhone na iOS 26 a novšom. Okamžité prepínanie profilov z panela záložiek nie je dostupné, keď je toto zapnuté. + Nastaviť šírku karty a polomer rohov. + ZOBRAZENIE + DOMOV + TÉMA + Kolekcia • %1$s + Zobrazovaný názov + Nainštalujte doplnok s katalógmi kompatibilnými s tabuľou na konfiguráciu riadkov domovskej obrazovky. + Žiadne katalógy domovskej obrazovky + Zdroj hero sekcie + Skryté + Udržať domovskú obrazovku v popredí + %1$s • Dosiahnutý limit (max %2$d) + Nie sú vybrané žiadne zdroje hero sekcie + Nie v hero sekcii + Zrušte pripnutie nahor z kolekcie, aby ste ju mohli presunúť + Pripnuté + Pripnuté nahor + Zmeniť poradie + KATALÓGY + KATALÓGY A KOLEKCIE + KOLEKCIE + Rozloženie domovskej obrazovky + Katalógy hero sekcie + %1$d z %2$d vybraných + Zobraziť hero sekciu + Zobraziť hero karusel v hornej časti domovskej obrazovky. + Skryť nevydaný obsah + Skryť filmy a seriály, ktoré ešte neboli vydané. + Skryť podčiarknutie katalógu + Odstrániť zvýrazňovací riadok pod názvami katalógov a kolekcií v celej aplikácii. + %1$d z %2$d katalógov viditeľných • %3$d zdrojov hero sekcie vybraných + Otvorte katalóg iba vtedy, keď ho potrebujete premenovať alebo zmeniť poradie. + Viditeľné + Skryť hodnotu + Prehrávač, titulky a automatické prehrávanie + Polomer rohov + Štýl karty plagátu + Šírka + Vlastné + Nastaviť šírku karty a polomer rohov. + Skryť popisky + Horizontálne plagáty + Živý náhľad + %1$s (%2$s) + Polomer rohov: %1$ddp + Výška: %1$ddp + Šírka: %1$ddp + Klasický + Pilulka + Zaoblený + Ostrý + Jemný + Vyvážený + Komfortný + Kompaktný + Hustý + Veľký + Štandardný + Zobraziť hodnotu + Zobraziť vyskakovacie okno na pokračovanie tam, kde ste skončili, pri otvorení aplikácie po opustení prehrávača. + Výzva na pokračovanie pri spustení + Rozmazať miniatúry ďalších epizód v sekcii Pokračovať v sledovaní, aby sa predišlo spoilerom. + Rozmazať nepozreté v Pokračovať v sledovaní + Zahrnúť nadchádzajúce epizódy do sekcie Pokračovať v sledovaní pred ich vysielaním. + Zobraziť nevysielané ďalšie epizódy + PORADIE ZORADENIA + Poradie zoradenia + Predvolené + Zoradiť všetky položky podľa aktuálnosti + Štýl streamovania + Vydané položky ako prvé, nadchádzajúce na konci + Štýl karty plagátu + PRI SPUSTENÍ + SPRÁVANIE ĎALŠEJ EPIZÓDY + VIDITEĽNOSŤ + Zobraziť policu Pokračovať v sledovaní na domovskej obrazovke. + Zobraziť Pokračovať v sledovaní + Karta + Horizontálna karta v štýle TV + Plagát + Karta plagátu s dôrazom na umelecké dielo + Široká + Horizontálna karta s hustými informáciami + Zobraziť ďalšiu epizódu na základe najďalej pozretej epizódy. Vypnite pre opätovné sledovanie, aby sa použila naposledy pozretá epizóda. + Ďalšia epizóda od najďalej pozretej + Uprednostniť miniatúry epizód, ak sú dostupné. + Uprednostniť miniatúry epizód v Pokračovať v sledovaní + DOMOV + ZDROJE + Inštalovať, odstrániť, obnoviť a zoradiť zdroje obsahu. + Inštalovať repozitáre JavaScript scrapers a testovať poskytovateľov interne. + Upraviť rozloženie domovskej obrazovky, viditeľnosť obsahu a správanie plagátov + Nastavenia pre obrazovky s podrobnosťami a epizódami. + Vytvoriť vlastné skupiny katalógov s priečinkami zobrazenými na domovskej obrazovke. + Integrácie + Ovládacie prvky obohatenia metadát + Poskytovatelia externých hodnotení + Pripojiť účty pre odkazy a prístup ku knižnici + Prepojené služby + Tieto integrácie sú experimentálne a môžu byť zachované, zmenené alebo neskôr odstránené. + Cloudová knižnica + Prehliadať a prehrávať súbory, ktoré sú už vo vašich prepojených účtoch. + Riešiť prehrávateľné odkazy + Požiadať prepojenú službu o prehrávateľné odkazy, keď ich výsledok potrebuje. Toto môže pridať položku do danej služby. + Riešiť pomocou + Vyberte, ktorý prepojený účet spracováva prehrávateľné odkazy. + Najprv pripojte účet. + Účty + Pripojiť váš účet %1$s. + Prepojiť váš účet %1$s v prehliadači. + API kľúč %1$s + Zadajte váš API kľúč %1$s. + Zadajte API kľúč %1$s + Nenastavené + Pripojené + Pripojiť %1$s + Odpojiť %1$s + Odpojiť + %1$s je pripojený na tomto zariadení. + Spúšťa sa bezpečné prihlásenie... + Otvorte odkaz a zadajte tento kód na schválenie Nuvio. + Kód skopírovaný. + Otvoriť odkaz + Čaká sa na schválenie... + Nepodarilo sa spustiť prihlásenie. + Táto metóda prihlásenia nie je nakonfigurovaná v tejto zostave. + Platnosť tohto kódu vypršala. Skúste znova. + Príprava odkazov + Pripraviť odkazy + Riešiť prehrávateľné odkazy pred začatím prehrávania. + Počet odkazov na prípravu + Ak je to možné, použite nižší počet. Prepojené služby môžu obmedzovať počet odkazov, ktoré je možné vyriešiť za určité časové obdobie. Otvorenie filmu alebo epizódy sa môže zarátať do týchto limitov, aj keď nestlačíte Sledovať, pretože sa odkazy pripravujú vopred. + 1 odkaz + %1$d odkazov + Formátovanie + Šablóna názvu + Ovláda, ako sa zobrazujú názvy výsledkov. Nechajte prázdne, aby sa použil pôvodný názov výsledku. + Šablóna popisu + Ovláda metadáta zobrazené pod každým výsledkom. Nechajte prázdne, aby sa použili pôvodné podrobnosti výsledku. + Obnoviť formátovanie + Obnoviť predvolené formátovanie výsledkov. + Štýl Fusion + Odznaky veľkosti + Zobraziť odznaky veľkosti súboru vo výsledkoch streamov a paneloch zdrojov prehrávača. + Logo doplnku + Zobraziť logo a názov doplnku vedľa zdrojov streamov. + Zobrazenie + Poloha odznaku + Vyberte, či sa odznaky Fusion a veľkosti zobrazujú nad alebo pod kartami streamov. + Poloha odznaku + Vyberte, kde sa odznaky streamov zobrazujú na kartách streamov. + Hore + Dole + URL odznakov Fusion + Importovať až %1$d URL JSON odznakov streamov v štýle Fusion. Každú URL je možné aktualizovať alebo odstrániť samostatne. + Spravovať importované URL JSON odznakov streamov v štýle Fusion. + Import odznaku zlyhal. + Zadajte URL JSON odznaku. + URL odznaku musí začínať http:// alebo https://. + Môžete importovať až %1$d URL odznakov. + %1$d/%2$d URL, %3$d aktívnych odznakov Fusion + Žiadne URL odznakov Fusion nie sú importované. + URL JSON odznaku Fusion + %1$d/%2$d URL Fusion importovaných + Aktívne + Neaktívne + %1$s, %2$d povolených odznakov, %3$d skupín + Náhľad + Náhľad odznaku Fusion + %1$d odznakov v štýle Fusion z tejto URL + Žiadne obrázky odznakov v štýle Fusion na tejto URL. + Skupina %1$d + Ostatné odznaky Fusion + API kľúč overený. + Nepodarilo sa overiť tento API kľúč. + Pred zapnutím hodnotení pridajte nižšie váš API kľúč MDBList. + Vyžaduje sa na načítanie hodnotení z MDBList + API kľúč + API kľúč + Povoliť hodnotenia MDBList + Načítať hodnotenia od externých poskytovateľov na obrazovke s podrobnosťami metadát + API kľúč + Poskytovatelia externých hodnotení + Hodnotenia MDBList + Akcie + Ovládacie prvky prehrávania a ukladania. + Obsadenie + Zoznam hlavného obsadenia. + Kinematografické pozadie + Rozmazané pozadie za obsahom, podobne ako na obrazovke streamov. + Kolekcia + Súvisiaca kolekcia alebo franšíza. + Komentáre + Recenzie z Trakt + Podrobnosti + Dĺžka, stav, vydanie, jazyk a súvisiace informácie. + Prehrávanie trailera v hero sekcii + Prehrávať náhľady trailera v hero sekcii metadát, keď je trailer dostupný. + Karty epizód + Vyberte, ako sa epizódy zobrazujú na obrazovke metadát. + Horizontálne + Riadkové karty v štýle pozadia + Zoznam + Stohovité karty s dôrazom na podrobnosti + Epizódy + Série a zoznam epizód pre seriály. + Rozmazať nepozreté epizódy + Rozmazať miniatúry epizód, kým nie sú pozreté, aby sa predišlo spoilerom. + Skupina %1$d + Podobné tituly + Odporúčané pozadia TMDB na stránke s podrobnosťami + Žiadne + Prehľad + Synopsa, hodnotenia, žánre a hlavné kredity. + Produkcia + Štúdiá a siete. + VZHĽAD + SEKCIE + Skupina záložiek %1$d + Rozloženie záložiek + Zoskupiť sekcie do záložiek ako v TV aplikácii. Priradiť až 3 sekcie na skupinu záložiek. + Trailery + Rad trailerov a skratky prehrávania. + Oznámenia sú momentálne vypnuté v Nuvio. + Upozornenia na vydanie epizód + Naplánovať lokálne oznámenia, keď bude dostupná nová epizóda uloženého seriálu. + Systémové oznámenia sú pre Nuvio vypnuté. Povoľte ich, aby ste dostávali upozornenia a testovacie oznámenia. + %1$d upozornení na vydanie je momentálne naplánovaných na tomto zariadení. + UPOZORNENIA + TEST + Odoslať testovacie oznámenie + Odosielanie testovacieho oznámenia... + Odoslať lokálne testovacie oznámenie pre %1$s. + Najprv uložte seriál do knižnice, aby ste mohli testovať oznámenia. + Testovacie oznámenie + Komunita + Pozrite sa na ľudí, ktorí budujú a podporujú Nuvio na Mobile, TV a Webe. + Server a údržba tohto mesiaca + Pokryté. Ďalšia podpora ide na vývoj. + Po 100 % ide ďalšia podpora na vývoj. + Načítavanie postupu financovania... + API podporovateľov nie je nakonfigurované. Pridajte DONATIONS_BASE_URL do local.properties. + Prispievatelia + Podporovatelia + Otvoriť GitHub + Profil GitHub nie je dostupný + Žiadna správa nie je priložená. + Načítavanie prispievateľov... + Načítavanie podporovateľov... + Nepodarilo sa načítať prispievateľov + Nepodarilo sa načítať podporovateľov + Nenašli sa žiadni prispievatelia. + Nenašli sa žiadni podporovatelia. + Nepodarilo sa načítať prispievateľov. + Nepodarilo sa načítať podporovateľov. + Momentálne sa nepodarilo načítať prispievateľov. + Momentálne sa nepodarilo načítať podporovateľov. + %1$d celkových commitov + Jan + Feb + Mar + Apr + Máj + Jún + Júl + Aug + Sep + Okt + Nov + Dec + %1$s %2$s, %3$s + Všetky nainštalované doplnky + Všetky povolené pluginy + Povolené doplnky + Povolené pluginy + Anime Skip + ID klienta AnimeSkip + Zadajte ID klienta API AnimeSkip. Získajte ho na anime-skip.com. + Povoliť odosielanie intro + Zobraziť tlačidlo na odosielanie časových pečiatok intro/outro do komunitnej databázy. + API kľúč IntroDB + Zadajte váš API kľúč IntroDB na odosielanie časových pečiatok. Vyžaduje sa na odosielanie. + Tiež vyhľadávať v AnimeSkip časové pečiatky preskočenia (vyžaduje ID klienta). + Automaticky prehrať ďalšiu epizódu + Spustiť ďalšiu epizódu automaticky, keď sa zobrazí výzva. + Iba dekodéry zariadenia + Uprednostniť dekodéry aplikácie (FFmpeg) + Uprednostniť dekodéry zariadenia + Priorita dekodéra + Klepnutím mimo zatvoríte + Klepnutím mimo uložíte a zatvoríte + %1$d deň + %1$d dní + %1$d hodina + %1$d hodín + Použiť libass pre titulky ASS/SSA + Experimentálne: pokročilé vykresľovanie ASS/SSA (štýly, polohovanie, animácie) + Externý prehrávač + Aplikácia externého prehrávača + Otvoriť nové prehrávanie s predvolenou video aplikáciou Androidu alebo systémovým výberom. + Otvoriť nové prehrávanie s vybraným nainštalovaným prehrávačom. + Preposlať titulky do externého prehrávača + Načítať titulky doplnku vo vašom preferovanom jazyku a odovzdať ich externému prehrávači. + Nie sú nainštalované žiadne podporované externé prehrávače + Prehrávač + Interný + Externý + Vyberte, ktorý prehrávač spracováva nové prehrávanie. + Rýchlosť podržania + Podržať pre rýchlosť + Dlhým stlačením kdekoľvek na povrchu prehrávača dočasne zvýšite rýchlosť prehrávania. + Dotykové gestá + Povoliť potiahnutia a dvojité klepnutia na povrchu prehrávača na posúvanie, nastavenie jasu alebo hlasitosti. + Neplatný vzor regex + Trvanie vyrovnávacej pamäte posledného odkazu + DV7 - záloha HEVC + Mapovať Dolby Vision Profile 7 na štandardný HEVC pre zariadenia bez hardvérovej podpory DV + Prahové minúty + Záloha, keď neexistuje časová pečiatka outro. + %1$s min + Nie sú dostupné žiadne položky + Nenastavené + Predvolené (mediálny súbor) + Jazyk zariadenia + Vynútené + Žiadne + Všetky titulky + Načítať a zobraziť všetky titulky doplnku pre video. + Rýchle spustenie + Preskočiť automatické načítanie titulkov doplnku, kým ich nepožiadate v prehrávači. + Spustenie titulkov doplnku + Iba preferované + Načítať titulky doplnku, ale zobraziť iba zhody v preferovanom jazyku. + Uprednostniť skupinu binge (ďalšia epizóda) + Najprv vyskúšať rovnaký profil zdroja (rovnaká skupina doplnku/kvality) pred bežnými pravidlami automatického prehrávania. + Znovu použiť skupinu binge + Zapamätať a znovu použiť poslednú skupinu binge naprieč reláciami (Pokračovať v sledovaní, Podrobnosti atď.). + Preferovaný jazyk zvuku + Preferovaný jazyk + Predvoľby + Porovnáva sa s názvom/titulom/popisom/doplnkom/URL streamu. Príklad: 4K|2160p|Remux + Vzor regex + Žiadny vzor nenastavený. Príklad: 4K|2160p|Remux + Akékoľvek 1080p+ + AVC / x264 + Kvalita BluRay + Dolby Atmos / DTS + Angličtina + HDR / Dolby Vision + HEVC / x265 + Bez CAM/TS + Bez REMUX/HDR + 1080p štandard + 4K / Remux + 720p / Menšie + WEB zdroje + Režim vykresľovania Libass + Štandardné cues + Efekty Canvas + Efekty OpenGL + Prekrytie Canvas + Prekrytie OpenGL (odporúčané) + Znovu použiť posledný odkaz + Automaticky prehrať posledný funkčný stream pre rovnaký film/epizódu, keď je vyrovnávacia pamäť stále platná + Sekundárny jazyk zvuku + Sekundárny preferovaný jazyk + DEKODÉR + ĎALŠIA EPIZÓDA + PREHRÁVAČ + PRESKOČIŤ SEGMENTY + AUTOMATICKÉ PREHRÁVANIE STREAMU + VÝBER STREAMU + TITULKY A ZVUK + VYKRESĽOVANIE TITULKOV + %1$d vybraných + Prekrytie načítavania + Zobraziť obrazovku načítavania, kým sa neobjaví prvý snímok videa. + Farba pozadia + Tučné + Použiť hrubšiu váhu písma titulkov. + Priehľadné + Obrys + Farba obrysu + Nakresliť okraj okolo textu titulkov. + Zobraziť iba preferované jazyky + Zobraziť iba titulky zodpovedajúce vašim preferovaným jazykom titulkov. + Veľkosť titulkov + Farba textu + Použiť vynútené titulky + Uprednostniť vynútené titulky pri zhode s nastaveniami jazyka titulkov. + Vertikálny posun + Preskočiť intro + Použiť introdb.app na detekciu intro a rekapitulácií. + Rozsah zdrojov automatického prehrávania + Všetky nainštalované doplnky + Automatické prehrávanie zohľadňuje iba streamy z vašich nainštalovaných doplnkov. + Všetky zdroje + Automatické prehrávanie môže používať nainštalované doplnky aj povolené pluginy. + Iba povolené pluginy + Automatické prehrávanie zohľadňuje iba streamy z povolených pluginov. + Iba nainštalované doplnky + Automatické prehrávanie zohľadňuje iba streamy z vašich nainštalovaných doplnkov. + Automatický výber streamu + Automaticky prehrať prvý zdroj + Automaticky prehrať prvý dostupný zdroj. + Manuálne (vybrať stream) + Vždy zobraziť zoznam zdrojov a nechať ma vybrať. + Automatické prehrávanie podľa regex + Prehrať prvý zdroj, ktorého text zodpovedá vášmu vzoru regex. + Časový limit výberu streamu + Čas čakania na doplnky pred výberom. + Prahové minúty + Režim prahu ďalšej epizódy + Minúty pred koncom + Percentuálne + Prahové percento + Záloha, keď neexistuje časová pečiatka outro. + %1$s% + Okamžite + %1$ss + Neobmedzene + Tunelované prehrávanie + Synchronizácia zvuku/videa na hardvérovej úrovni. Môže zlepšiť prehrávanie na niektorých zariadeniach Android TV + Pred zapnutím obohatenia pridajte nižšie vlastný API kľúč TMDB. + API kľúč + Povoliť obohatenie TMDB + Použiť TMDB ako zdroj metadát na vylepšenie dát doplnku + Zadajte váš API kľúč TMDB v3. + Kód jazyka + Umelecké diela + Obrázky loga a pozadia z TMDB + Základné informácie + Popis, žánre a hodnotenie z TMDB + Kolekcie + Filmové kolekcie TMDB v poradí vydania + Kredity + Obsadenie s fotografiami, režisér a scenárista z TMDB + Podrobnosti + Dĺžka, stav, krajina a jazyk z TMDB + Epizódy + Názvy epizód, prehľady, miniatúry a dĺžka z TMDB + Podobné tituly + Odporúčané pozadia TMDB na stránke s podrobnosťami + Siete + Siete s logami z TMDB + Produkcie + Produkčné spoločnosti z TMDB + Plagáty sérií + Použiť plagáty sérií TMDB vo výbere sérií na obrazovke metadát pre seriály. + Trailery + Kandidáti na trailer z videí TMDB pre sekciu trailerov v podrobnostiach + Osobný API kľúč + Jazyk + Jazyk metadát TMDB pre názov, logo a povolené polia + PRIHLASOVACIE ÚDAJE + LOKALIZÁCIA + MODULY + Obohatenie TMDB + Po schválení budete automaticky presmerovaný späť. + AUTENTIFIKÁCIA + Komentáre + Zobraziť recenzie Trakt na stránkach metadát + Pripojiť Trakt + Pripojený ako %1$s + Používateľ Trakt + Odpojiť + Nepodarilo sa otvoriť prehliadač + FUNKCIE + Dokončite prihlásenie do Trakt vo vašom prehliadači + Synchronizujte váš zoznam sledovania, postup sledovania, pokračovanie v sledovaní, scrobbles a osobné zoznamy s Trakt. + Chýbajúce prihlasovacie údaje Trakt v local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Otvoriť prihlásenie Trakt + Vaše akcie uloženia môžu teraz cieliť na zoznam sledovania Trakt a osobné zoznamy. + Prihláste sa pomocou Trakt, aby ste povolili ukladanie na základe zoznamov a režim knižnice Trakt. + Zdroj knižnice + Vyberte, ktorú knižnicu použiť na ukladanie a prezeranie vašej kolekcie + Zdroj knižnice + Vyberte, kde ukladať a spravovať položky knižnice + Trakt + Knižnica Nuvio + Vybraná knižnica Trakt + Vybraná knižnica Nuvio + Postup sledovania + Vyberte, ktorý zdroj postupu poháňa pokračovanie a sledovanie + Postup sledovania + Vyberte, či má pokračovanie a sledovanie používať Trakt alebo Nuvio Sync, kým zostáva aktívne scrobbling Trakt. + Trakt + Nuvio Sync + Zdroj postupu sledovania nastavený na Trakt + Zdroj postupu sledovania nastavený na Nuvio Sync + Zdroj podobných titulov + Vyberte, odkiaľ pochádzajú odporúčania na stránkach s podrobnosťami + Zdroj podobných titulov + Vyberte zdroj odporúčaní zobrazených na stránkach s podrobnosťami. + Trakt + TMDB + Okno pokračovania v sledovaní + História Trakt zohľadnená pre pokračovanie v sledovaní + Okno pokračovania v sledovaní + Vyberte, koľko aktivity Trakt sa má zobrazovať v pokračovaní v sledovaní. + Celá história + %1$d dní + Skóre divákov + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Neznámy + Jantárová + Karmínová + Smaragdová + Oceán + Ružová + Fialová + Biela + Ďalšia epizóda + Hľadanie zdroja… + Prehráva sa cez %1$s za %2$d… + Miniatúra ďalšej epizódy + Nevysielané + Preskočiť + Preskočiť intro + Preskočiť outro + Preskočiť rekapituláciu + Nenašli sa žiadne titulky + Afrikánčina + Albánčina + Amharčina + Arabčina + Arménčina + Azerbajdžančina + Baskičtina + Bieloruština + Bengálčina + Bosniačtina + Bulharčina + Barmčina + Katalánčina + Čínčina + Čínčina (zjednodušená) + Čínčina (tradičná) + Chorvátčina + Čeština + Dánčina + Holandčina + Angličtina + Estónčina + Filipínčina + Fínčina + Francúzština + Galícijčina + Gruzínčina + Nemčina + Gréčtina + Gudžarátčina + Hebrejčina + Hindčina + Maďarčina + Islandčina + Indonézčina + Írčina + Taliančina + Japončina + Kannadčina + Kazaština + Khmérčina + Kórejčina + Laoština + Lotyština + Litovčina + Macedónčina + Malajčina + Malajálamčina + Maltčina + Maráthčina + Mongolčina + Nepálčina + Nórčina + Perzština + Poľština + Portugalčina (Portugalsko) + Portugalčina (Brazília) + Pandžábčina + Rumunčina + Ruština + Srbčina + Sinhalčina + Slovenčina + Slovinčina + Španielčina + Španielčina (Latinská Amerika) + Svahilčina + Švédčina + Tamilčina + Telugčina + Thajčina + Turečtina + Ukrajinčina + Urdčina + Uzbečtina + Vietnamčina + Waleština + Zuluština + Vymazať + Pokračovať + Ignorovať + Inštalovať + Neskôr + Nie + Aktualizovať + Áno + Chcete ukončiť aplikáciu? + Ukončiť aplikáciu + Tento katalóg nevrátil žiadne položky. + Nenašli sa žiadne tituly + Ďalšie akcie + Skontrolujte pripojenie Wi-Fi alebo mobilné dáta a skúste znova. + Režisér + Nepodarilo sa načítať + Podobné tituly + Poháňané TMDB + Poháňané Trakt + Série + Tento doplnok vrátil videá pre seriál, ale žiadne neobsahovalo čísla série alebo epizódy. + Tento doplnok neposkytol metadáta epizód pre tento seriál. + Epizódy ešte neboli zverejnené týmto doplnkom. + Vaše zariadenie je online, ale Nuvio nemohlo dosiahnuť požadované servery. + Zobraziť menej + Zobraziť viac ▾ + Scenárista + Všetky žánre + Katalóg + %1$s • %2$s + Vybraný katalóg nevrátil položky objavovania. + Nepodarilo sa načítať objavovanie + Nainštalované doplnky nezverejňujú katalógy kompatibilné s tabuľou pre objavovanie. + Žiadne katalógy objavovania + Vybraný katalóg a filtre nevrátili žiadne položky. + Nenašli sa žiadne tituly + Pred prehliadaním katalógov objavovania nainštalujte a overte aspoň jeden doplnok. + Vybrať katalóg + Vybrať žáner + Vybrať typ + Typ + Označiť predchádzajúce ako nepozreté + Označiť predchádzajúce ako pozreté + Označiť %1$s ako nepozretú + Označiť %1$s ako pozretú + Označiť predchádzajúce série ako pozreté + Označiť ako nepozreté + Označiť ako pozreté + Ďalšie na rade + %1$s pozreté + Zostatok %1$dh %2$dm + Zostatok %1$dm + Pred načítaním riadkov katalógu na domovskej obrazovke nainštalujte a overte aspoň jeden doplnok. + Nainštalované doplnky momentálne nezverejňujú katalógy kompatibilné s tabuľou bez požadovaných doplnkov. + Žiadne riadky domovskej obrazovky + Zobraziť podrobnosti + Ovládacie prvky prehrávania a ukladania. + Akcie + Zoznam hlavného obsadenia. + Súvisiaca kolekcia alebo franšíza. + Kolekcia + Sekcia komentárov Trakt. + Dĺžka, stav, vydanie, jazyk a súvisiace informácie. + Podrobnosti + Série a zoznam epizód pre seriály. + Rad odporúčaní. + Podobné tituly + Synopsa, hodnotenia, žánre a hlavné kredity. + Prehľad + Štúdiá a siete. + Produkcia + Rad trailerov a skratky prehrávania. + Späť online + Nie je možné dosiahnuť servery + Prázdne telo odpovede + Požiadavka zlyhala s HTTP %1$d + Žiadne internetové pripojenie + (vek %1$d) + Narodený %1$s%2$s + Zomrel %1$s + Známy pre: %1$s + Najnovšie + Nepodarilo sa načítať podrobnosti pre %1$s + Populárne + Niečo sa pokazilo + Nadchádzajúce + Vymazať + Zrušiť + Zadať PIN + Zadať PIN pre %1$s + Zabudli ste PIN? + Nesprávny PIN + Zamknuté. Skúste znova za %1$ds + Možnosti avatara sa zobrazia tu, keď sa katalóg načíta. + Avatar: %1$s + Zadajte platnú URL obrázka http:// alebo https://. + Vybrať avatar + Vyberte avatar nižšie. + Vytvoriť profil + Vybraná vlastná URL avatara. + Vlastná URL avatara + Vložte odkaz na obrázok alebo nechajte prázdne, aby sa použil vstavaný katalóg avatarov. + https://example.com/avatar.png + Všetky dáta pre „%1$s" budú natrvalo odstránené. + Odstrániť profil + Pridať profil + Upraviť profil + Zadajte aktuálny PIN + Zadajte nový PIN + Profil %1$d + Načítavanie avatarov... + Spravovať profily + Názov profilu + Nový profil + Primárne doplnky vypnuté + Primárne doplnky zapnuté + Odstrániť PIN pre %1$s + Odstrániť zámok PIN + Ukladanie... + Zabezpečenie + Pridajte PIN, ak chcete tento profil zamknúť pred prepnutím do neho. + Tento profil je chránený PINom. + Vyberte avatar pre tento profil. + Nastaviť zámok PIN + Nepomenovaný profil + Použiť primárne doplnky + Zdieľať nastavenie doplnkov hlavného profilu namiesto správy samostatného zoznamu. + Kto sleduje? + Stiahnuté + Pokračovať + Aktívne scrapery + Kontrola ďalších doplnkov… + Kopírovať odkaz na stream + Stiahnuť súbor + Otvoriť v externom prehrávači + Otvoriť vo vstavanom prehrávači + Nainštalované doplnky streamov nevrátili platnú odpoveď streamu. + Nepodarilo sa načítať streamy + Najprv nainštalujte doplnok na načítanie streamov pre tento titul. + Vaše nainštalované doplnky neposkytujú streamy pre tento typ titulu. + Žiadny doplnok streamov nie je dostupný + Žiadny z vašich nainštalovaných doplnkov nevrátil streamy pre tento titul. + S%1$d E%2$d + Epizóda + S%1$dE%2$d - %3$s + Načítavanie… + Hľadanie zdroja… + Načítavanie titulkov z doplnkov… + Hľadanie streamov… + Odkaz na stream skopírovaný + Žiadny priamy odkaz na stream nie je dostupný + Žiadne metadáta nie sú dostupné + Obnoviť streamy + Pokračovať od %1$d%% + Pokračovať od %1$s + VEĽKOSŤ %1$s + Tento typ streamu nie je podporovaný + Pripojte účet v Nastaveniach. + Nie je uložené v cache na Torbox. + Platnosť tohto odkazu vypršala. Obnovujú sa výsledky. + Nepodarilo sa otvoriť tento odkaz. + Nepodarilo sa otvoriť externý prehrávač + Najprv vyberte externý prehrávač v nastaveniach + Žiadny externý prehrávač nie je dostupný + Zavrieť trailer + Nepodarilo sa prehrať trailer + Nepodarilo sa načítať zoznamy Trakt + Nepodarilo sa aktualizovať zoznamy Trakt + %1$s • %2$s + Kontrola aktualizácií zlyhala + Sťahovanie zlyhalo + Prázdne telo sťahovania + Stiahnutý súbor aktualizácie chýba. + Sťahovanie %1$d%% + Nepodarilo sa spustiť inštaláciu + Používate najnovšiu verziu. + Povoľte inštalácie aplikácií pre Nuvio a potom sa vráťte a pokračujte. + Sťahovanie aktualizácie... + Nenašli sa žiadne aktualizácie. + Nová verzia je pripravená na inštaláciu. + Aktualizácie v aplikácii nie sú dostupné v tejto zostave. + Príprava sťahovania + Poznámky k vydaniu + Povoliť inštalácie na pokračovanie + Aktualizácia dostupná + Stav aktualizácie + Tento doplnok je už nainštalovaný. + Zadajte platnú URL doplnku + Nepodarilo sa načítať manifest + Nuvio + Odstránenie účtu zlyhalo + Prihlásenie zlyhalo + Odhlásenie zlyhalo + Registrácia zlyhala + Nepodarilo sa načítať položky katalógu. + Ďalšie na rade + Ďalšie na rade • S%1$dE%2$d + Logo %1$s + Nepodarilo sa načítať komentáre + Nepodarilo sa načítať podrobnosti z žiadneho doplnku. + Siete + Žiadny doplnok neposkytuje metadáta pre tento obsah. + Sťahovanie zlyhalo + Zobrazuje živý postup sťahovania a ovládacie prvky. + Stiahnuté + Sťahovanie dokončené + Sťahovanie %1$s • %2$s + Sťahovanie %1$s • %2$s / %3$s + Sťahovanie zlyhalo + Pozastavené %1$s + Odstrániť + Odstrániť %1$s zo %2$s? + Odstrániť %1$s z vašej knižnice? + Odstrániť z knižnice? + Film + Upozornenia, keď je vydaná nová epizóda uloženého seriálu. + Náhľad upozornenia na vydanie epizódy. + Nepodarilo sa odoslať testovacie oznámenie. + Testovacie oznámenie odoslané pre %1$s. + Nepodarilo sa prehrať tento stream. + Engine prehrávača MPV nie je dostupný. Prosím, znovu zostavte aplikáciu. + PIN tohto profilu sa zmenil. Pripojte sa raz, aby ste obnovili zámok na tomto zariadení. + Nepodarilo sa odstrániť zámok PIN. Skúste znova. + Pripojte sa na internet, aby ste odstránili zámok PIN. + Tento PIN ešte nie je možné overiť offline na tomto zariadení. Pripojte sa raz a odomknite ho online. + Nepodarilo sa nastaviť PIN. Skúste znova. + Pripojte sa na internet, aby ste nastavili PIN. + Tento profil používa primárne doplnky. + Nepodarilo sa načítať %1$s + Stream + Vložené + Autorizácia zamietnutá + Dokončite prihlásenie do Trakt vo vašom prehliadači + Pripojený k Trakt + Odpojený od Trakt + Neplatný callback Trakt + Neplatný stav callbacku Trakt + Neplatná odpoveď tokenu Trakt + Nepodarilo sa načítať knižnicu Trakt + Zoznam %1$d + Trakt nevrátil autorizačný kód + Chýbajúce prihlasovacie údaje Trakt + Nepodarilo sa načítať postup Trakt + Verejný zoznam Trakt + Zadajte platné ID alebo URL zoznamu Trakt + %1$d položiek + %1$d páči sa + Chýbajúce prihlasovacie údaje Trakt v local.properties (TRAKT_CLIENT_ID). + Chýbajúce ID zoznamu Trakt + Zoznam Trakt neobsahoval číselné ID + Zoznam Trakt sa nenašiel alebo nie je verejný + Dosiahnutý limit rýchlosti Trakt + Požiadavka Trakt zlyhala + Nepodarilo sa dokončiť prihlásenie do Trakt + Používateľ Trakt + Zoznam sledovania + + Platnosť autorizácie Trakt vypršala + Zoznam Trakt sa nenašiel + Dosiahnutý limit zoznamu Trakt + Dosiahnutý limit rýchlosti Trakt + Požiadavka Trakt zlyhala + Nepodarilo sa pridať do zoznamu sledovania Trakt + Nepodarilo sa pridať do zoznamu Trakt + Chýbajúce kompatibilné ID Trakt + Prázdne telo odpovede + Pridajte API kľúč TMDB v Nastaveniach, aby ste mohli používať zdroje TMDB. + Kolekcia TMDB %1$d + Kolekcia TMDB sa nenašla + Produkcia TMDB %1$d + Spoločnosť TMDB sa nenašla + Režisér TMDB %1$d + Objavovanie TMDB nevrátilo žiadne dáta + Objavovanie TMDB + Zadajte platné ID alebo URL TMDB. + Zoznam TMDB %1$d + Zoznam TMDB sa nenašiel + Nepodarilo sa načítať zdroj TMDB + Chýbajúce ID kolekcie TMDB + Chýbajúce ID zoznamu TMDB + Chýbajúce ID osoby TMDB + Sieť TMDB %1$d + Sieť TMDB sa nenašla + Kredity osoby TMDB sa nenašli + Osoba TMDB %1$d + Osoba TMDB sa nenašla + Trailer + Neznáme + Doplnok + Uložené + Prehrať %1$s + Pokračovať v %1$s + JSON je prázdny. + Kolekcia %1$d má prázdne ID. + Kolekcia „%1$s" má prázdny názov. + Priečinok %1$d v „%2$s" má prázdne ID. + Priečinok „%1$s" v „%2$s" má prázdny názov. + Zdroj %1$d v priečinku „%2$s" má prázdne polia. + Zdroju %1$d v priečinku „%2$s" chýba ID zoznamu Trakt. + Neplatný JSON: %1$s + Doplnok sa nenašiel: %1$s + Filmový zoznam Trakt + Seriálový zoznam Trakt + Január + Február + Marec + Apríl + Máj + Jún + Júl + August + September + Október + November + December + Jan + Feb + Mar + Apr + Máj + Jún + Júl + Aug + Sep + Okt + Nov + Dec + Produkčná spoločnosť + Sieť + Nepodarilo sa načítať %1$s + Populárne + Nedávne + %1$s • %2$s + Najlepšie hodnotené + Certifikácia + Podrobnosti o filme + Pôvodný jazyk + Krajina pôvodu + Informácie o vydaní + Dĺžka + Plagáty + Text + Podrobnosti o seriáli + Stav + Videá + SÚBOR + Žiadny priamy odkaz na stream nie je dostupný + Predchádzajúce sťahovanie nahradené + Sťahovanie spustené + Nepodporovaný formát streamu pre sťahovanie + Prázdne telo odpovede + Nepodarilo sa dokončiť súbor sťahovania + Požiadavka zlyhala s HTTP %1$d + Systém sťahovania nie je inicializovaný + Nepodarilo sa otvoriť čiastočný súbor sťahovania + Čiastočný súbor sťahovania nie je otvorený + Požiadavka sťahovania zlyhala + Nepodarilo sa zapísať čiastočný súbor sťahovania + %1$s - %2$s + Uložené tituly sa zobrazia tu po klepnutí na Uložiť na obrazovke s podrobnosťami. + Vaša knižnica je prázdna + Nepodarilo sa načítať knižnicu + Ostatné + Cloud + Uložené + Knižnica + Pripojte Trakt a uložte tituly do zoznamu sledovania alebo osobných zoznamov. + Vaša knižnica Trakt je prázdna + Nepodarilo sa načítať knižnicu Trakt + Knižnica Trakt + Pripojiť účet + Pripojte účet v nastaveniach Prepojených služieb, aby ste mohli prehliadať prehrávateľné súbory z vašej cloudovej knižnice. + Žiadny cloudový účet nie je pripojený + Otvoriť Prepojené služby + Zapnite Cloudovú knižnicu v nastaveniach Prepojených služieb, aby ste mohli prehliadať súbory z prepojených účtov. + Cloudová knižnica je vypnutá + Žiadne prehrávateľné cloudové súbory nezodpovedajú aktuálnym filtrom. + Zatiaľ tu nič nie je + Vyberte súbor na prehranie + Nepodarilo sa načítať cloudovú knižnicu %1$s + Táto položka nezverejňuje prehrávateľný video súbor. + Žiadne prehrávateľné súbory + Žiadne prehrávateľné súbory + Cloudová knižnica je vypnutá. + Nepodarilo sa prehrať tento cloudový súbor. + Prehrať súbor + Cloudová služba nie je pripojená. + %1$s nie je pripojený. + %1$d prehrávateľných súborov + Všetky + Obnoviť cloudovú knižnicu + Vybrať poskytovateľa + Vybrať typ + Pripravené na prehranie + Všetky + Torrenty + Usenet + Web + Súbory + Anime + Kanály + Filmy + Seriály + TV + %1$s je teraz dostupný + %1$s • %2$s je teraz dostupný + Nová epizóda je teraz dostupná + %1$s je teraz dostupný + Vydania epizód + Alkohol/Drogy + Desivé + Nahota + Vulgárny jazyk + Mierny + Stredný + Závažný + Násilie + Tvorca + Režisér + Scenárista + Skóre divákov + Nenašiel sa žiadny prehrávateľný stream trailera. + Séria %1$d - %2$s + B + KB + MB + GB + + Odoslať intro + Nastavenia videa + + Cloudová knižnica nie je dostupná pre %1$s. + + Video + Obnoviť ladenie + Predvoľba výstupu + Detekcia vrcholu HDR + Odhadnúť vrcholový jas HDR, keď sú metadáta chybné alebo chýbajú. + Mapovanie tónov + Deband + Znížiť farebné pruhovanie za malú cenu výkonu. + Interpolácia snímkov + Plynulý pohyb, keď mpv môže čisto použiť synchronizáciu displeja. + Jas + Kontrast + Sýtosť + Gama + + Natívny EDR + Najlepšie pre iPhony a iPady s podporou HDR. + SDR s mapovaním tónov + Predvídateľnejšie biele a čierne na výstupe v štýle SDR. + Kompatibilita + Najbližšie k staršiemu správaniu iOS MPV. + Vlastné + Použiť vaše pokročilé hodnoty nižšie. + Vypnuté + + Výstup videa iOS + Hardvérový dekodér + Rozšírený dynamický rozsah + Predvolený režim výstupu Metal pre nové relácie prehrávania. + Nápoveda farby displeja + Nechať mpv predvolene cieliť na aktívny farebný priestor displeja. + Cieľové primárne farby + Cieľový prenos + + Výstup zvuku iOS + Výstup zvuku + Najprv vyskúšať AVFoundation, potom zálohovať na AudioUnit. + Experimentálna podpora priestorového zvuku a viackanálového výstupu. + Použiť starší výstup AudioUnit. + + Správa výsledkov + Maximálny počet výsledkov + Obmedziť počet zobrazených výsledkov. + Zoradiť výsledky + Vyberte spôsob zoradenia výsledkov. + Limit na rozlíšenie + Obmedziť opakujúce sa výsledky 2160p, 1080p, 720p po zoradení. + Limit na kvalitu + Obmedziť opakujúce sa výsledky BluRay, WEB-DL, REMUX po zoradení. + Rozsah veľkosti + Filtrovať výsledky podľa veľkosti súboru. + + Zistiť viac + Predvolený formát + Pôvodný formát + Zadajte jednu skupinu na riadok. + + Pôvodné poradie + Najlepšia kvalita ako prvá + Najväčšie ako prvé + Najmenšie ako prvé + Najlepší zvuk ako prvý + Jazyk ako prvý + + Akýkoľvek + %1$d vybraných + + Všetky výsledky + %1$d výsledkov + + Až do %1$dGB + %1$dGB+ + %1$d-%2$dGB + + Preferované rozlíšenia + Zoradiť vybrané rozlíšenia ako prvé, v predvolenom poradí. + Požadované rozlíšenia + Zobraziť iba vybrané rozlíšenia. + Vylúčené rozlíšenia + Skryť vybrané rozlíšenia. + + Preferované kvality + Zoradiť vybrané kvality ako prvé, v predvolenom poradí. + Požadované kvality + Zobraziť iba vybrané kvality. + Vylúčené kvality + Skryť vybrané kvality. + + Preferované vizuálne značky + Zoradiť DV, HDR, 10bit, IMAX a podobné značky. + Požadované vizuálne značky + Vyžadovať DV, HDR, 10bit, IMAX, SDR a podobné značky. + Vylúčené vizuálne značky + Skryť DV, HDR, 10bit, 3D a podobné značky. + + Preferované zvukové značky + Zoradiť Atmos, TrueHD, DTS, AAC a podobné značky. + Požadované zvukové značky + Vyžadovať Atmos, TrueHD, DTS, AAC a podobné značky. + Vylúčené zvukové značky + Skryť vybrané zvukové značky. + + Preferované kanály + Zoradiť preferované rozloženia kanálov ako prvé. + Požadované kanály + Zobraziť iba vybrané rozloženia kanálov. + Vylúčené kanály + Skryť vybrané rozloženia kanálov. + + Preferované kódovania + Zoradiť AV1, HEVC, AVC a podobné kódovania. + Požadované kódovania + Vyžadovať AV1, HEVC, AVC a podobné kódovania. + Vylúčené kódovania + Skryť vybrané kódovania. + + Preferované jazyky + Zoradiť preferované zvukové jazyky ako prvé. + Požadované jazyky + Zobraziť iba výsledky s vybranými jazykmi. + Vylúčené jazyky + Skryť výsledky, kde sú všetky jazyky vylúčené. + + Požadované release skupiny + Zobraziť iba vybrané release skupiny. + Vylúčené release skupiny + Skryť vybrané release skupiny. + + Odoslať časové značky + TYP SEGMENTU + Intro + Rekapitulácia + Outro + ZAČIATOČNÝ ČAS (MM:SS) + KONCOVÝ ČAS (MM:SS) + Odoslať + Zachytiť + + Hardvérový dekodér + Zvukový výstup + Cieľové primárne farby + Cieľový prenos + + Vyriešený Trakt zoznam + TMDB Objavovanie + US, KR, JP, IN + + Zadajte názov, URL alebo ID Trakt zoznamu + Zadajte platné TMDB ID alebo URL. + Nepodarilo sa načítať TMDB zdroj + Zadajte ID alebo URL Trakt zoznamu + Nepodarilo sa načítať Trakt zoznam + + Kanada + Austrália + Nemecko + + Filmy + Seriály + + Cloudová knižnica je vypnutá. + + Neplatný API kľúč alebo zlyhalo pripojenie + V manifeste chýba \"%1$s\" + TMDB Kolekcia %1$s + TMDB Režisér %1$s + TMDB Objavovanie + Zadajte platné TMDB ID alebo URL. + TMDB Zoznam %1$s + TMDB Sieť %1$s + TMDB Osoba %1$s + TMDB Produkcia %1$s + Nepodarilo sa načítať TMDB zdroj + ID %1$s + Pridajte TMDB API kľúč v Nastaveniach na použitie TMDB zdrojov. + TMDB kolekcia nebola nájdená + TMDB spoločnosť nebola nájdená + TMDB objavovanie nevrátilo žiadne dáta + TMDB zoznam nebol nájdený + Chýba TMDB ID kolekcie + Chýba TMDB ID zoznamu + Chýba TMDB ID osoby + TMDB sieť nebola nájdená + TMDB kredity osoby neboli nájdené + TMDB osoba nebola nájdená + Chýbajú Trakt prihlasovacie údaje. + %1$s (%2$d) + Zadajte platné ID alebo URL Trakt zoznamu + %1$d položiek + %1$d páči sa mi + Trakt zoznam nebol nájdený alebo nie je verejný + Chýba ID Trakt zoznamu + Trakt zoznam neobsahoval číselné ID + Trakt verejný zoznam + Dosiahnutý limit Trakt požiadaviek + Trakt požiadavka zlyhala + Nepodarilo sa načítať Trakt komentáre (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Nepodarilo sa dokončiť súbor na stiahnutie + Nepodarilo sa otvoriť čiastočný súbor na stiahnutie + Čiastočný súbor na stiahnutie nie je otvorený + Nepodarilo sa zapísať čiastočný súbor na stiahnutie + Nuvio Knižnica + Problém s pripojením + Prázdne telo odpovede + Skontrolujte prosím svoje pripojenie a skúste to znova. + Požiadavka zlyhala s HTTP %1$d + %1$s (%2$s) + MPV prehrávač nie je k dispozícii. Prosím, znovu zostavte aplikáciu. + Nie je možné prehrať tento stream. + Pluginy vypnuté + Pluginy zapnuté + %1$d poskytovateľov + Obnovuje sa + %1$d repozitárov + Chýba TMDB API kľúč + TMDB API kľúč nastavený + Nainštalovať repozitár pluginov + Inštaluje sa… + Otestovať poskytovateľa + Testuje sa… + Odstrániť repozitár pluginov + Obnoviť repozitár pluginov + Zatiaľ nie sú k dispozícii žiadni poskytovatelia. + Pridajte URL repozitára na inštaláciu pluginov poskytovateľov pre vyhľadávanie streamov. + Zatiaľ nie sú nainštalované žiadne repozitáre pluginov. + Použiť pluginy poskytovateľov počas vyhľadávania streamov. + Povoliť pluginy poskytovateľov globálne + Tento repozitár pluginov je už nainštalovaný. + Zadajte URL repozitára pluginov. + Zadajte platnú URL pluginu. + Nepodarilo sa nainštalovať repozitár pluginov + Poskytovateľ nebol nájdený + Nepodarilo sa obnoviť repozitár + Pluginy nie sú v tomto zostavení k dispozícii. + V Streamoch zobraziť jedného poskytovateľa na repozitár namiesto jedného na zdroj. + Zoskupiť pluginy poskytovateľov podľa repozitára + URL manifestu pluginu + Chýba názov manifestu. + Manifest nemá poskytovateľov. + Chýba verzia manifestu. + Chýba názov manifestu. + Manifest nemá poskytovateľov. + Chýba verzia manifestu. + Nainštalované %1$s. + Vypnuté repozitárom + Bez popisu + v%1$s + Repozitár pluginov + Verzia %1$s + Tento repozitár pluginov je už nainštalovaný. + Nepodarilo sa nainštalovať repozitár pluginov + Nepodarilo sa obnoviť repozitár + PRIDAŤ REPOZITÁR + NAINŠTALOVANÉ REPOZITÁRE + PREHĽAD + POSKYTOVATELIA + Chyba + Test poskytovateľa zlyhal + Výsledky testu (%1$d) + Pluginy poskytovateľov vyžadujú TMDB API kľúč. Nastavte ho na obrazovke TMDB, inak pluginy poskytovateľov nebudú fungovať správne. + Žiadne výsledky vyhľadávania pre %1$s. + Neplatný API kľúč alebo zlyhalo pripojenie + Repozitár pluginov + Odoslať Intro + Odoslať + Zachytiť + KONCOVÝ ČAS (MM:SS) + TYP SEGMENTU + ZAČIATOČNÝ ČAS (MM:SS) + Pripojené k Trakt + Odpojené od Trakt + TB + V vydaní nebol nájdený žiadny APK súbor + Stiahnutie zlyhalo s HTTP %1$d + Stiahnutý aktualizačný súbor chýba. + Prázdne telo stiahnutia + Chyba GitHub releases API: %1$d + Zatiaľ nebola zverejnená žiadna aktualizácia. + Vydanie nemá tag ani názov + + Vysielanie %1$s + Vysielanie dnes + Vysielanie zajtra + + Vysielanie o %1$d deň + Vysielanie o %1$d dní + + %1$s + Dnes + Zajtra + + O %1$d deň + O %1$d dní + + Nová epizóda + Nová séria + + Trakt Zoznam %1$s + Systémový prehrávač Android + P2P Streamovanie + Tento stream používa peer-to-peer (P2P) technológiu. Povolením P2P potvrdzujete a súhlasíte s tým, že:\n\n• Vaša IP adresa bude viditeľná pre ostatných účastníkov v sieti\n• Ste výhradne zodpovední za obsah, ku ktorému pristupujete\n• Potvrdzujete, že máte zákonné právo streamovať tento obsah vo vašej jurisdikcii\n• Nuvio nehostuje, nedistribuuje ani nekontroluje žiadny P2P obsah\n• Nuvio nenesie žiadnu zodpovednosť za akékoľvek právne dôsledky vyplývajúce z vášho používania P2P streamovania\n\nTúto funkciu používate výhradne na vlastné riziko. P2P môžete kedykoľvek vypnúť v Nastaveniach. + Povoliť P2P + Zrušiť + Neznáma chyba torrentu + P2P Streamovanie + Povoliť peer-to-peer (torrent) streamy + Skryť štatistiky torrentu + Skryť buffer, seedy, peery a rýchlosť sťahovania počas načítavania a prehrávania + %1$d seedov · %2$d peerov + %1$s bufferované · %2$s · %3$s + %1$s · %2$s + %1$d peerov · %2$d seedov · %3$d%% + Pripájanie k peerom… + Spúšťa sa P2P engine… + Nepodarilo sa spustiť torrent: %1$s + Chyba torrentu: %1$s + + Prepnúť + Preneste svoju knižnicu do Nuvia pomocou doplnkov, ktoré používate. + Pridajte doplnok a začnite si vytvárať knižnicu. + Prispôsobte si Nuvio. + URL doplnku + Pridať doplnok + Zatiaľ tu nič nie je + Doplnky, ktoré používate, tu zatiaľ nemajú čo zobraziť. + Registráciou súhlasím s + Podmienkami + Jazyk zariadenia + Pridávajte a spravujte doplnky, ktoré používate s Nuviom. + Odosielať časové značky intra a outra + Odovzdať vyriešené časové značky intra a outra externému prehrávaču na automatické preskakovanie. Funguje iba v prehrávačoch, ktoré to podporujú; ostatné prehrávače to ignorujú. + Pôvodný jazyk + Vyžaduje zapnuté obohacovanie TMDB + Prehrávací engine + Najprv použiť ExoPlayer a pri zlyhaní prehrávania prejsť na libmpv. + Použiť ExoPlayer a dekodéry Android Media3. + Použiť libmpv na prehrávanie v Androide. + Renderer libmpv + Renderer libmpv + Hardvérové dekódovanie libmpv + Použiť hardvérové dekódovanie mpv, ak je dostupné. + Kompatibilita libmpv YUV420P + Vynútiť výstup YUV420P pre zariadenia s problémami renderera alebo farieb. + Načítavajú sa segmenty preskočenia… + diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 1340bde9..7a6fb839 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -31,6 +31,13 @@ Kullanılamıyor Eklentiyi ayarla Eklentiyi sil + Kullandığın eklentilerle kitaplığını Nuvio'ya taşı. + Kitaplığını oluşturmaya başlamak için bir eklenti ekle. + Nuvio'yu kendine göre ayarla. + Eklenti URL'si + Eklenti ekle + Burada henüz bir şey yok + Kullandığın eklentilerin burada gösterecek bir şeyi yok. Nuvio'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL'si ekle. Henüz eklenti kurulmamış. Bir eklenti URL'si gir. @@ -402,7 +409,9 @@ Uygulamanın yeni sürümü var mı kontrol et. Güncellemeleri kontrol et Eklentileri ve keşif kaynaklarını yönet. + Eklentileri yönet ve Nuvio’da nelerin görüneceğini seç. İndirdiğin film ve bölümleri yönet. + Bu cihaza kaydettiklerini yönet. İndirilenler GENEL TMDB ve MDBList servislerini bağla. @@ -587,6 +596,7 @@ ANA SAYFA KAYNAKLAR İçerik kaynaklarını kur, kaldır, yenile ve sırala. + Nuvio ile kullandığın eklentileri ekle ve yönet. JavaScript scraper depoları kur ve sağlayıcıları içeriden test et. Ana sayfada hangi katalogların hangi sırayla görüneceğini kontrol et. Detay bölümlerini kapat ve öne çıkanların altındaki her şeyi yeniden sırala. diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml new file mode 100644 index 00000000..f249e04c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -0,0 +1,1973 @@ + + + Nguồn dữ liệu, giấy phép và ghi nhận + Thông tin dự án và ghi nhận đóng góp + Quay lại + Hủy + Đóng + Xóa + Xong + Sửa + Nhập + Tiếp + OK + Phát + Trước + Gỡ + Sắp xếp + Đặt lại + Tiếp tục + Thử lại + Lưu + Đang lưu… + Chuyển + Xác minh + Đang cài… + Addon + Đang hoạt động + %1$d danh mục + Có thể tùy chỉnh + Đã tắt + Đang làm mới + %1$d nguồn phát + Không khả dụng + Cấu hình addon + Xóa addon + Kết nối máy chủ để duyệt và phát thư viện cá nhân trong Nuvio + Thêm URL máy chủ để hiển thị thư viện cá nhân trong Nuvio + Chưa kết nối thư viện + URL máy chủ + Thêm + Thêm URL manifest để tải danh mục, thông tin, nguồn phát và phụ đề + Chưa có addon + Vui lòng nhập URL addon + URL addon + Cài addon + Đang tải manifest… + Đang xác minh manifest và tải thông tin addon trước khi cài đặt + Đang kiểm tra addon + Cài đặt thất bại + %1$s đã được xác minh và cài đặt thành công + Đã cài addon + Chuyển xuống + Chuyển lên + Hoạt động + Addon + Danh mục + Làm mới addon + Thêm addon + Addon đã cài + Tổng quan + %1$d quy tắc ID + Phiên bản %1$s + Đã chọn + Sao chép JSON + %1$d bộ sưu tập, %2$d thư mục + Xóa "%1$s"? Hành động này không thể hoàn tác + Xóa bộ sưu tập + Thêm danh mục + Thêm thư mục + Tất cả thể loại + Thêm danh mục từ addon đã cài để thiết lập nội dung cho thư mục này + Chưa có nguồn danh mục + Chọn + Biểu tượng + URL ảnh + Không có + Ảnh bìa + Tạo bộ sưu tập + Xong + Chỉnh sửa bộ sưu tập + Chỉnh sửa thư mục + Thiết lập thông tin, giao diện và nguồn danh mục như khi chỉnh sửa bộ sưu tập + Thêm thư mục để bắt đầu + Chưa có thư mục + Thư mục + Lọc theo thể loại + Chỉ hiển thị ảnh bìa + Ẩn tiêu đề + Thư mục mới + Hiển thị bộ sưu tập ở đầu Trang chủ. Nếu ghim nhiều bộ sưu tập, chúng sẽ được sắp theo thứ tự tạo + Ghim lên đầu + URL ảnh nền (tùy chọn) + Tên thư mục + URL ảnh GIF (chỉ phát khi được chọn) + Tên bộ sưu tập + Lưu thay đổi + Lưu + Giao diện + Thông tin cơ bản + Nguồn danh mục + Chọn các danh mục sẽ được hiển thị trong thư mục này + Chọn danh mục + Chọn thể loại + Đã chọn %1$d + %1$d danh mục + Đã chọn %1$d + Poster + Vuông + Ngang + Gộp mọi danh mục vào một tab + Hiển thị tab "Tất cả" + Thay thế ảnh bìa bằng ảnh GIF khi khả dụng + Hiển thị ảnh GIF + %1$d nguồn · %2$s + Kiểu ô + Hàng + Tab + Chế độ hiển thị + Nguồn TMDB + Danh sách công khai + Hãng sản xuất + Đơn vị phát hành + Bộ sưu tập + Diễn viên + Đạo diễn + Tùy chỉnh + Chọn nguồn có sẵn. Bạn có thể sửa hoặc xóa sau khi thêm + Dán URL danh sách công khai trên TMDB hoặc nhập ID danh sách + Tìm theo tên hãng sản xuất hoặc nhập ID/URL TMDB để thêm nhanh + Nhập ID đơn vị phát hành. Các đơn vị phổ biến có trong phần Mẫu có sẵn và Bộ lọc tùy chỉnh + Tìm tên hoặc nhập ID bộ sưu tập TMDB + Nhập ID hoặc URL diễn viên TMDB để tạo danh sách phim + Nhập ID hoặc URL đạo diễn TMDB để tạo danh sách tác phẩm + Tạo danh sách TMDB bằng bộ lọc tùy chỉnh. Bỏ trống mục không cần + Danh sách TMDB công khai + ID đơn vị phát hành + ID bộ sưu tập + ID cá nhân + Tên, ID hoặc URL của hãng sản xuất + ID hoặc URL TMDB + https://www.themoviedb.org/list/8504994 hoặc 8504994 + 213 cho Netflix, 49 cho HBO, 2739 cho Disney+ + 10 cho Star Wars Collection + Marvel Studios, 420 hoặc URL hãng sản xuất + 31 cho Tom Hanks hoặc URL cá nhân + Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420 + Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập + Ví dụ: Netflix 213, HBO 49, Disney+ 2739 + Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994 + Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31 + Tên hiển thị + Hiển thị tên tùy chỉnh. Bỏ trống để Nuvio tự động tạo tên từ nguồn dữ liệu + Phim Marvel, Netflix Originals, Pixar + Phim Tom Hanks, Diễn viên yêu thích + Phim Christopher Nolan, Đạo diễn yêu thích + Phim hành động hay nhất, Phim Hàn Quốc, Hoạt hình 2024 + Kết quả tìm kiếm + Bộ sưu tập TMDB + Hãng sản xuất TMDB %1$d + Bộ sưu tập TMDB %1$d + Loại + Phim + Loạt phim + Tất cả + Sắp xếp + Bộ lọc + Bỏ trống mục không cần + Thể loại + Ngôn ngữ + Quốc gia + Từ khóa + Hãng sản xuất + Đơn vị phát hành + ID thể loại + Dùng ID thể loại TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị + 28,12 + 18,35 + Ngày phát hành từ + Ngày phát hành đến + Định dạng YYYY-MM-DD, ví dụ 2024-01-01 + 2020-01-01 + 2024-12-31 + Điểm tối thiểu + Điểm tối đa + Điểm TMDB từ 0 đến 10, ví dụ 7.0 + 7.0 + 10 + Lượt đánh giá tối thiểu + Loại các tựa phim có quá ít lượt đánh giá, ví dụ 100 + 100 + Ngôn ngữ gốc + Dùng mã ngôn ngữ gồm 2 ký tự, ví dụ en, ko, ja, hi + en, ko, ja, hi + Quốc gia sản xuất + Dùng mã quốc gia gồm 2 ký tự, ví dụ US, KR, JP, IN + US, KR, JP, IN + ID từ khóa + Dùng ID từ khóa TMDB. Gợi ý nhanh sẽ tự điền các từ khóa phổ biến + 9715 cho Siêu anh hùng + ID hãng sản xuất + Dùng ID hãng sản xuất hoặc studio. Gợi ý nhanh sẽ tự điền các hãng sản xuất phổ biến + 420 cho Marvel Studios + ID đơn vị phát hành + Chỉ áp dụng cho loạt phim, ví dụ Netflix 213 hoặc HBO 49 + 213 cho Netflix + Năm + Nhập năm gồm 4 chữ số, ví dụ 2024 + 2024 + Mẫu có sẵn + Tìm kiếm + Thêm nguồn + Thêm danh sách Trakt + Sửa danh sách Trakt + Danh sách Trakt + Danh sách Trakt + Tìm theo tên, URL hoặc ID danh sách Trakt + Dùng URL danh sách công khai, ID hoặc tìm theo tên + Phim cuối tuần, Phim đoạt giải + Kết quả tìm kiếm + Đang thịnh hành + Phổ biến + Thứ tự + Tăng dần + Giảm dần + Mặc định + Mới thêm + Tiêu đề + Ngày phát hành + Thời lượng + Phổ biến + Tỷ lệ + Lượt bình chọn + Nhập tên, URL hoặc ID danh sách Trakt + Nhập ID hoặc URL danh sách Trakt + Không thể tải danh sách Trakt + Không tìm thấy danh sách Trakt + Đã xác thực danh sách Trakt + Danh sách Trakt %1$d + Hành động + Phiêu lưu + Hoạt hình + Hài + Kinh dị + Khoa học viễn tưởng + Chính kịch + Tội phạm + Thực tế + Tiếng Anh + Tiếng Hàn + Tiếng Nhật + Tiếng Hindi + Tiếng Tây Ban Nha + Hoa Kỳ + Hàn Quốc + Nhật Bản + Ấn Độ + Anh + Siêu anh hùng + Chuyển thể từ tiểu thuyết + Du hành thời gian + Không gian + Marvel + Disney + Pixar + Lucasfilm + Warner Bros + Netflix + HBO + Disney+ + Prime Video + Hulu + Mặc định + Phổ biến + Đánh giá cao + Mới nhất + Nhiều đánh giá + Khu vực phát hành + Dùng mã quốc gia có thể xem nội dung theo chuẩn ISO 3166-1, ví dụ US, GB + Khu vực + ID nền tảng + Dùng ID nền tảng TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị + 8|337|350 + Nền tảng + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + Danh sách TMDB + Bộ sưu tập phim TMDB + Hãng sản xuất + Đơn vị phát hành + Diễn viên + Đạo diễn + Khám phá TMDB + Tạo bộ sưu tập để sắp xếp các danh mục + Chưa có bộ sưu tập + %1$d thư mục + Không có nội dung + Không tìm thấy thư mục + Bộ sưu tập + Nhập bộ sưu tập + JSON + Dán dữ liệu JSON của bộ sưu tập vào đây + Nhập + Bộ sưu tập mới + Đã ghim + Tất cả + Bộ sưu tập của bạn + Được tạo với ❤️ bởi Tapframe và những người bạn + Phiên bản %1$s (%2$s) + Tắt + Bật + Tạm dừng + Tải lại + Đã có tài khoản? + Tiếp tục không cần tài khoản + Tạo tài khoản + Chưa có tài khoản? + Email + hoặc + Mật khẩu + Đăng nhập để đồng bộ thư viện và tiến trình xem + Đăng nhập + Tạo tài khoản để đồng bộ trên mọi thiết bị + Đăng ký + Dữ liệu chỉ được lưu trên thiết bị này + Mang thư viện phim theo bạn mọi nơi + Khi đăng ký, tôi đồng ý với + Điều khoản sử dụng + Chào mừng trở lại + Thư viện + Thư viện Trakt + Trang chủ + Thư viện + Hồ sơ + Tìm kiếm + Âm thanh + Âm thanh + Tự đồng bộ + In đậm + Tích hợp + Lề dưới + Chụp phụ đề + Đóng trình phát + Màu sắc + Đang phát + Tập %1$d + P%1$d-T%2$d + P%1$d-T%2$d • %3$s + Danh sách tập + Cỡ chữ + %1$dsp + Khóa điều khiển + Đang tải phụ đề… + Không tìm thấy phụ đề + Không thể tải phụ đề + Không có âm thanh + Không có tập + Không tìm thấy nguồn phát + Không có + Viền + Màu viền + Tập phim + Nguồn + Nguồn phát + Lỗi phát + Đang phát + Tải phụ đề + Quay lại + Tải lại + Đặt lại + Khôi phục mặc định + Lấp đầy + Vừa màn hình + Phóng to + Lùi 10 giây + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Tua 10 giây + Nguồn phát + Kiểu chữ + Hãy chọn phụ đề từ addon trước + Phụ đề + Độ trễ phụ đề + Phụ đề + Độ mờ chữ + Độ sáng %1$s + Âm lượng %1$s + Đã tắt tiếng + Đã tải xuống + Phát sóng + Sắp cập nhật + Chạm để mở khóa + Âm thanh %1$d + Mở khóa điều khiển + Bạn đang xem + Thêm hồ sơ + Xóa tìm kiếm + Khám phá + Các addon đã cài không trả về kết quả hợp lệ + Tìm kiếm thất bại + Hãy cài và bật ít nhất một addon để tìm kiếm + Chưa có addon hoạt động + Không tìm thấy nội dung phù hợp + Không có kết quả + Các addon đã cài không hỗ trợ tìm kiếm + Không hỗ trợ tìm kiếm + Tìm phim, chương trình... + Tìm kiếm gần đây + Xóa tìm kiếm gần đây + Giới thiệu + Chung + Tài khoản + Addon + Nâng cao + Giao diện + Nội dung & Khám phá + Tiếp tục xem + Dịch vụ kết nối + Trang chủ + Tích hợp + Giấy phép & Ghi nhận + Đánh giá MDBList + Trang chi tiết + Thông báo + Phát + Plugin + Kiểu poster + Cài đặt + Nguồn phát + Nhà tài trợ & Cộng tác viên + Bổ sung dữ liệu TMDB + Trakt + GIỚI THIỆU + Quản lý tài khoản và đồng bộ + TÀI KHOẢN + Thiết lập khởi động và hồ sơ + NÂNG CAO + Tùy chỉnh Trang chủ, nguồn phát, bộ sưu tập và trang chi tiết + Tải phiên bản mới nhất + Kiểm tra cập nhật + Quản lý addon và nguồn nội dung + Quản lý nội dung đã tải xuống + Tải xuống + CHUNG + Quản lý các dịch vụ tích hợp + Quản lý thông báo và gửi thông báo thử nghiệm + Cấu hình hiển thị nguồn phát và URL nhãn thông số + Chuyển sang hồ sơ khác + Chuyển hồ sơ + Mở trang kết nối Trakt + Không tìm thấy cài đặt phù hợp + Tìm cài đặt... + KẾT QUẢ + KHỞI ĐỘNG + BỘ NHỚ ĐỆM + Ghi nhớ hồ sơ gần nhất + Tự động mở hồ sơ gần nhất khi khởi động + Xóa bộ nhớ đệm Tiếp tục xem + Xóa dữ liệu Tiếp tục xem và đồng bộ lại tiến trình + Đã xóa bộ nhớ đệm + GIẤY PHÉP ỨNG DỤNG + DỮ LIỆU & DỊCH VỤ + GIẤY PHÉP TRÌNH PHÁT + Nuvio Mobile + Mã nguồn và điều khoản cấp phép có trong kho lưu trữ của dự án + Cấp phép theo GNU General Public License v3.0 + The Movie Database (TMDB) + Nuvio dùng API TMDB để lấy thông tin phim, chương trình TV, hình ảnh, trailer, diễn viên, hãng sản xuất, bộ sưu tập và gợi ý nội dung. Sản phẩm này sử dụng API TMDB nhưng không được TMDB chứng nhận hoặc bảo trợ + IMDb Non-Commercial Datasets + Nuvio dùng IMDb Non-Commercial Datasets, gồm title.ratings.tsv.gz, để hiển thị điểm IMDb và số lượt đánh giá. Dữ liệu do IMDb cung cấp (https://www.imdb.com) và được sử dụng theo điều khoản dành cho mục đích cá nhân, phi thương mại + Trakt + Nuvio kết nối với Trakt để xác thực tài khoản, đồng bộ lịch sử xem, tiến trình, thư viện, đánh giá, danh sách và bình luận. Nuvio không liên kết hoặc được Trakt bảo trợ + Premiumize + Nuvio kết nối với Premiumize để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được Premiumize bảo trợ + TorBox + Nuvio kết nối với TorBox để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được TorBox bảo trợ + MDBList + Nuvio dùng MDBList để lấy điểm đánh giá và dữ liệu từ các dịch vụ xếp hạng khác. Nuvio không liên kết hoặc được MDBList bảo trợ + IntroDB + Nuvio dùng API IntroDB để lấy dữ liệu cộng đồng về thời điểm mở đầu, tóm tắt, phần credit và đoạn xem trước cho tính năng bỏ qua. Nuvio không liên kết hoặc được IntroDB bảo trợ + MPVKit + Dùng để phát nội dung trên iOS + MPVKit được cấp phép theo LGPL v3.0. Các thành phần đi kèm như libmpv và FFmpeg cũng được cấp phép theo LGPL v3.0 + AndroidX Media3 ExoPlayer 1.8.0 + Dùng để phát nội dung trên Android + Cấp phép theo Apache License Version 2.0 + Đang tải danh sách Trakt… + Chọn danh sách để lưu nội dung này trên Trakt + Ủng hộ + Xem chi tiết + Xóa + Xem từ đầu + Phát + %1$d/10 + Đánh giá + Tình tiết phim + Chưa có đánh giá trên Trakt + %1$d lượt thích + Bình luận này có chứa tình tiết phim + Bình luận này có chứa tình tiết phim và đã được ẩn + Bình luận + Trailer + %1$s (%2$d) + Trailer + Chưa có tập nào tải xong + Chưa có nội dung nào được tải xuống + %1$d tập đã tải xuống + Đang tải + Phim + Loạt phim + Hiển thị nội dung tải xuống + Hoàn tất • %1$s + Đang tải • %1$s + Thất bại + Đã tạm dừng • %1$s + Đã xem + Mùa %1$d + Tập đặc biệt + Tiếp tục từ lần dừng trước + Thêm vào thư viện + Đánh dấu chưa xem + Đánh dấu đã xem + Xóa khỏi thư viện + Xem tất cả + Chọn nguồn phát + Logo %1$s + Tài khoản + Xóa tài khoản + Tài khoản và toàn bộ dữ liệu sẽ bị xóa vĩnh viễn + Hành động này không thể hoàn tác. Toàn bộ dữ liệu, hồ sơ và lịch sử đồng bộ sẽ bị xóa vĩnh viễn + Xóa tài khoản? + Email + Chưa đăng nhập + Đăng xuất + Bạn sẽ quay lại màn hình đăng nhập + Đăng xuất? + Trạng thái + Khách + Đã đăng nhập + Đen AMOLED + Dùng nền đen tuyệt đối cho màn hình OLED + Ngôn ngữ ứng dụng + Theo ngôn ngữ thiết bị + Chọn ngôn ngữ + Thiết lập Tiếp tục xem + Liquid Glass + Dùng thanh tab gốc của iPhone trên iOS 26 trở lên + Điều chỉnh chiều rộng và bo góc poster + HIỂN THỊ + TRANG CHỦ + GIAO DIỆN + Bộ sưu tập • %1$s + Tên hiển thị + Hãy cài addon có danh mục tương thích để tùy chỉnh Trang chủ + Chưa có danh mục Trang chủ + Nguồn Hero + Ẩn + Thông tin Trang chủ + %1$s • Đã đạt giới hạn (tối đa %2$d) + Chưa chọn nguồn Hero + Không có trong Hero + Hãy bỏ ghim bộ sưu tập trước khi di chuyển + Đã ghim + Ghim lên đầu + Sắp xếp + DANH MỤC + DANH MỤC & BỘ SƯU TẬP + BỘ SƯU TẬP + BỐ CỤC TRANG CHỦ + DANH MỤC HERO + Đã chọn %1$d/%2$d + Hiển thị Hero + Hiển thị Hero ở đầu Trang chủ + Ẩn nội dung chưa phát hành + Ẩn phim và chương trình chưa phát hành + Ẩn gạch chân danh mục + Ẩn gạch chân dưới tiêu đề danh mục và bộ sưu tập + %1$d/%2$d danh mục • %3$d nguồn Hero + Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp + Hiển thị + Ẩn giá trị + Trình phát, phụ đề và tự động phát + Độ bo góc + KIỂU POSTER + Chiều rộng + Tùy chỉnh + Điều chỉnh chiều rộng và bo góc poster + Ẩn tiêu đề + Poster ngang + Xem trước + %1$s (%2$s) + Bo góc: %1$ddp + Chiều cao: %1$ddp + Chiều rộng: %1$ddp + Cổ điển + Viên thuốc + Bo tròn + Vuông + Bo nhẹ + Cân đối + Thoải mái + Gọn + Dày + Lớn + Tiêu chuẩn + Hiện giá trị + Hiển thị hộp thoại Tiếp tục xem khi mở lại ứng dụng + Lời nhắc Tiếp tục xem + Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung + Làm mờ tập chưa xem + Hiển thị tập sắp chiếu trong Tiếp tục xem trước ngày phát hành + Hiển thị tập sắp chiếu + THỨ TỰ SẮP XẾP + Thứ tự sắp xếp + Mặc định + Sắp theo thời gian xem gần nhất + Kiểu dịch vụ phát trực tuyến + Ưu tiên nội dung đã ra mắt, nội dung sắp chiếu nằm cuối cùng + KIỂU POSTER + KHI KHỞI ĐỘNG + TẬP TIẾP THEO + HIỂN THỊ + Hiển thị mục Tiếp tục xem trên Trang chủ + Hiển thị Tiếp tục xem + Thẻ + Thẻ ngang kiểu TV + Poster + Ưu tiên hiển thị poster + Ngang + Thẻ ngang hiển thị nhiều thông tin hơn + Hiển thị tập tiếp theo dựa trên tiến trình xem. Tắt nếu đang xem lại để tiếp tục từ tập vừa xem + Lấy tập tiếp theo từ tiến trình + Ưu tiên dùng ảnh thu nhỏ của từng tập nếu có + Ưu tiên ảnh thu nhỏ + TRANG CHỦ + NGUỒN + Cài đặt, xóa, làm mới và sắp xếp nguồn + Kết nối nguồn nội dung cá nhân và quản lý thư viện riêng + Cài đặt kho mã nguồn Scraper và kiểm thử nguồn nội bộ + Tùy chỉnh bố cục Trang chủ và cách hiển thị nội dung + Thiết lập trang chi tiết và danh sách tập + Tạo bộ sưu tập tùy chỉnh bằng thư mục trên Trang chủ + Tích hợp + Thiết lập dữ liệu TMDB + Dịch vụ điểm đánh giá bên ngoài + Kết nối tài khoản để truy cập nguồn và thư viện đám mây + Dịch vụ kết nối + Tính năng này đang được thử nghiệm, có thể thay đổi hoặc gỡ bỏ trong tương lai + Thư viện đám mây + Duyệt và phát tệp trong tài khoản đã kết nối + Tạo liên kết phát + Yêu cầu dịch vụ đã kết nối tạo liên kết phát khi cần. Có thể thêm nội dung vào dịch vụ đó + Sử dụng dịch vụ + Chọn dịch vụ dùng để tạo liên kết phát + Hãy kết nối tài khoản trước + Tài khoản + Kết nối tài khoản %1$s + Liên kết tài khoản %1$s bằng trình duyệt + Khóa API %1$s + Nhập khóa API %1$s + Nhập khóa API %1$s + Chưa thiết lập + Đã kết nối + Kết nối %1$s + Ngắt kết nối %1$s + Ngắt kết nối + %1$s đã được kết nối trên thiết bị này + Đang đăng nhập… + Mở liên kết và nhập mã để xác nhận Nuvio + Đã sao chép mã + Mở liên kết + Đang xác nhận… + Đăng nhập thất bại + Phương thức đăng nhập này chưa được hỗ trợ trong phiên bản hiện tại + Mã đã hết hạn, vui lòng thử lại + Chuẩn bị liên kết phát + Chuẩn bị liên kết phát + Xác thực liên kết trước khi bắt đầu + Liên kết cần xử lý + Hãy đặt số lượng thấp nhất có thể. Các dịch vụ kết nối có thể giới hạn số lượng liên kết được xác thực trong một khoảng thời gian. Việc mở một bộ phim hoặc tập phim có thể được tính vào hạn mức này ngay cả khi bạn không nhấn "Xem", vì các liên kết đã được chuẩn bị sẵn từ trước + 1 liên kết + %1$d liên kết + Định dạng hiển thị + Mẫu tên + Tùy chỉnh tên hiển thị. Bỏ trống nếu dùng tên gốc + Mẫu mô tả + Tùy chỉnh hiển thị mô tả. Bỏ trống nếu dùng mô tả mặc định + Khôi phục định dạng + Khôi phục định dạng mặc định + KIỂU FUSION + Nhãn thông số + Hiển thị nhãn trong danh sách nguồn phim và bảng chọn nguồn phát + Biểu tượng addon + Hiển thị biểu tượng và tên addon bên cạnh mỗi nguồn phát + HIỂN THỊ + Vị trí nhãn + Chọn vị trí hiển thị nhãn Fusion và thông số + Vị trí nhãn + Chọn vị trí hiển thị nhãn trên thẻ nguồn phát + Phía trên + Phía dưới + URL nhãn Fusion + Có thể thêm tối đa %1$d URL chuỗi JSON. Mỗi URL có thể được cập nhật hoặc xóa riêng biệt + Quản lý các URL chuỗi JSON đã thêm + Nhập thất bại + Nhập URL chuỗi JSON + URL phải bắt đầu bằng http:// hoặc https:// + Có thể thêm tối đa %1$d URL + %1$d/%2$d URL • %3$d nhãn Fusion đang hoạt động + Chưa có URL nhãn Fusion + URL chuỗi JSON nhãn Fusion + Đã nhập %1$d/%2$d URL Fusion + Hiển thị + Ẩn + %1$s • %2$d nhãn • %3$d nhóm + Xem trước + Xem trước nhãn Fusion + %1$d nhãn Fusion từ URL này + Không có hình ảnh nhãn Fusion trong URL này + Nhóm %1$d + Các nhãn Fusion khác + Khóa API hợp lệ + Không thể xác minh khóa API này + Hãy thêm khóa API MDBList trước khi bật tính năng đánh giá + Cần để lấy điểm đánh giá từ MDBList + Khóa API + Khóa API + Hiển thị đánh giá MDBList + Hiển thị điểm đánh giá từ các nguồn bên ngoài trên trang chi tiết + Khóa API + Nguồn điểm đánh giá + Đánh giá MDBList + Thao tác + Phát và lưu nội dung + Diễn viên + Danh sách diễn viên chính + Ảnh nền điện ảnh + Hiển thị ảnh nền mờ phía sau nội dung, tương tự màn hình nguồn phát + Chế độ nền + Chọn cách hiển thị nền phía sau trang chi tiết + Cơ bản + Sử dụng nền mặc định của ứng dụng + Điện ảnh + Hiển thị nền mờ nhẹ phía sau + Màu chủ đạo + Đồng bộ màu nền với tông màu chủ đạo của ảnh nền + Bộ sưu tập + Hiển thị bộ sưu tập hoặc thương hiệu liên quan + Bình luận + Đánh giá và bình luận từ Trakt + Thông tin + Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan + Phát trailer Hero + Tự động phát trailer trong Hero + Kiểu hiển thị tập phim + Chọn cách hiển thị danh sách tập + Ngang + Thẻ ngang với ảnh nền + Danh sách + Danh sách dọc ưu tiên thông tin + Tập phim + Danh sách mùa và tập phim + Làm mờ tập chưa xem + Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung + Nhóm %1$d + Có thể bạn sẽ thích + Hiển thị gợi ý từ TMDB + Không có + Tổng quan + Tóm tắt, đánh giá, thể loại và thông tin chính + Sản xuất + Hãng sản xuất và đơn vị phát hành + GIAO DIỆN + MỤC HIỂN THỊ + Nhóm tab %1$d + Bố cục tab + Nhóm các mục thành từng tab như trên ứng dụng TV. Gán tối đa 3 mục cho mỗi tab + Trailer + Danh sách trailer và phím tắt điều khiển + Thông báo hiện đang bị tắt trên Nuvio + Thông báo tập mới + Thông báo khi có tập mới của chương trình đã lưu + Thông báo cho Nuvio hiện đang bị tắt. Hãy bật thông báo để nhận các cảnh báo và gửi thông báo thử nghiệm + %1$d thông báo phát hành đang được lên lịch trên thiết bị này + THÔNG BÁO + KIỂM TRA + Gửi thông báo thử nghiệm + Đang gửi thông báo thử nghiệm… + Gửi thông báo thử nghiệm cho %1$s + Hãy lưu chương trình vào thư viện trước để kiểm tra thông báo + Thông báo thử nghiệm + Cộng đồng + Gặp gỡ đội ngũ phát triển và cộng đồng Nuvio trên Mobile, TV và Web + Chi phí máy chủ & bảo trì tháng này + Đã đủ chi phí. Mọi khoản ủng hộ tiếp theo sẽ dành cho phát triển + Sau khi đạt 100%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án + Đang tải tiến độ ủng hộ… + API Supporters chưa được cấu hình. Hãy thêm DONATIONS_BASE_URL vào local.properties + Cộng tác viên + Nhà tài trợ + Mở GitHub + Không thể mở hồ sơ GitHub + Không có lời nhắn + Đang tải danh sách cộng tác viên… + Đang tải danh sách nhà tài trợ… + Không thể tải danh sách cộng tác viên + Không thể tải danh sách nhà tài trợ + Chưa có cộng tác viên + Chưa có nhà tài trợ + Không thể tải cộng tác viên + Không thể tải nhà tài trợ + Hiện không thể tải danh sách cộng tác viên + Hiện không thể tải danh sách nhà tài trợ + %1$d lượt commit + Th1 + Th2 + Th3 + Th4 + Th5 + Th6 + Th7 + Th8 + Th9 + Th10 + Th11 + Th12 + %2$s %1$s, %3$s + Tất cả addon đã cài + Tất cả plugin đã bật + Addon được cho phép + Plugin được cho phép + Anime Skip + Client ID Anime Skip + Nhập Client ID API Anime Skip. Tạo tại anime-skip.com + Cho phép gửi mốc mở đầu + Hiển thị nút gửi mốc Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng + Khóa API IntroDB + Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để dùng tính năng này + Tìm thêm mốc bỏ qua từ Anime Skip (yêu cầu Client ID) + Tự động phát tập tiếp theo + Tự động phát tập tiếp theo khi thông báo xuất hiện + Chỉ dùng decoder của thiết bị + Ưu tiên decoder của ứng dụng (FFmpeg) + Ưu tiên decoder của thiết bị + Ưu tiên decoder + Chạm bên ngoài để đóng + Chạm bên ngoài để lưu và đóng + %1$d ngày + %1$d ngày + %1$d giờ + %1$d giờ + Dùng libass cho phụ đề ASS/SSA + Thử nghiệm: hiển thị ASS/SSA nâng cao (kiểu chữ, vị trí, hiệu ứng) + Trình phát ngoài + Trình phát ngoài + Mở nội dung bằng ứng dụng phát video mặc định hoặc trình chọn hệ thống của Android + Mở nội dung bằng trình phát ngoài đã chọn + Chuyển phụ đề sang trình phát ngoài + Tải phụ đề từ addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài. Chỉ hoạt động với trình phát hỗ trợ + Không tìm thấy trình phát ngoài tương thích + Trình phát + Trình phát tích hợp + Trình phát ngoài + Chọn trình phát mặc định cho nội dung mới + Tốc độ khi giữ + Giữ để tăng tốc + Nhấn giữ bất kỳ đâu trên trình phát để tạm thời tăng tốc độ + Cử chỉ cảm ứng + Cho phép vuốt và chạm hai lần để tua, chỉnh độ sáng hoặc âm lượng + Biểu thức Regex không hợp lệ + Thời gian ghi nhớ nguồn phát gần nhất + DV7 → HEVC + Chuyển Dolby Vision Profile 7 sang HEVC cho thiết bị không hỗ trợ + Thời gian trước khi kết thúc + Dùng khi không có mốc Đoạn kết thúc + %1$s phút + Không có mục nào + Chưa thiết lập + Mặc định (tệp phương tiện) + Ngôn ngữ thiết bị + Ngôn ngữ gốc + Yêu cầu kích hoạt tính năng cập nhật thông tin từ TMDB + Phụ đề bắt buộc + Không + Tất cả phụ đề + Tải và hiển thị tất cả phụ đề từ addon + Khởi chạy nhanh + Chỉ tải phụ đề từ addon khi bạn yêu cầu trong trình phát + Khởi chạy phụ đề từ addon + Chỉ hiển thị ngôn ngữ ưu tiên + Chỉ hiển thị ngôn ngữ ưu tiên khi tải phụ đề từ addon + Ưu tiên chế độ xem liên tiếp + Ưu tiên chọn nguồn phát của tập tiếp theo giống với hiện tại (cùng addon/chất lượng) trước khi áp dụng các quy tắc khác + Ghi nhớ chế độ xem liên tiếp + Ghi nhớ chế độ xem liên tiếp xuyên suốt ứng dụng (Tiếp tục xem, Trang chi tiết,...) + Ngôn ngữ âm thanh ưu tiên + Ngôn ngữ phụ đề ưu tiên + Mẫu có sẵn + Áp dụng cho tên nguồn, tiêu đề, mô tả, addon hoặc URL. Ví dụ: 4K|2160p|Remux + Biểu thức Regex + Chưa thiết lập Regex. Ví dụ: 4K|2160p|Remux + Từ 1080p trở lên + AVC / x264 + Chất lượng Blu-ray + Dolby Atmos / DTS + Tiếng Anh + HDR / Dolby Vision + HEVC / x265 + Loại bỏ CAM/TS + Loại bỏ REMUX/HDR + 1080p tiêu chuẩn + 4K / Remux + 720p / Dung lượng nhỏ + Nguồn WEB + Chế độ renderer libass + Cue tiêu chuẩn + Hiệu ứng Canvas + Hiệu ứng OpenGL + Lớp phủ Canvas + Lớp phủ OpenGL (Khuyến nghị) + Lưu liên kết phát gần nhất + Tự động phát lại nguồn phim mà bạn đã xem gần nhất khi thời gian ghi nhớ còn hiệu lực + Ngôn ngữ âm thanh phụ + Ngôn ngữ phụ đề phụ + DECODER + Engine phát + Ưu tiên ExoPlayer, tự chuyển sang libmpv nếu phát thất bại + Dùng ExoPlayer với decoder Android Media3 + Dùng libmpv để phát trên Android + Renderer libmpv + Renderer libmpv + Giải mã phần cứng libmpv + Dùng giải mã phần cứng của libmpv khi khả dụng + Chế độ tương thích YUV420P của libmpv + Buộc xuất định dạng YUV420P nếu thiết bị gặp lỗi hiển thị hoặc màu sắc + TẬP TIẾP THEO + TRÌNH PHÁT + BỎ QUA PHÂN ĐOẠN + TỰ ĐỘNG PHÁT + CHỌN NGUỒN PHÁT + PHỤ ĐỀ VÀ ÂM THANH + HIỂN THỊ PHỤ ĐỀ + Đã chọn %1$d + Biểu tượng tải dữ liệu + Hiển thị màn hình tải dữ liệu đến khi khung hình đầu tiên xuất hiện + Cảnh báo nội dung + Hiển thị cảnh báo phân loại nội dung khi bắt đầu phát + Màu nền + In đậm + Hiển thị phụ đề in đậm + Trong suốt + Viền chữ + Màu viền chữ + Hiển thị viền quanh chữ phụ đề + Chỉ hiện ngôn ngữ ưu tiên + Chỉ hiển thị phụ đề khớp với ngôn ngữ ưu tiên + Cỡ chữ + Màu chữ + Chọn phụ đề tự động + Tự động áp dụng phụ đề khớp với ngôn ngữ đã chọn + Căn chỉnh dọc + Tự động bỏ qua Đoạn mở đầu + Dùng IntroDB để phát hiện Đoạn mở đầu và Tóm tắt + Phạm vi chọn nguồn phát tự động + Tất cả addon đã cài + Chỉ tự động phát từ nguồn do addon đã cài cung cấp + Tất cả nguồn + Tự động phát từ cả addon đã cài và plugin đang bật + Chỉ plugin đang bật + Chỉ tự động phát từ nguồn do plugin đang bật cung cấp + Chỉ addon đã cài + Chỉ tự động phát từ nguồn do addon đã cài cung cấp + Chọn nguồn phát tự động + Tự động phát nguồn đầu tiên + Tự động phát nguồn đầu tiên khả dụng + Thủ công + Luôn hiển thị danh sách nguồn phát để chọn + Tự động phát theo Regex + Phát nguồn đầu tiên khớp với mẫu Regex + Thời gian chờ nguồn phát + Thời gian chờ addon trước khi chọn + Số phút + Ngưỡng tập tiếp theo + Số phút trước khi kết thúc + Theo phần trăm + Ngưỡng phần trăm + Dùng khi không có mốc Đoạn kết thúc + %1$s% + Ngay lập tức + %1$ss + Không giới hạn + Tunneled Playback + Sử dụng phần cứng để đồng bộ âm thanh và video, có thể cải thiện khả năng phát trên một số thiết bị Android TV + Thêm khóa API TMDB bên dưới để bật tính năng bổ sung thông tin + Khóa API + Bổ sung thông tin từ TMDB + Dùng TMDB để bổ sung thông tin cho addon + Nhập khóa API TMDB v3 + Mã ngôn ngữ + Hình ảnh + Logo và ảnh nền từ TMDB + Thông tin cơ bản + Mô tả, thể loại và điểm đánh giá từ TMDB + Bộ sưu tập + Bộ sưu tập phim TMDB theo thứ tự phát hành + Đoàn phim + Diễn viên, đạo diễn và biên kịch từ TMDB + Thông tin + Thời lượng, trạng thái, quốc gia và ngôn ngữ từ TMDB + Tập phim + Tiêu đề, mô tả, ảnh thu nhỏ và thời lượng từ TMDB + Có thể bạn sẽ thích + Gợi ý từ TMDB trên trang chi tiết + Đơn vị phát hành + Đơn vị phát hành kèm logo từ TMDB + Hãng sản xuất + Hãng sản xuất từ TMDB + Poster mùa phim + Dùng poster từ TMDB cho từng mùa trong danh sách hiển thị + Trailer + Hiển thị trailer từ TMDB + Khóa API cá nhân + Ngôn ngữ + Ngôn ngữ dữ liệu TMDB cho tiêu đề, logo và các trường thông tin + THÔNG TIN XÁC THỰC + BẢN ĐỊA HÓA + MÔ-ĐUN + Thông tin từ TMDB + Bạn sẽ được tự động chuyển hướng sau khi yêu cầu được duyệt + XÁC THỰC + Bình luận + Hiển thị bình luận từ Trakt trên trang chi tiết + Kết nối Trakt + Đã kết nối với %1$s + Người dùng Trakt + Ngắt kết nối + Không thể mở trình duyệt + TÍNH NĂNG + Hoàn tất đăng nhập Trakt trong trình duyệt + Đồng bộ danh sách theo dõi, tiến trình xem, tiếp tục xem, scrobble và danh sách cá nhân với Trakt + Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET) + Mở trang đăng nhập Trakt + Thao tác Lưu bây giờ sẽ được đồng bộ với danh sách theo dõi và danh sách cá nhân trên Trakt + Đăng nhập Trakt để quản lý danh sách và sử dụng thư viện Trakt + Nguồn thư viện + Chọn thư viện để lưu và quản lý bộ sưu tập + Nguồn thư viện + Chọn nơi lưu và quản lý thư viện + Trakt + Thư viện Nuvio + Đã chọn thư viện Trakt + Đã chọn thư viện Nuvio + Tiến độ xem + Chọn nguồn cập nhật tiến độ xem để tính năng Tiếp tục xem hoạt động chính xác + Tiến độ xem + Chọn nguồn lưu tiến độ (Trakt hoặc Nuvio Sync) trong khi vẫn bật Scrobbling cho Trakt + Trakt + Nuvio Sync + Đã chọn Trakt làm nguồn tiến độ xem + Đã chọn Nuvio Sync làm nguồn tiến độ xem + Nguồn gợi ý + Chọn nguồn gợi ý trên trang chi tiết + Nguồn gợi ý + Chọn nguồn hiển thị nội dung gợi ý trên trang chi tiết + Trakt + TMDB + Khoảng thời gian Tiếp tục xem + Khoảng thời gian Trakt dùng cho Tiếp tục xem + Khoảng thời gian Tiếp tục xem + Chọn khoảng thời gian Trakt hiển thị trong Tiếp tục xem + Toàn bộ lịch sử + %1$d ngày + Điểm từ khán giả + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Không xác định + Cam + Đỏ thẫm + Xanh ngọc + Xanh lam + Hồng + Tím + Trắng + Tập tiếp theo + Đang tìm nguồn phát… + Phát qua %1$s sau %2$d… + Ảnh thu nhỏ tập tiếp theo + Chưa phát hành + Bỏ qua + Bỏ qua Đoạn mở đầu + Bỏ qua Đoạn kết thúc + Bỏ qua Tóm tắt + Không tìm thấy phụ đề + Tiếng Afrikaans + Tiếng Albania + Tiếng Amharic + Tiếng Ả Rập + Tiếng Armenia + Tiếng Azerbaijan + Tiếng Basque + Tiếng Belarus + Tiếng Bengal + Tiếng Bosnia + Tiếng Bulgaria + Tiếng Miến Điện + Tiếng Catalan + Tiếng Trung + Tiếng Trung (Giản thể) + Tiếng Trung (Phồn thể) + Tiếng Croatia + Tiếng Séc + Tiếng Đan Mạch + Tiếng Hà Lan + Tiếng Anh + Tiếng Estonia + Tiếng Filipino + Tiếng Phần Lan + Tiếng Pháp + Tiếng Galicia + Tiếng Georgia + Tiếng Đức + Tiếng Hy Lạp + Tiếng Gujarat + Tiếng Do Thái + Tiếng Hindi + Tiếng Hungary + Tiếng Iceland + Tiếng Indonesia + Tiếng Ireland + Tiếng Ý + Tiếng Nhật + Tiếng Kannada + Tiếng Kazakh + Tiếng Khmer + Tiếng Hàn + Tiếng Lào + Tiếng Latvia + Tiếng Litva + Tiếng Macedonia + Tiếng Mã Lai + Tiếng Malayalam + Tiếng Malta + Tiếng Marathi + Tiếng Mông Cổ + Tiếng Nepal + Tiếng Na Uy + Tiếng Ba Tư + Tiếng Ba Lan + Tiếng Bồ Đào Nha (Bồ Đào Nha) + Tiếng Bồ Đào Nha (Brazil) + Tiếng Punjab + Tiếng Romania + Tiếng Nga + Tiếng Serbia + Tiếng Sinhala + Tiếng Slovakia + Tiếng Slovenia + Tiếng Tây Ban Nha + Tiếng Tây Ban Nha (Mỹ Latinh) + Tiếng Swahili + Tiếng Thụy Điển + Tiếng Tamil + Tiếng Telugu + Tiếng Thái + Tiếng Thổ Nhĩ Kỳ + Tiếng Ukraina + Tiếng Urdu + Tiếng Uzbek + Tiếng Việt + Tiếng Wales + Tiếng Zulu + Xóa + Tiếp tục + Bỏ qua + Cài đặt + Để sau + Không + Cập nhật + + Bạn có muốn thoát ứng dụng không? + Thoát ứng dụng + Danh mục này chưa có nội dung + Không tìm thấy nội dung + Thao tác khác + Kiểm tra kết nối Wi-Fi hoặc dữ liệu di động rồi thử lại + Đạo diễn + Không thể tải + Có thể bạn sẽ thích + Gợi ý từ TMDB + Gợi ý từ Trakt + Các mùa + Addon đã trả về tập phim nhưng thiếu thông tin mùa hoặc số tập + Addon không cung cấp thông tin tập phim cho nội dung này + Các tập này chưa được addon phát hành + Thiết bị đã kết nối Internet nhưng Nuvio không thể kết nối đến máy chủ + Thu gọn + Xem thêm ▾ + Biên kịch + Tất cả thể loại + Danh mục + %1$s • %2$s + Không thể tải nội dung từ danh mục đã chọn + Không thể tải Khám phá + Các addon đã cài chưa cung cấp danh mục tương thích cho Khám phá + Chưa có danh mục khám phá + Không tìm thấy nội dung phù hợp với danh mục và bộ lọc đã chọn + Không tìm thấy nội dung + Hãy cài đặt và kích hoạt ít nhất một addon để dùng Khám phá + Chọn danh mục + Chọn thể loại + Chọn loại + Loại + Đánh dấu các tập trước là chưa xem + Đánh dấu các tập trước là đã xem + Đánh dấu %1$s là chưa xem + Đánh dấu %1$s là đã xem + Đánh dấu các mùa trước là đã xem + Đánh dấu là chưa xem + Đánh dấu là đã xem + Tiếp theo + Đã xem %1$s + Còn %1$d giờ %2$d phút + Còn %1$d phút + Hãy cài đặt và kích hoạt ít nhất một addon để hiển thị nội dung trên Trang chủ + Các addon đã cài chưa cung cấp danh mục tương thích cho Trang chủ + Chưa có nội dung trên Trang chủ + Xem thông tin + Các tùy chọn phát và lưu + Thao tác + Danh sách diễn viên chính + Bộ sưu tập hoặc nội dung liên quan + Bộ sưu tập + Bình luận từ Trakt + Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan + Thông tin + Danh sách mùa và tập phim + Nội dung gợi ý + Có thể bạn sẽ thích + Tóm tắt, đánh giá, thể loại và thông tin chính + Tổng quan + Hãng sản xuất và đơn vị phát hành + Sản xuất + Danh sách trailer và phím tắt điều khiển + Đã kết nối lại + Không thể kết nối đến máy chủ + Máy chủ trả về dữ liệu trống + Yêu cầu thất bại (HTTP %1$d) + Không có kết nối Internet + (%1$d tuổi) + Sinh %1$s%2$s + Mất %1$s + Nổi tiếng với: %1$s + Mới nhất + Không thể tải thông tin của %1$s + Nổi bật + Đã xảy ra lỗi + Sắp ra mắt + Xóa ký tự + Hủy + Nhập mã PIN + Nhập mã PIN cho %1$s + Quên mã PIN? + Sai mã PIN + Đã bị khóa, thử lại sau %1$d giây + Hình đại diện sẽ xuất hiện sau khi tải danh mục + Hình đại diện: %1$s + Hãy nhập URL hình ảnh hợp lệ (http:// hoặc https://) + Chọn hình đại diện + Chọn một hình đại diện bên dưới + Tạo hồ sơ + Đã chọn URL hình đại diện tùy chỉnh + URL hình đại diện tùy chỉnh + Dán URL hình ảnh hoặc bỏ trống để dùng thư viện có sẵn + https://example.com/avatar.png + Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn + Xóa hồ sơ + Thêm hồ sơ + Chỉnh sửa hồ sơ + Nhập mã PIN hiện tại + Nhập mã PIN mới + Hồ sơ %1$d + Đang tải hình đại diện… + Quản lý hồ sơ + Tên hồ sơ + Hồ sơ mới + Không dùng addon chính + Đang dùng addon chính + Xóa mã PIN của %1$s + Xóa mã PIN + Đang lưu… + Bảo mật + Thêm mã PIN để khóa hồ sơ khi chuyển đổi + Hồ sơ này đang được bảo vệ bằng mã PIN + Chọn hình đại diện cho hồ sơ + Đặt mã PIN + Hồ sơ chưa đặt tên + Dùng addon chính + Dùng cấu hình addon của hồ sơ chính thay vì quản lý riêng + Ai đang xem? + Đã tải xuống + Tiếp tục xem + Scraper đang hoạt động + Đang kiểm tra thêm addon… + Sao chép liên kết nguồn phát + Tải xuống tệp + Mở bằng trình phát ngoài + Mở bằng trình phát tích hợp + Các addon không trả về nguồn phát hợp lệ + Không thể tải nguồn phát + Hãy cài ít nhất một addon để tìm nguồn phát cho nội dung này + Các addon đã cài không hỗ trợ loại nội dung này + Không có addon nguồn phát + Không có addon nào trả về nguồn phát + P%1$d T%2$d + Tập + P%1$dT%2$d - %3$s + Đang tải… + Đang tìm nguồn… + Đang tải phụ đề từ addon… + Đang tải mốc bỏ qua… + Đang tìm nguồn phát… + Đã sao chép liên kết nguồn phát + Không có liên kết trực tiếp + Không có thông tin + Làm mới nguồn phát + Tiếp tục từ %1$d% + Tiếp tục từ %1$s + DUNG LƯỢNG %1$s + Loại nguồn phát này không được hỗ trợ + Hãy kết nối tài khoản trong Cài đặt + Chưa có trong bộ nhớ đệm TorBox + Liên kết đã hết hạn, đang làm mới + Không thể mở liên kết này + Không thể mở trình phát ngoài + Hãy chọn trình phát ngoài trong Cài đặt trước khi tiếp tục + Không có trình phát ngoài + Đóng trailer + Không thể phát trailer + Không thể tải danh sách Trakt + Không thể cập nhật danh sách Trakt + %1$s • %2$s + Không thể kiểm tra cập nhật + Tải cập nhật thất bại + Dữ liệu tải về trống + Không tìm thấy tệp cập nhật đã tải xuống + Đang tải %1$d% + Không thể bắt đầu cài đặt + Bạn đang dùng phiên bản mới nhất + Hãy cấp quyền cài đặt ứng dụng cho Nuvio rồi quay lại để tiếp tục + Đang tải bản cập nhật… + Không có bản cập nhật mới + Sẵn sàng cài đặt phiên bản mới + Phiên bản này không hỗ trợ cập nhật trong ứng dụng + Đang chuẩn bị tải xuống + Ghi chú phát hành + Cho phép cài đặt để tiếp tục + Có bản cập nhật mới + Trạng thái cập nhật + Addon này đã được cài đặt + Hãy nhập URL addon hợp lệ + Không thể tải manifest + Nuvio + Xóa tài khoản thất bại + Đăng nhập thất bại + Đăng xuất thất bại + Đăng ký thất bại + Không thể tải nội dung + Tiếp theo + Tiếp theo • P%1$dT%2$d + Logo của %1$s + Không thể tải bình luận + Không thể tải thông tin từ bất kỳ addon nào + Đơn vị phát hành + Không có addon nào cung cấp thông tin cho nội dung này + Tải xuống thất bại + Hiển thị tiến trình tải xuống và các điều khiển liên quan + Tải xuống + Tải xuống hoàn tất + Đang tải %1$s • %2$s + Đang tải %1$s • %2$s / %3$s + Tải xuống thất bại + Đã tạm dừng %1$s + Xóa + Xóa %1$s khỏi %2$s? + Xóa %1$s khỏi thư viện? + Xóa khỏi thư viện? + Phim + Thông báo khi có tập mới của các phim đã lưu + Đây là thông báo thử nghiệm cho tập mới + Không thể gửi thông báo thử nghiệm + Đã gửi thông báo thử nghiệm cho %1$s + Không thể phát nguồn này + Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng + Mã PIN của hồ sơ đã thay đổi. Hãy kết nối Internet để cập nhật mã PIN mới nhất trên thiết bị này + Không thể xóa mã PIN. Hãy thử lại + Hãy kết nối Internet để xóa mã PIN + Chưa thể xác minh mã PIN khi thiết bị ngoại tuyến. Hãy kết nối Internet và thử lại sau + Không thể đặt mã PIN. Hãy thử lại + Hãy kết nối Internet để đặt mã PIN + Hồ sơ này đang dùng addon chính + Không thể tải %1$s + Nguồn phát + Nhúng + Quyền truy cập bị tử chối + Hoàn tất đăng nhập Trakt trong trình duyệt + Đã kết nối Trakt + Đã ngắt kết nối Trakt + Lỗi xác thực Trakt + Lỗi trạng thái xác thực Trakt + Lỗi phản hồi token Trakt + Không thể tải thư viện Trakt + Danh sách %1$d + Không thể lấy mã xác thực từ Trakt + Thiếu thông tin xác thực Trakt + Không thể tải tiến độ xem từ Trakt + Danh sách công khai Trakt + Hãy nhập ID hoặc URL danh sách Trakt hợp lệ + %1$d mục + %1$d lượt thích + Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID) + Thiếu ID danh sách Trakt + Danh sách Trakt không chứa ID dạng số + Danh sách Trakt không tồn tại hoặc ở chế độ riêng tư + Đã vượt quá giới hạn yêu cầu của Trakt + Yêu cầu tới Trakt thất bại + Không thể hoàn tất đăng nhập Trakt + Người dùng Trakt + Danh sách theo dõi + Quyền truy cập Trakt đã hết hạn + Không tìm thấy danh sách Trakt + Đã đạt giới hạn danh sách Trakt + Đã vượt quá giới hạn yêu cầu của Trakt + Yêu cầu tới Trakt thất bại + Không thể thêm vào Danh sách theo dõi + Không thể thêm vào danh sách Trakt + Thiếu ID tương thích với Trakt + Máy chủ trả về dữ liệu trống + Hãy thêm khóa API TMDB trong Cài đặt để dùng nguồn TMDB + Bộ sưu tập TMDB %1$d + Không tìm thấy bộ sưu tập TMDB + Hãng sản xuất TMDB %1$d + Không tìm thấy hãng sản xuất trên TMDB + Đạo diễn TMDB %1$d + Không có dữ liệu từ TMDB + Khám phá TMDB + Hãy nhập ID hoặc URL TMDB hợp lệ + Danh sách TMDB %1$d + Không tìm thấy danh sách TMDB + Không thể tải nguồn TMDB + Thiếu ID bộ sưu tập TMDB + Thiếu ID danh sách TMDB + Thiếu ID hồ sơ TMDB + Đơn vị phát hành TMDB %1$d + Không tìm thấy đơn vị phát hành trên TMDB + Không tìm thấy danh sách tác phẩm trên TMDB + Hồ sơ TMDB %1$d + Không tìm thấy hồ sơ trên TMDB + Trailer + Không xác định + Addon + Đã lưu + Phát %1$s + Tiếp tục %1$s + JSON trống + Bộ sưu tập %1$d chưa có ID + Bộ sưu tập "%1$s" chưa có tên + Thư mục %1$d trong "%2$s" chưa có ID + Thư mục "%1$s" trong "%2$s" chưa có tên + Nguồn %1$d trong thư mục "%2$s" còn thiếu thông tin + Nguồn %1$d trong thư mục "%2$s" chưa có ID danh sách Trakt + JSON không hợp lệ: %1$s + Không tìm thấy addon: %1$s + Danh sách phim Trakt + Danh sách phim bộ Trakt + Tháng Một + Tháng Hai + Tháng Ba + Tháng Tư + Tháng Năm + Tháng Sáu + Tháng Bảy + Tháng Tám + Tháng Chín + Tháng Mười + Tháng Mười Một + Tháng Mười Hai + Th1 + Th2 + Th3 + Th4 + Th5 + Th6 + Th7 + Th8 + Th9 + Th10 + Th11 + Th12 + Hãng sản xuất + Đơn vị phát hành + Không thể tải %1$s + Phổ biến + Mới nhất + %1$s • %2$s + Đánh giá cao + Phân loại + Thông tin + Ngôn ngữ gốc + Quốc gia sản xuất + Thông tin phát hành + Thời lượng + Poster + Văn bản + Thông tin + Trạng thái + Video + TỆP + Không có liên kết trực tiếp + Đã thay thế lượt tải trước + Đã bắt đầu tải xuống + Định dạng nguồn phát không hỗ trợ tải xuống + Máy chủ không phản hồi + Không thể hoàn tất tệp tải xuống + Yêu cầu thất bại (HTTP %1$d) + Hệ thống tải xuống chưa được khởi tạo + Không thể mở tệp đang tải + Tệp đang tải không mở được + Yêu cầu tải xuống thất bại + Không thể ghi vào tệp đang tải + %1$s - %2$s + Nội dung bạn lưu từ trang chi tiết sẽ xuất hiện tại đây + Chưa có nội dung đã lưu + Không thể tải thư viện + Khác + Đám mây + Đã lưu + Thư viện + Kết nối Trakt và lưu nội dung vào Danh sách theo dõi hoặc danh sách cá nhân + Thư viện Trakt đang trống + Không thể tải thư viện Trakt + Thư viện Trakt + Kết nối tài khoản + Hãy kết nối tài khoản trong Dịch vụ kết nối để duyệt và phát nội dung từ thư viện đám mây + Chưa kết nối tài khoản đám mây + Mở Dịch vụ kết nối + Hãy bật Thư viện đám mây trong Dịch vụ kết nối để duyệt tệp từ các tài khoản đã liên kết + Thư viện đám mây đang tắt + Không có tệp phù hợp với bộ lọc hiện tại + Chưa có nội dung + Chọn tệp để phát + Không thể tải thư viện đám mây %1$s + Không tìm thấy tệp video để phát + Không tìm thấy tệp để phát + Không tìm thấy tệp để phát + Thư viện đám mây đang tắt + Không thể phát tệp này + Phát + Chưa kết nối dịch vụ đám mây + Chưa kết nối %1$s + %1$d tệp có thể phát + Tất cả + Làm mới thư viện đám mây + Chọn dịch vụ + Chọn loại + Sẵn sàng phát + Tất cả + Torrent + Usenet + Web + Tệp + Anime + Kênh + Phim + Loạt phim + TV + %1$s đã phát hành + %1$s • %2$s đã phát hành + Đã có tập mới + %1$s đã phát hành + Tập mới + Rượu bia/Chất kích thích + Yếu tố kinh dị + Khỏa thân + Ngôn từ thô tục + Nhẹ + Trung bình + Nặng + Bạo lực + Nhà sáng tạo + Đạo diễn + Biên kịch + Điểm từ khán giả + Không tìm thấy nguồn phát trailer khả dụng + Phần %1$d - %2$s + B + KB + MB + GB + Gửi mốc Đoạn mở đầu + Cài đặt video + Thư viện đám mây không khả dụng với %1$s + Video + Đặt lại tinh chỉnh + Cấu hình đầu ra + Tự động nhận diện HDR + Tự động tính độ sáng HDR nếu dữ liệu hình ảnh bị lỗi + Tone Mapping + Khử bết màu + Làm mượt dải màu (ảnh hưởng nhẹ đến hiệu năng) + Tăng độ mượt chuyển động + Chuyển động mượt mà khi đồng bộ hiển thị + Độ sáng + Độ tương phản + Độ bão hòa + Gamma + Native EDR + Tối ưu cho iPhone và iPad có hỗ trợ HDR + SDR Tone Mapped + Cân bằng vùng sáng và tối trên màn hình SDR + Chế độ tương thích + Giống với MPV trên iOS phiên bản cũ + Tùy chỉnh + Sử dụng các thiết lập nâng cao bên dưới + Tắt + Đầu ra video trên iOS + Decoder phần cứng + Dải tương phản động mở rộng + Cài đặt Metal mặc định khi bắt đầu phát video + Cấu hình màu hiển thị + Đồng bộ không gian màu với màn hình + Không gian màu mục tiêu + Cấu hình truyền tải + Đầu ra âm thanh trên iOS + Đầu ra âm thanh + Dùng AudioUnit khi đầu ra AVFoundation tạm thời bị vô hiệu hóa + Thử nghiệm: Spatial Audio và Âm thanh đa kênh + Dùng AudioUnit truyền thống + Quản lý kết quả + Số kết quả tối đa + Giới hạn số kết quả hiển thị + Sắp xếp kết quả + Thứ tự hiển thị kết quả + Giới hạn theo độ phân giải + Thiết lập giới hạn số lượng cho mỗi độ phân giải (2160p, 1080p, 720p) sau khi sắp xếp + Giới hạn theo chất lượng + Thiết lập giới hạn số lượng cho mỗi chất lượng (BluRay, WEB-DL và Remux) sau khi sắp xếp + Giới hạn dung lượng + Lọc kết quả theo dung lượng tệp + Tìm hiểu thêm + Định dạng mặc định + Định dạng gốc + Nhập mỗi nhóm trên một dòng + Thứ tự gốc + Chất lượng cao nhất + Dung lượng lớn nhất + Dung lượng nhỏ nhất + Âm thanh tốt nhất + Ưu tiên ngôn ngữ + Tất cả + Đã chọn %1$d + Tất cả kết quả + %1$d kết quả + Đến %1$d GB + Từ %1$d GB + %1$d–%2$d GB + Độ phân giải ưu tiên + Ưu tiên sắp xếp các độ phân giải đã chọn theo thứ tự mặc định + Độ phân giải bắt buộc + Chỉ hiển thị các độ phân giải đã chọn + Độ phân giải loại trừ + Ẩn các độ phân giải đã chọn + Chất lượng ưu tiên + Ưu tiên sắp xếp các chất lượng đã chọn theo thứ tự mặc định + Chất lượng bắt buộc + Chỉ hiển thị các chất lượng đã chọn + Chất lượng loại trừ + Ẩn các chất lượng đã chọn + Thẻ hình ảnh ưu tiên + Ưu tiên sắp xếp các thẻ như Dolby Vision, HDR, 10-bit và IMAX + Thẻ hình ảnh bắt buộc + Chỉ hiển thị kết quả có các thẻ như Dolby Vision, HDR, 10-bit, IMAX và SDR + Thẻ hình ảnh loại trừ + Ẩn các kết quả có thẻ như Dolby Vision, HDR, 10-bit và 3D + Thẻ âm thanh ưu tiên + Ưu tiên sắp xếp các thẻ như Atmos, TrueHD, DTS và AAC + Thẻ âm thanh bắt buộc + Chỉ hiển thị kết quả có các thẻ như Atmos, TrueHD, DTS và AAC + Thẻ âm thanh loại trừ + Ẩn các kết quả có thẻ âm thanh đã chọn + Kênh âm thanh ưu tiên + Ưu tiên sắp xếp các kênh âm thanh đã chọn + Kênh âm thanh bắt buộc + Chỉ hiển thị các kênh âm thanh đã chọn + Kênh âm thanh loại trừ + Ẩn các kênh âm thanh đã chọn + Encode ưu tiên + Ưu tiên sắp xếp các Encode như AV1, HEVC và AVC + Encode bắt buộc + Chỉ hiển thị kết quả có Encode như AV1, HEVC và AVC + Encode loại trừ + Ẩn các Encode đã chọn + Ngôn ngữ ưu tiên + Ưu tiên sắp xếp các ngôn ngữ âm thanh đã chọn + Ngôn ngữ bắt buộc + Chỉ hiển thị kết quả có các ngôn ngữ đã chọn + Ngôn ngữ loại trừ + Ẩn các kết quả nếu tất cả ngôn ngữ đều thuộc danh sách loại trừ + Nhóm phát hành bắt buộc + Chỉ hiển thị các Nhóm phát hành đã chọn + Nhóm phát hành loại trừ + Ẩn các Nhóm phát hành đã chọn + Gửi mốc thời gian + LOẠI PHÂN ĐOẠN + Đoạn mở đầu + Tóm tắt + Đoạn kết thúc + THỜI ĐIỂM BẮT ĐẦU (MM:SS) + THỜI ĐIỂM KẾT THÚC (MM:SS) + Gửi + Đánh dấu thời điểm này + Decoder phần cứng + Đầu ra âm thanh + Không gian màu mục tiêu + Cấu hình truyền tải mục tiêu + Danh sách Trakt đã xác thực + TMDB Discover + US, KR, JP, IN + Nhập tên, URL hoặc ID danh sách Trakt + Hãy nhập ID hoặc URL TMDB hợp lệ + Không thể tải nguồn TMDB + Hãy nhập ID hoặc URL danh sách Trakt + Không thể tải danh sách Trakt + Canada + Úc + Đức + Phim + Loạt phim + Thư viện đám mây đang tắt + Khóa API không hợp lệ hoặc không thể kết nối + Manifest thiếu trường "%1$s" + Bộ sưu tập TMDB %1$s + Đạo diễn TMDB %1$s + TMDB Discover + Hãy nhập ID hoặc URL TMDB hợp lệ + Danh sách TMDB %1$s + Đơn vị phát hành TMDB %1$s + Hồ sơ TMDB %1$s + Hãng sản xuất TMDB %1$s + Không thể tải nguồn TMDB + ID %1$s + Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB + Không tìm thấy bộ sưu tập TMDB + Không tìm thấy hãng sản xuất trên TMDB + TMDB trả về dữ liệu trống + Không tìm thấy danh sách TMDB + Thiếu ID bộ sưu tập TMDB + Thiếu ID danh sách TMDB + Thiếu ID hồ sơ TMDB + Không tìm thấy đơn vị phát hành trên TMDB + Không tìm thấy thông tin đoàn phim của hồ sơ này trên TMDB + Không tìm thấy hồ sơ trên TMDB + Thiếu thông tin xác thực Trakt + %1$s (%2$d) + Hãy nhập ID hoặc URL danh sách Trakt hợp lệ + %1$d mục + %1$d lượt thích + Không tìm thấy danh sách Trakt hoặc danh sách đang ở chế độ riêng tư + Thiếu ID danh sách Trakt + Danh sách Trakt không chứa ID dạng số + Danh sách công khai Trakt + Đã vượt quá giới hạn yêu cầu của Trakt + Yêu cầu tới Trakt thất bại + Không thể tải bình luận Trakt (%1$d) + %1$d giờ %2$d phút + %1$d giờ + %1$d phút + Không thể hoàn tất tệp tải xuống + Không thể mở tệp đang tải + Tệp đang tải không mở được + Không thể ghi vào tệp đang tải + Thư viện Nuvio + Sự cố kết nối + Không có phản hồi từ máy chủ + Vui lòng kiểm tra kết nối và thử lại + Yêu cầu thất bại (HTTP %1$d) + %1$s (%2$s) + Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng + Không thể phát nguồn này + Đã tắt + Đã bật + %1$d nhà cung cấp + Đang làm mới + %1$d kho + Thiếu khóa API TMDB + Đã thiết lập khóa API TMDB + Cài đặt kho Plugin + Đang cài đặt… + Kiểm tra nhà cung cấp + Đang kiểm tra… + Xóa kho Plugin + Làm mới kho Plugin + Chưa có nhà cung cấp nào + Thêm địa chỉ URL kho Plugin để tìm nguồn phát + Chưa cài đặt kho Plugin nào + Sử dụng các nguồn plugin khi tìm kiếm nguồn phát + Bật plugin cung cấp nguồn phát trên toàn hệ thống + Kho Plugin này đã được cài đặt + Nhập URL kho Plugin + Nhập URL kho Plugin hợp lệ + Không thể cài đặt kho Plugin + Không tìm thấy nhà cung cấp + Không thể làm mới kho Plugin + Phiên bản này không hỗ trợ Plugin + Trong nguồn phát, hiển thị một nhà cung cấp cho mỗi kho Plugin thay vì một nhà cung cấp cho mỗi nguồn + Nhóm nhà cung cấp theo kho Plugin + URL manifest của Plugin + Manifest chưa có tên + Manifest chưa có nhà cung cấp + Manifest chưa có phiên bản + Manifest chưa có tên + Manifest chưa có nhà cung cấp + Manifest chưa có phiên bản + Đã cài đặt %1$s + Đã bị kho Plugin vô hiệu hóa + Chưa có mô tả + v%1$s + Kho Plugin + Phiên bản %1$s + Kho Plugin này đã được cài đặt + Không thể cài đặt kho Plugin + Không thể làm mới kho Plugin + THÊM KHO PLUGIN + KHO PLUGIN ĐÃ CÀI ĐẶT + TỔNG QUAN + NHÀ CUNG CẤP + Lỗi + Kiểm tra nhà cung cấp thất bại + Kết quả kiểm tra (%1$d) + Plugin yêu cầu khóa API TMDB. Vui lòng thiết lập trong màn hình TMDB để đảm bảo tính năng hoạt động chính xác + Không tìm thấy kết quả trong %1$s + Khóa API không hợp lệ hoặc lỗi kết nối + Kho Plugin + Gửi mốc Đoạn mở đầu + Gửi + Đánh dấu + THỜI ĐIỂM KẾT THÚC (MM:SS) + LOẠI PHÂN ĐOẠN + THỜI ĐIỂM BẮT ĐẦU (MM:SS) + Đã kết nối với Trakt + Đã ngắt kết nối Trakt + TB + Không tìm thấy tệp APK trong bản phát hành + Tải xuống thất bại (HTTP %1$d) + Không tìm thấy tệp cập nhật đã tải xuống + Dữ liệu tải về trống + Lỗi GitHub Releases API: %1$d + Chưa có bản cập nhật nào được phát hành + Bản phát hành chưa có tên hoặc tag + Phát sóng %1$s + Phát sóng hôm nay + Phát sóng ngày mai + + Phát sóng sau %1$d ngày + Phát sóng sau %1$d ngày + + %1$s + Hôm nay + Ngày mai + + Sau %1$d ngày + Sau %1$d ngày + + Tập mới + Mùa mới + Danh sách Trakt %1$s + Trình phát mặc định của Android + Phát qua P2P + Nguồn phát này sử dụng công nghệ ngang hàng (P2P)\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:\n\n• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt + Bật P2P + Hủy + Lỗi torrent không xác định + Phát qua P2P + Cho phép phát từ nguồn torrent (P2P) + Ẩn thống kê torrent + Ẩn bộ đệm, số lượng seed, peer và tốc độ tải trong khi tải và phát + %1$d seed · %2$d peer + Đã đệm %1$s · %2$s · %3$s + %1$s · %2$s + %1$d peer · %2$d seed · %3$d%% + Đang kết nối tới các peer… + Đang khởi động Engine P2P… + Không thể khởi động torrent: %1$s + Lỗi torrent: %1$s + ID bộ sưu tập "%1$s" đã tồn tại + ID thư mục "%1$s" đã tồn tại trong "%2$s" + Quản lý addon và tùy chỉnh nội dung hiển thị trong Nuvio + Quản lý dữ liệu đã lưu trên thiết bị + Mở thư mục tải xuống + Không thể mở thư mục tải xuống + Giới thiệu + Danh mục + Quốc gia + + %d tiêu đề + %d tiêu đề + + Loại + Tiểu sử + Ngày sinh + Tác phẩm tham gia + Nơi sinh + Đang tải xuống phụ đề... + Đang tải xuống phụ đề từ addon... + Tắt báo cáo sự cố? + Báo cáo sự cố trên thiết bị này sẽ bị tắt. Điều này có thể khiến việc khắc phục các lỗi đặc thù trên một số thiết bị trở nên khó khăn hơn + Bật báo cáo sự cố? + Báo cáo sự cố giúp chúng tôi phát hiện lỗi trên thiết bị của bạn mà một cách tự động bằng việc trích xuất nhật ký ADB + Cách thức hoạt động + Báo cáo được nhóm theo phiên bản ứng dụng, dòng máy, phiên bản Android và lỗi hệ thống để chúng tôi có thể sửa chữa các sự cố thường gặp một cách dễ dàng hơn + Tiếp tục bật + Thông tin không được gửi + Mật khẩu, access token, refresh token, nội dung yêu cầu, nội dung phản hồi, header, cookie, ảnh chụp màn hình, cấu trúc giao diện, bản ghi phiên làm việc, dữ liệu chẩn đoán thô và tham số truy vấn hoặc giá trị phân mảnh của URL nguồn phát + Thông tin được gửi + Thông tin sự cố, lỗi treo ứng dụng, lỗi hệ thống, phiên bản ứng dụng, loại bản dựng, biến thể bản dựng, thông tin thiết bị và hệ điều hành, cùng các thông tin breadcrumb mạng an toàn bao gồm phương thức, máy chủ, đường dẫn, mã trạng thái, thời gian phản hồi và loại ngoại lệ + Tắt + Bật + CHUẨN ĐOÁN + Báo cáo sự cố + Tự động gửi báo cáo sự cố với nhà phát triển. Được bật theo mặc định + Bật hiệu ứng chiều sâu + Thêm hiệu ứng ánh sáng và phản chiếu nhẹ cho thẻ hình ảnh giúp tăng chiều sâu cho giao diện + Áp dụng cho + Phạm vi viền + Phía trên + Một nửa + Toàn bộ + Độ sáng viền + Độ sáng viền + Nhẹ nhàng + Cân đối + Nổi bật + Phạm vi viền + Độ phản chiếu + Tinh chỉnh + Tinh chỉnh hiệu ứng + Kéo chấm tròn sang phải để tăng độ sáng viền, kéo lên để tăng độ phản chiếu. Dùng thanh trượt để mở rộng độ sáng cho toàn bộ viền + Độ sáng viền → + ↑ Độ phản chiếu + Tên tập phim + P1 • T1 • 45 phút + HIỆU ỨNG CHIỀU SÂU + Độ phản chiếu + Tắt + Nhẹ + + Poster + Tiếp tục xem + Thẻ tập phim + Trailer + Diễn viên + Chưa có nội dung + Các addon hiện tại không cung cấp nội dung cho mục này + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 71d719ef..d81ca03d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,11 +32,13 @@ Unavailable Configure addon Delete addon - Connect your own media server to browse and play from your personal library. - Add a server URL above when you want Nuvio to show your private library. - No personal libraries connected. - Server URL - Add + Bring your library into Nuvio with the addons you use. + Add an addon to start building your library. + Make Nuvio yours. + Addon URL + Add Addon + Nothing here yet + Your current addons don’t have anything to show here. Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio. No addons installed yet. Enter an addon URL. @@ -442,7 +444,9 @@ Download latest release Check for updates Manage addons and discovery sources. + Manage addons and choose what appears in Nuvio. Manage your downloaded movies and episodes. + Manage what you’ve saved on this device. Downloads GENERAL Manage available integrations @@ -455,9 +459,25 @@ Search settings... RESULTS STARTUP + DIAGNOSTICS CACHE Remember Last Profile Remember last selected profile at startup + Sentry Crash Reports + Send crash and ANR reports with safe context. On by default. + Turn on crash reports? + Crash reports help identify device-specific failures without asking you to reproduce the issue with ADB logs. + Turn off crash reports? + Future crashes and current ANRs from this device will not be reported. This can make device-specific bugs much harder to fix. + How it helps + Reports are grouped by app version, device model, Android version, and stack trace so the most common real crashes can be fixed first. + Sent + Crashes, current ANRs, stack traces, app version, build type, flavor, device and OS metadata, plus safe network breadcrumbs with method, host, path, status, duration, and exception type. + Not sent + Passwords, access tokens, refresh tokens, request bodies, response bodies, headers, cookies, screenshots, view hierarchy, session replay, raw diagnostics, and stream URL query or fragment values. + Keep on + Turn off + Turn on Clear Continue Watching Cache Remove cached Continue Watching data and refresh watch progress Cache cleared @@ -508,6 +528,8 @@ No completed episodes No downloads yet %1$d downloaded episode(s) + Open downloads folder + Couldn't open downloads folder Active Movies Shows @@ -540,9 +562,6 @@ Status Anonymous Signed in - Sync backend - Switch backend? - Switch to %1$s and sign out? You can sign in again on the selected backend. AMOLED Black Use pure black backgrounds for OLED screens. App Language @@ -586,6 +605,37 @@ Visible Hide value Player, subtitles, and auto-play + Card Depth Effect + Adds a lit top edge and a soft sheen to image cards for a subtle sense of depth. + Enable depth effect + Edge glow + Subtle + Balanced + Bold + Top sheen + Off + Soft + Bright + Apply to + Fine-tune + Fine-tune Depth + Drag the dot right for a brighter edge, up for more sheen. Use the slider to extend the glow around the full outline. + Edge glow → + ↑ Sheen + Edge glow + Sheen + Edge coverage + Top only + Half + Full outline + Edge coverage + Episode Title + S1 · E1 · 45 min + Posters + Continue Watching + Episode cards + Cast + Trailers Corner Radius Poster Card Style Width @@ -641,7 +691,7 @@ HOME SOURCES Install, remove, refresh, and sort your content sources. - Connect personal media sources and manage access to your own library. + Add and manage the addons you use with Nuvio. Install JavaScript scraper repositories and test providers internally. Adjust home layout, content visibility, and poster behavior Settings for the detail and episode screens. @@ -742,6 +792,14 @@ Principal cast list. Cinematic Background Blurred backdrop behind content, similar to stream screen. + Background Mode + Choose how artwork appears behind metadata pages. + Normal + Use the standard app background. + Cinematic + Show a soft blurred backdrop behind the page. + Dominant Color + Match the page background to the main color from the backdrop. Collection Related collection or franchise rail. Comments @@ -791,7 +849,7 @@ See the people building and supporting Nuvio across Mobile, TV, and Web. This month's server & maintenance Covered. Additional support now goes to development. - After 100%%, additional support goes to development. + After 100%, additional support goes to development. Loading funding progress... Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties. Contributors @@ -855,6 +913,8 @@ Open new playback with the selected installed player. Forward Subtitles to External Player Fetch addon subtitles in your preferred language and pass them to the external player. + Send Intro and Outro Timestamps + Pass resolved intro and outro skip timestamps to the external player for auto-skipping. Only works in players that support it; other players ignore it. No supported external players installed Player Internal @@ -941,6 +1001,8 @@ %1$d selected Loading Overlay Show loading screen until first video frame appears. + Content Warnings + Show parental guidance overlay when playback starts. Background Color Bold Use a heavier subtitle font weight. @@ -1317,6 +1379,7 @@ Fetching… Finding source… Loading subtitles from addons… + Loading skip segments… Finding streams… Stream link copied No direct stream link available @@ -1463,8 +1526,10 @@ Resume %1$s JSON is empty. Collection %1$d has blank id. + Collection id '%1$s' is used more than once. Collection '%1$s' has blank title. Folder %1$d in '%2$s' has blank id. + Folder id '%1$s' is used more than once in '%2$s'. Folder '%1$s' in '%2$s' has blank title. Source %1$d in folder '%2$s' has blank fields. Source %1$d in folder '%2$s' is missing a Trakt list ID. @@ -1921,4 +1986,19 @@ Starting P2P engine… Failed to start torrent: %1$s Torrent error: %1$s + + Born + Place of birth + Credits + Biography + Country + Type + Catalogue + About + Loading subtitles from addons... + Downloading subtitles... + + %d title + %d titles + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 8a02385b..49217a04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -3,6 +3,7 @@ package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -27,8 +28,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Home -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -43,6 +51,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder @@ -56,21 +65,17 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.serialization.SavedStateConfiguration import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.CachePolicy @@ -83,15 +88,20 @@ import com.nuvio.app.core.deeplink.AppDeepLink import com.nuvio.app.core.deeplink.AppDeepLinkRepository import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository -import com.nuvio.app.core.network.SupabaseProvider -import com.nuvio.app.core.network.SyncBackendRefreshResult -import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.sync.AppForegroundMonitor import com.nuvio.app.core.sync.ProfileSettingsSync +import com.nuvio.app.core.sync.RealtimeSyncConfig +import com.nuvio.app.core.sync.RealtimeSyncInvalidationService import com.nuvio.app.core.sync.SyncManager import com.nuvio.app.core.ui.NuvioNavigationBar +import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet -import com.nuvio.app.core.ui.NuvioPosterActionSheet +import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay +import com.nuvio.app.core.ui.PosterZoomAnchor +import com.nuvio.app.core.ui.PosterZoomAnchorHolder +import com.nuvio.app.core.ui.PosterZoomOverlayAction +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.platformExitApp @@ -105,12 +115,13 @@ import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NativeNavigationTab +import com.nuvio.app.core.ui.NativeProfileSwitcherController import com.nuvio.app.core.ui.NativeTabBridge import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.core.ui.nuvio -import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.features.auth.AuthScreen +import com.nuvio.app.features.addons.AddAddonResult import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen @@ -120,6 +131,7 @@ import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.cloud.providerPosterUrl @@ -128,6 +140,7 @@ import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsScreen +import com.nuvio.app.features.downloads.DownloadItem import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.details.MetaPerson @@ -149,7 +162,6 @@ import com.nuvio.app.features.p2p.P2pConsentDialog import com.nuvio.app.features.p2p.P2pSettingsRepository import com.nuvio.app.features.player.PlayerLaunch import com.nuvio.app.features.player.PlayerLaunchStore -import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.player.ExternalPlayerIntentResult @@ -162,7 +174,6 @@ import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile -import com.nuvio.app.features.profiles.NativeProfileSwitcherPopup import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen @@ -183,7 +194,10 @@ import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository +import com.nuvio.app.features.collection.CollectionEditorPage import com.nuvio.app.features.collection.CollectionSyncService +import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.collection.disposeCollectionEditorPage import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy @@ -208,6 +222,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watchprogress.ResumePromptRepository import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions @@ -216,7 +231,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import com.nuvio.app.navigation.* import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library @@ -232,28 +250,34 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -@Serializable -object TabsRoute - -@Serializable -data class DetailRoute(val type: String, val id: String) - -@Serializable -data class PersonDetailRoute( - val personId: Int, - val personName: String, - val personPhoto: String? = null, - val castAvatarTransitionKey: String? = null, - val preferCrew: Boolean = false, -) - -@Serializable -data class EntityBrowseRoute( - val entityKind: String, - val entityId: Int, - val entityName: String, - val sourceType: String = "tv", -) +private val navigationSavedStateConfiguration = SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(TabsRoute::class, TabsRoute.serializer()) + subclass(DetailRoute::class, DetailRoute.serializer()) + subclass(PersonDetailRoute::class, PersonDetailRoute.serializer()) + subclass(EntityBrowseRoute::class, EntityBrowseRoute.serializer()) + subclass(SettingsPageRoute::class, SettingsPageRoute.serializer()) + subclass(HomescreenSettingsRoute::class, HomescreenSettingsRoute.serializer()) + subclass(MetaScreenSettingsRoute::class, MetaScreenSettingsRoute.serializer()) + subclass(ContinueWatchingSettingsRoute::class, ContinueWatchingSettingsRoute.serializer()) + subclass(DownloadsSettingsRoute::class, DownloadsSettingsRoute.serializer()) + subclass(DownloadShowRoute::class, DownloadShowRoute.serializer()) + subclass(AddonsSettingsRoute::class, AddonsSettingsRoute.serializer()) + subclass(PluginsSettingsRoute::class, PluginsSettingsRoute.serializer()) + subclass(AccountSettingsRoute::class, AccountSettingsRoute.serializer()) + subclass(SupportersContributorsSettingsRoute::class, SupportersContributorsSettingsRoute.serializer()) + subclass(LicensesAttributionsSettingsRoute::class, LicensesAttributionsSettingsRoute.serializer()) + subclass(CollectionsRoute::class, CollectionsRoute.serializer()) + subclass(CollectionEditorRoute::class, CollectionEditorRoute.serializer()) + subclass(CollectionEditorPageRoute::class, CollectionEditorPageRoute.serializer()) + subclass(FolderDetailRoute::class, FolderDetailRoute.serializer()) + subclass(StreamRoute::class, StreamRoute.serializer()) + subclass(CatalogRoute::class, CatalogRoute.serializer()) + subclass(PlayerRoute::class, PlayerRoute.serializer()) + } + } +} private data class PendingP2pStreamOpen( val stream: StreamItem, @@ -264,52 +288,6 @@ private data class PendingP2pStreamOpen( val isAutoPlay: Boolean, ) -@Serializable -object HomescreenSettingsRoute - -@Serializable -object MetaScreenSettingsRoute - -@Serializable -object ContinueWatchingSettingsRoute - -@Serializable -object DownloadsSettingsRoute - -@Serializable -object AddonsSettingsRoute - -@Serializable -object PluginsSettingsRoute - -@Serializable -object AccountSettingsRoute - -@Serializable -object SupportersContributorsSettingsRoute - -@Serializable -object LicensesAttributionsSettingsRoute - -@Serializable -object CollectionsRoute - -@Serializable -data class CollectionEditorRoute(val collectionId: String? = null) - -@Serializable -data class FolderDetailRoute(val collectionId: String, val folderId: String) - -@Serializable -data class StreamRoute( - val launchId: Long, -) - -@Serializable -data class CatalogRoute( - val launchId: Long, -) - private data class CatalogLaunch( val title: String, val subtitle: String, @@ -333,6 +311,35 @@ private object CatalogLaunchStore { } } +/** Idempotent cleanup used by both Navigation 3 and SwiftUI interactive-pop handling. */ +fun disposeRoute(route: AppRoute) { + when (route) { + is StreamRoute -> { + StreamsRepository.clear() + StreamLaunchStore.remove(route.launchId) + } + + is PlayerRoute -> { + ResumePromptRepository.markPlayerExitedNormally() + PlayerLaunchStore.remove(route.launchId) + } + + is CatalogRoute -> { + CatalogRepository.clear() + CatalogLaunchStore.remove(route.launchId) + } + + is CollectionEditorRoute -> CollectionEditorRepository.clear() + is CollectionEditorPageRoute -> { + runCatching { CollectionEditorPage.valueOf(route.pageName) } + .getOrNull() + ?.let(::disposeCollectionEditorPage) + } + is FolderDetailRoute -> FolderDetailRepository.clear() + else -> Unit + } +} + private data class PosterActionTarget( val preview: MetaPreview, val libraryItem: LibraryItem? = null, @@ -344,6 +351,12 @@ enum class AppScreenTab { Search, Library, Settings, + ; + + companion object { + fun fromName(name: String): AppScreenTab = + entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Home + } } private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { @@ -380,36 +393,33 @@ private enum class AppGateScreen { Main, } -private suspend fun refreshSyncBackendSelection() { - SyncBackendRepository.ensureLoaded() +private object NativeAppGateRequests { + val profileSelection = MutableSharedFlow(extraBufferCapacity = 1) - when (val result = SyncBackendRepository.refreshFromManifest()) { - SyncBackendRefreshResult.NotConfigured, - is SyncBackendRefreshResult.Failed, - SyncBackendRefreshResult.Unchanged, - -> Unit - is SyncBackendRefreshResult.Applied -> { - SupabaseProvider.rebuildClient() - NetworkStatusRepository.requestRefresh(force = true) - } - is SyncBackendRefreshResult.RequiresLogout -> { - AuthRepository.resetForSyncBackendChange() - .onSuccess { - SyncBackendRepository.applyBackendAfterLogout( - backend = result.targetBackend, - revision = result.revision, - ) - SupabaseProvider.rebuildClient() - NetworkStatusRepository.requestRefresh(force = true) - } - } + fun requestProfileSelection() { + profileSelection.tryEmit(Unit) } } @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview -fun App() { +fun App( + initialTab: AppScreenTab = AppScreenTab.Home, + initialRoute: AppRoute = TabsRoute, + useNativeNavigation: Boolean = false, + useNativeTabBar: Boolean = false, + useTabletFloatingTabBar: Boolean = false, + ownsAppRuntime: Boolean = true, + bypassAppGate: Boolean = false, + onNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, + onGoBack: (() -> Unit)? = null, + onReplace: ((AppRoute) -> Unit)? = null, + onActivate: ((AppScreenTab) -> Unit)? = null, + onAppReady: ((Boolean) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + nativeProfileSwitcherController: NativeProfileSwitcherController? = null, +) { setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) .crossfade(true) @@ -427,18 +437,35 @@ fun App() { }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { + if (bypassAppGate) { + MainAppContent( + initialTab = initialTab, + initialRoute = initialRoute, + useNativeNavigation = useNativeNavigation, + useNativeTabBar = useNativeTabBar, + useTabletFloatingTabBar = useTabletFloatingTabBar, + ownsAppRuntime = false, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = onActivate, + onTabTitles = onTabTitles, + nativeProfileSwitcherController = nativeProfileSwitcherController, + onSwitchProfile = { + onActivate?.invoke(AppScreenTab.Home) + NativeAppGateRequests.requestProfileSelection() + }, + ) + return@NuvioTheme + } + LaunchedEffect(Unit) { - refreshSyncBackendSelection() + if (!ownsAppRuntime) return@LaunchedEffect AuthRepository.initialize() } LaunchedEffect(Unit) { - AppForegroundMonitor.events().collect { - refreshSyncBackendSelection() - } - } - - LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() AvatarRepository.fetchAvatars() @@ -476,6 +503,20 @@ fun App() { var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(gateScreen, onAppReady) { + if (gateScreen != AppGateScreen.Main.name) { + onAppReady?.invoke(false) + } + } + + LaunchedEffect(useNativeNavigation, ownsAppRuntime) { + if (!useNativeNavigation || !ownsAppRuntime) return@LaunchedEffect + NativeAppGateRequests.profileSelection.collect { + autoSkipProfileSelection = false + gateScreen = AppGateScreen.ProfileSelection.name + } + } + fun rememberedStartupProfile(profiles: List): NuvioProfile? { val currentProfileState = ProfileRepository.state.value if ( @@ -618,7 +659,7 @@ fun App() { .background(MaterialTheme.nuvio.colors.background), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) + NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.accent) } } AppGateScreen.Auth.name -> { @@ -664,6 +705,23 @@ fun App() { } AppGateScreen.Main.name -> { MainAppContent( + initialTab = initialTab, + initialRoute = initialRoute, + useNativeNavigation = useNativeNavigation, + useNativeTabBar = useNativeTabBar, + useTabletFloatingTabBar = useTabletFloatingTabBar, + ownsAppRuntime = ownsAppRuntime, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = onActivate, + onTabTitles = onTabTitles, + nativeProfileSwitcherController = nativeProfileSwitcherController, + onRootContentReady = { ready -> + onAppReady?.invoke( + ready && gateScreen == AppGateScreen.Main.name, + ) + }, onSwitchProfile = { autoSkipProfileSelection = false gateScreen = AppGateScreen.ProfileSelection.name @@ -678,37 +736,64 @@ fun App() { @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable private fun MainAppContent( + initialTab: AppScreenTab = AppScreenTab.Home, + initialRoute: AppRoute = TabsRoute, + useNativeNavigation: Boolean = false, + useNativeTabBar: Boolean = false, + useTabletFloatingTabBar: Boolean = false, + ownsAppRuntime: Boolean = true, + onNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, + onGoBack: (() -> Unit)? = null, + onReplace: ((AppRoute) -> Unit)? = null, + onActivate: ((AppScreenTab) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + nativeProfileSwitcherController: NativeProfileSwitcherController? = null, + onRootContentReady: ((Boolean) -> Unit)? = null, onSwitchProfile: () -> Unit = {}, ) { - val navController = rememberNavController() + val navBackStack = rememberNavBackStack(navigationSavedStateConfiguration, initialRoute) + val navController = remember(navBackStack, onNavigate, onGoBack, onReplace) { + NuvioNavigator( + backStack = navBackStack, + onExternalNavigate = onNavigate, + onExternalBack = onGoBack, + onExternalReplace = onReplace, + onRouteRemoved = ::disposeRoute, + ) + } val appUpdaterController = rememberAppUpdaterController() - remember { - EpisodeReleaseNotificationsRepository.ensureLoaded() - } - remember { - CollectionSyncService.startObserving() - } - remember { - ProfileSettingsSync.startObserving() + if (ownsAppRuntime) { + remember { + EpisodeReleaseNotificationsRepository.ensureLoaded() + } + remember { + CollectionSyncService.startObserving() + } + remember { + ProfileSettingsSync.startObserving() + } } val hapticFeedback = LocalHapticFeedback.current val focusManager = LocalFocusManager.current + val uriHandler = LocalUriHandler.current val coroutineScope = rememberCoroutineScope() - var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + var selectedTab by rememberSaveable(initialTab) { mutableStateOf(initialTab) } var searchFocusRequestCount by remember { mutableStateOf(0) } val homeScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } - var nativeProfileSwitcherVisible by remember { mutableStateOf(false) } - val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStack.lastOrNull() as? AppRoute val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterActionTarget by remember { mutableStateOf(null) } + var selectedPosterAnchor by remember { mutableStateOf(null) } + val posterOverlayHazeState = rememberHazeState() var selectedContinueWatchingForActions by remember { mutableStateOf(null) } + var selectedContinueWatchingZoomAnchor by remember { mutableStateOf(null) } var requestedSettingsPageName by rememberSaveable { mutableStateOf(null) } var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } @@ -729,6 +814,7 @@ private fun MainAppContent( val openPosterActions: (PosterActionTarget) -> Unit = { target -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) focusManager.clearFocus(force = true) + selectedPosterAnchor = PosterZoomAnchorHolder.consume() coroutineScope.launch { withFrameNanos { } selectedPosterActionTarget = target @@ -762,6 +848,7 @@ private fun MainAppContent( val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured) val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable) val externalPlayerFailedText = stringResource(Res.string.external_player_failed) + val failedOpenBrowserText = stringResource(Res.string.settings_trakt_failed_open_browser) val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) @@ -769,15 +856,39 @@ private fun MainAppContent( val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search) val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library) val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile) + val homescreenSettingsTitle = stringResource(Res.string.compose_settings_page_homescreen) + val metaScreenSettingsTitle = stringResource(Res.string.compose_settings_page_meta_screen) + val continueWatchingSettingsTitle = stringResource(Res.string.compose_settings_page_continue_watching) + val debridSettingsTitle = stringResource(Res.string.compose_settings_page_debrid) + val downloadsSettingsTitle = stringResource(Res.string.compose_settings_root_downloads_title) + val addonsSettingsTitle = stringResource(Res.string.compose_settings_page_addons) + val pluginsSettingsTitle = stringResource(Res.string.compose_settings_page_plugins) + val accountSettingsTitle = stringResource(Res.string.compose_settings_page_account) + val supportersSettingsTitle = stringResource(Res.string.compose_settings_page_supporters_contributors) + val licensesSettingsTitle = stringResource(Res.string.compose_settings_page_licenses_attributions) + val collectionsTitle = stringResource(Res.string.collections_header) + val newCollectionTitle = stringResource(Res.string.collections_new) + val detailsFallbackTitle = stringResource(Res.string.meta_section_details_title) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT - var initialHomeReady by rememberSaveable { mutableStateOf(false) } + var initialHomeReady by rememberSaveable(ownsAppRuntime) { + mutableStateOf(!ownsAppRuntime) + } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) } + var watchSourceReconnectPending by remember { mutableStateOf(false) } + + fun activateTab(tab: AppScreenTab) { + if (useNativeNavigation && onActivate != null) { + onActivate(tab) + } else { + selectedTab = tab + } + } fun handleRootTabClick(tab: AppScreenTab) { if (selectedTab != tab) { - selectedTab = tab + activateTab(tab) return } @@ -792,18 +903,27 @@ private fun MainAppContent( } } - LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + LaunchedEffect( + liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled, + useNativeNavigation, + currentRoute, + selectedTab, + ) { NativeTabBridge.requestedTabs.collectLatest { requestedTab -> - if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { - handleRootTabClick(requestedTab.toAppScreenTab()) - } - } - } - - LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { - NativeTabBridge.profileTabLongPresses.collectLatest { - if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { - nativeProfileSwitcherVisible = true + val requestedAppTab = requestedTab.toAppScreenTab() + if ( + useNativeNavigation && + currentRoute is TabsRoute && + requestedAppTab == selectedTab + ) { + handleRootTabClick(requestedAppTab) + } else if ( + !useNativeNavigation && + liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled + ) { + handleRootTabClick(requestedAppTab) } } } @@ -813,6 +933,7 @@ private fun MainAppContent( nativeTabSearchTitle, nativeTabLibraryTitle, nativeTabProfileTitle, + onTabTitles, ) { NativeTabBridge.publishTabTitles( home = nativeTabHomeTitle, @@ -820,6 +941,12 @@ private fun MainAppContent( library = nativeTabLibraryTitle, profile = nativeTabProfileTitle, ) + onTabTitles?.invoke( + nativeTabHomeTitle, + nativeTabSearchTitle, + nativeTabLibraryTitle, + nativeTabProfileTitle, + ) } LaunchedEffect(selectedTab) { @@ -831,35 +958,69 @@ private fun MainAppContent( var profileSwitchLoading by remember { mutableStateOf(false) } - DisposableEffect( - navController, + LaunchedEffect(nativeProfileSwitcherController, ownsAppRuntime) { + if (!ownsAppRuntime) return@LaunchedEffect + nativeProfileSwitcherController?.selectedProfileIndices?.collectLatest { profileIndex -> + val profile = ProfileRepository.state.value.profiles + .firstOrNull { it.profileIndex == profileIndex } + ?: return@collectLatest + profileSwitchLoading = true + activateTab(AppScreenTab.Home) + ProfileRepository.selectProfile(profile.profileIndex) + SyncManager.pullAllForProfile(profile.profileIndex) + } + } + + LaunchedEffect(nativeProfileSwitcherController, ownsAppRuntime, onSwitchProfile) { + if (!ownsAppRuntime) return@LaunchedEffect + nativeProfileSwitcherController?.requestedManageProfiles?.collectLatest { + activateTab(AppScreenTab.Home) + onSwitchProfile() + } + } + val launchOverlayState = remember(ownsAppRuntime) { + MutableTransitionState( + ownsAppRuntime && (!initialHomeReady || profileSwitchLoading), + ) + } + launchOverlayState.targetState = + ownsAppRuntime && (!initialHomeReady || profileSwitchLoading) + + LaunchedEffect( + launchOverlayState.targetState, + ownsAppRuntime, + onRootContentReady, + ) { + if (ownsAppRuntime) { + onRootContentReady?.invoke(!launchOverlayState.targetState) + } + } + + LaunchedEffect( + currentRoute, liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled, initialHomeReady, profileSwitchLoading, + useNativeNavigation, ) { - fun publishNativeTabVisibilityForCurrentRoute() { - val visible = liquidGlassNativeTabBarSupported && - liquidGlassNativeTabBarEnabled && - initialHomeReady && - !profileSwitchLoading && - navController.currentDestination?.hasRoute() == true - NativeTabBridge.publishTabBarVisible(visible) - } + val visible = !useNativeNavigation && + liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled && + initialHomeReady && + !profileSwitchLoading && + currentRoute is TabsRoute + NativeTabBridge.publishTabBarVisible(visible) + } - val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ -> - publishNativeTabVisibilityForCurrentRoute() - } - - publishNativeTabVisibilityForCurrentRoute() - navController.addOnDestinationChangedListener(destinationChangedListener) + DisposableEffect(Unit) { onDispose { - navController.removeOnDestinationChangedListener(destinationChangedListener) NativeTabBridge.publishTabBarVisible(false) } } LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect NetworkStatusRepository.ensureStarted() EpisodeReleaseNotificationsRepository.refreshAsync() kotlinx.coroutines.delay(5_000) @@ -867,12 +1028,14 @@ private fun MainAppContent( } LaunchedEffect(Unit) { + if (!ownsAppRuntime) return@LaunchedEffect AppForegroundMonitor.events().collect { NetworkStatusRepository.requestForegroundRefresh() } } LaunchedEffect(networkStatusUiState.condition) { + if (!ownsAppRuntime) return@LaunchedEffect val condition = networkStatusUiState.condition if (!networkToastBaselineReady) { networkToastBaselineReady = true @@ -909,12 +1072,50 @@ private fun MainAppContent( lastNetworkToastCondition = condition.name } + LaunchedEffect( + networkStatusUiState.condition, + (authState as? AuthState.Authenticated)?.userId, + profileState.activeProfile?.profileIndex, + ) { + if (!ownsAppRuntime) return@LaunchedEffect + when (networkStatusUiState.condition) { + NetworkCondition.NoInternet, + NetworkCondition.ServersUnreachable, + -> watchSourceReconnectPending = true + + NetworkCondition.Online -> { + if (!watchSourceReconnectPending) return@LaunchedEffect + + val profileId = profileState.activeProfile?.profileIndex + ?: ProfileRepository.activeProfileId + val authenticatedState = authState as? AuthState.Authenticated + if (authenticatedState != null && !authenticatedState.isAnonymous) { + SyncManager.requestForegroundPull(profileId = profileId, force = true) + watchSourceReconnectPending = false + } else { + val result = WatchProgressSourceCoordinator.refreshActiveSource( + profileId = profileId, + force = true, + ) + if (result.succeeded) { + watchSourceReconnectPending = false + } + } + } + + NetworkCondition.Unknown, + NetworkCondition.Checking, + -> Unit + } + } + LaunchedEffect( initialHomeReady, offlineLaunchRouteHandled, networkStatusUiState.condition, downloadsUiState.completedItems, ) { + if (!ownsAppRuntime) return@LaunchedEffect if (!initialHomeReady || offlineLaunchRouteHandled) return@LaunchedEffect when (networkStatusUiState.condition) { @@ -934,8 +1135,8 @@ private fun MainAppContent( DownloadsRepository.playableLocalFileUri(it) != null } if (hasPlayableDownload) { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { + activateTab(AppScreenTab.Settings) + navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) { launchSingleTop = true } } @@ -944,10 +1145,57 @@ private fun MainAppContent( } LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { + if (!ownsAppRuntime) return@LaunchedEffect + if (!RealtimeSyncConfig.ENABLED) { + RealtimeSyncInvalidationService.stop() + return@LaunchedEffect + } + val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect if (authenticatedState.isAnonymous) return@LaunchedEffect val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect + RealtimeSyncInvalidationService.start( + userId = authenticatedState.userId, + profileId = activeProfileId, + ) + } + + DisposableEffect(authState, profileState.activeProfile?.profileIndex) { + val authenticatedState = authState as? AuthState.Authenticated + if (ownsAppRuntime && ( + !RealtimeSyncConfig.ENABLED || + authenticatedState == null || + authenticatedState.isAnonymous || + profileState.activeProfile == null + )) { + RealtimeSyncInvalidationService.stop() + } + onDispose { + if (ownsAppRuntime) RealtimeSyncInvalidationService.stop() + } + } + + DisposableEffect(authState, profileState.activeProfile?.profileIndex) { + val authenticatedState = authState as? AuthState.Authenticated + val activeProfileId = profileState.activeProfile?.profileIndex + if (ownsAppRuntime && authenticatedState != null && !authenticatedState.isAnonymous && activeProfileId != null) { + SyncManager.startPeriodicNuvioSyncPull(activeProfileId) + } else if (ownsAppRuntime) { + SyncManager.stopPeriodicNuvioSyncPull() + } + onDispose { + if (ownsAppRuntime) SyncManager.stopPeriodicNuvioSyncPull() + } + } + + LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { + if (!ownsAppRuntime) return@LaunchedEffect + val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect + if (authenticatedState.isAnonymous) return@LaunchedEffect + + val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect + SyncManager.pullAllForProfile(activeProfileId) AppForegroundMonitor.events().collect { SyncManager.requestForegroundPull(activeProfileId, force = true) } @@ -1031,6 +1279,7 @@ private fun MainAppContent( profileState.activeProfile?.profileIndex, continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) { + if (!ownsAppRuntime) return@LaunchedEffect if (!initialHomeReady || profileSwitchLoading) return@LaunchedEffect if (resumePromptItem != null) return@LaunchedEffect if (continueWatchingPreferencesUiState.showResumePromptOnLaunch) { @@ -1038,28 +1287,58 @@ private fun MainAppContent( } } - LaunchedEffect(currentBackStackEntry?.destination) { - val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute() == true || - currentBackStackEntry?.destination?.hasRoute() == true + LaunchedEffect(currentRoute) { + val inPlaybackFlow = currentRoute is StreamRoute || currentRoute is PlayerRoute if (inPlaybackFlow) { resumePromptItem = null } } LaunchedEffect(navController) { + if (!ownsAppRuntime) return@LaunchedEffect AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink -> when (deepLink) { is AppDeepLink.Meta -> { - selectedTab = AppScreenTab.Home - navController.navigate(DetailRoute(type = deepLink.type, id = deepLink.id)) { + activateTab(AppScreenTab.Home) + val routeTitle = runCatching { + MetaDetailsRepository.fetch(deepLink.type, deepLink.id)?.name + }.getOrNull().orEmpty().ifBlank { detailsFallbackTitle } + navController.navigate( + DetailRoute( + type = deepLink.type, + id = deepLink.id, + title = routeTitle, + ) + ) { launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) } + is AppDeepLink.AddonInstall -> { + activateTab(AppScreenTab.Settings) + navController.navigate(AddonsSettingsRoute(addonsSettingsTitle)) { + launchSingleTop = true + } + NuvioToastController.show(getString(Res.string.addons_modal_checking_title)) + AddonRepository.initialize() + when (val result = AddonRepository.addAddon(deepLink.manifestUrl)) { + is AddAddonResult.Success -> { + NuvioToastController.show( + getString(Res.string.addons_modal_success_message, result.manifest.name), + ) + } + + is AddAddonResult.Error -> { + NuvioToastController.show(result.message) + } + } + AppDeepLinkRepository.markConsumed(deepLink) + } + AppDeepLink.Downloads -> { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { + activateTab(AppScreenTab.Settings) + navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) { launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) @@ -1082,14 +1361,18 @@ private fun MainAppContent( val baseRequest = launch.toExternalPlayerPlaybackRequest() val shouldForwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles && !playerSettingsUiState.preferredSubtitleLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true) + val shouldSendSkipSegments = playerSettingsUiState.externalPlayerSendSkipSegments if (shouldForwardSubtitles) { StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_subtitles)) + } else if (shouldSendSkipSegments) { + StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_skip_segments)) } val enrichedRequest = prepareExternalPlayerLaunch( request = baseRequest, type = launch.contentType ?: launch.parentMetaType, videoId = launch.videoId ?: launch.parentMetaId, forwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles, + sendSkipSegments = shouldSendSkipSegments, preferredLanguage = playerSettingsUiState.preferredSubtitleLanguage, secondaryLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, onOverlayMessage = { _ -> }, @@ -1119,6 +1402,57 @@ private fun MainAppContent( } } + fun openDownloadedItem(item: DownloadItem) { + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return + val resumeEntry = item.videoId + .takeIf { it.isNotBlank() } + ?.let(WatchProgressRepository::progressForVideo) + ?.takeIf { it.isResumable } + + val playerLaunch = PlayerLaunch( + profileId = activePlaybackProfileId, + title = item.title, + sourceUrl = sourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), + streamType = null, + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + streamTitle = item.streamTitle, + streamSubtitle = item.streamSubtitle, + providerName = item.providerName, + providerAddonId = item.providerAddonId, + contentType = item.contentType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, + initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + coroutineScope.launch { openExternalPlayback(playerLaunch) } + return + } + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) + } + + fun openExternalStreamUrl(url: String): Boolean { + val opened = runCatching { + uriHandler.openUri(url) + }.isSuccess + if (!opened) { + NuvioToastController.show(failedOpenBrowserText) + } + return opened + } + suspend fun launchCloudLibraryFile( item: CloudLibraryItem, file: CloudLibraryFile, @@ -1157,7 +1491,7 @@ private fun MainAppContent( true } else { val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) true } } @@ -1228,7 +1562,7 @@ private fun MainAppContent( return } val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) return } } @@ -1256,7 +1590,7 @@ private fun MainAppContent( ), ) navController.navigate( - StreamRoute(launchId = streamLaunchId), + StreamRoute(launchId = streamLaunchId, title = title), ) } @@ -1317,6 +1651,8 @@ private fun MainAppContent( navController.navigate( CatalogRoute( launchId = launchId, + title = section.title, + subtitle = section.subtitle, ), ) } @@ -1341,6 +1677,8 @@ private fun MainAppContent( navController.navigate( CatalogRoute( launchId = launchId, + title = section.displayTitle, + subtitle = librarySectionSubtitle, ), ) } @@ -1422,8 +1760,24 @@ private fun MainAppContent( openContinueWatching(item, true, false) } + val onContinueWatchingRemove: (ContinueWatchingItem) -> Unit = { item -> + if (item.isNextUp) { + ContinueWatchingPreferencesRepository.addDismissedNextUpKey( + nextUpDismissKey( + item.parentMetaId, + item.nextUpSeedSeasonNumber, + item.nextUpSeedEpisodeNumber, + ), + ) + } else { + WatchProgressRepository.removeProgress(contentId = item.parentMetaId) + } + } + val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + val zoomAnchor = PosterZoomAnchorHolder.consume() + selectedContinueWatchingZoomAnchor = zoomAnchor selectedContinueWatchingForActions = item } @@ -1432,18 +1786,35 @@ private fun MainAppContent( .fillMaxSize() .background(MaterialTheme.nuvio.colors.background), ) { + Box( + modifier = Modifier + .fillMaxSize() + .then( + if (selectedPosterActionTarget != null || selectedContinueWatchingZoomAnchor != null) { + Modifier.hazeSource(state = posterOverlayHazeState) + } else { + Modifier + }, + ) + .background(MaterialTheme.nuvio.colors.background), + ) { SharedTransitionLayout { - NavHost( - navController = navController, - startDestination = TabsRoute, - modifier = Modifier.fillMaxSize(), + CompositionLocalProvider( + LocalUseNativeNavigation provides useNativeNavigation, + LocalNativeNavigationBarHidden provides (currentRoute?.hidesNavigationBar == true), ) { - composable { + NavDisplay( + backStack = navBackStack, + modifier = Modifier.fillMaxSize(), + onBack = { navController.popBackStack() }, + sharedTransitionScope = this@SharedTransitionLayout, + entryProvider = entryProvider { + entry { PlatformBackHandler( enabled = true, onBack = { if (selectedTab != AppScreenTab.Home) { - selectedTab = AppScreenTab.Home + activateTab(AppScreenTab.Home) } else { showExitConfirmation = !showExitConfirmation } @@ -1451,20 +1822,17 @@ private fun MainAppContent( ) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val isTabletLayout = maxWidth >= 768.dp - val useNativeBottomTabs = + val isTabletLayout = useTabletFloatingTabBar || maxWidth >= 768.dp + val useNativeBottomTabs = if (useNativeNavigation) { + useNativeTabBar + } else { liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady - val nativeTabSafeBottomPadding = nuvioBottomNavigationBarInsets() - .asPaddingValues() - .calculateBottomPadding() - val nativeProfileTabAnchorBottomPadding = - nativeTabSafeBottomPadding + NuvioTokens.Space.s10 - val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true + } + val tabsRouteActive = currentRoute is TabsRoute val onProfileSelected: (NuvioProfile) -> Unit = { profile -> - nativeProfileSwitcherVisible = false profileSwitchLoading = true NativeTabBridge.publishTabBarVisible(false) - selectedTab = AppScreenTab.Home + activateTab(AppScreenTab.Home) ProfileRepository.selectProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } @@ -1529,13 +1897,13 @@ private fun MainAppContent( animateHomeCollectionGifs = tabsRouteActive, onCatalogClick = onCatalogClick, onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name)) }, onPosterLongClick = { meta -> openPosterActions(PosterActionTarget(preview = meta)) }, onLibraryPosterClick = { item -> - navController.navigate(DetailRoute(type = item.type, id = item.id)) + navController.navigate(DetailRoute(type = item.type, id = item.id, title = item.name)) }, onLibraryPosterLongClick = { item, section -> openPosterActions( @@ -1550,7 +1918,10 @@ private fun MainAppContent( onCloudFilePlay = { item, file -> coroutineScope.launch { val resumeItem = WatchProgressRepository - .progressForVideo(item.playbackVideoId(file)) + .progressForVideo( + videoId = item.playbackVideoId(file), + parentMetaId = item.id, + ) ?.takeIf { it.isResumable } ?.toContinueWatchingItem() if ( @@ -1566,30 +1937,47 @@ private fun MainAppContent( } }, onConnectCloudClick = { - requestedSettingsPageName = "Debrid" - selectedTab = AppScreenTab.Settings + if (useNativeNavigation && !isTabletLayout) { + activateTab(AppScreenTab.Settings) + navController.navigate( + SettingsPageRoute( + pageName = "Debrid", + title = debridSettingsTitle, + ) + ) + } else { + requestedSettingsPageName = "Debrid" + activateTab(AppScreenTab.Settings) + } }, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, onSwitchProfile = onSwitchProfile, - onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, - onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, - onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, - onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, - onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, + onSettingsPageClick = if (useNativeNavigation && !isTabletLayout) { + { pageName, title -> + navController.navigate(SettingsPageRoute(pageName, title)) + } + } else { + null + }, + onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute(homescreenSettingsTitle)) }, + onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute(metaScreenSettingsTitle)) }, + onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute(continueWatchingSettingsTitle)) }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) }, + onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute(addonsSettingsTitle)) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { - navController.navigate(PluginsSettingsRoute) + navController.navigate(PluginsSettingsRoute(pluginsSettingsTitle)) } }, - onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, + onAccountSettingsClick = { navController.navigate(AccountSettingsRoute(accountSettingsTitle)) }, onSupportersContributorsSettingsClick = { if (AppFeaturePolicy.supportersContributorsPageEnabled) { - navController.navigate(SupportersContributorsSettingsRoute) + navController.navigate(SupportersContributorsSettingsRoute(supportersSettingsTitle)) } }, onLicensesAttributionsSettingsClick = { - navController.navigate(LicensesAttributionsSettingsRoute) + navController.navigate(LicensesAttributionsSettingsRoute(licensesSettingsTitle)) }, onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { { @@ -1601,9 +1989,21 @@ private fun MainAppContent( } else { null }, - onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, + onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) }, onFolderClick = { collectionId, folderId -> - navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) + val folderTitle = CollectionRepository.collections.value + .firstOrNull { it.id == collectionId } + ?.folders + ?.firstOrNull { it.id == folderId } + ?.title + .orEmpty() + navController.navigate( + FolderDetailRoute( + collectionId = collectionId, + folderId = folderId, + title = folderTitle.ifBlank { collectionsTitle }, + ) + ) }, requestedSettingsPageName = requestedSettingsPageName, onRequestedSettingsPageConsumed = { @@ -1618,43 +2018,23 @@ private fun MainAppContent( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, - onAddProfileRequested = { - nativeProfileSwitcherVisible = false - onSwitchProfile() - }, - ) - } - - if (!isTabletLayout && useNativeBottomTabs && tabsRouteActive) { - NativeProfileSwitcherPopup( - visible = nativeProfileSwitcherVisible, - isSwitchingProfile = profileSwitchLoading, - onDismissRequest = { nativeProfileSwitcherVisible = false }, - onProfileSelected = onProfileSelected, - onAddProfileRequested = { - nativeProfileSwitcherVisible = false - onSwitchProfile() - }, - modifier = Modifier - .fillMaxSize() - .padding(bottom = nativeProfileTabAnchorBottomPadding), + onAddProfileRequested = onSwitchProfile, ) } } } } } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) + val animatedVisibilityScope = LocalNavAnimatedContentScope.current val directorRole = stringResource(Res.string.person_role_director) val writerRole = stringResource(Res.string.person_role_writer) val creatorRole = stringResource(Res.string.person_role_creator) MetaDetailsScreen( type = route.type, id = route.id, - onBack = { - navController.popBackStack() - }, + onBack = onBack, onPlay = onPlay, onPlayManually = onPlayManually, onOpenMeta = { preview -> @@ -1674,6 +2054,7 @@ private fun MainAppContent( DetailRoute( type = preview.type, id = resolvedId, + title = preview.name, ), ) } @@ -1713,19 +2094,20 @@ private fun MainAppContent( } }, sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, + animatedVisibilityScope = animatedVisibilityScope, modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) + val animatedVisibilityScope = LocalNavAnimatedContentScope.current PersonDetailScreen( personId = route.personId, personName = route.personName, initialProfilePhoto = route.personPhoto, avatarTransitionKey = route.castAvatarTransitionKey, preferCrew = route.preferCrew, - onBack = { navController.popBackStack() }, + onBack = onBack, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { @@ -1743,23 +2125,24 @@ private fun MainAppContent( DetailRoute( type = preview.type, id = resolvedId, + title = preview.name, ), ) } }, sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, + animatedVisibilityScope = animatedVisibilityScope, modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) TmdbEntityBrowseScreen( entityKind = TmdbEntityKind.fromRouteValue(route.entityKind), entityId = route.entityId, entityName = route.entityName, sourceType = route.sourceType, - onBack = { navController.popBackStack() }, + onBack = onBack, onOpenMeta = { preview -> coroutineScope.launch { val resolvedId = if (preview.id.startsWith("tmdb:")) { @@ -1777,6 +2160,7 @@ private fun MainAppContent( DetailRoute( type = preview.type, id = resolvedId, + title = preview.name, ), ) } @@ -1784,34 +2168,21 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) val launch = remember(route.launchId) { StreamLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { - StreamsRepository.clear() - navController.popBackStack() + onBack() } - return@composable + return@entry } val pauseDescription = launch.pauseDescription val streamRouteScope = rememberCoroutineScope() var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) } var pendingP2pStreamOpen by remember { mutableStateOf(null) } - val lifecycleOwner = backStackEntry - DisposableEffect(lifecycleOwner, route.launchId) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_DESTROY) { - StreamLaunchStore.remove(route.launchId) - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } val shouldResolveEpisodeVideoId = launch.parentMetaId != null && launch.seasonNumber != null && @@ -1939,7 +2310,7 @@ private fun MainAppContent( val launchId = PlayerLaunchStore.put(playerLaunch) StreamsRepository.cancelLoading() - navController.navigate(PlayerRoute(launchId = launchId)) { + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) { if (replaceStreamRoute) { popUpTo { inclusive = true } } @@ -2064,7 +2435,7 @@ private fun MainAppContent( StreamsRepository.clear() reuseNavigated = true val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) { + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) { popUpTo { inclusive = true } } } @@ -2200,7 +2571,7 @@ private fun MainAppContent( StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) { + navController.navigate(PlayerRoute(launchId = launchId, title = playerLaunch.title)) { popUpTo { inclusive = true } } } @@ -2210,9 +2581,9 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) + NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.accent) } - return@composable + return@entry } fun openSelectedStream( @@ -2268,6 +2639,13 @@ private fun MainAppContent( ) return } + if (stream.shouldOpenExternally) { + val opened = stream.externalOpenUrl?.let(::openExternalStreamUrl) == true + if (opened) { + StreamsRepository.cancelLoading() + } + return + } val sourceUrl = stream.playableDirectUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -2321,15 +2699,17 @@ private fun MainAppContent( ) if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) { - coroutineScope.launch { openExternalPlayback(playerLaunch) } - StreamsRepository.cancelLoading() + streamRouteScope.launch { + openExternalPlayback(playerLaunch) + StreamsRepository.cancelLoading() + } return } val launchId = PlayerLaunchStore.put(playerLaunch) StreamsRepository.cancelLoading() navController.navigate( - PlayerRoute(launchId = launchId) + PlayerRoute(launchId = launchId, title = playerLaunch.title) ) } @@ -2376,10 +2756,7 @@ private fun MainAppContent( forceInternal = !openExternally, ) }, - onBack = { - StreamsRepository.clear() - navController.popBackStack() - }, + onBack = onBack, modifier = Modifier.fillMaxSize(), ) pendingP2pStreamOpen?.let { pending -> @@ -2414,7 +2791,7 @@ private fun MainAppContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(MaterialTheme.nuvio.spacing.cardPadding), ) { - CircularProgressIndicator(color = MaterialTheme.nuvio.colors.playerControlsForeground) + NuvioLoadingIndicator(color = MaterialTheme.nuvio.colors.playerControlsForeground) Text( text = stringResource(Res.string.streams_finding_source), color = MaterialTheme.nuvio.colors.playerControlsForeground.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy), @@ -2425,28 +2802,27 @@ private fun MainAppContent( } } } - composable( - enterTransition = { - if (isIos) fadeIn(animationSpec = tween(220)) else null + entry( + metadata = if (isIos) { + NavDisplay.transitionSpec { + fadeIn(animationSpec = tween(220)) togetherWith + fadeOut(animationSpec = tween(220)) + } + NavDisplay.popTransitionSpec { + fadeIn(animationSpec = tween(220)) togetherWith + fadeOut(animationSpec = tween(220)) + } + } else { + emptyMap() }, - exitTransition = { - if (isIos) fadeOut(animationSpec = tween(220)) else null - }, - popEnterTransition = { - if (isIos) fadeIn(animationSpec = tween(220)) else null - }, - popExitTransition = { - if (isIos) fadeOut(animationSpec = tween(220)) else null - }, - ) { backStackEntry -> - val route = backStackEntry.toRoute() + ) { route -> + val onBack = rememberGuardedPopBackStack(navController, route) val launch = remember(route.launchId) { PlayerLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { - navController.popBackStack() + onBack() } Box(modifier = Modifier.fillMaxSize()) - return@composable + return@entry } LaunchedEffect(launch.videoId) { launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) } @@ -2484,11 +2860,7 @@ private fun MainAppContent( initialPositionMs = launch.initialPositionMs, initialProgressFraction = launch.initialProgressFraction, contentLanguage = launch.contentLanguage, - onBack = { - ResumePromptRepository.markPlayerExitedNormally() - PlayerLaunchStore.remove(route.launchId) - navController.popBackStack() - }, + onBack = onBack, onOpenInExternalPlayer = { request -> val playerLaunch = PlayerLaunch( profileId = launch.profileId, @@ -2534,30 +2906,29 @@ private fun MainAppContent( } } }, + onOpenExternalUrl = { url -> + openExternalStreamUrl(url) + }, modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) val launch = remember(route.launchId) { CatalogLaunchStore.get(route.launchId) } if (launch == null) { LaunchedEffect(route.launchId) { - navController.popBackStack() + onBack() } - return@composable + return@entry } val target = launch.target CatalogScreen( title = launch.title, subtitle = launch.subtitle, target = target, - onBack = { - CatalogRepository.clear() - CatalogLaunchStore.remove(route.launchId) - navController.popBackStack() - }, + onBack = onBack, onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name)) }, onPosterLongClick = { meta -> openPosterActions( @@ -2575,115 +2946,127 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - composable { + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = it, + route = route, ) HomescreenSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) MetaScreenSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) ContinueWatchingSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) - DownloadsScreen( - onBack = onBack, - onOpenDownload = { item -> - val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen - val resumeEntry = item.videoId - .takeIf { it.isNotBlank() } - ?.let(WatchProgressRepository::progressForVideo) - ?.takeIf { it.isResumable } - - val playerLaunch = PlayerLaunch( - profileId = activePlaybackProfileId, - title = item.title, - sourceUrl = sourceUrl, - sourceHeaders = emptyMap(), - sourceResponseHeaders = emptyMap(), - externalSubtitles = emptyList(), - streamType = null, - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - streamTitle = item.streamTitle, - streamSubtitle = item.streamSubtitle, - providerName = item.providerName, - providerAddonId = item.providerAddonId, - contentType = item.contentType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, - initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, - ) - if (playerSettingsUiState.externalPlayerEnabled) { - coroutineScope.launch { openExternalPlayback(playerLaunch) } - return@DownloadsScreen + SettingsScreen( + modifier = Modifier.fillMaxSize(), + initialPageName = route.pageName, + rootActionsEnabled = false, + onNavigatePage = { pageName, title -> + navController.navigate(SettingsPageRoute(pageName, title)) + }, + onExternalBack = onBack, + showInternalHeader = !useNativeNavigation, + onDownloadsClick = { + navController.navigate(DownloadsSettingsRoute(downloadsSettingsTitle)) + }, + onCollectionsClick = { + navController.navigate(CollectionsRoute(collectionsTitle)) + }, + onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { + { + appUpdaterController.checkForUpdates( + force = true, + showNoUpdateFeedback = true, + ) } - val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) + } else { + null }, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = ::openDownloadedItem, + onNavigateToShow = if (useNativeNavigation) { + { showId, title -> + navController.navigate(DownloadShowRoute(showId, title)) + } + } else { + null + }, + ) + } + entry { route -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = ::openDownloadedItem, + initialShowId = route.showId, + onBackFromShow = onBack, + ) + } + entry { route -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, ) AddonsSettingsScreen( onBack = onBack, ) } if (AppFeaturePolicy.pluginsEnabled) { - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) PluginsSettingsScreen( onBack = onBack, ) } } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) AccountSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) if (AppFeaturePolicy.supportersContributorsPageEnabled) { SupportersContributorsSettingsScreen( @@ -2695,134 +3078,284 @@ private fun MainAppContent( } } } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) LicensesAttributionsSettingsScreen( onBack = onBack, ) } - composable { backStackEntry -> + entry { route -> val onBack = rememberGuardedPopBackStack( navController = navController, - backStackEntry = backStackEntry, + route = route, ) CollectionManagementScreen( onBack = onBack, onNavigateToEditor = { collectionId -> - navController.navigate(CollectionEditorRoute(collectionId = collectionId)) + val editorTitle = collectionId + ?.let { id -> + CollectionRepository.collections.value.firstOrNull { it.id == id }?.title + } + .orEmpty() + navController.navigate( + CollectionEditorRoute( + collectionId = collectionId, + title = editorTitle.ifBlank { newCollectionTitle }, + ) + ) }, ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, + ) CollectionEditorScreen( collectionId = route.collectionId, - onBack = { - CollectionEditorRepository.clear() - navController.popBackStack() + onBack = onBack, + initialPage = if (useNativeNavigation) CollectionEditorPage.Root else null, + onNavigateToPage = if (useNativeNavigation) { + { page, title -> + navController.navigate( + CollectionEditorPageRoute( + collectionId = route.collectionId, + pageName = page.name, + title = title, + ) + ) + } + } else { + null }, ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + entry { route -> + val page = remember(route.pageName) { + runCatching { CollectionEditorPage.valueOf(route.pageName) }.getOrNull() + } + val onBack = rememberGuardedPopBackStack( + navController = navController, + route = route, + ) + if (page == null || page == CollectionEditorPage.Root) { + LaunchedEffect(route) { onBack() } + return@entry + } + CollectionEditorScreen( + collectionId = route.collectionId, + initialPage = page, + initializeRepository = false, + onBack = onBack, + onNavigateToPage = { nextPage, title -> + navController.navigate( + CollectionEditorPageRoute( + collectionId = route.collectionId, + pageName = nextPage.name, + title = title, + ) + ) + }, + ) + } + entry { route -> + val onBack = rememberGuardedPopBackStack(navController, route) LaunchedEffect(route.collectionId, route.folderId) { FolderDetailRepository.initialize(route.collectionId, route.folderId) } FolderDetailScreen( - onBack = { - FolderDetailRepository.clear() - navController.popBackStack() - }, + onBack = onBack, onCatalogClick = onCatalogClick, onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + navController.navigate(DetailRoute(type = meta.type, id = meta.id, title = meta.name)) }, ) } + }, + ) } } + } - NuvioPosterActionSheet( - item = selectedPosterActionTarget?.preview, - isSaved = selectedPosterActionTarget?.preview?.let { preview -> - LibraryRepository.isSaved(preview.id, preview.type) - } == true, - isWatched = selectedPosterActionTarget?.preview?.let { preview -> - WatchingState.isPosterWatched( + selectedPosterActionTarget?.let { posterActionTarget -> + key(posterActionTarget) { + val preview = posterActionTarget.preview + val isSaved = LibraryRepository.isSaved(preview.id, preview.type) + val isWatched = WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = preview, ) - } == true, - onDismiss = { selectedPosterActionTarget = null }, - onToggleLibrary = { - selectedPosterActionTarget?.let { target -> - val preview = target.preview - val libraryItem = target.libraryItem ?: preview.toLibraryItem(savedAtEpochMs = 0L) - if (target.libraryItem != null) { - if (isTraktLibrarySource) { - coroutineScope.launch { - runCatching { - val listKey = target.libraryListKey - if (listKey.isNullOrBlank()) { - val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem) - LibraryRepository.applyMembershipChanges( - item = libraryItem, - desiredMembership = currentMembership.mapValues { false }, - ) + // Trakt items long-pressed outside the library open the list picker + // instead of removing, so only true removals disintegrate. + val removesFromLibrary = isSaved && + (posterActionTarget.libraryItem != null || !isTraktLibrarySource) + NuvioPosterZoomActionOverlay( + imageUrl = selectedPosterAnchor?.imageUrl ?: preview.poster, + title = preview.name, + subtitle = preview.releaseInfo + ?.takeIf { it.isNotBlank() } + ?.let { formatReleaseDateForDisplay(it) } + ?: preview.type.replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + }, + isWatched = isWatched, + anchor = selectedPosterAnchor, + actions = listOf( + PosterZoomOverlayAction( + icon = if (isSaved) Icons.Default.DeleteOutline else Icons.Default.Add, + label = if (isSaved) { + stringResource(Res.string.hero_remove_from_library) + } else { + stringResource(Res.string.hero_add_to_library) + }, + isDestructive = removesFromLibrary, + onSelected = { + val libraryItem = posterActionTarget.libraryItem + ?: preview.toLibraryItem(savedAtEpochMs = 0L) + if (posterActionTarget.libraryItem != null) { + if (isTraktLibrarySource) { + coroutineScope.launch { + runCatching { + val listKey = posterActionTarget.libraryListKey + if (listKey.isNullOrBlank()) { + val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem) + LibraryRepository.applyMembershipChanges( + item = libraryItem, + desiredMembership = currentMembership.mapValues { false }, + ) + } else { + LibraryRepository.removeFromList(libraryItem, listKey) + } + }.onFailure { error -> + NuvioToastController.show( + error.message ?: getString(Res.string.trakt_lists_update_failed), + ) + } + } } else { - LibraryRepository.removeFromList(libraryItem, listKey) + LibraryRepository.remove(libraryItem.id) } - }.onFailure { error -> - NuvioToastController.show( - error.message ?: getString(Res.string.trakt_lists_update_failed), - ) - } - } - } else { - LibraryRepository.remove(libraryItem.id) - } - } else { - if (!isTraktLibrarySource) { - LibraryRepository.toggleSaved(libraryItem) - } else { - pickerItem = libraryItem - pickerTitle = preview.name - pickerTabs = LibraryRepository.libraryListTabs() - pickerMembership = pickerTabs.associate { it.key to false } - pickerPending = true - pickerError = null - showLibraryListPicker = true - coroutineScope.launch { - runCatching { - val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.libraryListTabs() - pickerTabs = tabs - pickerMembership = tabs.associate { tab -> - tab.key to (snapshot[tab.key] == true) + } else { + if (!isTraktLibrarySource) { + LibraryRepository.toggleSaved(libraryItem) + } else { + pickerItem = libraryItem + pickerTitle = preview.name + pickerTabs = LibraryRepository.libraryListTabs() + pickerMembership = pickerTabs.associate { it.key to false } + pickerPending = true + pickerError = null + showLibraryListPicker = true + coroutineScope.launch { + runCatching { + val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) + val tabs = LibraryRepository.libraryListTabs() + pickerTabs = tabs + pickerMembership = tabs.associate { tab -> + tab.key to (snapshot[tab.key] == true) + } + }.onFailure { error -> + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) + } + pickerPending = false + } } - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } - pickerPending = false + }, + ), + PosterZoomOverlayAction( + icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, + label = if (isWatched) { + stringResource(Res.string.hero_mark_unwatched) + } else { + stringResource(Res.string.hero_mark_watched) + }, + onSelected = { + coroutineScope.launch { + WatchingActions.togglePosterWatched(preview) + } + }, + ), + ), + hazeState = posterOverlayHazeState, + onDismissed = { + selectedPosterActionTarget = null + selectedPosterAnchor = null + }, + ) + } + } + + selectedContinueWatchingForActions?.let { item -> + selectedContinueWatchingZoomAnchor?.let { anchor -> + key(item.videoId, anchor) { + val showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState) + val showDetailsOption = !item.isCloudLibraryContinueWatchingItem() + NuvioPosterZoomActionOverlay( + imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl), + title = item.title, + subtitle = localizedContinueWatchingSubtitle(item), + anchor = anchor, + actions = buildList { + if (showDetailsOption) { + add( + PosterZoomOverlayAction( + icon = Icons.Default.Info, + label = stringResource(Res.string.cw_action_go_to_details), + onSelected = { + navController.navigate( + DetailRoute( + type = item.parentMetaType, + id = item.parentMetaId, + title = item.title, + ), + ) + }, + ), + ) } - } - } + if (showManualPlayOption) { + add( + PosterZoomOverlayAction( + icon = Icons.Default.PlayArrow, + label = stringResource(Res.string.play_manually), + onSelected = { onContinueWatchingPlayManually(item) }, + ), + ) + } + if (!item.isNextUp) { + add( + PosterZoomOverlayAction( + icon = Icons.Default.Replay, + label = stringResource(Res.string.cw_action_start_from_beginning), + onSelected = { onContinueWatchingStartFromBeginning(item) }, + ), + ) + } + add( + PosterZoomOverlayAction( + icon = Icons.Default.DeleteOutline, + label = stringResource(Res.string.cw_action_remove), + isDestructive = true, + onSelected = { onContinueWatchingRemove(item) }, + ), + ) + }, + hazeState = posterOverlayHazeState, + onDismissed = { + selectedContinueWatchingForActions = null + selectedContinueWatchingZoomAnchor = null + }, + ) } - }, - onToggleWatched = { - selectedPosterActionTarget?.preview?.let { preview -> - coroutineScope.launch { - WatchingActions.togglePosterWatched(preview) - } - } - }, - ) + } + } NuvioContinueWatchingActionSheet( - item = selectedContinueWatchingForActions, + item = selectedContinueWatchingForActions.takeIf { selectedContinueWatchingZoomAnchor == null }, showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState), showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true, onDismiss = { selectedContinueWatchingForActions = null }, @@ -2832,6 +3365,7 @@ private fun MainAppContent( DetailRoute( type = item.parentMetaType, id = item.parentMetaId, + title = item.title, ), ) } @@ -2842,19 +3376,7 @@ private fun MainAppContent( onPlayManually = selectedContinueWatchingForActions ?.let { item -> { onContinueWatchingPlayManually(item) } }, onRemove = { - selectedContinueWatchingForActions?.let { item -> - if (item.isNextUp) { - ContinueWatchingPreferencesRepository.addDismissedNextUpKey( - nextUpDismissKey( - item.parentMetaId, - item.nextUpSeedSeasonNumber, - item.nextUpSeedEpisodeNumber, - ), - ) - } else { - WatchProgressRepository.removeProgress(contentId = item.parentMetaId) - } - } + selectedContinueWatchingForActions?.let(onContinueWatchingRemove) }, ) @@ -2915,7 +3437,7 @@ private fun MainAppContent( ) androidx.compose.animation.AnimatedVisibility( - visible = !initialHomeReady || profileSwitchLoading, + visibleState = launchOverlayState, enter = fadeIn(), exit = fadeOut(androidx.compose.animation.core.tween(400)), ) { @@ -2969,19 +3491,18 @@ private fun MainAppContent( @Composable private fun rememberGuardedPopBackStack( - navController: NavHostController, - backStackEntry: NavBackStackEntry, + navController: NuvioNavigator, + route: AppRoute, beforePop: () -> Unit = {}, ): () -> Unit { - val currentBackStackEntry by navController.currentBackStackEntryAsState() - var popHandled by remember(backStackEntry) { mutableStateOf(false) } + var popHandled by remember(route) { mutableStateOf(false) } - return remember(navController, backStackEntry, currentBackStackEntry, popHandled, beforePop) { + return remember(navController, route, popHandled, beforePop) { { - if (!popHandled && currentBackStackEntry == backStackEntry) { + if (!popHandled && navController.currentRoute == route) { popHandled = true beforePop() - navController.popBackStack() + navController.popBackStack(expectedRoute = route) } } } @@ -3009,6 +3530,7 @@ private fun AppTabHost( onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null, + onSettingsPageClick: ((pageName: String, title: String) -> Unit)? = null, onHomescreenSettingsClick: () -> Unit = {}, onMetaScreenSettingsClick: () -> Unit = {}, onContinueWatchingSettingsClick: () -> Unit = {}, @@ -3074,6 +3596,7 @@ private fun AppTabHost( requestedPageName = requestedSettingsPageName, onRequestedPageConsumed = onRequestedSettingsPageConsumed, rootActionsEnabled = rootActionsEnabled, + onNavigatePage = onSettingsPageClick, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, @@ -3271,7 +3794,7 @@ private fun AppLaunchOverlay( contentScale = ContentScale.Fit, ) Spacer(modifier = Modifier.height(tokens.spacing.sectionGap)) - CircularProgressIndicator(color = tokens.colors.accent) + NuvioLoadingIndicator(color = tokens.colors.accent) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index 169f2c6f..c0c1abba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -2,7 +2,6 @@ package com.nuvio.app.core.auth import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider -import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.storage.LocalAccountDataCleaner import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.providers.builtin.Email @@ -11,13 +10,13 @@ import io.github.jan.supabase.exceptions.RestException import io.github.jan.supabase.functions.functions import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -39,46 +38,40 @@ object AuthRepository { if (initialized) return initialized = true + val savedAnonId = AuthStorage.loadAnonymousUserId() + if (savedAnonId != null) { + _state.value = AuthState.Authenticated( + userId = savedAnonId, + email = null, + isAnonymous = true, + ) + } + scope.launch { - SyncBackendRepository.state.collectLatest { backendState -> - if (!backendState.isLoaded) return@collectLatest - validatedRemoteUserId = null - - AuthStorage.loadAnonymousUserId()?.let { savedAnonId -> - _state.value = AuthState.Authenticated( - userId = savedAnonId, - email = null, - isAnonymous = true, - ) - } ?: run { - _state.value = AuthState.Loading - } - - SupabaseProvider.client.auth.sessionStatus.collect { status -> - if (AuthStorage.loadAnonymousUserId() != null) return@collect - when (status) { - is SessionStatus.Authenticated -> { - val user = status.session.user - val userId = user?.id.orEmpty() - if (!validateRemoteSession(userId)) return@collect - _state.value = AuthState.Authenticated( - userId = userId, - email = user?.email, - isAnonymous = false, - ) - } - is SessionStatus.NotAuthenticated -> { - _state.value = AuthState.Unauthenticated - } - is SessionStatus.Initializing -> { - if (AuthStorage.loadAnonymousUserId() == null) { - _state.value = AuthState.Loading - } - } - is SessionStatus.RefreshFailure -> { - _state.value = AuthState.Unauthenticated + SupabaseProvider.client.auth.sessionStatus.collect { status -> + if (AuthStorage.loadAnonymousUserId() != null) return@collect + when (status) { + is SessionStatus.Authenticated -> { + val user = status.session.user + val userId = user?.id.orEmpty() + if (!validateRemoteSession(userId)) return@collect + _state.value = AuthState.Authenticated( + userId = userId, + email = user?.email, + isAnonymous = false, + ) + } + is SessionStatus.NotAuthenticated -> { + _state.value = AuthState.Unauthenticated + } + is SessionStatus.Initializing -> { + if (AuthStorage.loadAnonymousUserId() == null) { + _state.value = AuthState.Loading } } + is SessionStatus.RefreshFailure -> { + _state.value = AuthState.Unauthenticated + } } } } @@ -138,19 +131,44 @@ object AuthRepository { _error.value = e.message ?: getString(Res.string.auth_sign_in_failed) } - suspend fun signOut(): Result = runCatching { + suspend fun signOut(): Result { _error.value = null - val wasAnonymous = AuthStorage.loadAnonymousUserId() != null - AuthStorage.clearAnonymousUserId() + val anonymousRead = runCatching { AuthStorage.loadAnonymousUserId() } + val wasAnonymous = anonymousRead.getOrNull() != null + val anonymousClear = runCatching { AuthStorage.clearAnonymousUserId() } validatedRemoteUserId = null - if (!wasAnonymous) { - SupabaseProvider.client.auth.signOut() + val remoteSignOut = if (wasAnonymous) { + Result.success(Unit) + } else { + runCatching { SupabaseProvider.client.auth.signOut() } } + + val fallbackSessionClear = if (remoteSignOut.isFailure) { + runCatching { SupabaseProvider.client.auth.clearSession() } + .onFailure { error -> log.w(error) { "Failed to clear Supabase session after sign-out failure" } } + } else { + Result.success(Unit) + } + val localCleanup = runCatching { LocalAccountDataCleaner.wipe() } _state.value = AuthState.Unauthenticated - LocalAccountDataCleaner.wipe() - }.onFailure { e -> - log.e(e) { "Sign-out failed" } - _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) + + val failure = anonymousRead.exceptionOrNull() + ?: anonymousClear.exceptionOrNull() + ?: remoteSignOut.exceptionOrNull() + ?: fallbackSessionClear.exceptionOrNull() + ?: localCleanup.exceptionOrNull() + val cancellation = remoteSignOut.exceptionOrNull() as? CancellationException + ?: fallbackSessionClear.exceptionOrNull() as? CancellationException + if (cancellation != null) throw cancellation + return if (failure == null) { + Result.success(Unit) + } else { + log.e(failure) { "Sign-out did not complete cleanly; all local cleanup steps were attempted" } + _error.value = failure.message ?: runCatching { + getString(Res.string.auth_sign_out_failed) + }.getOrDefault("Sign out failed") + Result.failure(failure) + } } suspend fun signOutIfSessionInvalid(error: Throwable, source: String): Boolean { @@ -170,29 +188,11 @@ object AuthRepository { }.onFailure { e -> log.w(e) { "Failed to clear Supabase session after remote invalidation; continuing local reset" } } + val localCleanup = runCatching { LocalAccountDataCleaner.wipe() } _state.value = AuthState.Unauthenticated - LocalAccountDataCleaner.wipe() - } - - suspend fun resetForSyncBackendChange(): Result = runCatching { - _error.value = null - val wasAnonymous = AuthStorage.loadAnonymousUserId() != null - AuthStorage.clearAnonymousUserId() - validatedRemoteUserId = null - - if (!wasAnonymous) { - runCatching { - SupabaseProvider.client.auth.signOut() - }.onFailure { e -> - log.w(e) { "Supabase sign-out failed during sync backend reset; continuing local reset" } - } + localCleanup.onFailure { error -> + log.e(error) { "Local account cleanup failed after remote session invalidation" } } - - _state.value = AuthState.Unauthenticated - LocalAccountDataCleaner.wipe() - }.onFailure { e -> - log.e(e) { "Sync backend auth reset failed" } - _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) } suspend fun deleteAccount(): Result = runCatching { @@ -200,8 +200,11 @@ object AuthRepository { SupabaseProvider.client.functions.invoke("delete-account") SupabaseProvider.client.auth.signOut() validatedRemoteUserId = null - _state.value = AuthState.Unauthenticated - LocalAccountDataCleaner.wipe() + try { + LocalAccountDataCleaner.wipe() + } finally { + _state.value = AuthState.Unauthenticated + } }.onFailure { e -> log.e(e) { "Account deletion failed" } _error.value = e.message ?: getString(Res.string.auth_account_deletion_failed) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index 3c3f7470..ec7ed177 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -15,5 +15,4 @@ expect object AppFeaturePolicy { val heroTrailerPlaybackSupported: Boolean val inAppUpdaterEnabled: Boolean val imdbRatingLogoEnabled: Boolean - val debugBackendSwitcherEnabled: Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt index e36e3934..4089f5f1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt @@ -7,16 +7,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -sealed interface AppDeepLink { +internal sealed interface AppDeepLink { data class Meta( val type: String, val id: String, ) : AppDeepLink + data class AddonInstall( + val manifestUrl: String, + ) : AppDeepLink + data object Downloads : AppDeepLink } -object AppDeepLinkRepository { +internal object AppDeepLinkRepository { private val _pendingDeepLink = MutableStateFlow(null) val pendingDeepLink: StateFlow = _pendingDeepLink.asStateFlow() @@ -53,19 +57,118 @@ fun buildMetaDeepLinkUrl( fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads" -private fun parseAppDeepLink(url: String): AppDeepLink? { +internal fun parseAppDeepLink(url: String): AppDeepLink? { val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null - if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null + val scheme = parsedUrl.protocol.name.lowercase() + if (scheme == "stremio") { + return if (looksLikeAddonHost(parsedUrl.host.lowercase())) { + customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall) + } else { + null + } + } + if (scheme != "nuvio") return null - return when (parsedUrl.host.lowercase()) { + val host = parsedUrl.host.lowercase() + val pathSegments = parsedUrl.pathSegments.map(String::trim).filter(String::isNotBlank) + return when (host) { "meta" -> { - val type = parsedUrl.parameters["type"]?.trim().orEmpty() - val id = parsedUrl.parameters["id"]?.trim().orEmpty() + parseMetaFromParameters(parsedUrl) + ?: parseMetaFromPath(pathSegments) + } + + "detail", "details", "open", "watch" -> parseMetaFromPath(pathSegments) + + "movie", "movies", "series", "show", "shows", "tv" -> { + val type = normalizeDeepLinkMediaType(host) + val id = pathSegments.firstOrNull()?.let(::normalizeDeepLinkId).orEmpty() if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) } + "imdb", "tmdb" -> parseProviderMetaDeepLink(host, pathSegments, parsedUrl) + "downloads" -> AppDeepLink.Downloads - else -> null + "auth" -> null + + else -> { + if (looksLikeAddonHost(host)) { + customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall) + } else { + null + } + } } -} \ No newline at end of file +} + +private fun parseMetaFromParameters(parsedUrl: Url): AppDeepLink.Meta? { + val type = firstParameter(parsedUrl, "type", "mediaType", "media_type") + ?.let(::normalizeDeepLinkMediaType) + .orEmpty() + val id = firstParameter(parsedUrl, "id", "imdb", "imdbId", "imdb_id") + ?.let(::normalizeDeepLinkId) + ?: firstParameter(parsedUrl, "tmdb", "tmdbId", "tmdb_id") + ?.let { "tmdb:${it.removePrefix("tmdb:").trim()}" } + ?: "" + return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) +} + +private fun parseMetaFromPath(pathSegments: List): AppDeepLink.Meta? { + if (pathSegments.size < 2) return null + val type = normalizeDeepLinkMediaType(pathSegments[0]) + val id = normalizeDeepLinkId(pathSegments[1]) + return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) +} + +private fun parseProviderMetaDeepLink( + provider: String, + pathSegments: List, + parsedUrl: Url, +): AppDeepLink.Meta? { + val first = pathSegments.firstOrNull().orEmpty() + val second = pathSegments.getOrNull(1).orEmpty() + val firstAsType = normalizeDeepLinkMediaType(first) + val queryType = firstParameter(parsedUrl, "type", "mediaType", "media_type") + ?.let(::normalizeDeepLinkMediaType) + .orEmpty() + val type = firstAsType.ifBlank { queryType } + val rawId = if (firstAsType.isNotBlank()) second else first + val id = when (provider) { + "tmdb" -> rawId.removePrefix("tmdb:").trim().takeIf(String::isNotBlank)?.let { "tmdb:$it" } + else -> normalizeDeepLinkId(rawId).takeIf(String::isNotBlank) + }.orEmpty() + return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) +} + +private fun firstParameter(parsedUrl: Url, vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> + parsedUrl.parameters[key]?.trim()?.takeIf(String::isNotBlank) + } + +private fun normalizeDeepLinkMediaType(value: String): String = + when (value.trim().lowercase()) { + "movie", "movies", "film", "films" -> "movie" + "series", "show", "shows", "tv", "tvshow", "tvshows" -> "series" + else -> "" + } + +private fun normalizeDeepLinkId(value: String): String = + value.trim() + .removePrefix("imdb:") + .takeIf(String::isNotBlank) + .orEmpty() + +private fun looksLikeAddonHost(host: String): Boolean = + host.contains('.') || + host.equals("localhost", ignoreCase = true) || + host.any(Char::isDigit) + +private fun customSchemeToHttpsUrl(url: String, scheme: String): String? { + val prefix = "$scheme://" + val rest = url.trim() + .takeIf { it.startsWith(prefix, ignoreCase = true) } + ?.substring(prefix.length) + ?.takeIf { it.isNotBlank() && !it.startsWith("/") } + ?: return null + return "https://$rest" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt index 62673271..bb270c98 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt @@ -136,11 +136,12 @@ object NetworkStatusRepository { return NetworkCondition.NoInternet } - val backendConfig = SyncBackendRepository.selectedBackend - val supabaseReachable = probeReachable( - url = "${backendConfig.normalizedSupabaseUrl}/rest/v1/", - headers = mapOf("apikey" to backendConfig.anonKey), - ) + val supabaseReachable = SupabaseEndpointConfig.restEndpointUrls().any { url -> + probeReachable( + url = url, + headers = mapOf("apikey" to SupabaseConfig.ANON_KEY), + ) + } if (!supabaseReachable) { return NetworkCondition.ServersUnreachable } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseEndpointConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseEndpointConfig.kt new file mode 100644 index 00000000..8c9e26ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseEndpointConfig.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.core.network + +internal object SupabaseEndpointConfig { + private val primaryBaseUrl: String = SupabaseConfig.URL.normalizedBaseUrl() + private val fallbackBaseUrl: String = SupabaseConfig.FALLBACK_URL.normalizedBaseUrl() + + val hasFallback: Boolean + get() = fallbackBaseUrl.isNotBlank() && !fallbackBaseUrl.equals(primaryBaseUrl, ignoreCase = true) + + fun restEndpointUrls(): List = + endpointUrls("/rest/v1/") + + fun fallbackUrlFor(requestUrl: String): String? { + if (!hasFallback || primaryBaseUrl.isBlank()) return null + val trimmedUrl = requestUrl.trim() + if (!trimmedUrl.startsWith(primaryBaseUrl, ignoreCase = true)) return null + return fallbackBaseUrl + trimmedUrl.substring(primaryBaseUrl.length) + } + + fun shouldRetryWithFallback(requestUrl: String, cause: Throwable): Boolean { + return fallbackUrlFor(requestUrl) != null && cause.isOriginFailure() + } + + fun shouldRetryWithFallback(requestUrl: String, statusCode: Int): Boolean { + return fallbackUrlFor(requestUrl) != null && statusCode.isOriginFailureStatus() + } + + private fun endpointUrls(path: String): List = + buildList { + if (primaryBaseUrl.isNotBlank()) add(primaryBaseUrl + path) + if (hasFallback) add(fallbackBaseUrl + path) + } + + private fun String.normalizedBaseUrl(): String = + trim().trimEnd('/') + + private fun Int.isOriginFailureStatus(): Boolean = + this in 520..526 || this == 502 || this == 503 || this == 504 + + private fun Throwable.isOriginFailure(): Boolean { + val text = generateSequence(this) { it.cause } + .joinToString(separator = " ") { error -> + listOfNotNull(error::class.simpleName, error.message).joinToString(separator = " ") + } + .lowercase() + + return originFailureMarkers.any(text::contains) + } + + private val originFailureMarkers = listOf( + "ssl", + "tls", + "certificate", + "certpath", + "handshake", + "timeout", + "timed out", + "connection reset", + "failed to connect", + "unable to resolve host", + "network is unreachable", + "closed", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt index 0e317985..36478de8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt @@ -1,46 +1,48 @@ package com.nuvio.app.core.network import com.nuvio.app.core.build.AppVersionConfig -import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.createSupabaseClient import io.github.jan.supabase.functions.Functions import io.github.jan.supabase.postgrest.Postgrest +import io.github.jan.supabase.realtime.Realtime +import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.defaultRequest import io.ktor.http.HttpHeaders +import io.ktor.http.takeFrom object SupabaseProvider { - private data class ClientHolder( - val backend: SyncBackendConfig, - val client: SupabaseClient, - ) - - private var holder: ClientHolder? = null - - val selectedBackend: SyncBackendConfig - get() = SyncBackendRepository.selectedBackend - @OptIn(SupabaseInternal::class) - val client: SupabaseClient - get() = clientFor(selectedBackend) - - fun rebuildClient() { - holder = null - } - - @OptIn(SupabaseInternal::class) - private fun clientFor(config: SyncBackendConfig): SupabaseClient { - holder - ?.takeIf { it.backend.hasSameConnectionIdentity(config) } - ?.let { return it.client } - + val client by lazy { val userAgent = "NuvioMobile/${AppVersionConfig.VERSION_NAME.ifBlank { "dev" }}" - val nextClient = createSupabaseClient( - supabaseUrl = config.normalizedSupabaseUrl, - supabaseKey = config.anonKey, + createSupabaseClient( + supabaseUrl = SupabaseConfig.URL, + supabaseKey = SupabaseConfig.ANON_KEY, ) { httpConfig { + if (SupabaseEndpointConfig.hasFallback) { + install(HttpRequestRetry) { + retryOnExceptionIf(maxRetries = 1) { request, cause -> + SupabaseEndpointConfig.shouldRetryWithFallback( + requestUrl = request.url.buildString(), + cause = cause, + ) + } + retryIf(maxRetries = 1) { request, response -> + SupabaseEndpointConfig.shouldRetryWithFallback( + requestUrl = request.url.toString(), + statusCode = response.status.value, + ) + } + modifyRequest { request -> + SupabaseEndpointConfig.fallbackUrlFor(request.url.buildString())?.let { fallbackUrl -> + request.url.takeFrom(fallbackUrl) + } + } + constantDelay(millis = 100) + } + } defaultRequest { headers.append(HttpHeaders.UserAgent, userAgent) } @@ -48,8 +50,7 @@ object SupabaseProvider { install(Auth) install(Postgrest) install(Functions) + install(Realtime) } - holder = ClientHolder(backend = config, client = nextClient) - return nextClient } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt deleted file mode 100644 index 9510e6c1..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.nuvio.app.core.network - -import kotlinx.serialization.Serializable - -internal const val SYNC_BACKEND_HOSTED_ID = "hosted" -internal const val SYNC_BACKEND_NUVIO_ID = "nuvio" - -@Serializable -data class SyncBackendConfig( - val id: String, - val displayName: String, - val supabaseUrl: String, - val anonKey: String, - val avatarPublicBaseUrl: String, - val schemaVersion: Int = 1, -) { - val normalizedSupabaseUrl: String - get() = supabaseUrl.trim().trimEnd('/') - - val normalizedAvatarPublicBaseUrl: String - get() = avatarPublicBaseUrl.trim().trimEnd('/') - - fun avatarStorageUrl(storagePath: String): String = - "${normalizedAvatarPublicBaseUrl}/${storagePath.trim().trimStart('/')}" - - fun normalized(): SyncBackendConfig = - copy( - id = id.trim().lowercase(), - supabaseUrl = normalizedSupabaseUrl, - anonKey = anonKey.trim(), - avatarPublicBaseUrl = normalizedAvatarPublicBaseUrl, - ) -} - -@Serializable -data class SyncBackendManifest( - val version: Int = 1, - val activeBackend: String, - val revision: String = "", - val forceLogoutOnChange: Boolean = true, -) - -data class SyncBackendState( - val selectedBackend: SyncBackendConfig = SyncBackendDefaults.hosted(), - val appliedRevision: String = "", - val isLoaded: Boolean = false, - val lastManifestError: String? = null, - val isManualDebugOverride: Boolean = false, -) - -sealed interface SyncBackendRefreshResult { - data object NotConfigured : SyncBackendRefreshResult - data object Unchanged : SyncBackendRefreshResult - data class Applied( - val backend: SyncBackendConfig, - val revision: String, - ) : SyncBackendRefreshResult - data class RequiresLogout( - val currentBackend: SyncBackendConfig, - val targetBackend: SyncBackendConfig, - val revision: String, - val forceLogout: Boolean, - ) : SyncBackendRefreshResult - data class Failed(val message: String) : SyncBackendRefreshResult -} - -@Serializable -internal data class StoredSyncBackendSelection( - val backend: SyncBackendConfig? = null, - val backendId: String = "", - val appliedRevision: String = "", - val manualDebugOverride: Boolean = false, -) - -object SyncBackendDefaults { - fun hosted(): SyncBackendConfig = - SyncBackendConfig( - id = SYNC_BACKEND_HOSTED_ID, - displayName = "Hosted", - supabaseUrl = SupabaseConfig.URL, - anonKey = SupabaseConfig.ANON_KEY, - avatarPublicBaseUrl = "${SupabaseConfig.URL.trim().trimEnd('/')}/storage/v1/object/public/avatars", - schemaVersion = 1, - ).normalized() - - fun nuvio(): SyncBackendConfig = - SyncBackendConfig( - id = SYNC_BACKEND_NUVIO_ID, - displayName = "Nuvio", - supabaseUrl = SupabaseConfig.NUVIO_URL, - anonKey = SupabaseConfig.NUVIO_ANON_KEY, - avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/storage/v1/object/public/avatars", - schemaVersion = 1, - ).normalized() - - fun byId(id: String): SyncBackendConfig? = - when (id.trim().lowercase()) { - SYNC_BACKEND_HOSTED_ID -> hosted() - SYNC_BACKEND_NUVIO_ID -> nuvio() - else -> null - } -} - -internal fun SyncBackendConfig.hasSameConnectionIdentity(other: SyncBackendConfig): Boolean = - id == other.id && - normalizedSupabaseUrl == other.normalizedSupabaseUrl && - anonKey.trim() == other.anonKey.trim() && - schemaVersion == other.schemaVersion - -internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendConfig? { - if (version != 1) return null - - val activeId = activeBackend.trim().lowercase() - return SyncBackendDefaults.byId(activeId) - ?.takeIf { it.isUsableClientConfig() } -} - -internal fun SyncBackendConfig.isUsableClientConfig(): Boolean = - id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) && - normalizedSupabaseUrl.startsWith("https://") && - anonKey.isNotBlank() && - !anonKey.startsWith("<") && - normalizedAvatarPublicBaseUrl.startsWith("https://") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt deleted file mode 100644 index 8491efc8..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.nuvio.app.core.network - -import co.touchlab.kermit.Logger -import com.nuvio.app.core.build.AppFeaturePolicy -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -object SyncBackendRepository { - private val log = Logger.withTag("SyncBackendRepository") - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - } - - private val _state = MutableStateFlow(SyncBackendState()) - val state: StateFlow = _state.asStateFlow() - - val selectedBackend: SyncBackendConfig - get() { - ensureLoaded() - return _state.value.selectedBackend - } - - fun ensureLoaded() { - if (_state.value.isLoaded) return - - val storedSelection = SyncBackendStorage.loadSelectionPayload() - ?.takeIf { it.isNotBlank() } - ?.let { payload -> - runCatching { json.decodeFromString(payload) } - .onFailure { error -> log.w(error) { "Failed to parse stored sync backend selection" } } - .getOrNull() - } - - val storedManualDebugOverride = storedSelection?.manualDebugOverride == true - val backend = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) { - SyncBackendDefaults.hosted() - } else { - storedSelection - ?.let { selection -> - selection.backendId.ifBlank { selection.backend?.id.orEmpty() } - } - ?.let(SyncBackendDefaults::byId) - ?: SyncBackendDefaults.hosted() - } - - _state.value = SyncBackendState( - selectedBackend = backend, - appliedRevision = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) { - "" - } else { - storedSelection?.appliedRevision.orEmpty() - }, - isLoaded = true, - isManualDebugOverride = storedManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled, - ) - } - - suspend fun refreshFromManifest(): SyncBackendRefreshResult { - ensureLoaded() - - if (_state.value.isManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled) { - return SyncBackendRefreshResult.Unchanged - } - - val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim() - if (manifestUrl.isBlank()) { - return SyncBackendRefreshResult.NotConfigured - } - - val manifest = runCatching { - json.decodeFromString( - fetchSyncBackendManifestText(manifestUrl), - ) - }.onFailure { error -> - val message = error.message ?: "Failed to fetch sync backend manifest" - log.w(error) { message } - _state.value = _state.value.copy(lastManifestError = message) - }.getOrNull() ?: return SyncBackendRefreshResult.Failed( - _state.value.lastManifestError ?: "Failed to fetch sync backend manifest", - ) - - val targetBackend = manifest.backendConfigForActiveBackend() - ?: return SyncBackendRefreshResult.Failed("Sync backend manifest is invalid") - val revision = manifest.revision.trim() - val currentBackend = _state.value.selectedBackend - - if (currentBackend.hasSameConnectionIdentity(targetBackend)) { - saveSelection(targetBackend, revision) - return SyncBackendRefreshResult.Unchanged - } - - if (!manifest.forceLogoutOnChange) { - saveSelection(targetBackend, revision) - return SyncBackendRefreshResult.Applied(targetBackend, revision) - } - - return SyncBackendRefreshResult.RequiresLogout( - currentBackend = currentBackend, - targetBackend = targetBackend, - revision = revision, - forceLogout = true, - ) - } - - fun applyBackendAfterLogout( - backend: SyncBackendConfig, - revision: String, - ): SyncBackendConfig { - val normalizedBackend = backend.normalized() - saveSelection(normalizedBackend, revision, manualDebugOverride = false) - return normalizedBackend - } - - fun debugSelectableBackends(): List = - listOf(SyncBackendDefaults.hosted(), SyncBackendDefaults.nuvio()) - .filter { backend -> backend.isUsableClientConfig() } - - fun applyDebugBackendAfterLogout(backend: SyncBackendConfig): SyncBackendConfig? { - if (!AppFeaturePolicy.debugBackendSwitcherEnabled) return null - - val normalizedBackend = backend.normalized() - .takeIf { it.isUsableClientConfig() } - ?: return null - - saveSelection( - backend = normalizedBackend, - revision = DEBUG_MANUAL_REVISION, - manualDebugOverride = true, - ) - return normalizedBackend - } - - private fun saveSelection( - backend: SyncBackendConfig, - revision: String, - manualDebugOverride: Boolean = false, - ) { - val normalizedBackend = backend.normalized() - val payload = json.encodeToString( - StoredSyncBackendSelection( - backendId = normalizedBackend.id, - appliedRevision = revision, - manualDebugOverride = manualDebugOverride, - ), - ) - SyncBackendStorage.saveSelectionPayload(payload) - _state.value = SyncBackendState( - selectedBackend = normalizedBackend, - appliedRevision = revision, - isLoaded = true, - isManualDebugOverride = manualDebugOverride, - ) - } - - private const val DEBUG_MANUAL_REVISION = "debug-manual" -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt deleted file mode 100644 index 748a8c18..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.nuvio.app.core.network - -internal expect object SyncBackendStorage { - fun loadSelectionPayload(): String? - fun saveSelectionPayload(payload: String) -} - -internal expect suspend fun fetchSyncBackendManifestText(url: String): String 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 a027d803..68fa9f0a 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 @@ -1,6 +1,8 @@ package com.nuvio.app.core.storage import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.sync.SyncManager +import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository @@ -25,14 +27,25 @@ import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import com.nuvio.app.features.watched.WatchedRepository internal object LocalAccountDataCleaner { fun wipe() { - PlatformLocalAccountDataCleaner.wipe() + SyncManager.cancelAccountSync() + WatchProgressSourceCoordinator.clearLocalState() + ProfileSettingsSync.clearAccountState() + ContinueWatchingEnrichmentCache.clearLocalState() + WatchProgressRepository.clearLocalState() + WatchedRepository.clearLocalState() + LibraryRepository.runAccountStorageWipe { + PlatformLocalAccountDataCleaner.wipe() + } ProfileRepository.clearInMemory() AddonRepository.clearLocalState() @@ -43,14 +56,13 @@ internal object LocalAccountDataCleaner { HomeCatalogSettingsRepository.clearLocalState() MetaScreenSettingsRepository.clearLocalState() LibraryRepository.clearLocalState() - WatchProgressRepository.clearLocalState() - WatchedRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState() CollectionMobileSettingsRepository.clearLocalState() CollectionRepository.clearLocalState() ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() + CardDepthStyleRepository.clearLocalState() TraktAuthRepository.clearLocalState() TraktSettingsRepository.clearLocalState() PlayerSettingsRepository.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 26616f91..ad077420 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 @@ -17,6 +17,8 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepositor import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.core.ui.CardDepthStyleRepository +import com.nuvio.app.core.ui.CardDepthStyleStorage import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.settings.ThemeSettingsStorage @@ -39,7 +41,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -86,6 +87,12 @@ object ProfileSettingsSync { observeLocalChangesAndPush() } + fun clearAccountState() { + observeJob?.cancel() + observeJob = null + skipNextPushSignature = null + } + suspend fun pull(profileId: Int): Boolean { ensureRepositoriesLoaded() return syncMutex.withLock { @@ -110,9 +117,6 @@ object ProfileSettingsSync { if (remoteJson == null) { log.i { "pull(profileId=$profileId) — no remote settings blob found" } - if (localSignature != defaultSignature()) { - pushToRemoteLocked(profileId, localBlob) - } return@withLock false } @@ -124,7 +128,6 @@ object ProfileSettingsSync { log.e(error) { "pull(profileId=$profileId) — failed to decode remote settings blob" } return@withLock false } - val remoteSignature = buildSignature(remoteBlob) if (remoteSignature == localSignature) { log.d { "pull(profileId=$profileId) — remote matches local" } @@ -149,17 +152,18 @@ object ProfileSettingsSync { } } - suspend fun pushCurrentProfileToRemote() { + suspend fun pushCurrentProfileToRemote(): Boolean { ensureRepositoriesLoaded() - syncMutex.withLock { + return syncMutex.withLock { runCatching { val profileId = ProfileRepository.activeProfileId val blob = exportSettingsBlob() - if (ProfileRepository.activeProfileId != profileId) return@runCatching + if (ProfileRepository.activeProfileId != profileId) return@runCatching false pushToRemoteLocked(profileId, blob) + true }.onFailure { error -> log.e(error) { "pushCurrentProfileToRemote() — FAILED" } - } + }.getOrDefault(false) } } @@ -170,6 +174,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, + CardDepthStyleRepository.uiState.map { "card_depth_style" }, PlayerSettingsRepository.uiState.map { "player" }, StreamBadgeSettingsRepository.uiState.map { "stream_badges" }, DebridSettingsRepository.uiState.map { "debrid" }, @@ -206,6 +211,7 @@ object ProfileSettingsSync { put("p_profile_id", profileId) put("p_platform", MOBILE_SYNC_PLATFORM) put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params) log.d { "pushToRemoteLocked(profileId=$profileId) — success" } @@ -217,6 +223,7 @@ object ProfileSettingsSync { features = MobileProfileSettingsFeatures( themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), + cardDepthStyleSettingsPayload = CardDepthStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(), debridSettings = DebridSettingsStorage.exportToSyncPayload(), @@ -241,6 +248,9 @@ object ProfileSettingsSync { PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload) PosterCardStyleRepository.onProfileChanged() + CardDepthStyleStorage.savePayload(blob.features.cardDepthStyleSettingsPayload) + CardDepthStyleRepository.onProfileChanged() + PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() @@ -278,6 +288,7 @@ object ProfileSettingsSync { private fun ensureRepositoriesLoaded() { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() + CardDepthStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.ensureLoaded() DebridSettingsRepository.ensureLoaded() @@ -294,14 +305,12 @@ object ProfileSettingsSync { private fun buildSignature(blob: MobileProfileSettingsBlob): String = json.encodeToString(MobileProfileSettingsBlob.serializer(), blob) - private fun defaultSignature(): String = - buildSignature(MobileProfileSettingsBlob()) - private fun currentObservedStateSignature(): String = listOf( "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", + "card_depth_style=${CardDepthStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", "stream_badges=${StreamBadgeSettingsRepository.uiState.value}", "debrid=${DebridSettingsRepository.uiState.value}", @@ -314,6 +323,7 @@ object ProfileSettingsSync { "trakt_comments=${TraktCommentsSettings.enabled.value}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", ).joinToString(separator = "||") + } @Serializable @@ -326,6 +336,7 @@ private data class MobileProfileSettingsBlob( private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", + @SerialName("card_depth_style_settings_payload") val cardDepthStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), @SerialName("stream_badge_settings") val streamBadgeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt new file mode 100644 index 00000000..145c30c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt @@ -0,0 +1,180 @@ +package com.nuvio.app.core.sync + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.network.SupabaseProvider +import io.github.jan.supabase.postgrest.query.filter.FilterOperator +import io.github.jan.supabase.realtime.PostgresAction +import io.github.jan.supabase.realtime.channel +import io.github.jan.supabase.realtime.postgresChangeFlow +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val REALTIME_INVALIDATION_COALESCE_MS = 500L +private const val REALTIME_SUBSCRIBE_TIMEOUT_MS = 15_000L +private const val REALTIME_RETRY_BASE_DELAY_MS = 1_000L +private const val REALTIME_RETRY_MAX_DELAY_MS = 10_000L + +object RealtimeSyncInvalidationService { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val log = Logger.withTag("RealtimeSyncInvalidation") + private val pendingMutex = Mutex() + private val pendingSurfaces = mutableSetOf() + + private var subscriptionJob: Job? = null + private var drainJob: Job? = null + private var activeUserId: String? = null + private var activeProfileId: Int? = null + + fun start(userId: String, profileId: Int) { + if ( + subscriptionJob?.isActive == true && + activeUserId == userId && + activeProfileId == profileId + ) { + log.d { "Realtime sync already active for profile $profileId" } + return + } + + stop() + activeUserId = userId + activeProfileId = profileId + subscriptionJob = scope.launch { + var attempt = 1 + while (isActive) { + val channelName = "sync-invalidations:$userId:$profileId:$attempt" + val channel = SupabaseProvider.client.channel(channelName) + val realtime = channel.realtime + val realtimeStatusJob = launch { + realtime.status + .collect { status -> + log.i { "Realtime client status=$status channel=$channelName" } + } + } + val channelStatusJob = launch { + channel.status + .collect { status -> + log.i { "Realtime channel status=$status channel=$channelName" } + } + } + val changesJob = channel.postgresChangeFlow(schema = "public") { + table = "sync_invalidations" + filter("user_id", FilterOperator.EQ, userId) + }.onEach { action -> + handleInsert(profileId, action.record) + }.launchIn(this) + + try { + log.i { "Subscribing to sync invalidations channel=$channelName attempt=$attempt user=$userId profile=$profileId" } + withTimeout(REALTIME_SUBSCRIBE_TIMEOUT_MS) { + channel.subscribe(blockUntilSubscribed = true) + } + log.i { "Subscribed to sync invalidations channel=$channelName profile=$profileId" } + awaitCancellation() + } catch (error: TimeoutCancellationException) { + log.e(error) { + "Timed out subscribing to sync invalidations channel=$channelName " + + "realtimeStatus=${realtime.status.value} channelStatus=${channel.status.value}" + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { + "Failed to subscribe to sync invalidations channel=$channelName " + + "realtimeStatus=${realtime.status.value} channelStatus=${channel.status.value}" + } + } finally { + changesJob.cancel() + realtimeStatusJob.cancel() + channelStatusJob.cancel() + runCatching { channel.unsubscribe() } + .onSuccess { log.i { "Unsubscribed from sync invalidations channel=$channelName" } } + .onFailure { error -> log.w(error) { "Failed to unsubscribe from sync invalidations channel=$channelName" } } + } + + if (isActive) { + val retryDelay = (REALTIME_RETRY_BASE_DELAY_MS * attempt) + .coerceAtMost(REALTIME_RETRY_MAX_DELAY_MS) + log.w { "Retrying sync invalidations subscription in ${retryDelay}ms profile=$profileId nextAttempt=${attempt + 1}" } + delay(retryDelay) + attempt += 1 + } + } + } + } + + fun stop() { + if (subscriptionJob != null || drainJob != null) { + log.i { "Stopping realtime sync invalidation service for profile $activeProfileId" } + } + subscriptionJob?.cancel() + drainJob?.cancel() + subscriptionJob = null + drainJob = null + activeUserId = null + activeProfileId = null + pendingSurfaces.clear() + } + + private fun handleInsert(profileId: Int, record: JsonObject) { + val eventId = record["id"]?.jsonPrimitive?.contentOrNull + val createdAt = record["created_at"]?.jsonPrimitive?.contentOrNull + val originClientId = record["origin_client_id"]?.jsonPrimitive?.contentOrNull + if (originClientId != null && originClientId == SyncClientIdentity.currentClientId()) { + log.d { "Ignoring self-originated sync invalidation id=$eventId originClientId=$originClientId" } + return + } + val surface = record["surface"]?.jsonPrimitive?.contentOrNull ?: run { + log.w { "Received sync invalidation without surface id=$eventId createdAt=$createdAt keys=${record.keys}" } + return + } + val eventProfileId = record["profile_id"]?.jsonPrimitive?.intOrNull + log.i { + "Received sync invalidation id=$eventId surface=$surface eventProfile=$eventProfileId " + + "activeProfile=$profileId originClientId=$originClientId createdAt=$createdAt" + } + if (surface != "profiles" && eventProfileId != null && eventProfileId != profileId) { + log.d { "Ignoring sync invalidation id=$eventId for inactive profile $eventProfileId" } + return + } + enqueue(surface, profileId) + } + + private fun enqueue(surface: String, profileId: Int) { + scope.launch { + pendingMutex.withLock { + pendingSurfaces += surface + log.d { "Queued realtime surface pull surface=$surface profile=$profileId pending=${pendingSurfaces.size}" } + if (drainJob?.isActive == true) return@withLock + drainJob = scope.launch { + delay(REALTIME_INVALIDATION_COALESCE_MS) + val surfaces = pendingMutex.withLock { + pendingSurfaces.toList().also { + pendingSurfaces.clear() + } + } + log.i { "Draining realtime surface pulls profile=$profileId surfaces=$surfaces" } + surfaces.forEach { pendingSurface -> + SyncManager.requestRealtimeSurfacePull(profileId, pendingSurface) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentity.kt new file mode 100644 index 00000000..c116f88b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentity.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.core.sync + +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.put +import kotlin.random.Random + +private const val CLIENT_ID_LENGTH = 32 +private const val CLIENT_ID_PREFIX = "nuvio-mobile-" +private const val ORIGIN_CLIENT_ID_PARAM = "p_origin_client_id" +private const val CLIENT_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" + +object SyncClientIdentity { + private var cachedClientId: String? = null + + fun currentClientId(): String { + cachedClientId?.let { return it } + + val stored = SyncClientIdentityStorage.loadClientId() + ?.trim() + ?.takeIf { it.isValidSyncClientId() } + if (stored != null) { + cachedClientId = stored + return stored + } + + val generated = generateClientId() + SyncClientIdentityStorage.saveClientId(generated) + cachedClientId = generated + return generated + } + + private fun generateClientId(): String = + CLIENT_ID_PREFIX + buildString(CLIENT_ID_LENGTH) { + repeat(CLIENT_ID_LENGTH) { + append(CLIENT_ID_ALPHABET[Random.nextInt(CLIENT_ID_ALPHABET.length)]) + } + } + + private fun String.isValidSyncClientId(): Boolean = + length in 16..96 && all { it.isLetterOrDigit() || it == '-' || it == '_' } +} + +internal fun JsonObjectBuilder.putSyncOriginClientId() { + put(ORIGIN_CLIENT_ID_PARAM, SyncClientIdentity.currentClientId()) +} + +internal expect object SyncClientIdentityStorage { + fun loadClientId(): String? + fun saveClientId(clientId: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 5b8e22e2..4f459078 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -7,122 +7,523 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionSyncService import com.nuvio.app.features.home.HomeCatalogSettingsSyncService +import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktCredentialSync import com.nuvio.app.features.trakt.TraktPlatformClock -import com.nuvio.app.features.watched.WatchedRepository -import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.effectiveLibrarySourceMode +import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch private const val FOREGROUND_PULL_DELAY_MS = 2500L private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L +private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 240_000L + +internal enum class ProfileSyncStep { + Addons, + Plugins, + ProfileSettings, + TraktCredentials, + Library, + ActiveWatchSource, + Collections, + HomeCatalogSettings, +} + +internal data class ProfileSyncOperations( + val pullAddons: suspend (Int) -> Unit, + val pullPlugins: suspend (Int) -> Unit, + val pullProfileSettings: suspend (Int) -> Unit, + val pullTraktCredentials: suspend (Int) -> Unit, + val pullLibrary: suspend (Int) -> Unit, + val refreshActiveWatchSource: suspend (Int) -> Unit, + val pullCollections: suspend (Int) -> Unit, + val pullHomeCatalogSettings: suspend (Int) -> Unit, +) + +internal data class ProfileSyncResult( + val failedSteps: Set, +) { + val succeeded: Boolean + get() = failedSteps.isEmpty() +} + +internal suspend fun runOrderedProfileSync( + profileId: Int, + pluginsEnabled: Boolean, + operations: ProfileSyncOperations, + onFailure: (ProfileSyncStep, Throwable) -> Unit = { _, _ -> }, +): ProfileSyncResult { + val failureLock = SynchronizedObject() + val failedSteps = mutableSetOf() + + suspend fun runStep( + step: ProfileSyncStep, + operation: suspend (Int) -> Unit, + ) { + try { + operation(profileId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + synchronized(failureLock) { + failedSteps += step + } + onFailure(step, error) + } + } + + runStep(ProfileSyncStep.Addons, operations.pullAddons) + if (pluginsEnabled) { + runStep(ProfileSyncStep.Plugins, operations.pullPlugins) + } + + coroutineScope { + val settingsJob = launch { + runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) + } + val credentialsJob = launch { + runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials) + } + settingsJob.join() + credentialsJob.join() + } + + coroutineScope { + launch { + runStep(ProfileSyncStep.Library, operations.pullLibrary) + } + launch { + runStep(ProfileSyncStep.ActiveWatchSource, operations.refreshActiveWatchSource) + } + launch { + runStep(ProfileSyncStep.Collections, operations.pullCollections) + } + launch { + runStep(ProfileSyncStep.HomeCatalogSettings, operations.pullHomeCatalogSettings) + } + } + return ProfileSyncResult( + failedSteps = synchronized(failureLock) { failedSteps.toSet() }, + ) +} + +internal enum class ProfileSyncRequestResult { + Started, + Coalesced, + Replaced, +} + +internal class ProfileSyncRequestGate { + private data class PendingRequest( + val scope: CoroutineScope, + val profileId: Int, + val block: suspend () -> Unit, + ) + + private val lock = SynchronizedObject() + private var activeProfileId: Int? = null + private var activeJob: Job? = null + private var pendingRequest: PendingRequest? = null + + fun launch( + scope: CoroutineScope, + profileId: Int, + queueIfCoalesced: Boolean = false, + block: suspend () -> Unit, + ): ProfileSyncRequestResult { + lateinit var newJob: Job + var previousJob: Job? = null + val result = synchronized(lock) { + val active = activeJob?.takeUnless(Job::isCompleted) + if (active != null && activeProfileId == profileId) { + if (queueIfCoalesced) { + pendingRequest = PendingRequest(scope = scope, profileId = profileId, block = block) + } + return ProfileSyncRequestResult.Coalesced + } + + previousJob = active + pendingRequest = null + val requestResult = if (active == null) { + ProfileSyncRequestResult.Started + } else { + ProfileSyncRequestResult.Replaced + } + + newJob = scope.launch(start = CoroutineStart.LAZY) { + block() + } + activeProfileId = profileId + activeJob = newJob + newJob.invokeOnCompletion { + var pending: PendingRequest? = null + synchronized(lock) { + if (activeJob === newJob) { + activeJob = null + activeProfileId = null + pending = pendingRequest + pendingRequest = null + } + } + pending?.let { request -> + launch( + scope = request.scope, + profileId = request.profileId, + queueIfCoalesced = false, + block = request.block, + ) + } + } + requestResult + } + + previousJob?.cancel() + newJob.start() + return result + } + + fun cancel() { + val job = synchronized(lock) { + activeJob.also { + activeJob = null + activeProfileId = null + pendingRequest = null + } + } + job?.cancel() + } +} object SyncManager { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("SyncManager") + private val fullSyncRequestGate = ProfileSyncRequestGate() + private val accountScopeLock = SynchronizedObject() + private var accountScopeJob: Job = SupervisorJob() + private var accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default) + private val pullStateLock = SynchronizedObject() private var foregroundPullJob: Job? = null - private var lastForegroundPullAtMs: Long = 0L + private var foregroundPullProfileId: Int? = null + private var periodicNuvioSyncPullJob: Job? = null + private var periodicNuvioSyncProfileId: Int? = null + private var lastFullPullAtMs: Long = 0L + private var lastFullPullProfileId: Int? = null + + private val profileSyncOperations = ProfileSyncOperations( + pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) }, + pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) }, + pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) }, + pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) }, + pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) }, + refreshActiveWatchSource = { profileId -> + val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) + check(result.succeeded) { + "Active watch source refresh was incomplete: " + + "progress=${result.progressRefreshed} watched=${result.watchedHistoryRefreshed}" + } + }, + pullCollections = { profileId -> CollectionSyncService.pullFromServer(profileId) }, + pullHomeCatalogSettings = { profileId -> HomeCatalogSettingsSyncService.pullFromServer(profileId) }, + ) fun pullAllForProfile(profileId: Int) { - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated) return - if (authState.isAnonymous) return + startFullProfilePull(profileId = profileId, reason = "requested") + } - scope.launch { - log.i { "pullAllForProfile($profileId) — auth=${(authState as AuthState.Authenticated).isAnonymous}" } - - log.i { "pullAllForProfile — pulling addons first (await)..." } - runCatching { AddonRepository.pullFromServer(profileId) } - .onSuccess { log.i { "pullAllForProfile — addons pull completed" } } - .onFailure { log.e(it) { "Addon pull failed" } } - - if (AppFeaturePolicy.pluginsEnabled) { - log.i { "pullAllForProfile — pulling plugins (await)..." } - runCatching { PluginRepository.pullFromServer(profileId) } - .onSuccess { log.i { "pullAllForProfile — plugins pull completed" } } - .onFailure { log.e(it) { "Plugin pull failed" } } + internal fun cancelAccountSync() { + fullSyncRequestGate.cancel() + val previousAccountJob = synchronized(accountScopeLock) { + accountScopeJob.also { + accountScopeJob = SupervisorJob() + accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default) } - - log.i { "pullAllForProfile — launching remaining pulls in parallel" } - launch { - runCatching { LibraryRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Library pull failed" } } - } - launch { - runCatching { WatchProgressRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "WatchProgress pull failed" } } - } - launch { - runCatching { WatchedRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Watched pull failed" } } - } - launch { - runCatching { ProfileSettingsSync.pull(profileId) } - .onFailure { log.e(it) { "ProfileSettings pull failed" } } - } - launch { - runCatching { CollectionSyncService.pullFromServer(profileId) } - .onFailure { log.e(it) { "Collections pull failed" } } - } - launch { - runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } - .onFailure { log.e(it) { "HomeCatalogSettings pull failed" } } - } - - log.i { "pullAllForProfile($profileId) — all pulls launched" } } + previousAccountJob.cancel() + val foregroundJob = synchronized(pullStateLock) { + foregroundPullJob.also { + foregroundPullJob = null + foregroundPullProfileId = null + lastFullPullAtMs = 0L + lastFullPullProfileId = null + } + } + foregroundJob?.cancel() + stopPeriodicNuvioSyncPull() + } + + private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { + accountScope } fun requestForegroundPull(profileId: Int, force: Boolean = false) { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) return - val now = TraktPlatformClock.nowEpochMs() - if (!force && foregroundPullJob?.isActive == true) return - if (!force && now - lastForegroundPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS) return - - foregroundPullJob = scope.launch { - if (!force) { - delay(FOREGROUND_PULL_DELAY_MS) + if (!force && hasRecentFullPull(profileId)) { + return + } + lateinit var requestJob: Job + var previousJob: Job? = null + synchronized(pullStateLock) { + if ( + !force && + foregroundPullJob?.isCompleted == false && + foregroundPullProfileId == profileId + ) { + return } - val currentAuthState = AuthRepository.state.value - if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) return@launch - - lastForegroundPullAtMs = TraktPlatformClock.nowEpochMs() - pullForegroundForProfile(profileId) + previousJob = foregroundPullJob + requestJob = accountScopeSnapshot().launch(start = CoroutineStart.LAZY) { + try { + if (!force) { + delay(FOREGROUND_PULL_DELAY_MS) + } + if (!force && hasRecentFullPull(profileId)) return@launch + if (ProfileRepository.activeProfileId != profileId) return@launch + pullForegroundForProfile(profileId) + } finally { + synchronized(pullStateLock) { + if (foregroundPullJob === requestJob) { + foregroundPullJob = null + foregroundPullProfileId = null + } + } + } + } + foregroundPullProfileId = profileId + foregroundPullJob = requestJob } + previousJob?.cancel() + requestJob.start() } - private fun pullForegroundForProfile(profileId: Int) { - scope.launch { - log.i { "pullForegroundForProfile($profileId) — syncing watch progress, library, collections, and home settings" } + private fun hasRecentFullPull(profileId: Int): Boolean = + synchronized(pullStateLock) { + lastFullPullProfileId == profileId && + TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS + } + private suspend fun pullForegroundForProfile(profileId: Int) { + log.i { "Foreground sync started profile=$profileId" } + + runCatching { ProfileRepository.pullProfiles() } + .onFailure { log.e(it) { "Foreground profiles pull failed" } } + runCatching { ProfileSettingsSync.pull(profileId) } + .onFailure { log.e(it) { "Foreground profile settings pull failed" } } + + coroutineScope { + launch { + runCatching { AddonRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground addons pull failed" } } + } + if (AppFeaturePolicy.pluginsEnabled) { + launch { + runCatching { PluginRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground plugins pull failed" } } + } + } launch { runCatching { LibraryRepository.pullFromServer(profileId) } .onFailure { log.e(it) { "Foreground library pull failed" } } } - launch { - runCatching { WatchProgressRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground watch progress pull failed" } } + runCatching { + WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) + }.onFailure { log.e(it) { "Foreground active watch source pull failed" } } } - launch { runCatching { CollectionSyncService.pullFromServer(profileId) } .onFailure { log.e(it) { "Foreground collections pull failed" } } } - launch { runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } .onFailure { log.e(it) { "Foreground home catalog settings pull failed" } } } } + + log.i { "Foreground sync completed profile=$profileId" } + } + + private fun startFullProfilePull( + profileId: Int, + reason: String, + queueIfCoalesced: Boolean = false, + ) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return + if (ProfileRepository.activeProfileId != profileId) return + + val result = fullSyncRequestGate.launch( + scope = accountScopeSnapshot(), + profileId = profileId, + queueIfCoalesced = queueIfCoalesced, + ) { + val currentAuthState = AuthRepository.state.value + if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) return@launch + if (ProfileRepository.activeProfileId != profileId) return@launch + + log.i { "Full profile sync started profile=$profileId reason=$reason" } + WatchProgressSourceCoordinator.pauseAutomaticTransitions() + val syncResult = try { + runOrderedProfileSync( + profileId = profileId, + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + operations = profileSyncOperations, + onFailure = { step, error -> + log.e(error) { "Full profile sync step failed profile=$profileId step=$step" } + }, + ) + } finally { + WatchProgressSourceCoordinator.resumeAutomaticTransitions() + } + if (syncResult.succeeded) { + synchronized(pullStateLock) { + lastFullPullAtMs = TraktPlatformClock.nowEpochMs() + lastFullPullProfileId = profileId + } + } else { + log.w { + "Full profile sync incomplete profile=$profileId reason=$reason " + + "failedSteps=${syncResult.failedSteps}" + } + } + log.i { "Full profile sync completed profile=$profileId reason=$reason" } + } + + when (result) { + ProfileSyncRequestResult.Started -> Unit + ProfileSyncRequestResult.Coalesced -> { + log.d { "Full profile sync coalesced profile=$profileId reason=$reason" } + } + ProfileSyncRequestResult.Replaced -> { + log.d { "Full profile sync replaced stale profile request with profile=$profileId reason=$reason" } + } + } + } + + fun startPeriodicNuvioSyncPull(profileId: Int) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + stopPeriodicNuvioSyncPull() + return + } + if (periodicNuvioSyncPullJob?.isActive == true && periodicNuvioSyncProfileId == profileId) return + + stopPeriodicNuvioSyncPull() + periodicNuvioSyncProfileId = profileId + periodicNuvioSyncPullJob = accountScopeSnapshot().launch { + while (isActive) { + delay(PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS) + + val currentAuthState = AuthRepository.state.value + if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) { + continue + } + if (ProfileRepository.activeProfileId != profileId) { + continue + } + + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + + val traktAuthenticated = TraktAuthRepository.isAuthenticated.value + val settings = TraktSettingsRepository.uiState.value + val shouldPullLibrary = effectiveLibrarySourceMode( + isAuthenticated = traktAuthenticated, + source = settings.librarySourceMode, + ) == LibrarySourceMode.LOCAL + val shouldPullWatchProgress = !shouldUseTraktProgress( + isAuthenticated = traktAuthenticated, + source = settings.watchProgressSource, + ) + + if (!shouldPullLibrary && !shouldPullWatchProgress) { + continue + } + + log.i { + "Periodic Nuvio sync pull profile=$profileId " + + "library=$shouldPullLibrary watchProgress=$shouldPullWatchProgress" + } + if (shouldPullLibrary) { + runCatching { LibraryRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Periodic Nuvio library pull failed" } } + } + if (shouldPullWatchProgress) { + runCatching { + WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = false) + }.onFailure { log.e(it) { "Periodic Nuvio watch source pull failed" } } + } + } + } + } + + fun stopPeriodicNuvioSyncPull() { + periodicNuvioSyncPullJob?.cancel() + periodicNuvioSyncPullJob = null + periodicNuvioSyncProfileId = null + } + + fun requestRealtimeSurfacePull(profileId: Int, surface: String) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return + + accountScopeSnapshot().launch { + log.i { "requestRealtimeSurfacePull($profileId, $surface)" } + when (surface) { + "addons" -> { + runCatching { AddonRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Realtime addons pull failed" } } + } + "plugins" -> { + if (AppFeaturePolicy.pluginsEnabled) { + runCatching { PluginRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Realtime plugins pull failed" } } + } + } + "library" -> { + runCatching { LibraryRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Realtime library pull failed" } } + } + "watch_progress", "watched_items" -> { + runCatching { + WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = false) + }.onFailure { log.e(it) { "Realtime active watch source pull failed" } } + } + "profile_settings" -> { + runCatching { ProfileSettingsSync.pull(profileId) } + .onFailure { log.e(it) { "Realtime profile settings pull failed" } } + } + "collections" -> { + runCatching { CollectionSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Realtime collections pull failed" } } + } + "home_catalog_settings" -> { + runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Realtime home catalog settings pull failed" } } + } + "profiles" -> { + runCatching { ProfileRepository.pullProfiles() } + .onFailure { log.e(it) { "Realtime profiles pull failed" } } + } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.kt new file mode 100644 index 00000000..b93c933c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.graphics.drawscope.DrawScope + +internal expect val ashSwarmMaxGrainBudget: Int + +internal expect class AshSwarmRenderer(maxGrains: Int) { + fun draw( + scope: DrawScope, + count: Int, + centersX: FloatArray, + centersY: FloatArray, + halfWidths: FloatArray, + halfHeights: FloatArray, + colors: IntArray, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt new file mode 100644 index 00000000..ed9581ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt @@ -0,0 +1,96 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.border +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +@Composable +fun rememberCardDepthStyleUiState(): CardDepthStyleUiState { + CardDepthStyleRepository.ensureLoaded() + val uiState by CardDepthStyleRepository.uiState.collectAsState() + return uiState +} + +@Composable +fun Modifier.nuvioCardDepth( + shape: Shape, + surface: NuvioCardDepthSurface, + fallbackBorderAlpha: Float = 0f, +): Modifier { + val state = rememberCardDepthStyleUiState() + if (!state.isEnabledFor(surface)) { + return if (fallbackBorderAlpha > 0f) { + border( + width = 1.dp, + color = Color.White.copy(alpha = fallbackBorderAlpha), + shape = shape, + ) + } else { + this + } + } + + return cardDepthVisual( + shape = shape, + edgeStrength = state.edgeStrength.toFloat(), + sheenStrength = state.sheenStrength.toFloat(), + edgeCoverage = state.edgeCoverage.toFloat(), + ) +} + +fun Modifier.cardDepthVisual( + shape: Shape, + edgeStrength: Float, + sheenStrength: Float, + edgeCoverage: Float = DefaultCardDepthEdgeCoverage.toFloat(), +): Modifier { + val edgeTop = edgeStrength.coerceIn(0f, 100f) / 100f + val sheen = sheenStrength.coerceIn(0f, 100f) / 100f + val coverage = edgeCoverage.coerceIn(0f, 100f) / 100f + + val withEdge = if (edgeTop > 0f) { + border( + width = 1.dp, + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = edgeTop), + Color.White.copy(alpha = edgeTop * (0.33f + 0.67f * coverage)), + Color.White.copy(alpha = edgeTop * coverage), + ), + ), + shape = shape, + ) + } else { + this + } + + return if (sheen > 0f) { + withEdge.drawWithContent { + drawContent() + val sheenHeight = size.height * 0.22f + if (sheenHeight > 0f) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = sheen), + Color.Transparent, + ), + startY = 0f, + endY = sheenHeight, + ), + size = Size(size.width, sheenHeight), + ) + } + } + } else { + withEdge + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt new file mode 100644 index 00000000..bb73c305 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt @@ -0,0 +1,175 @@ +package com.nuvio.app.core.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal const val DefaultCardDepthEdgeStrength = 28 +internal const val DefaultCardDepthSheenStrength = 10 +internal const val DefaultCardDepthEdgeCoverage = 0 + +enum class NuvioCardDepthSurface { + Posters, + ContinueWatching, + EpisodeCards, + Cast, + Trailers, +} + +@Serializable +private data class StoredCardDepthStylePreferences( + val enabled: Boolean = false, + val edgeStrength: Int = DefaultCardDepthEdgeStrength, + val sheenStrength: Int = DefaultCardDepthSheenStrength, + val edgeCoverage: Int = DefaultCardDepthEdgeCoverage, + val postersEnabled: Boolean = true, + val continueWatchingEnabled: Boolean = true, + val episodeCardsEnabled: Boolean = true, + val castEnabled: Boolean = true, + val trailersEnabled: Boolean = true, +) + +data class CardDepthStyleUiState( + val enabled: Boolean = false, + val edgeStrength: Int = DefaultCardDepthEdgeStrength, + val sheenStrength: Int = DefaultCardDepthSheenStrength, + val edgeCoverage: Int = DefaultCardDepthEdgeCoverage, + val postersEnabled: Boolean = true, + val continueWatchingEnabled: Boolean = true, + val episodeCardsEnabled: Boolean = true, + val castEnabled: Boolean = true, + val trailersEnabled: Boolean = true, +) { + fun isEnabledFor(surface: NuvioCardDepthSurface): Boolean = + enabled && isSurfaceEnabled(surface) + + fun isSurfaceEnabled(surface: NuvioCardDepthSurface): Boolean = + when (surface) { + NuvioCardDepthSurface.Posters -> postersEnabled + NuvioCardDepthSurface.ContinueWatching -> continueWatchingEnabled + NuvioCardDepthSurface.EpisodeCards -> episodeCardsEnabled + NuvioCardDepthSurface.Cast -> castEnabled + NuvioCardDepthSurface.Trailers -> trailersEnabled + } +} + +object CardDepthStyleRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(CardDepthStyleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = CardDepthStyleUiState() + } + + fun setEnabled(enabled: Boolean) { + update { it.copy(enabled = enabled) } + } + + fun setEdgeStrength(strength: Int) { + update { it.copy(edgeStrength = strength.coerceIn(0, 100)) } + } + + fun setSheenStrength(strength: Int) { + update { it.copy(sheenStrength = strength.coerceIn(0, 100)) } + } + + fun setEdgeCoverage(coverage: Int) { + update { it.copy(edgeCoverage = coverage.coerceIn(0, 100)) } + } + + fun setSurfaceEnabled(surface: NuvioCardDepthSurface, enabled: Boolean) { + update { + when (surface) { + NuvioCardDepthSurface.Posters -> it.copy(postersEnabled = enabled) + NuvioCardDepthSurface.ContinueWatching -> it.copy(continueWatchingEnabled = enabled) + NuvioCardDepthSurface.EpisodeCards -> it.copy(episodeCardsEnabled = enabled) + NuvioCardDepthSurface.Cast -> it.copy(castEnabled = enabled) + NuvioCardDepthSurface.Trailers -> it.copy(trailersEnabled = enabled) + } + } + } + + fun resetToDefaults() { + ensureLoaded() + if (_uiState.value == CardDepthStyleUiState()) return + _uiState.value = CardDepthStyleUiState() + persist() + } + + private fun update(transform: (CardDepthStyleUiState) -> CardDepthStyleUiState) { + ensureLoaded() + val next = transform(_uiState.value) + if (_uiState.value == next) return + _uiState.value = next + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = CardDepthStyleStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = CardDepthStyleUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = if (stored != null) { + CardDepthStyleUiState( + enabled = stored.enabled, + edgeStrength = stored.edgeStrength.coerceIn(0, 100), + sheenStrength = stored.sheenStrength.coerceIn(0, 100), + edgeCoverage = stored.edgeCoverage.coerceIn(0, 100), + postersEnabled = stored.postersEnabled, + continueWatchingEnabled = stored.continueWatchingEnabled, + episodeCardsEnabled = stored.episodeCardsEnabled, + castEnabled = stored.castEnabled, + trailersEnabled = stored.trailersEnabled, + ) + } else { + CardDepthStyleUiState() + } + } + + private fun persist() { + CardDepthStyleStorage.savePayload( + json.encodeToString( + StoredCardDepthStylePreferences( + enabled = _uiState.value.enabled, + edgeStrength = _uiState.value.edgeStrength, + sheenStrength = _uiState.value.sheenStrength, + edgeCoverage = _uiState.value.edgeCoverage, + postersEnabled = _uiState.value.postersEnabled, + continueWatchingEnabled = _uiState.value.continueWatchingEnabled, + episodeCardsEnabled = _uiState.value.episodeCardsEnabled, + castEnabled = _uiState.value.castEnabled, + trailersEnabled = _uiState.value.trailersEnabled, + ), + ), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt new file mode 100644 index 00000000..055940f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +internal expect object CardDepthStyleStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt index 7b0be071..dca8c7e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Components.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -73,6 +72,8 @@ import org.jetbrains.compose.resources.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import com.nuvio.app.navigation.LocalNativeNavigationBarHidden +import com.nuvio.app.navigation.LocalUseNativeNavigation @Composable fun NuvioScreen( @@ -143,6 +144,20 @@ fun NuvioScreenHeader( ) { val tokens = MaterialTheme.nuvio val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val nativeDetailNavigation = LocalUseNativeNavigation.current && + !LocalNativeNavigationBarHidden.current && + onBack != null + if (nativeDetailNavigation) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = NuvioTokens.Space.s4), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + return + } val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none Box( modifier = modifier.fillMaxWidth(), @@ -264,6 +279,8 @@ fun NuvioBackButton( iconSize: Dp = NuvioTokens.Icon.md, contentDescription: String = stringResource(Res.string.action_back), ) { + if (LocalUseNativeNavigation.current && !LocalNativeNavigationBarHidden.current) return + Box( modifier = modifier .size(buttonSize) @@ -431,9 +448,8 @@ fun NuvioStatusModal( modifier = Modifier.padding(tokens.spacing.dialogPadding), ) { if (isBusy) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = tokens.colors.accent, - strokeWidth = NuvioTokens.Border.medium + NuvioTokens.Space.hairline, ) Spacer(modifier = Modifier.height(NuvioTokens.Space.s16)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingActionSheet.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingActionSheet.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DisintegrationEffect.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DisintegrationEffect.kt new file mode 100644 index 00000000..29da4ad2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DisintegrationEffect.kt @@ -0,0 +1,259 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +@Composable +fun DisintegratingContainer( + disintegrating: Boolean, + onDisintegrated: () -> Unit, + modifier: Modifier = Modifier, + durationMillis: Int = 1500, + content: @Composable () -> Unit, +) { + val graphicsLayer = rememberGraphicsLayer() + val progress = remember { Animatable(0f) } + var field by remember { mutableStateOf(null) } + val seed = remember { Random.nextLong() } + val onDisintegratedState = rememberUpdatedState(onDisintegrated) + + LaunchedEffect(disintegrating) { + if (!disintegrating) return@LaunchedEffect + val bitmap = runCatching { graphicsLayer.toImageBitmap() }.getOrNull() + if (bitmap == null) { + onDisintegratedState.value() + return@LaunchedEffect + } + field = withContext(Dispatchers.Default) { buildAshField(bitmap, seed) } + progress.snapTo(0f) + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + ) + onDisintegratedState.value() + } + + Box( + modifier = modifier.drawWithContent { + val activeField = field + if (activeField == null) { + graphicsLayer.record { this@drawWithContent.drawContent() } + drawLayer(graphicsLayer) + } else { + drawAsh(activeField, progress.value) + } + }, + ) { + content() + } +} + +private class AshField( + val bitmap: ImageBitmap, + val cols: Int, + val rows: Int, + val wavePhase: Float, + val argb: IntArray, + val startDelay: FloatArray, + val dirX: FloatArray, + val speed: FloatArray, + val swirlPhase: FloatArray, + val swirlFreq: FloatArray, + val swirlAmp: FloatArray, +) { + val count = argb.size + + val centersX = FloatArray(count) + val centersY = FloatArray(count) + val halfWidths = FloatArray(count) + val halfHeights = FloatArray(count) + val colors = IntArray(count) + val renderer = AshSwarmRenderer(count) + val clipOutline = Path() +} + +private const val ASH_COLS = 130 +private const val TILE_LIFESPAN = 0.5f +private const val TAU = 6.2831855f + +private fun frontWave(ny: Float, phase: Float): Float = + sin(ny * TAU * 1.15f + phase) * 0.045f + sin(ny * TAU * 2.4f + phase * 1.7f) * 0.02f + +private fun frontValue(nx: Float, ny: Float, phase: Float): Float = + 0.52f * (1f - ny) + 0.48f * nx + frontWave(ny, phase) + +private fun buildAshField(bitmap: ImageBitmap, seed: Long): AshField { + val aspect = bitmap.height.toFloat() / bitmap.width.toFloat() + val maxTiles = ashSwarmMaxGrainBudget + var cols = ASH_COLS + var rows = (cols * aspect).toInt().coerceAtLeast(8) + if (cols * rows > maxTiles) { + cols = (kotlin.math.sqrt(maxTiles / aspect)).toInt().coerceAtLeast(12) + rows = (cols * aspect).toInt().coerceAtLeast(8) + } + val pixels = bitmap.toPixelMap() + val rnd = Random(seed) + val wavePhase = rnd.nextFloat() * TAU + + val count = cols * rows + val argb = IntArray(count) + val startDelay = FloatArray(count) + val dirX = FloatArray(count) + val speed = FloatArray(count) + val swirlPhase = FloatArray(count) + val swirlFreq = FloatArray(count) + val swirlAmp = FloatArray(count) + + for (index in 0 until count) { + val nx = (index % cols + 0.5f) / cols + val ny = (index / cols + 0.5f) / rows + + val px = (nx * (bitmap.width - 1)).toInt().coerceIn(0, bitmap.width - 1) + val py = (ny * (bitmap.height - 1)).toInt().coerceIn(0, bitmap.height - 1) + val sampled = pixels[px, py] + val shade = 0.80f + rnd.nextFloat() * 0.34f + argb[index] = Color( + red = (sampled.red * shade).coerceIn(0f, 1f), + green = (sampled.green * shade).coerceIn(0f, 1f), + blue = (sampled.blue * shade).coerceIn(0f, 1f), + alpha = sampled.alpha, + ).toArgb() + + val front = frontValue(nx, ny, wavePhase) + startDelay[index] = ((1f - front) * (1f - TILE_LIFESPAN) - rnd.nextFloat() * 0.05f) + .coerceIn(0f, 1f - TILE_LIFESPAN) + + dirX[index] = 0.55f + (rnd.nextFloat() - 0.4f) * 1.3f + speed[index] = 0.7f + rnd.nextFloat() * 0.7f + swirlPhase[index] = rnd.nextFloat() * TAU + swirlFreq[index] = 1.1f + rnd.nextFloat() * 1.8f + swirlAmp[index] = 0.6f + rnd.nextFloat() * 0.9f + } + return AshField(bitmap, cols, rows, wavePhase, argb, startDelay, dirX, speed, swirlPhase, swirlFreq, swirlAmp) +} + +private fun DrawScope.drawAsh(field: AshField, p: Float) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return + + val threshold = 1f - p / (1f - TILE_LIFESPAN) + if (threshold > -0.1f) { + val intact = field.clipOutline + intact.rewind() + val steps = 28 + for (i in 0..steps) { + val ny = i / steps.toFloat() + val boundary = (threshold - 0.52f * (1f - ny) - frontWave(ny, field.wavePhase)) / 0.48f + val x = (boundary * w).coerceIn(0f, w) + val y = ny * h + if (i == 0) intact.moveTo(x, y) else intact.lineTo(x, y) + } + intact.lineTo(0f, h) + intact.lineTo(0f, 0f) + intact.close() + clipPath(intact) { + drawImage( + image = field.bitmap, + dstOffset = IntOffset.Zero, + dstSize = IntSize(w.toInt().coerceAtLeast(1), h.toInt().coerceAtLeast(1)), + ) + } + } + + val cols = field.cols + val cellW = w / cols + val cellH = h / field.rows + val tileW = cellW + 0.6f + val tileH = cellH + 0.6f + val riseMax = h * 1.2f + val driftMax = w * 0.6f + val swirlMaxX = w * 0.14f + val swirlMaxY = h * 0.05f + val globalPhase = p * TAU + val invCols = 1f / cols + val invRows = 1f / field.rows + var grainCount = 0 + + for (index in 0 until field.count) { + val local = (p - field.startDelay[index]) / TILE_LIFESPAN + if (local <= 0f) continue + if (local >= 1f) continue + + val alpha = 1f - smoothstep(0.45f, 1f, local) + if (alpha < 0.02f) continue + + val nx = (index % cols + 0.5f) * invCols + val ny = (index / cols + 0.5f) * invRows + + val ease = local * local + val easeSoft = local * local * (3f - 2f * local) + + val rise = riseMax * field.speed[index] * (0.15f * easeSoft + 0.85f * ease * (1f + 0.4f * local)) + val t1 = field.swirlPhase[index] + local * TAU * field.swirlFreq[index] + val flowPhase = (nx * 2.3f + ny * 3.7f) * TAU + val t2 = flowPhase + globalPhase + val swirlAmp = field.swirlAmp[index] + val meanderX = ( + sin(t2) * 0.65f + + sin(t1) * swirlAmp + + cos(t2 * 0.5f + local * TAU * 1.3f) * 0.4f + ) * swirlMaxX * easeSoft + val meanderY = ( + cos(t2 * 0.8f) * 0.5f + + sin(t1 * 0.7f + 1.3f) * swirlAmp * 0.6f + ) * swirlMaxY * easeSoft + val scale = 1f - 0.4f * easeSoft + + val i = grainCount++ + field.centersX[i] = nx * w + field.dirX[index] * ease * driftMax + meanderX + field.centersY[i] = ny * h - rise + meanderY + field.halfWidths[i] = tileW * scale * 0.5f + field.halfHeights[i] = tileH * scale * 0.5f + val base = field.argb[index] + val alphaByte = ((base ushr 24) * alpha).toInt().coerceIn(0, 255) + field.colors[i] = (alphaByte shl 24) or (base and 0x00FFFFFF) + } + + field.renderer.draw( + scope = this, + count = grainCount, + centersX = field.centersX, + centersY = field.centersY, + halfWidths = field.halfWidths, + halfHeights = field.halfHeights, + colors = field.colors, + ) +} + +private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float { + val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f) + return t * t * (3f - 2f * t) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DropdownChip.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DropdownChip.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/FloatingPrompt.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/FloatingPrompt.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioGradients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Gradients.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioGradients.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Gradients.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/LoadingIndicator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/LoadingIndicator.kt new file mode 100644 index 00000000..d1c282bd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/LoadingIndicator.kt @@ -0,0 +1,57 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import io.github.alexzhirkevich.compottie.Compottie +import io.github.alexzhirkevich.compottie.LottieCompositionSpec +import io.github.alexzhirkevich.compottie.animateLottieCompositionAsState +import io.github.alexzhirkevich.compottie.rememberLottieComposition +import io.github.alexzhirkevich.compottie.rememberLottiePainter +import nuvio.composeapp.generated.resources.Res + +@Composable +fun NuvioLoadingIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.nuvio.colors.textSecondary, + size: Dp = NuvioTokens.Space.s40, +) { + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center, + ) { + val composition by rememberLottieComposition { + LottieCompositionSpec.JsonString( + Res.readBytes("files/nuvio_loading_indicator.json").decodeToString() + ) + } + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = Compottie.IterateForever, + ) + + Image( + painter = rememberLottiePainter( + composition = composition, + progress = { progress }, + ), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + clip = false + }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index 2b9d4816..d8de062c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -1,8 +1,23 @@ package com.nuvio.app.core.ui +import com.nuvio.app.features.profiles.AvatarRepository +import com.nuvio.app.features.profiles.AvatarCatalogItem +import com.nuvio.app.features.profiles.MAX_PROFILES +import com.nuvio.app.features.profiles.NuvioProfile +import com.nuvio.app.features.profiles.PinVerifyResult +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.profiles.profileAvatarImageUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch internal enum class NativeNavigationTab { Home, @@ -20,17 +35,11 @@ internal enum class NativeNavigationTab { internal object NativeTabBridge { private val _requestedTabs = MutableSharedFlow(extraBufferCapacity = 1) val requestedTabs: SharedFlow = _requestedTabs.asSharedFlow() - private val _profileTabLongPresses = MutableSharedFlow(extraBufferCapacity = 1) - val profileTabLongPresses: SharedFlow = _profileTabLongPresses.asSharedFlow() fun requestTab(tabName: String) { _requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName)) } - fun requestProfileTabLongPress() { - _profileTabLongPresses.tryEmit(Unit) - } - fun publishSelectedTab(tab: NativeNavigationTab) { publishNativeSelectedTab(tab.name) } @@ -71,12 +80,113 @@ internal object NativeTabBridge { } } -fun nativeTabSelect(tabName: String) { - NativeTabBridge.requestTab(tabName) +data class NativeProfileOption( + val profileIndex: Int, + val name: String, + val avatarColorHex: String, + val avatarImageUrl: String?, + val avatarBackgroundColorHex: String?, + val pinEnabled: Boolean, + val active: Boolean, +) + +data class NativeProfileSwitcherState( + val profiles: List, + val isLoaded: Boolean, + val canAddProfile: Boolean, +) + +class NativeProfileSwitcherController { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val profileSelections = Channel(Channel.BUFFERED) + private val manageProfileRequests = Channel(Channel.BUFFERED) + private var observationJob: Job? = null + + internal val selectedProfileIndices = profileSelections.receiveAsFlow() + internal val requestedManageProfiles = manageProfileRequests.receiveAsFlow() + + fun currentState(): NativeProfileSwitcherState = nativeState( + profilesLoaded = ProfileRepository.state.value.isLoaded, + profiles = ProfileRepository.state.value.profiles, + activeProfileIndex = ProfileRepository.state.value.activeProfile?.profileIndex, + avatarsById = AvatarRepository.avatars.value.associateBy { it.id }, + ) + + fun observeState(callback: (NativeProfileSwitcherState) -> Unit) { + observationJob?.cancel() + observationJob = scope.launch { + combine(ProfileRepository.state, AvatarRepository.avatars) { state, avatars -> + nativeState( + profilesLoaded = state.isLoaded, + profiles = state.profiles, + activeProfileIndex = state.activeProfile?.profileIndex, + avatarsById = avatars.associateBy { it.id }, + ) + }.collect { callback(it) } + } + } + + fun stopObserving() { + observationJob?.cancel() + observationJob = null + } + + private fun nativeState( + profilesLoaded: Boolean, + profiles: List, + activeProfileIndex: Int?, + avatarsById: Map, + ): NativeProfileSwitcherState { + val options = profiles.map { profile -> + val avatar = profile.avatarId?.let(avatarsById::get) + NativeProfileOption( + profileIndex = profile.profileIndex, + name = profile.name, + avatarColorHex = profile.avatarColorHex, + avatarImageUrl = profileAvatarImageUrl(profile, avatar), + avatarBackgroundColorHex = avatar?.bgColor, + pinEnabled = profile.pinEnabled, + active = profile.profileIndex == activeProfileIndex, + ) + } + return NativeProfileSwitcherState( + profiles = options, + isLoaded = profilesLoaded, + canAddProfile = profiles.size < MAX_PROFILES, + ) + } + + fun chooseProfile( + profileIndex: Int, + pin: String?, + completion: (PinVerifyResult) -> Unit, + ) { + scope.launch { + val profile = ProfileRepository.state.value.profiles + .firstOrNull { it.profileIndex == profileIndex } + if (profile == null) { + completion(PinVerifyResult(message = null)) + return@launch + } + val result = if (profile.pinEnabled) { + ProfileRepository.verifyPin(profileIndex, pin.orEmpty()) + } else { + PinVerifyResult(unlocked = true) + } + if (result.unlocked) { + profileSelections.trySend(profileIndex) + } + completion(result) + } + } + + fun requestManageProfiles() { + manageProfileRequests.trySend(Unit) + } } -fun nativeProfileTabLongPress() { - NativeTabBridge.requestProfileTabLongPress() +fun nativeTabSelect(tabName: String) { + NativeTabBridge.requestTab(tabName) } internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNavigationBar.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NetworkOfflineCard.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NetworkOfflineCard.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt deleted file mode 100644 index 6ad66ea4..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ /dev/null @@ -1,234 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.CheckCircleOutline -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.nuvio.app.core.format.formatReleaseDateForDisplay -import com.nuvio.app.features.home.MetaPreview -import kotlinx.coroutines.launch -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.episodes_cd_watched -import nuvio.composeapp.generated.resources.hero_add_to_library -import nuvio.composeapp.generated.resources.hero_mark_unwatched -import nuvio.composeapp.generated.resources.hero_mark_watched -import nuvio.composeapp.generated.resources.hero_remove_from_library -import org.jetbrains.compose.resources.stringResource - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NuvioPosterActionSheet( - item: MetaPreview?, - isSaved: Boolean, - isWatched: Boolean, - onDismiss: () -> Unit, - onToggleLibrary: () -> Unit, - onToggleWatched: () -> Unit, -) { - if (item == null) return - val tokens = MaterialTheme.nuvio - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val coroutineScope = rememberCoroutineScope() - - NuvioModalBottomSheet( - onDismissRequest = { - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = onDismiss, - ) - } - }, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)), - ) { - PosterSheetHeader(item = item) - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = if (isSaved) Icons.Default.Check else Icons.Default.Add, - title = if (isSaved) { - stringResource(Res.string.hero_remove_from_library) - } else { - stringResource(Res.string.hero_add_to_library) - }, - onClick = { - onToggleLibrary() - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = onDismiss, - ) - } - }, - ) - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, - title = if (isWatched) { - stringResource(Res.string.hero_mark_unwatched) - } else { - stringResource(Res.string.hero_mark_watched) - }, - onClick = { - onToggleWatched() - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = onDismiss, - ) - } - }, - ) - } - } -} - -@Composable -fun NuvioWatchedBadge( - modifier: Modifier = Modifier, -) { - val tokens = MaterialTheme.nuvio - Box( - modifier = modifier - .size(NuvioTokens.Icon.md) - .clip(tokens.shapes.avatar) - .background(tokens.colors.accent), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(Res.string.episodes_cd_watched), - tint = tokens.colors.onAccent, - modifier = Modifier.size(NuvioTokens.Icon.xs), - ) - } -} - -@Composable -fun NuvioAnimatedWatchedBadge( - isVisible: Boolean, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = isVisible, - enter = fadeIn(), - exit = fadeOut(), - modifier = modifier, - ) { - NuvioWatchedBadge() - } -} - -@Composable -fun BoxScope.NuvioPosterWatchedOverlay( - isWatched: Boolean, - modifier: Modifier = Modifier, - padding: Dp = NuvioTokens.Space.s6, -) { - NuvioAnimatedWatchedBadge( - isVisible = isWatched, - modifier = modifier - .align(Alignment.TopEnd) - .padding(padding), - ) -} - -@Composable -private fun PosterSheetHeader( - item: MetaPreview, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - val tokens = MaterialTheme.nuvio - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14), - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12) - .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(tokens.colors.surfaceCard), - contentAlignment = Alignment.Center, - ) { - if (item.poster != null) { - AsyncImage( - model = item.poster, - contentDescription = item.name, - modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop, - ) - } else { - Text( - text = item.name, - modifier = Modifier.padding(tokens.spacing.listGap), - style = MaterialTheme.typography.bodyMedium, - color = tokens.colors.textMuted, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - ) { - Text( - text = item.name, - style = MaterialTheme.typography.titleLarge, - color = tokens.colors.textPrimary, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = item.releaseInfo?.takeIf { it.isNotBlank() }?.let { formatReleaseDateForDisplay(it) } - ?: item.type.replaceFirstChar { char -> - if (char.isLowerCase()) char.titlecase() else char.toString() - }, - style = MaterialTheme.typography.bodyMedium, - color = tokens.colors.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt index 2fc73a4f..bd4c58f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt @@ -13,6 +13,10 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp @Composable internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets +/** Physical display-safe top inset, excluding any enclosing native toolbar. */ +@Composable +internal expect fun platformPhysicalTopInset(): Dp + internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterActionSheet.kt new file mode 100644 index 00000000..d56e6827 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterActionSheet.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.episodes_cd_watched +import org.jetbrains.compose.resources.stringResource + +@Composable +fun NuvioWatchedBadge( + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + Box( + modifier = modifier + .size(NuvioTokens.Icon.md) + .clip(tokens.shapes.avatar) + .background(tokens.colors.accent), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.episodes_cd_watched), + tint = tokens.colors.onAccent, + modifier = Modifier.size(NuvioTokens.Icon.xs), + ) + } +} + +@Composable +fun NuvioAnimatedWatchedBadge( + isVisible: Boolean, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier, + ) { + NuvioWatchedBadge() + } +} + +@Composable +fun BoxScope.NuvioPosterWatchedOverlay( + isWatched: Boolean, + modifier: Modifier = Modifier, + padding: Dp = NuvioTokens.Space.s6, +) { + NuvioAnimatedWatchedBadge( + isVisible = isWatched, + modifier = modifier + .align(Alignment.TopEnd) + .padding(padding), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt new file mode 100644 index 00000000..34bb8ee3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -0,0 +1,495 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.unit.min +import coil3.compose.AsyncImage +import dev.chrisbanes.haze.HazeInputScale +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Geometry of the shelf card that was long-pressed, captured in root coordinates + * right before the long-press callback fires. Used as the start frame of the + * shared-element zoom in [NuvioPosterZoomActionOverlay]. + */ +data class PosterZoomAnchor( + val boundsInRoot: Rect, + val imageUrl: String?, + val cornerRadius: Dp, +) + +/** + * Hand-off slot between the pressed card and the overlay host. The card stashes + * its anchor synchronously inside the long-press callback; the host consumes it + * in the same call stack, so the value can never go stale. + */ +object PosterZoomAnchorHolder { + private var pending: PosterZoomAnchor? = null + + fun stash(anchor: PosterZoomAnchor) { + pending = anchor + } + + fun consume(): PosterZoomAnchor? = pending.also { pending = null } +} + +class PosterZoomOverlayAction( + val icon: ImageVector, + val label: String, + val isDestructive: Boolean = false, + val onSelected: () -> Unit, +) + +private enum class PosterZoomPhase { + Open, + Closing, + Disintegrating, +} + +private const val PosterZoomVisibilityThreshold = 0.0005f +private val PosterZoomExpandSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 340f, + visibilityThreshold = PosterZoomVisibilityThreshold, +) +private val PosterZoomCollapseSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 460f, + visibilityThreshold = PosterZoomVisibilityThreshold, +) +private val PosterZoomMenuSpring = spring( + dampingRatio = 0.8f, + stiffness = 420f, + visibilityThreshold = Spring.DefaultDisplacementThreshold, +) + +/** + * Apple-style long-press preview: the pressed poster lifts off the shelf and + * springs to the centre of the screen, the app content behind is blurred and + * dimmed, and an action menu cascades in underneath. Destructive actions burn + * the centred poster away with [DisintegratingContainer] instead of zooming back. + */ +@Composable +fun NuvioPosterZoomActionOverlay( + imageUrl: String?, + title: String, + subtitle: String?, + isWatched: Boolean = false, + anchor: PosterZoomAnchor?, + actions: List, + hazeState: HazeState, + onDismissed: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + val hapticFeedback = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + + // A context menu shows a snapshot of the moment it was invoked; don't let + // repository updates mid-animation relabel or reorder the rows. + val frozenActions = remember { actions } + + val zoom = remember { Animatable(0f) } + val scrim = remember { Animatable(0f) } + val menu = remember { Animatable(0f) } + val shadowFade = remember { Animatable(1f) } + var phase by remember { mutableStateOf(PosterZoomPhase.Open) } + + var rootOrigin by remember { mutableStateOf(Offset.Zero) } + var slotBounds by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + launch { scrim.animateTo(1f, tween(durationMillis = 260, easing = NuvioTokens.Motion.standard)) } + launch { zoom.animateTo(1f, PosterZoomExpandSpring) } + launch { + delay(60) + menu.animateTo(1f, PosterZoomMenuSpring) + } + } + + fun close() { + if (phase != PosterZoomPhase.Open) return + phase = PosterZoomPhase.Closing + scope.launch { + coroutineScope { + launch { menu.animateTo(0f, tween(durationMillis = 140)) } + launch { + delay(80) + scrim.animateTo(0f, tween(durationMillis = 260, easing = NuvioTokens.Motion.standard)) + } + launch { zoom.animateTo(0f, PosterZoomCollapseSpring) } + } + onDismissed() + } + } + + fun select(action: PosterZoomOverlayAction) { + if (phase != PosterZoomPhase.Open) return + if (action.isDestructive) { + phase = PosterZoomPhase.Disintegrating + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + action.onSelected() + scope.launch { + launch { menu.animateTo(0f, tween(durationMillis = 160)) } + launch { shadowFade.animateTo(0f, tween(durationMillis = 350)) } + } + } else { + action.onSelected() + close() + } + } + + PlatformBackHandler(enabled = true) { + close() + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> rootOrigin = coordinates.positionInRoot() } + .pointerInput(Unit) { + detectTapGestures { close() } + }, + ) { + val anchorAspect = anchor + ?.boundsInRoot + ?.takeIf { it.height > 0f } + ?.let { it.width / it.height } + ?: 0.675f + val aspect = anchorAspect.coerceIn(0.35f, 2.4f) + val maxPosterWidth = if (aspect >= 1f) maxWidth * 0.8f else maxWidth * 0.6f + val posterHeight = min(maxPosterWidth / aspect, maxHeight * 0.44f) + val posterWidth = posterHeight * aspect + val menuWidth = min(280.dp, maxWidth - NuvioTokens.Space.s48) + val columnWidth = max(posterWidth, menuWidth) + + Box( + modifier = Modifier + .fillMaxSize() + .hazeEffect(state = hazeState) { + blurRadius = 36.dp + inputScale = HazeInputScale.Auto + noiseFactor = 0f + blurredEdgeTreatment = BlurredEdgeTreatment.Rectangle + clipToAreasBounds = false + expandLayerBounds = true + alpha = scrim.value.coerceIn(0f, 1f) + }, + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Color.Black.copy(alpha = 0.45f * scrim.value.coerceIn(0f, 1f)), + ), + ) + + Column( + modifier = Modifier + .align(Alignment.Center) + .width(columnWidth), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Invisible slot marking where the zoomed poster comes to rest. + Box( + modifier = Modifier + .size(width = posterWidth, height = posterHeight) + .onGloballyPositioned { coordinates -> slotBounds = coordinates.boundsInRoot() }, + ) + + Spacer(modifier = Modifier.height(NuvioTokens.Space.s18)) + + Column( + modifier = Modifier + .width(columnWidth) + .graphicsLayer { + val progress = menu.value.coerceIn(0f, 1f) + alpha = progress + translationY = (1f - progress) * NuvioTokens.Space.s10.toPx() + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = NuvioTokens.Space.s12), + ) + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(NuvioTokens.Space.s4)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = NuvioTokens.Space.s12), + ) + } + } + + Spacer(modifier = Modifier.height(NuvioTokens.Space.s14)) + + Column( + modifier = Modifier + .width(menuWidth) + .graphicsLayer { + val progress = menu.value + val clamped = progress.coerceIn(0f, 1f) + alpha = clamped + val scale = 0.62f + 0.38f * progress + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 0f) + } + .graphicsLayer { + shape = RoundedCornerShape(NuvioTokens.Space.s20.toPx()) + clip = true + } + .background(tokens.colors.surfaceElevated), + ) { + frozenActions.forEachIndexed { index, action -> + if (index > 0) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(NuvioTokens.Space.hairline) + .background(tokens.colors.textPrimary.copy(alpha = 0.08f)), + ) + } + PosterZoomMenuRow( + action = action, + enabled = phase == PosterZoomPhase.Open, + onSelected = { select(action) }, + modifier = Modifier.graphicsLayer { + val stagger = index * 0.07f + val progress = ((menu.value.coerceIn(0f, 1f) - stagger) / (1f - stagger)) + .coerceIn(0f, 1f) + alpha = progress + translationY = (1f - progress) * NuvioTokens.Space.s8.toPx() + }, + ) + } + } + } + + // The travelling poster itself, drawn above the slot column. + Box( + modifier = Modifier + .size(width = posterWidth, height = posterHeight) + .graphicsLayer { + val slot = slotBounds + if (slot == null || slot.width <= 0f) { + alpha = 0f + return@graphicsLayer + } + val progress = zoom.value + val clamped = progress.coerceIn(0f, 1f) + val start = posterStartBounds(anchor = anchor, slot = slot) + val scale = posterScale(anchor = anchor, slot = slot, progress = progress) + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0f) + translationX = lerp(start.left, slot.left, progress) - rootOrigin.x + translationY = lerp(start.top, slot.top, progress) - rootOrigin.y + alpha = if (anchor == null) { + clamped + } else { + (clamped / 0.08f).coerceIn(0f, 1f) + } + shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale)) + clip = false + shadowElevation = NuvioTokens.Space.s24.toPx() * clamped * shadowFade.value + }, + ) { + DisintegratingContainer( + disintegrating = phase == PosterZoomPhase.Disintegrating, + onDisintegrated = { + scope.launch { + scrim.animateTo(0f, tween(durationMillis = 300, easing = NuvioTokens.Motion.standard)) + onDismissed() + } + }, + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val clamped = zoom.value.coerceIn(0f, 1f) + val slot = slotBounds + val scale = if (slot != null && slot.width > 0f) { + posterScale(anchor = anchor, slot = slot, progress = zoom.value) + } else { + 1f + } + shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale)) + clip = true + } + .background(tokens.colors.surfaceCard), + contentAlignment = Alignment.Center, + ) { + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = title, + modifier = Modifier.padding(NuvioTokens.Space.s14), + style = MaterialTheme.typography.titleMedium, + color = tokens.colors.textMuted, + textAlign = TextAlign.Center, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + NuvioPosterWatchedOverlay( + isWatched = isWatched, + modifier = Modifier.graphicsLayer { + val slot = slotBounds + if (slot != null && slot.width > 0f) { + val scale = posterScale( + anchor = anchor, + slot = slot, + progress = zoom.value, + ).coerceAtLeast(0.001f) + scaleX = 1f / scale + scaleY = 1f / scale + transformOrigin = TransformOrigin(1f, 0f) + } + }, + ) + } + } + } + } +} + +@Composable +private fun PosterZoomMenuRow( + action: PosterZoomOverlayAction, + enabled: Boolean, + onSelected: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + val contentColor = if (action.isDestructive) tokens.colors.danger else tokens.colors.textPrimary + Row( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onSelected) + .padding(horizontal = NuvioTokens.Space.s18, vertical = NuvioTokens.Space.s16), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s12), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = action.label, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = action.icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(NuvioTokens.Icon.md), + ) + } +} + +private fun lerp(start: Float, stop: Float, fraction: Float): Float = + start + (stop - start) * fraction + +private fun posterStartBounds(anchor: PosterZoomAnchor?, slot: Rect): Rect = + anchor?.boundsInRoot ?: slot.scaleAboutCenter(0.85f) + +private fun posterScale(anchor: PosterZoomAnchor?, slot: Rect, progress: Float): Float = + lerp(posterStartBounds(anchor = anchor, slot = slot).width / slot.width, 1f, progress) + +private fun Rect.scaleAboutCenter(factor: Float): Rect { + val newWidth = width * factor + val newHeight = height * factor + return Rect( + offset = Offset(center.x - newWidth / 2f, center.y - newHeight / 2f), + size = androidx.compose.ui.geometry.Size(newWidth, newHeight), + ) +} + +private fun androidx.compose.ui.unit.Density.posterCornerRadiusPx( + anchor: PosterZoomAnchor?, + clampedProgress: Float, + scale: Float, +): Float { + val finalRadius = PosterZoomFinalCornerRadius.toPx() + val startRadius = anchor?.cornerRadius?.toPx() ?: finalRadius + val apparent = lerp(startRadius, finalRadius, clampedProgress) + return if (scale > 0f) apparent / scale else apparent +} + +private val PosterZoomFinalCornerRadius = NuvioTokens.Space.s18 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ProgressBar.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ProgressBar.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index 1569c849..d9554540 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight @@ -25,11 +27,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.style.TextAlign @@ -64,6 +71,8 @@ fun NuvioShelfSection( onViewAllClick: (() -> Unit)? = null, viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, key: ((T) -> Any)? = null, + animatePlacement: Boolean = false, + state: LazyListState = rememberLazyListState(), itemContent: @Composable (T) -> Unit, ) { val tokens = MaterialTheme.nuvio @@ -81,6 +90,7 @@ fun NuvioShelfSection( ) } LazyRow( + state = state, contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { @@ -89,11 +99,19 @@ fun NuvioShelfSection( items = entries.withDuplicateSafeLazyKeys(key), key = { entry -> entry.lazyKey }, ) { keyedEntry -> - itemContent(keyedEntry.value) + if (animatePlacement) { + Box(modifier = Modifier.animateItem()) { itemContent(keyedEntry.value) } + } else { + itemContent(keyedEntry.value) + } } } else { items(entries) { entry -> - itemContent(entry) + if (animatePlacement) { + Box(modifier = Modifier.animateItem()) { itemContent(entry) } + } else { + itemContent(entry) + } } } } @@ -134,7 +152,16 @@ fun NuvioPosterCard( .aspectRatio(shape.aspectRatio) .clip(cardShape) .background(tokens.colors.surface) - .posterCardClickable(onClick = onClick, onLongClick = onLongClick), + .nuvioCardDepth( + shape = cardShape, + surface = NuvioCardDepthSurface.Posters, + ) + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = imageUrl, + zoomCornerRadius = posterCardStyle.cornerRadiusDp.dp, + ), contentAlignment = Alignment.Center, ) { if (imageUrl != null) { @@ -335,15 +362,42 @@ private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp = } @OptIn(ExperimentalFoundationApi::class) +@Composable internal fun Modifier.posterCardClickable( onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, -): Modifier = - if (onClick != null || onLongClick != null) { - combinedClickable( + zoomImageUrl: String? = null, + zoomCornerRadius: Dp = NuvioTokens.Radius.poster, +): Modifier { + if (onClick == null && onLongClick == null) return this + val bounds = remember { mutableStateOf(null) } + return this + .onGloballyPositioned { coordinates -> bounds.value = coordinates.unclippedBoundsInRoot() } + .combinedClickable( onClick = { onClick?.invoke() }, - onLongClick = onLongClick, + onLongClick = onLongClick?.let { longClick -> + { + bounds.value?.let { cardBounds -> + PosterZoomAnchorHolder.stash( + PosterZoomAnchor( + boundsInRoot = cardBounds, + imageUrl = zoomImageUrl, + cornerRadius = zoomCornerRadius, + ), + ) + } + longClick() + } + }, ) - } else { - this - } +} + +private fun androidx.compose.ui.layout.LayoutCoordinates.unclippedBoundsInRoot(): Rect { + val position = positionInRoot() + return Rect( + left = position.x, + top = position.y, + right = position.x + size.width, + bottom = position.y + size.height, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Theme.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Theme.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokenPreview.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TokenPreview.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokenPreview.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TokenPreview.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokens.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Tokens.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokens.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/Tokens.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt index a9894b30..15566135 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -92,8 +91,7 @@ fun TraktListPickerDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { - CircularProgressIndicator( - strokeWidth = tokens.borders.medium, + NuvioLoadingIndicator( modifier = Modifier.size(tokens.icons.lg), ) Text( @@ -164,9 +162,8 @@ fun TraktListPickerDialog( enabled = !isPending, ) { if (isPending) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = tokens.colors.onAccent, - strokeWidth = tokens.borders.medium, modifier = Modifier.size(tokens.icons.sm), ) } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TypeScale.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TypeScale.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index f430f3ed..42d2875c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.addons import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.query.Order @@ -151,6 +152,7 @@ object AddonRepository { val params = buildJsonObject { put("p_profile_id", currentProfileId) put("p_addons", json.encodeToJsonElement(addons)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) log.i { "pullFromServer() — migration push done (${addons.size} addons)" } @@ -396,6 +398,7 @@ object AddonRepository { val params = buildJsonObject { put("p_profile_id", profileId) put("p_addons", json.encodeToJsonElement(addons)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) log.d { "pushToServer() — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt index cd510a5a..b0d3167c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -84,8 +84,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository -import com.nuvio.app.features.dev.DebugSyncBackendSwitch -import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch import kotlin.math.abs import kotlin.math.cos import kotlin.math.PI @@ -215,7 +213,6 @@ fun AuthScreen( AuthRepository.clearError() } - val showDebugSwitch = shouldShowDebugSyncBackendSwitch() val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() Box( @@ -263,7 +260,6 @@ fun AuthScreen( passwordVisible = passwordVisible, isLoading = isLoading, authError = authError, - showDebugSwitch = showDebugSwitch, formPaneWidth = if (compactLargeScreen) 460.dp else formPaneWidth, brandHorizontalPadding = brandHorizontalPadding, formHorizontalPadding = formHorizontalPadding, @@ -295,7 +291,6 @@ fun AuthScreen( passwordVisible = passwordVisible, isLoading = isLoading, authError = authError, - showDebugSwitch = showDebugSwitch, statusBarTop = statusBarTop, onEmailChange = { email = it @@ -329,7 +324,6 @@ private fun AuthMobileLayout( passwordVisible: Boolean, isLoading: Boolean, authError: String?, - showDebugSwitch: Boolean, statusBarTop: Dp, onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, @@ -360,15 +354,6 @@ private fun AuthMobileLayout( ) { AuthBrandLockup(logoHeight = 38.dp) - if (showDebugSwitch) { - Spacer(modifier = Modifier.height(24.dp)) - DebugSyncBackendSwitch( - modifier = Modifier.fillMaxWidth(), - requireConfirmation = false, - container = true, - ) - } - Spacer(modifier = Modifier.height(64.dp)) AuthHeading( @@ -419,7 +404,6 @@ private fun AuthLargeLayout( passwordVisible: Boolean, isLoading: Boolean, authError: String?, - showDebugSwitch: Boolean, formPaneWidth: Dp, brandHorizontalPadding: Dp, formHorizontalPadding: Dp, @@ -473,15 +457,6 @@ private fun AuthLargeLayout( fontWeight = FontWeight.Normal, ), ) - - if (showDebugSwitch) { - Spacer(modifier = Modifier.height(24.dp * scale)) - DebugSyncBackendSwitch( - modifier = Modifier.widthIn(max = 520.dp * scale), - requireConfirmation = false, - container = true, - ) - } } Box( @@ -897,10 +872,9 @@ private fun AuthPrimaryButton( ), ) { if (isLoading) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(20.dp * scale), color = AuthPrimaryButtonText, - strokeWidth = 2.dp * scale, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index e60bb169..c11312d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -7,19 +7,22 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio 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.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -58,6 +61,7 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState +import com.nuvio.app.navigation.LocalUseNativeNavigation import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -82,6 +86,7 @@ fun CatalogScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val initialScrollPosition = remember( target, homeCatalogSettingsUiState.hideUnreleasedContent, @@ -203,6 +208,7 @@ fun CatalogScreen( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, @@ -233,6 +239,16 @@ private fun CatalogHeader( onBack: () -> Unit, modifier: Modifier = Modifier, ) { + if (LocalUseNativeNavigation.current) { + Box( + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .height(44.dp), + ) + return + } + Column( modifier = modifier .fillMaxWidth() @@ -288,7 +304,12 @@ private fun CatalogPosterTile( .aspectRatio(item.posterShape.catalogAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) - .posterCardClickable(onClick = onClick, onLongClick = onLongClick), + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = item.poster, + zoomCornerRadius = cornerRadiusDp.dp, + ), ) { if (item.poster != null) { AsyncImage( @@ -377,10 +398,9 @@ private fun CatalogLoadingFooter() { .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(22.dp), color = MaterialTheme.colorScheme.primary, - strokeWidth = 2.dp, ) } } 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 e4cec4b5..b2ab5b29 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 @@ -71,6 +71,7 @@ data class CollectionEditorUiState( val traktTrendingResults: List = emptyList(), val traktPopularResults: List = emptyList(), val traktSearchError: String? = null, + val sourcePickerCompletionGeneration: Long = 0L, ) enum class TmdbBuilderMode { @@ -725,6 +726,7 @@ object CollectionEditorRepository { tmdbCompanyResults = emptyList(), tmdbCollectionResults = emptyList(), tmdbSearchError = null, + sourcePickerCompletionGeneration = _uiState.value.sourcePickerCompletionGeneration + 1L, ) } @@ -833,6 +835,7 @@ object CollectionEditorRepository { traktTitleInput = "", traktSearchResults = emptyList(), traktSearchError = null, + sourcePickerCompletionGeneration = state.sourcePickerCompletionGeneration + 1L, ) } 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 01c9ec61..3e80ef13 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 @@ -49,6 +49,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -75,47 +76,87 @@ import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState +enum class CollectionEditorPage { + Root, + FolderEditor, + CatalogPicker, + TmdbSourcePicker, + TraktSourcePicker, +} + +fun disposeCollectionEditorPage(page: CollectionEditorPage) { + when (page) { + CollectionEditorPage.Root -> Unit + CollectionEditorPage.FolderEditor -> CollectionEditorRepository.cancelFolderEdit() + CollectionEditorPage.CatalogPicker -> CollectionEditorRepository.hideCatalogPicker() + CollectionEditorPage.TmdbSourcePicker -> CollectionEditorRepository.hideTmdbSourcePicker() + CollectionEditorPage.TraktSourcePicker -> CollectionEditorRepository.hideTraktSourcePicker() + } +} + +private val autoDismissedPickerPages = setOf( + CollectionEditorPage.TmdbSourcePicker, + CollectionEditorPage.TraktSourcePicker, +) + +private fun CollectionEditorUiState.activeEditorPage(): CollectionEditorPage = when { + showCatalogPicker -> CollectionEditorPage.CatalogPicker + showTmdbSourcePicker -> CollectionEditorPage.TmdbSourcePicker + showTraktSourcePicker -> CollectionEditorPage.TraktSourcePicker + showFolderEditor && editingFolder != null -> CollectionEditorPage.FolderEditor + else -> CollectionEditorPage.Root +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun CollectionEditorScreen( collectionId: String?, onBack: () -> Unit, + initialPage: CollectionEditorPage? = null, + initializeRepository: Boolean = true, + onNavigateToPage: ((page: CollectionEditorPage, title: String) -> Unit)? = null, ) { val state by CollectionEditorRepository.uiState.collectAsState() val bottomInset = nuvioSafeBottomPadding() - LaunchedEffect(collectionId) { - CollectionEditorRepository.initialize(collectionId) + LaunchedEffect(collectionId, initializeRepository) { + if (initializeRepository) { + CollectionEditorRepository.initialize(collectionId) + } + } + + val page = initialPage ?: state.activeEditorPage() + val initialPickerCompletionGeneration = remember(initialPage) { + state.sourcePickerCompletionGeneration + } + + LaunchedEffect(initialPage, state.sourcePickerCompletionGeneration) { + if ( + initialPage in autoDismissedPickerPages && + state.sourcePickerCompletionGeneration != initialPickerCompletionGeneration + ) { + onBack() + } + } + + if ( + initialPage != null && + page != CollectionEditorPage.Root && + state.editingFolder == null + ) { + LaunchedEffect(initialPage) { onBack() } + return + } + + fun closePage(pageToClose: CollectionEditorPage, close: () -> Unit) { + close() + if (initialPage == pageToClose) { + onBack() + } } val editingFolder = state.editingFolder - if (state.showFolderEditor && editingFolder != null) { - if (state.showCatalogPicker) { - CatalogPickerScreen( - availableCatalogs = state.availableCatalogs, - selectedSources = editingFolder.resolvedCatalogSources, - onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onBack = { CollectionEditorRepository.hideCatalogPicker() }, - ) - return - } - - if (state.showTmdbSourcePicker) { - TmdbSourcePickerScreen( - state = state, - onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, - ) - return - } - - if (state.showTraktSourcePicker) { - TraktSourcePickerScreen( - state = state, - onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, - ) - return - } - + if (page == CollectionEditorPage.FolderEditor && editingFolder != null) { val genrePickerIndex = state.genrePickerSourceIndex val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() @@ -125,7 +166,18 @@ fun CollectionEditorScreen( FolderEditorPage( state = state, - onBack = { CollectionEditorRepository.cancelFolderEdit() }, + onBack = { + closePage(CollectionEditorPage.FolderEditor) { + CollectionEditorRepository.cancelFolderEdit() + } + }, + onNavigateToPage = onNavigateToPage, + onSave = { + CollectionEditorRepository.saveFolderEdit() + if (initialPage == CollectionEditorPage.FolderEditor) { + onBack() + } + }, ) if ( @@ -149,28 +201,40 @@ fun CollectionEditorScreen( return } - if (state.showCatalogPicker) { + if (page == CollectionEditorPage.CatalogPicker) { CatalogPickerScreen( availableCatalogs = state.availableCatalogs, selectedSources = state.editingFolder?.resolvedCatalogSources.orEmpty(), onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onBack = { CollectionEditorRepository.hideCatalogPicker() }, + onBack = { + closePage(CollectionEditorPage.CatalogPicker) { + CollectionEditorRepository.hideCatalogPicker() + } + }, ) return } - if (state.showTmdbSourcePicker) { + if (page == CollectionEditorPage.TmdbSourcePicker) { TmdbSourcePickerScreen( state = state, - onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, + onBack = { + closePage(CollectionEditorPage.TmdbSourcePicker) { + CollectionEditorRepository.hideTmdbSourcePicker() + } + }, ) return } - if (state.showTraktSourcePicker) { + if (page == CollectionEditorPage.TraktSourcePicker) { TraktSourcePickerScreen( state = state, - onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + onBack = { + closePage(CollectionEditorPage.TraktSourcePicker) { + CollectionEditorRepository.hideTraktSourcePicker() + } + }, ) return } @@ -335,7 +399,10 @@ fun CollectionEditorScreen( ) { NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders)) TextButton( - onClick = { CollectionEditorRepository.addFolder(newFolderTitle) }, + onClick = { + CollectionEditorRepository.addFolder(newFolderTitle) + onNavigateToPage?.invoke(CollectionEditorPage.FolderEditor, newFolderTitle) + }, ) { Icon( imageVector = Icons.Rounded.Add, @@ -351,9 +418,13 @@ fun CollectionEditorScreen( // Folder Items if (state.folders.isNotEmpty()) { item { + val editFolderTitle = stringResource(Res.string.collections_editor_edit_folder) FolderReorderableList( folders = state.folders, - onEdit = { CollectionEditorRepository.editFolder(it) }, + onEdit = { + CollectionEditorRepository.editFolder(it) + onNavigateToPage?.invoke(CollectionEditorPage.FolderEditor, editFolderTitle) + }, onDelete = { CollectionEditorRepository.removeFolder(it) }, ) } @@ -558,9 +629,15 @@ private fun FolderListItem( private fun FolderEditorPage( state: CollectionEditorUiState, onBack: () -> Unit, + onNavigateToPage: ((page: CollectionEditorPage, title: String) -> Unit)?, + onSave: () -> Unit, ) { val folder = state.editingFolder ?: return val bottomInset = nuvioSafeBottomPadding() + val catalogPickerTitle = stringResource(Res.string.collections_editor_select_catalogs) + val tmdbSourcePickerTitle = stringResource(Res.string.collections_editor_tmdb_sources) + val traktSourcePickerTitle = stringResource(Res.string.collections_editor_trakt_sources) + val editTraktSourcePickerTitle = stringResource(Res.string.collections_editor_edit_trakt_source) PlatformBackHandler(enabled = true) { onBack() @@ -725,7 +802,15 @@ private fun FolderEditorPage( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { + TextButton( + onClick = { + CollectionEditorRepository.showTmdbSourcePicker() + onNavigateToPage?.invoke( + CollectionEditorPage.TmdbSourcePicker, + tmdbSourcePickerTitle, + ) + }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, @@ -734,7 +819,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(Res.string.source_tmdb)) } - TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) { + TextButton( + onClick = { + CollectionEditorRepository.showTraktSourcePicker() + onNavigateToPage?.invoke( + CollectionEditorPage.TraktSourcePicker, + traktSourcePickerTitle, + ) + }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, @@ -743,7 +836,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(Res.string.collections_editor_add_trakt_source)) } - TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { + TextButton( + onClick = { + CollectionEditorRepository.showCatalogPicker() + onNavigateToPage?.invoke( + CollectionEditorPage.CatalogPicker, + catalogPickerTitle, + ) + }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, @@ -784,7 +885,13 @@ private fun FolderEditorPage( } else if (source.isTrakt) { FolderTraktSourceCard( source = source, - onEdit = { CollectionEditorRepository.editTraktSource(index) }, + onEdit = { + CollectionEditorRepository.editTraktSource(index) + onNavigateToPage?.invoke( + CollectionEditorPage.TraktSourcePicker, + editTraktSourcePickerTitle, + ) + }, onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, ) } else if (addonSource != null) { @@ -823,7 +930,7 @@ private fun FolderEditorPage( NuvioPrimaryButton( text = stringResource(Res.string.collections_editor_save), enabled = folder.title.isNotBlank(), - onClick = { CollectionEditorRepository.saveFolderEdit() }, + onClick = onSave, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt index 74deba81..6e0c3e88 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -55,6 +55,7 @@ import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope @@ -215,6 +216,9 @@ private fun CollectionReorderableList( ) { val hapticFeedback = LocalHapticFeedback.current val lazyListState = rememberLazyListState() + val keyedCollections = remember(collections) { + collections.withDuplicateSafeLazyKeys { collection -> collection.id } + } val reorderableLazyListState = rememberReorderableLazyListState( lazyListState = lazyListState, ) { from, to -> @@ -229,8 +233,9 @@ private fun CollectionReorderableList( state = lazyListState, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - itemsIndexed(collections, key = { _, collection -> collection.id }) { _, collection -> - ReorderableItem(reorderableLazyListState, key = collection.id) { isDragging -> + itemsIndexed(keyedCollections, key = { _, collection -> collection.lazyKey }) { _, keyedCollection -> + val collection = keyedCollection.value + ReorderableItem(reorderableLazyListState, key = keyedCollection.lazyKey) { isDragging -> val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) Surface( 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 b5baf26e..5f801327 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 @@ -19,9 +19,11 @@ import kotlinx.serialization.json.JsonElement import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title +import nuvio.composeapp.generated.resources.collections_import_error_collection_duplicate_id import nuvio.composeapp.generated.resources.collections_import_error_empty_json import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title +import nuvio.composeapp.generated.resources.collections_import_error_folder_duplicate_id import nuvio.composeapp.generated.resources.collections_import_error_invalid_json import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id @@ -128,6 +130,10 @@ object CollectionRepository { fun importFromJson(jsonString: String): Result> { return runCatching { + val validation = validateJson(jsonString) + if (!validation.valid) { + throw IllegalArgumentException(validation.error.orEmpty()) + } rawCollectionsJson = json.parseToJsonElement(jsonString) val imported = json.decodeFromString>(jsonString) _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported) @@ -145,87 +151,13 @@ object CollectionRepository { } return try { val collections = json.decodeFromString>(jsonString) - var totalFolders = 0 - collections.forEachIndexed { ci, c -> - if (c.id.isBlank()) { - return ValidationResult( - valid = false, - error = runBlocking { - getString(Res.string.collections_import_error_collection_blank_id, ci + 1) - }, - ) - } - if (c.title.isBlank()) { - return ValidationResult( - valid = false, - error = runBlocking { - getString(Res.string.collections_import_error_collection_blank_title, c.id) - }, - ) - } - c.folders.forEachIndexed { fi, f -> - if (f.id.isBlank()) { - return ValidationResult( - valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_folder_blank_id, - fi + 1, - c.title, - ) - }, - ) - } - if (f.title.isBlank()) { - return ValidationResult( - valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_folder_blank_title, - f.id, - c.title, - ) - }, - ) - } - f.resolvedSources.forEachIndexed { si, s -> - if (s.hasInvalidTraktListId()) { - return ValidationResult( - valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_trakt_list_id, - si + 1, - f.title, - ) - }, - ) - } - - val invalidAddon = !s.isTmdb && !s.isTrakt && - (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) - val invalidTmdb = s.isTmdb && - s.tmdbSourceType.isNullOrBlank() - if (invalidAddon || invalidTmdb) { - return ValidationResult( - valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_source_blank_fields, - si + 1, - f.title, - ) - }, - ) - } - } - totalFolders++ - } + validateImportModel(collections)?.let { error -> + return ValidationResult(valid = false, error = error.localizedMessage()) } ValidationResult( valid = true, collectionCount = collections.size, - folderCount = totalFolders, + folderCount = collections.sumOf { it.folders.size }, ) } catch (e: Exception) { ValidationResult( @@ -294,3 +226,78 @@ object CollectionRepository { rawCollectionsJson = it } } + +internal sealed interface CollectionImportModelError { + data class BlankCollectionId(val collectionIndex: Int) : CollectionImportModelError + data class DuplicateCollectionId(val collectionId: String) : CollectionImportModelError + data class BlankCollectionTitle(val collectionId: String) : CollectionImportModelError + data class BlankFolderId(val folderIndex: Int, val collectionTitle: String) : CollectionImportModelError + data class DuplicateFolderId(val folderId: String, val collectionTitle: String) : CollectionImportModelError + data class BlankFolderTitle(val folderId: String, val collectionTitle: String) : CollectionImportModelError + data class InvalidTraktListId(val sourceIndex: Int, val folderTitle: String) : CollectionImportModelError + data class BlankSourceFields(val sourceIndex: Int, val folderTitle: String) : CollectionImportModelError +} + +internal fun validateImportModel(collections: List): CollectionImportModelError? { + val collectionIds = mutableSetOf() + collections.forEachIndexed { ci, c -> + if (c.id.isBlank()) { + return CollectionImportModelError.BlankCollectionId(ci + 1) + } + if (!collectionIds.add(c.id)) { + return CollectionImportModelError.DuplicateCollectionId(c.id) + } + if (c.title.isBlank()) { + return CollectionImportModelError.BlankCollectionTitle(c.id) + } + + val folderIds = mutableSetOf() + c.folders.forEachIndexed { fi, f -> + if (f.id.isBlank()) { + return CollectionImportModelError.BlankFolderId(fi + 1, c.title) + } + if (!folderIds.add(f.id)) { + return CollectionImportModelError.DuplicateFolderId(f.id, c.title) + } + if (f.title.isBlank()) { + return CollectionImportModelError.BlankFolderTitle(f.id, c.title) + } + f.resolvedSources.forEachIndexed { si, s -> + if (s.hasInvalidTraktListId()) { + return CollectionImportModelError.InvalidTraktListId(si + 1, f.title) + } + + val invalidAddon = !s.isTmdb && !s.isTrakt && + (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) + val invalidTmdb = s.isTmdb && + s.tmdbSourceType.isNullOrBlank() + if (invalidAddon || invalidTmdb) { + return CollectionImportModelError.BlankSourceFields(si + 1, f.title) + } + } + } + } + return null +} + +private fun CollectionImportModelError.localizedMessage(): String = + runBlocking { + when (this@localizedMessage) { + is CollectionImportModelError.BlankCollectionId -> + getString(Res.string.collections_import_error_collection_blank_id, collectionIndex) + is CollectionImportModelError.DuplicateCollectionId -> + getString(Res.string.collections_import_error_collection_duplicate_id, collectionId) + is CollectionImportModelError.BlankCollectionTitle -> + getString(Res.string.collections_import_error_collection_blank_title, collectionId) + is CollectionImportModelError.BlankFolderId -> + getString(Res.string.collections_import_error_folder_blank_id, folderIndex, collectionTitle) + is CollectionImportModelError.DuplicateFolderId -> + getString(Res.string.collections_import_error_folder_duplicate_id, folderId, collectionTitle) + is CollectionImportModelError.BlankFolderTitle -> + getString(Res.string.collections_import_error_folder_blank_title, folderId, collectionTitle) + is CollectionImportModelError.InvalidTraktListId -> + getString(Res.string.collections_import_error_trakt_list_id, sourceIndex, folderTitle) + is CollectionImportModelError.BlankSourceFields -> + getString(Res.string.collections_import_error_source_blank_fields, sourceIndex, folderTitle) + } + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index 76aac768..e756a1f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -4,6 +4,7 @@ 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.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc @@ -117,6 +118,7 @@ object CollectionSyncService { val params = buildJsonObject { put("p_profile_id", profileId) put("p_collections_json", jsonElement) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_collections", params) log.d { "pushToRemote — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 5881ce2e..11ba0a6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -23,7 +23,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.material3.LocalRippleConfiguration -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -370,10 +370,9 @@ private fun PaginationLoadingFooter() { .padding(vertical = 8.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(28.dp), color = MaterialTheme.colorScheme.primary, - strokeWidth = 3.dp, ) } } @@ -393,10 +392,9 @@ private fun LoadingIndicator() { modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(32.dp), color = MaterialTheme.colorScheme.primary, - strokeWidth = 3.dp, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index de32bc32..f61818fa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -85,6 +85,7 @@ data class MetaVideo( val id: String, val title: String, val released: String? = null, + val available: Boolean = true, val thumbnail: String? = null, val seasonPoster: String? = null, val season: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 073ae7f7..769b9156 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -227,6 +227,7 @@ internal object MetaDetailsParser { id = id, title = title, released = video.string("released"), + available = video.boolean("available") ?: true, thumbnail = video.string("thumbnail"), seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"), season = video.int("season"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index b1654a89..687820da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -113,7 +114,7 @@ object MetaDetailsRepository { scope.launch { val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) - val manifests = findMetaManifests(type = type, id = metaLookupId) + val manifests = findReadyMetaManifests(type = type, id = metaLookupId) if (manifests.isEmpty()) { val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id) @@ -197,7 +198,7 @@ object MetaDetailsRepository { cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta } val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) - val manifests = findMetaManifests(type = type, id = metaLookupId) + val manifests = findReadyMetaManifests(type = type, id = metaLookupId) for (manifest in manifests) { val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) { @@ -215,6 +216,7 @@ object MetaDetailsRepository { } private const val FETCH_TIMEOUT_MS = 5_000L + private const val METADATA_PROVIDER_READY_TIMEOUT_MS = 10_000L private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L private const val MDBLIST_ENRICH_TIMEOUT_MS = 5_000L @@ -269,8 +271,27 @@ object MetaDetailsRepository { } } - private fun findMetaManifests(type: String, id: String): List = - AddonRepository.uiState.value.addons + private suspend fun findReadyMetaManifests(type: String, id: String): List { + AddonRepository.initialize() + + findMetaManifests(AddonRepository.uiState.value, type, id).takeIf { it.isNotEmpty() }?.let { return it } + + if (!AddonRepository.uiState.value.hasPendingEnabledAddonManifests()) { + return emptyList() + } + + val readyState = withTimeoutOrNull(METADATA_PROVIDER_READY_TIMEOUT_MS) { + AddonRepository.uiState.first { state -> + findMetaManifests(state, type, id).isNotEmpty() || + !state.hasPendingEnabledAddonManifests() + } + } ?: AddonRepository.uiState.value + + return findMetaManifests(readyState, type, id) + } + + private fun findMetaManifests(state: com.nuvio.app.features.addons.AddonsUiState, type: String, id: String): List = + state.addons .enabledAddons() .mapNotNull { it.manifest } .filter { manifest -> @@ -281,6 +302,9 @@ object MetaDetailsRepository { } } + private fun com.nuvio.app.features.addons.AddonsUiState.hasPendingEnabledAddonManifests(): Boolean = + addons.enabledAddons().any { addon -> addon.manifest == null && addon.isRefreshing } + private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String { val tmdbId = itemId .takeIf { it.startsWith("tmdb:", ignoreCase = true) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 56677608..5fc77649 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.Crossfade import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -33,9 +34,12 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlaylistAddCheckCircle import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -51,7 +55,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler @@ -65,9 +71,16 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.TrailerPlaybackMode import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay +import com.nuvio.app.core.ui.PosterZoomAnchor +import com.nuvio.app.core.ui.PosterZoomAnchorHolder +import com.nuvio.app.core.ui.PosterZoomOverlayAction import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.DetailSecondaryAction import com.nuvio.app.features.details.components.CommentDetailSheet @@ -111,6 +124,8 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import com.kmpalette.rememberDominantColorState +import com.kmpalette.extensions.painter.rememberPainterDominantColorState import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -159,10 +174,14 @@ fun MetaDetailsScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val watchProgressUiState by remember { WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState }.collectAsStateWithLifecycle() + val progressByVideoId = remember(watchProgressUiState.entries, id) { + watchProgressUiState.byVideoIdForContent(id) + } val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState @@ -171,6 +190,8 @@ fun MetaDetailsScreen( var autoLoadAttempted by remember(type, id) { mutableStateOf(false) } var observedOfflineState by remember(type, id) { mutableStateOf(false) } var selectedEpisodeForActions by remember(type, id) { mutableStateOf(null) } + var selectedEpisodeZoomAnchor by remember(type, id) { mutableStateOf(null) } + val episodeOverlayHazeState = rememberHazeState() var selectedSeasonForActions by remember(type, id) { mutableStateOf(null) } val commentsEnabled by remember { TraktCommentsSettings.ensureLoaded() @@ -302,9 +323,21 @@ fun MetaDetailsScreen( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - when { + Box( + modifier = Modifier + .fillMaxSize() + .then( + if (selectedEpisodeZoomAnchor != null) { + Modifier.hazeSource(state = episodeOverlayHazeState) + } else { + Modifier + }, + ) + .background(MaterialTheme.colorScheme.background), + ) { + when { displayedMeta == null && uiState.isLoading -> { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.align(Alignment.Center), color = MaterialTheme.colorScheme.primary, ) @@ -357,10 +390,11 @@ fun MetaDetailsScreen( ) { LibraryRepository.isSaved(meta.id, meta.type) } - val isWatched = remember(watchedUiState.watchedKeys, metaPreview) { + val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) { WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = metaPreview, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ) } val openLibraryListPicker = remember(meta) { @@ -400,9 +434,6 @@ fun MetaDetailsScreen( Unit } } - val progressByVideoId = remember(watchProgressUiState.entries) { - watchProgressUiState.byVideoId - } LaunchedEffect(meta.id, meta.type, watchProgressUiState.hasLoadedRemoteProgress) { if (meta.type.lowercase() in setOf("series", "show", "tv", "tvshow")) { WatchProgressRepository.refreshEpisodeProgress(meta.id) @@ -664,7 +695,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlay?.invoke( meta.type, @@ -693,7 +729,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlayManually?.invoke( meta.type, @@ -755,15 +796,66 @@ fun MetaDetailsScreen( ) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val colorScheme = MaterialTheme.colorScheme val isTablet = maxWidth >= 720.dp val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) - val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed + val backdropUrl = meta.background ?: meta.poster + val backgroundMode = metaScreenSettingsUiState.backgroundMode + val dominantColorEnabled = backgroundMode == MetaScreenBackgroundMode.DominantColor && + deferredMetaWorkAllowed && + !backdropUrl.isNullOrBlank() + var dominantBackdropPainter by remember(meta.id, backdropUrl) { + mutableStateOf(null) + } + var dominantBackdropImageBitmap by remember(meta.id, backdropUrl) { + mutableStateOf(null) + } + val dominantImageBitmapColorState = rememberDominantColorState( + defaultColor = colorScheme.background, + defaultOnColor = colorScheme.onBackground, + ) + val dominantPainterColorState = rememberPainterDominantColorState( + defaultColor = colorScheme.background, + defaultOnColor = colorScheme.onBackground, + ) + LaunchedEffect(dominantColorEnabled, dominantBackdropImageBitmap, dominantBackdropPainter) { + val imageBitmap = dominantBackdropImageBitmap + val painter = dominantBackdropPainter + if (dominantColorEnabled) { + when { + imageBitmap != null -> runCatching { + dominantImageBitmapColorState.updateFrom(imageBitmap) + } + painter != null -> runCatching { + dominantPainterColorState.updateFrom(painter) + } + } + } + } + val extractedDominantColor = if (dominantBackdropImageBitmap != null) { + dominantImageBitmapColorState.color + } else { + dominantPainterColorState.color + } + val dominantBackdropTargetColor = if (dominantColorEnabled) { + dominantBackdropBlendColor(extractedDominantColor, colorScheme.background) + } else { + colorScheme.background + } + val dominantBackdropColor by animateColorAsState( + targetValue = dominantBackdropTargetColor, + animationSpec = tween( + durationMillis = 320, + easing = LinearOutSlowInEasing, + ), + label = "detail_dominant_backdrop_color", + ) Box(modifier = Modifier.fillMaxSize()) { - if (cinematicEnabled) { - val backdropUrl = meta.background ?: meta.poster - if (backdropUrl != null) { + when (backgroundMode) { + MetaScreenBackgroundMode.Normal -> Unit + MetaScreenBackgroundMode.Cinematic -> if (deferredMetaWorkAllowed && backdropUrl != null) { AsyncImage( model = backdropUrl, contentDescription = null, @@ -775,7 +867,14 @@ fun MetaDetailsScreen( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.92f)), + .background(colorScheme.background.copy(alpha = 0.92f)), + ) + } + MetaScreenBackgroundMode.DominantColor -> if (deferredMetaWorkAllowed) { + Box( + modifier = Modifier + .fillMaxSize() + .background(dominantBackdropColor), ) } } @@ -797,6 +896,11 @@ fun MetaDetailsScreen( heroTrailerReady = heroTrailerReady, heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, heroTrailerMuted = heroTrailerMuted, + heroGradientColor = dominantBackdropColor.takeIf { dominantColorEnabled }, + onBackdropLoaded = { painter, imageBitmap -> + dominantBackdropPainter = painter + dominantBackdropImageBitmap = imageBitmap + }, onHeroTrailerMuteToggle = { HeroTrailerAudioState.toggleMuted() }, @@ -883,7 +987,10 @@ fun MetaDetailsScreen( watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, - onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onEpisodeLongPress = { video -> + selectedEpisodeZoomAnchor = PosterZoomAnchorHolder.consume() + selectedEpisodeForActions = video + }, onSeasonLongPress = { season -> selectedSeasonForActions = season }, onOpenMeta = onOpenMeta, onCastClick = onCastClick, @@ -897,8 +1004,9 @@ fun MetaDetailsScreen( } } - if (cinematicEnabled && heroHeightPx > 0) { - val blendColor = MaterialTheme.colorScheme.background + if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx > 0) { + val blendColor = dominantBackdropColor.takeIf { dominantColorEnabled } + ?: colorScheme.background Box( modifier = Modifier .zIndex(0.5f) @@ -936,12 +1044,15 @@ fun MetaDetailsScreen( meta = meta, isSaved = isSaved, progress = headerProgress, + backgroundColor = dominantBackdropColor.takeIf { dominantColorEnabled }, onBack = onBackFromDetails, onToggleSaved = toggleSaved, modifier = Modifier.zIndex(2f), ) - selectedEpisodeForActions?.let { selectedEpisode -> + selectedEpisodeForActions + ?.takeIf { selectedEpisodeZoomAnchor == null } + ?.let { selectedEpisode -> val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) { isEpisodeWatchedForActions( meta = meta, @@ -1183,6 +1294,155 @@ fun MetaDetailsScreen( contentColor = MaterialTheme.colorScheme.onBackground, ) } + } + + val meta = displayedMeta + val selectedEpisode = selectedEpisodeForActions + val zoomAnchor = selectedEpisodeZoomAnchor + if (meta != null && selectedEpisode != null && zoomAnchor != null) { + val todayIsoDate = CurrentDateProvider.todayIsoDate() + val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) { + isEpisodeWatchedForActions( + meta = meta, + episode = selectedEpisode, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) { + meta.previousReleasedEpisodesBefore( + target = selectedEpisode, + todayIsoDate = todayIsoDate, + ) + } + val seasonEpisodes = remember(meta, selectedEpisode, todayIsoDate) { + meta.releasedEpisodesForSeason( + seasonNumber = selectedEpisode.season, + todayIsoDate = todayIsoDate, + ) + } + val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, + episodes = previousEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, + episodes = seasonEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + val seasonLabel = selectedEpisode.season?.let { + stringResource(Res.string.episodes_season, it) + } ?: stringResource(Res.string.episodes_specials) + NuvioPosterZoomActionOverlay( + imageUrl = zoomAnchor.imageUrl ?: selectedEpisode.thumbnail ?: meta.background ?: meta.poster, + title = selectedEpisode.title, + subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel, + isWatched = isSelectedEpisodeWatched, + anchor = zoomAnchor, + actions = buildList { + add( + PosterZoomOverlayAction( + icon = Icons.Default.CheckCircle, + label = if (isSelectedEpisodeWatched) { + stringResource(Res.string.episode_mark_unwatched) + } else { + stringResource(Res.string.episode_mark_watched) + }, + onSelected = { + WatchingActions.toggleEpisodeWatched( + meta = meta, + episode = selectedEpisode, + isCurrentlyWatched = isSelectedEpisodeWatched, + ) + }, + ), + ) + if (previousEpisodes.isNotEmpty()) { + add( + PosterZoomOverlayAction( + icon = Icons.Default.DoneAll, + label = if (arePreviousEpisodesWatched) { + stringResource(Res.string.episode_mark_previous_unwatched) + } else { + stringResource(Res.string.episode_mark_previous_watched) + }, + onSelected = { + WatchingActions.togglePreviousEpisodesWatched( + meta = meta, + episodes = previousEpisodes, + areCurrentlyWatched = arePreviousEpisodesWatched, + ) + }, + ), + ) + } + add( + PosterZoomOverlayAction( + icon = Icons.Default.PlaylistAddCheckCircle, + label = if (isSeasonWatched) { + stringResource(Res.string.episode_mark_season_unwatched, seasonLabel) + } else { + stringResource(Res.string.episode_mark_season_watched, seasonLabel) + }, + onSelected = { + WatchingActions.toggleSeasonWatched( + meta = meta, + episodes = seasonEpisodes, + areCurrentlyWatched = isSeasonWatched, + ) + }, + ), + ) + if (onPlayManually != null && StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState)) { + add( + PosterZoomOverlayAction( + icon = Icons.Default.PlayArrow, + label = stringResource(Res.string.play_manually), + onSelected = { + val playbackVideoId = buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = selectedEpisode.season, + episodeNumber = selectedEpisode.episode, + fallbackVideoId = selectedEpisode.id, + ) + val streamVideoId = selectedEpisode.id.takeIf { it.isNotBlank() } ?: playbackVideoId + val savedProgress = progressByVideoId[streamVideoId] + ?.takeUnless { it.isCompleted } + onPlayManually.invoke( + meta.type, + streamVideoId, + meta.id, + meta.type, + meta.name, + meta.logo, + meta.poster, + meta.background, + selectedEpisode.season, + selectedEpisode.episode, + selectedEpisode.title, + selectedEpisode.thumbnail, + selectedEpisode.overview, + savedProgress?.lastPositionMs, + ) + }, + ), + ) + } + }, + hazeState = episodeOverlayHazeState, + onDismissed = { + selectedEpisodeForActions = null + selectedEpisodeZoomAnchor = null + }, + ) + } } } @@ -1794,3 +2054,16 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private fun dominantBackdropBlendColor(dominantColor: Color, backgroundColor: Color): Color = + backgroundColor.blendTowards(dominantColor, fraction = 0.42f) + +private fun Color.blendTowards(target: Color, fraction: Float): Color { + val clamped = fraction.coerceIn(0f, 1f) + return Color( + red = red + (target.red - red) * clamped, + green = green + (target.green - green) * clamped, + blue = blue + (target.blue - blue) * clamped, + alpha = alpha + (target.alpha - alpha) * clamped, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index f056bbe0..9e0c298c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -42,6 +42,7 @@ data class MetaScreenSectionItem( data class MetaScreenSettingsUiState( val items: List = emptyList(), + val backgroundMode: MetaScreenBackgroundMode = MetaScreenBackgroundMode.Normal, val cinematicBackground: Boolean = false, val heroTrailerPlayback: Boolean = false, val tabLayout: Boolean = false, @@ -49,6 +50,34 @@ data class MetaScreenSettingsUiState( val blurUnwatchedEpisodes: Boolean = false, ) +enum class MetaScreenBackgroundMode { + Normal, + Cinematic, + DominantColor, + ; + + val usesBackdropBackground: Boolean + get() = this != Normal + + companion object { + fun parse(raw: String?): MetaScreenBackgroundMode? = when (raw?.lowercase()) { + "normal" -> Normal + "cinematic" -> Cinematic + "dominant_color" -> DominantColor + else -> null + } + + fun persist(mode: MetaScreenBackgroundMode): String = when (mode) { + Normal -> "normal" + Cinematic -> "cinematic" + DominantColor -> "dominant_color" + } + + fun fromLegacyCinematic(enabled: Boolean): MetaScreenBackgroundMode = + if (enabled) Cinematic else Normal + } +} + enum class MetaEpisodeCardStyle { Horizontal, List, @@ -79,6 +108,8 @@ private data class StoredMetaScreenSectionPreference( @Serializable private data class StoredMetaScreenSettingsPayload( val items: List = emptyList(), + @SerialName("background_mode") + val backgroundMode: String? = null, val cinematicBackground: Boolean = false, @SerialName("hero_trailer_playback") val heroTrailerPlayback: Boolean = false, @@ -159,7 +190,7 @@ object MetaScreenSettingsRepository { private var hasLoaded = false private var preferences: MutableMap = mutableMapOf() - private var cinematicBackground: Boolean = false + private var backgroundMode: MetaScreenBackgroundMode = MetaScreenBackgroundMode.Normal private var heroTrailerPlayback: Boolean = false private var tabLayout: Boolean = false private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal @@ -176,7 +207,8 @@ object MetaScreenSettingsRepository { json.decodeFromString(payload) }.getOrNull() if (parsed != null) { - cinematicBackground = parsed.cinematicBackground + backgroundMode = MetaScreenBackgroundMode.parse(parsed.backgroundMode) + ?: MetaScreenBackgroundMode.fromLegacyCinematic(parsed.cinematicBackground) heroTrailerPlayback = parsed.heroTrailerPlayback tabLayout = parsed.tabLayout episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle) @@ -197,7 +229,7 @@ object MetaScreenSettingsRepository { fun onProfileChanged() { hasLoaded = false preferences.clear() - cinematicBackground = false + backgroundMode = MetaScreenBackgroundMode.Normal heroTrailerPlayback = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal @@ -207,8 +239,12 @@ object MetaScreenSettingsRepository { } fun setCinematicBackground(enabled: Boolean) { + setBackgroundMode(MetaScreenBackgroundMode.fromLegacyCinematic(enabled)) + } + + fun setBackgroundMode(mode: MetaScreenBackgroundMode) { ensureLoaded() - cinematicBackground = enabled + backgroundMode = mode publish() persist() } @@ -257,7 +293,7 @@ object MetaScreenSettingsRepository { fun clearLocalState() { hasLoaded = false preferences.clear() - cinematicBackground = false + backgroundMode = MetaScreenBackgroundMode.Normal heroTrailerPlayback = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal @@ -272,9 +308,10 @@ object MetaScreenSettingsRepository { tabLayout: Boolean, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, blurUnwatchedEpisodes: Boolean = false, + backgroundMode: MetaScreenBackgroundMode? = null, ) { ensureLoaded() - this.cinematicBackground = cinematicBackground + this.backgroundMode = backgroundMode ?: MetaScreenBackgroundMode.fromLegacyCinematic(cinematicBackground) this.heroTrailerPlayback = heroTrailerPlayback this.tabLayout = tabLayout this.episodeCardStyle = episodeCardStyle @@ -301,7 +338,7 @@ object MetaScreenSettingsRepository { fun resetToDefaults() { ensureLoaded() preferences.clear() - cinematicBackground = false + backgroundMode = MetaScreenBackgroundMode.Normal heroTrailerPlayback = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal @@ -369,7 +406,8 @@ object MetaScreenSettingsRepository { tabGroup = preference?.tabGroup, ) }, - cinematicBackground = cinematicBackground, + backgroundMode = backgroundMode, + cinematicBackground = backgroundMode.usesBackdropBackground, heroTrailerPlayback = heroTrailerPlayback, tabLayout = tabLayout, episodeCardStyle = episodeCardStyle, @@ -382,7 +420,8 @@ object MetaScreenSettingsRepository { json.encodeToString( StoredMetaScreenSettingsPayload( items = preferences.values.sortedBy { it.order }, - cinematicBackground = cinematicBackground, + backgroundMode = MetaScreenBackgroundMode.persist(backgroundMode), + cinematicBackground = backgroundMode.usesBackdropBackground, heroTrailerPlayback = heroTrailerPlayback, tabLayout = tabLayout, episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 5da68dc8..87da802c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -73,6 +73,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import com.nuvio.app.navigation.LocalUseNativeNavigation private sealed interface PersonDetailUiState { data object Loading : PersonDetailUiState @@ -146,19 +147,20 @@ fun PersonDetailScreen( ) } - // Back button overlaid on top - IconButton( - onClick = onBack, - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .padding(start = 4.dp, top = 4.dp) - .align(Alignment.TopStart), - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(Res.string.action_back), - tint = MaterialTheme.colorScheme.onSurface, - ) + if (!LocalUseNativeNavigation.current) { + IconButton( + onClick = onBack, + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 4.dp, top = 4.dp) + .align(Alignment.TopStart), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } } @@ -523,15 +525,15 @@ private fun PersonIdentitySidebar( Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { person.birthday?.let { birthday -> PersonSidebarFact( - label = "Born", + label = stringResource(Res.string.person_detail_born), value = personBirthLine(birthday = birthday, deathday = person.deathday), ) } person.placeOfBirth?.takeIf { it.isNotBlank() }?.let { place -> - PersonSidebarFact(label = "Place of birth", value = place) + PersonSidebarFact(label = stringResource(Res.string.person_detail_place_of_birth), value = place) } if (creditSummary.isNotBlank()) { - PersonSidebarFact(label = "Credits", value = creditSummary) + PersonSidebarFact(label = stringResource(Res.string.person_detail_credits), value = creditSummary) } } @@ -543,7 +545,7 @@ private fun PersonIdentitySidebar( .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), ) Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { - SidebarLabel(text = "Biography") + SidebarLabel(text = stringResource(Res.string.person_detail_biography)) Text( text = biography, style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), 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 9e655f77..296ea3e4 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 @@ -32,7 +32,7 @@ internal fun List.filterUnavailableFutureSeasons( val firstEpisode = episodes.minWithOrNull( compareBy({ it.episode ?: Int.MAX_VALUE }, { it.released.orEmpty() }), ) ?: return@filter false - !isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = firstEpisode.released) + !firstEpisode.isReleasedBy(todayIsoDate) } .keys @@ -48,7 +48,7 @@ internal fun MetaDetails.firstPlayableEpisode(): MetaVideo? = internal fun MetaDetails.firstReleasedPlayableEpisode(todayIsoDate: String): MetaVideo? = sortedPlayableEpisodes().firstOrNull { video -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = video.released) + video.isReleasedBy(todayIsoDate) } internal fun MetaDetails.nextReleasedEpisodeAfter( @@ -122,6 +122,7 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( todayIsoDate = todayIsoDate, releasedDate = episode.released, showUnairedNextUp = showUnairedNextUp, + available = episode.available, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 } @@ -181,7 +182,11 @@ internal fun WatchProgressEntry.resumeLabel(): String = resumeLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) internal fun MetaVideo.isReleasedBy(todayIsoDate: String): Boolean = - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) + isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = released, + available = available, + ) internal data class CompletedSeriesEpisode( val seasonNumber: Int, @@ -209,6 +214,7 @@ private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode = title = title, thumbnail = thumbnail, releasedDate = released, + available = available, ) private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 38d10fe3..1f4ceef7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth @@ -61,6 +62,7 @@ import com.nuvio.app.features.tmdb.TmdbEntityMediaType import com.nuvio.app.features.tmdb.TmdbEntityRailType import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.navigation.LocalUseNativeNavigation private sealed interface EntityBrowseUiState { data object Loading : EntityBrowseUiState @@ -125,18 +127,20 @@ fun TmdbEntityBrowseScreen( } } - IconButton( - onClick = onBack, - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .padding(start = 4.dp, top = 4.dp) - .align(Alignment.TopStart), - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(Res.string.action_back), - tint = MaterialTheme.colorScheme.onSurface, - ) + if (!LocalUseNativeNavigation.current) { + IconButton( + onClick = onBack, + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 4.dp, top = 4.dp) + .align(Alignment.TopStart), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } } @@ -404,15 +408,15 @@ private fun EntityIdentitySidebar( Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { header.originCountry?.takeIf { it.isNotBlank() }?.let { country -> - EntitySidebarFact(label = "Country", value = country) + EntitySidebarFact(label = stringResource(Res.string.entity_browse_country), value = country) } header.secondaryLabel?.takeIf { it.isNotBlank() }?.let { label -> - EntitySidebarFact(label = "Type", value = label) + EntitySidebarFact(label = stringResource(Res.string.entity_browse_type), value = label) } if (catalogueCount > 0) { EntitySidebarFact( - label = "Catalogue", - value = "$catalogueCount ${if (catalogueCount == 1) "title" else "titles"}", + label = stringResource(Res.string.entity_browse_catalogue), + value = pluralStringResource(Res.plurals.entity_browse_title_count, catalogueCount, catalogueCount), ) } } @@ -425,7 +429,7 @@ private fun EntityIdentitySidebar( .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), ) Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { - EntitySidebarLabel(text = "About") + EntitySidebarLabel(text = stringResource(Res.string.entity_browse_about)) Text( text = description, style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.kt new file mode 100644 index 00000000..1fac58b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.ui.graphics.ImageBitmap +import coil3.request.SuccessResult + +internal expect fun loadedBackdropImageBitmap(result: SuccessResult): ImageBitmap? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 306152a5..34de39b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest +import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.castAvatarSharedTransitionKey import nuvio.composeapp.generated.resources.* @@ -143,6 +145,10 @@ private fun CastItem( .background( color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape, + ) + .nuvioCardDepth( + shape = CircleShape, + surface = NuvioCardDepthSurface.Cast, ), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 1b7ecaf8..b5cec37a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -39,8 +39,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.platformPhysicalTopInset import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.isIos +import com.nuvio.app.navigation.LocalUseNativeNavigation import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -49,14 +51,20 @@ fun DetailFloatingHeader( meta: MetaDetails, isSaved: Boolean, progress: Float, + backgroundColor: Color? = null, onBack: () -> Unit, onToggleSaved: () -> Unit, modifier: Modifier = Modifier, ) { - val safeAreaTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val useNativeNavigation = LocalUseNativeNavigation.current + val safeAreaTop = if (useNativeNavigation) { + platformPhysicalTopInset() + } else { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + } val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f) val interactive = progress > 0.05f - val surfaceColor = if (isIos) { + val surfaceColor = backgroundColor ?: if (isIos) { MaterialTheme.colorScheme.surface.copy(alpha = 1.0f) } else { MaterialTheme.colorScheme.background @@ -92,7 +100,7 @@ fun DetailFloatingHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - if (interactive) { + if (interactive && !useNativeNavigation) { NuvioBackButton( onClick = onBack, modifier = Modifier.size(40.dp), @@ -102,6 +110,9 @@ fun DetailFloatingHeader( iconSize = 24.dp, ) } else { + // Native iOS navigation owns the back button, but retaining + // this slot keeps the Compose logo centered as the floating + // header replaces the hero while scrolling. Box(modifier = Modifier.size(40.dp)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 8e502f6f..32d6dd33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -39,8 +39,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -62,6 +64,8 @@ fun DetailHero( heroTrailerReady: Boolean = false, heroTrailerPlayWhenReady: Boolean = false, heroTrailerMuted: Boolean = true, + heroGradientColor: Color? = null, + onBackdropLoaded: (Painter, ImageBitmap?) -> Unit = { _, _ -> }, onHeroTrailerMuteToggle: () -> Unit = {}, onHeroTrailerReady: () -> Unit = {}, onHeroTrailerEnded: () -> Unit = {}, @@ -81,6 +85,7 @@ fun DetailHero( val heroChromeTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp + ((40.dp - muteIconSize) / 2) + val bottomGradientColor = heroGradientColor ?: MaterialTheme.colorScheme.background var logoLoadError by remember(meta.id, meta.logo) { mutableStateOf(false) } @@ -111,9 +116,15 @@ fun DetailHero( translationY = scrollOffset * 0.5f scaleX = 1.08f scaleY = 1.08f - }, + }, alignment = if (isTablet) Alignment.TopCenter else Alignment.Center, contentScale = ContentScale.Crop, + onSuccess = { state -> + onBackdropLoaded( + state.painter, + loadedBackdropImageBitmap(state.result), + ) + }, ) } else { Box( @@ -190,12 +201,12 @@ fun DetailHero( Brush.verticalGradient( colorStops = arrayOf( 0.00f to Color.Transparent, - 0.16f to MaterialTheme.colorScheme.background.copy(alpha = 0.04f), - 0.32f to MaterialTheme.colorScheme.background.copy(alpha = 0.14f), - 0.50f to MaterialTheme.colorScheme.background.copy(alpha = 0.34f), - 0.68f to MaterialTheme.colorScheme.background.copy(alpha = 0.62f), - 0.84f to MaterialTheme.colorScheme.background.copy(alpha = 0.84f), - 1.00f to MaterialTheme.colorScheme.background, + 0.16f to bottomGradientColor.copy(alpha = 0.04f), + 0.32f to bottomGradientColor.copy(alpha = 0.14f), + 0.50f to bottomGradientColor.copy(alpha = 0.34f), + 0.68f to bottomGradientColor.copy(alpha = 0.62f), + 0.84f to bottomGradientColor.copy(alpha = 0.84f), + 1.00f to bottomGradientColor, ), ), ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 3601b1d0..039e10bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -64,7 +64,10 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioProgressBar +import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaVideo @@ -662,24 +665,25 @@ private fun EpisodeHorizontalCard( val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) } val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } } val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) } + val imageUrl = video.thumbnail ?: fallbackImage Box( modifier = Modifier .width(metrics.cardWidth) .height(metrics.cardHeight) .clip(cardShape) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.12f), + .nuvioCardDepth( shape = cardShape, + surface = NuvioCardDepthSurface.EpisodeCards, + fallbackBorderAlpha = 0.12f, ) - .combinedClickable( - enabled = onClick != null || onLongPress != null, - onClick = { onClick?.invoke() }, + .posterCardClickable( + onClick = onClick, onLongClick = onLongPress, + zoomImageUrl = imageUrl, + zoomCornerRadius = metrics.cornerRadius, ), ) { - val imageUrl = video.thumbnail ?: fallbackImage val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched if (imageUrl != null) { AsyncImage( @@ -697,12 +701,12 @@ private fun EpisodeHorizontalCard( .fillMaxSize() .background( Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.10f), - Color.Black.copy(alpha = 0.42f), - Color.Black.copy(alpha = 0.78f), - ), + 0f to Color.Transparent, + 0.42f to Color.Transparent, + 0.56f to Color.Black.copy(alpha = 0.20f), + 0.70f to Color.Black.copy(alpha = 0.45f), + 0.84f to Color.Black.copy(alpha = 0.68f), + 1f to Color.Black.copy(alpha = 0.92f), ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index e9ef5fa8..5ca4fa96 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.features.details.MetaTrailer import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.detail_tab_trailer @@ -193,6 +195,10 @@ private fun TrailerCard( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(cornerRadius)) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadius), + surface = NuvioCardDepthSurface.Trailers, + ) .clickable(onClick = onClick), ) { AsyncImage( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt index 09976eba..5cb22bf3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.Icons -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -140,7 +140,7 @@ fun TrailerPlayerPopup( ) { when { isLoading -> { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + NuvioLoadingIndicator(color = MaterialTheme.colorScheme.primary) } activeError != null -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt deleted file mode 100644 index 7abe984b..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.nuvio.app.features.dev - -import androidx.compose.foundation.BorderStroke -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -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.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.auth.AuthRepository -import com.nuvio.app.core.build.AppFeaturePolicy -import com.nuvio.app.core.network.NetworkStatusRepository -import com.nuvio.app.core.network.SYNC_BACKEND_HOSTED_ID -import com.nuvio.app.core.network.SYNC_BACKEND_NUVIO_ID -import com.nuvio.app.core.network.SupabaseProvider -import com.nuvio.app.core.network.SyncBackendConfig -import com.nuvio.app.core.network.SyncBackendRepository -import com.nuvio.app.core.network.hasSameConnectionIdentity -import com.nuvio.app.core.ui.NuvioStatusModal -import com.nuvio.app.core.ui.NuvioTokens -import com.nuvio.app.core.ui.nuvio -import kotlinx.coroutines.launch -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.action_cancel -import nuvio.composeapp.generated.resources.action_switch -import nuvio.composeapp.generated.resources.debug_backend_switch_confirm_message -import nuvio.composeapp.generated.resources.debug_backend_switch_confirm_title -import nuvio.composeapp.generated.resources.settings_account_sync_backend -import org.jetbrains.compose.resources.stringResource - -internal fun shouldShowDebugSyncBackendSwitch(): Boolean = - AppFeaturePolicy.debugBackendSwitcherEnabled && - SyncBackendRepository.debugSelectableBackends().size >= 2 - -@Composable -internal fun DebugSyncBackendSwitch( - modifier: Modifier = Modifier, - requireConfirmation: Boolean, - container: Boolean = false, -) { - if (!shouldShowDebugSyncBackendSwitch()) return - - val backendState by SyncBackendRepository.state.collectAsStateWithLifecycle() - val coroutineScope = rememberCoroutineScope() - val selectableBackends = remember { SyncBackendRepository.debugSelectableBackends() } - val hostedBackend = selectableBackends.firstOrNull { backend -> backend.id == SYNC_BACKEND_HOSTED_ID } - ?: return - val nuvioBackend = selectableBackends.firstOrNull { backend -> backend.id == SYNC_BACKEND_NUVIO_ID } - ?: return - val selectedBackend = backendState.selectedBackend - val nuvioSelected = selectedBackend.id == SYNC_BACKEND_NUVIO_ID - val targetBackend = if (nuvioSelected) hostedBackend else nuvioBackend - val tokens = MaterialTheme.nuvio - var pendingBackend by remember { mutableStateOf(null) } - var isSwitching by remember { mutableStateOf(false) } - - fun switchToBackend(backend: SyncBackendConfig) { - if (isSwitching || selectedBackend.hasSameConnectionIdentity(backend)) return - - isSwitching = true - coroutineScope.launch { - AuthRepository.resetForSyncBackendChange() - .onSuccess { - val appliedBackend = SyncBackendRepository.applyDebugBackendAfterLogout(backend) - if (appliedBackend != null) { - SupabaseProvider.rebuildClient() - NetworkStatusRepository.requestRefresh(force = true) - } - } - pendingBackend = null - isSwitching = false - } - } - - fun requestBackendSwitch(backend: SyncBackendConfig) { - if (selectedBackend.hasSameConnectionIdentity(backend)) return - - if (requireConfirmation) { - pendingBackend = backend - } else { - switchToBackend(backend) - } - } - - val content: @Composable () -> Unit = { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isSwitching) { requestBackendSwitch(targetBackend) } - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Text( - text = stringResource(Res.string.settings_account_sync_backend), - style = MaterialTheme.typography.bodyMedium, - color = tokens.colors.textMuted, - ) - Text( - text = selectedBackend.displayName, - style = MaterialTheme.typography.bodyLarge, - color = tokens.colors.textPrimary, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (isSwitching) { - CircularProgressIndicator( - color = tokens.colors.accent, - strokeWidth = NuvioTokens.Border.medium, - ) - } else { - Switch( - checked = nuvioSelected, - onCheckedChange = { checked -> - requestBackendSwitch(if (checked) nuvioBackend else hostedBackend) - }, - colors = SwitchDefaults.colors( - checkedThumbColor = tokens.colors.onAccent, - checkedTrackColor = tokens.colors.accent, - uncheckedThumbColor = tokens.colors.textMuted, - uncheckedTrackColor = tokens.colors.borderDefault, - ), - ) - } - } - } - - if (container) { - Surface( - modifier = modifier.fillMaxWidth(), - color = tokens.colors.surface, - shape = tokens.shapes.compactCard, - border = BorderStroke(tokens.borders.hairline, tokens.colors.borderSubtle), - ) { - content() - } - } else { - Row(modifier = modifier.fillMaxWidth()) { - content() - } - } - - pendingBackend?.let { backend -> - NuvioStatusModal( - title = stringResource(Res.string.debug_backend_switch_confirm_title), - message = stringResource( - Res.string.debug_backend_switch_confirm_message, - backend.displayName, - ), - isVisible = true, - isBusy = isSwitching, - confirmText = stringResource(Res.string.action_switch), - dismissText = stringResource(Res.string.action_cancel), - onConfirm = { switchToBackend(backend) }, - onDismiss = { pendingBackend = null }, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt index 9fb32ced..077b8530 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -23,4 +23,6 @@ internal expect object DownloadsPlatformDownloader { fun removePartialFile(destinationFileName: String): Boolean fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? + + fun openDownloadsDirectory(): Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index b1fe7e33..ef6a1eb2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Refresh @@ -38,6 +39,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader +import com.nuvio.app.core.ui.NuvioToastController import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -45,13 +47,17 @@ import org.jetbrains.compose.resources.stringResource fun DownloadsScreen( onBack: () -> Unit, onOpenDownload: (DownloadItem) -> Unit, + initialShowId: String? = null, + onNavigateToShow: ((showId: String, title: String) -> Unit)? = null, + onBackFromShow: (() -> Unit)? = null, ) { val uiState by remember { DownloadsRepository.ensureLoaded() DownloadsRepository.uiState }.collectAsStateWithLifecycle() - var selectedShowId by rememberSaveable { mutableStateOf(null) } + var selectedShowId by rememberSaveable(initialShowId) { mutableStateOf(initialShowId) } + val openDownloadsDirectoryFailedText = stringResource(Res.string.downloads_open_directory_failed) val completedEpisodes = remember(uiState.items) { uiState.completedItems @@ -75,11 +81,25 @@ fun DownloadsScreen( }, onBack = { if (selectedShowId != null) { - selectedShowId = null + onBackFromShow?.invoke() ?: run { selectedShowId = null } } else { onBack() } }, + actions = { + IconButton( + onClick = { + if (!DownloadsPlatformDownloader.openDownloadsDirectory()) { + NuvioToastController.show(openDownloadsDirectoryFailedText) + } + }, + ) { + Icon( + imageVector = Icons.Rounded.Folder, + contentDescription = stringResource(Res.string.downloads_open_directory), + ) + } + }, ) } @@ -87,7 +107,9 @@ fun DownloadsScreen( downloadsRootContent( uiState = uiState, onOpenDownload = onOpenDownload, - onOpenShow = { showId -> selectedShowId = showId }, + onOpenShow = { showId, title -> + onNavigateToShow?.invoke(showId, title) ?: run { selectedShowId = showId } + }, ) } else { downloadsShowContent( @@ -102,7 +124,7 @@ fun DownloadsScreen( private fun LazyListScope.downloadsRootContent( uiState: DownloadsUiState, onOpenDownload: (DownloadItem) -> Unit, - onOpenShow: (String) -> Unit, + onOpenShow: (showId: String, title: String) -> Unit, ) { val activeItems = uiState.activeItems val completedMovies = uiState.completedItems.filterNot(DownloadItem::isEpisode) @@ -166,7 +188,7 @@ private fun LazyListScope.downloadsRootContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp) - .clickable { onOpenShow(item.parentMetaId) }, + .clickable { onOpenShow(item.parentMetaId, item.title) }, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainer, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt index 853b42a4..53d8f035 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.home import com.nuvio.app.core.i18n.localizedMediaTypeLabel +import com.nuvio.app.features.addons.AddonCatalog +import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.catalog.supportsPagination @@ -17,7 +19,21 @@ data class HomeCatalogDefinition( val type: String, val catalogId: String, val supportsPagination: Boolean, -) + val descriptorSignature: String, +) { + val cacheKey: String + get() = "$key|$descriptorSignature" +} + +fun buildHomeCatalogRefreshSignature(addons: List): List = + addons.enabledAddons().mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + addon to manifest + }.flatMap { (addon, manifest) -> + manifest.catalogs.map { catalog -> + buildHomeCatalogDescriptorSignature(addon, manifest, catalog) + } + }.sorted() fun buildHomeCatalogDefinitions(addons: List): List = addons.enabledAddons().mapNotNull { addon -> @@ -41,8 +57,76 @@ fun buildHomeCatalogDefinitions(addons: List): List + listOf( + resource.name, + resource.types.joinToString("/"), + resource.idPrefixes.joinToString("/"), + ).joinToString(":") + }) + append('|') + append( + listOf( + manifest.behaviorHints.configurable, + manifest.behaviorHints.configurationRequired, + manifest.behaviorHints.adult, + manifest.behaviorHints.p2p, + ).joinToString(":"), + ) + append('|') + append(catalog.type) + append('|') + append(catalog.id) + append('|') + append(catalog.name) + append('|') + append(catalog.supportsPagination()) + append('|') + append(catalog.extra.joinToString(",") { extra -> + listOf( + extra.name, + extra.isRequired.toString(), + extra.options.joinToString("/"), + extra.optionsLimit?.toString().orEmpty(), + ).joinToString(":") + }) + } + internal fun String.displayLabel(): String = localizedMediaTypeLabel(this) 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 067502d3..de504b5e 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 @@ -393,7 +393,7 @@ object HomeCatalogSettingsRepository { transform: (StoredHomeCatalogPreference) -> StoredHomeCatalogPreference, ) { ensureLoaded() - val current = preferences[key] ?: return + val current = preferences[key] ?: defaultPreferenceForMissingKey(key) ?: return val updated = transform(current) if (updated == current) return preferences[key] = updated @@ -442,9 +442,12 @@ object HomeCatalogSettingsRepository { fun exportToSyncPayload(): SyncHomeCatalogPayload { ensureLoaded() + val catalogDefinitionsByKey = definitions.associateBy { it.key } + val collectionDefinitionsByKey = collectionDefinitions.associateBy { it.key } val items = preferences.values.sortedBy { it.order }.map { pref -> - val parts = pref.key.split(":") - val isCollection = pref.key.startsWith("collection_") + val catalogDefinition = catalogDefinitionsByKey[pref.key] + val collectionDefinition = collectionDefinitionsByKey[pref.key] + val isCollection = collectionDefinition != null || pref.key.startsWith("collection_") if (isCollection) { SyncCatalogItem( addonId = "", @@ -454,17 +457,20 @@ object HomeCatalogSettingsRepository { order = pref.order, customTitle = pref.customTitle, isCollection = true, - collectionId = pref.key.removePrefix("collection_"), + collectionId = collectionDefinition?.collectionId ?: pref.key.removePrefix("collection_"), + key = pref.key, ) } else { + val legacyParts = pref.key.split(':', limit = 3) SyncCatalogItem( - addonId = parts.getOrElse(0) { "" }, - type = parts.getOrElse(1) { "" }, - catalogId = parts.getOrElse(2) { "" }, + addonId = catalogDefinition?.addonIdForSync() ?: legacyParts.getOrElse(0) { "" }, + type = catalogDefinition?.type ?: legacyParts.getOrElse(1) { "" }, + catalogId = catalogDefinition?.catalogId ?: legacyParts.getOrElse(2) { "" }, enabled = pref.enabled, order = pref.order, customTitle = pref.customTitle, isCollection = false, + key = pref.key, ) } } @@ -481,12 +487,8 @@ object HomeCatalogSettingsRepository { hideCatalogUnderline = payload.hideCatalogUnderline if (payload.items.isNotEmpty()) { val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } - preferences = payload.items.associate { item -> - val key = if (item.isCollection) { - "collection_${item.collectionId}" - } else { - "${item.addonId}:${item.type}:${item.catalogId}" - } + val remotePreferences = payload.items.associate { item -> + val key = item.preferenceKey() key to StoredHomeCatalogPreference( key = key, customTitle = item.customTitle, @@ -494,7 +496,14 @@ object HomeCatalogSettingsRepository { heroSourceEnabled = existingHeroState[key] ?: true, order = item.order, ) - }.toMutableMap() + } + val remoteKeys = remotePreferences.keys + val knownKeys = knownPreferenceKeys() + val preservedPreferences = preferences.filterKeys { key -> + key !in remoteKeys && (key in knownKeys || key.requiresExplicitSyncKey()) + } + preferences = (preservedPreferences + remotePreferences).toMutableMap() + normalizePreferences() } hasLoaded = true publish() @@ -532,6 +541,43 @@ object HomeCatalogSettingsRepository { preferences[itemKey] = current.copy(order = index) } } + + private fun defaultPreferenceForMissingKey(key: String): StoredHomeCatalogPreference? { + val isCollection = collectionDefinitions.any { it.key == key } + val isCatalog = definitions.any { it.key == key } + if (!isCollection && !isCatalog) return null + + return StoredHomeCatalogPreference( + key = key, + enabled = true, + heroSourceEnabled = isCatalog && + selectedHeroSourceCount(excludingKey = key) < HERO_SOURCE_SELECTION_LIMIT, + order = _uiState.value.items.firstOrNull { it.key == key }?.order + ?: ((preferences.values.maxOfOrNull { it.order } ?: -1) + 1), + ) + } + + private fun knownPreferenceKeys(): Set = + definitions.mapTo(mutableSetOf()) { it.key }.also { keys -> + keys.addAll(collectionDefinitions.map { it.key }) + } + + private fun HomeCatalogDefinition.addonIdForSync(): String { + val suffix = ":$type:$catalogId" + return key.removeSuffix(suffix) + } + + private fun SyncCatalogItem.preferenceKey(): String = + key.ifBlank { + if (isCollection) { + "collection_$collectionId" + } else { + "$addonId:$type:$catalogId" + } + } + + private fun String.requiresExplicitSyncKey(): Boolean = + !startsWith("collection_") && count { it == ':' } > 2 } internal data class CollectionCatalogDefinition( 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 15f5ffc7..e7604ccb 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 @@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc @@ -34,6 +35,7 @@ data class SyncCatalogItem( @SerialName("custom_title") val customTitle: String = "", @SerialName("is_collection") val isCollection: Boolean = false, @SerialName("collection_id") val collectionId: String = "", + val key: String = "", ) @Serializable @@ -136,6 +138,7 @@ object HomeCatalogSettingsSyncService { put("p_profile_id", profileId) put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM) put("p_settings_json", jsonElement) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) log.d { "pushToRemote — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index e0711eb1..bd1c424b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -34,7 +34,7 @@ object HomeRepository { private var activeJob: Job? = null private var activeRequestKey: String? = null - private var lastRequestKey: String? = null + private var completedRequestKey: String? = null private var currentDefinitions: List = emptyList() private var cachedSections: Map = emptyMap() private var cachedCollectionHeroItems: List = emptyList() @@ -47,27 +47,30 @@ object HomeRepository { val activeAddons = addons.enabledAddons() val requests = buildHomeCatalogDefinitions(activeAddons) currentDefinitions = requests - val requestKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::key) - cachedSections = cachedSections.filterKeys(requestKeys::contains) - val requestKey = requests.joinToString(separator = "|") { request -> - "${request.manifestUrl}:${request.type}:${request.catalogId}" - } + val requestCacheKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::cacheKey) + cachedSections = cachedSections.filterKeys(requestCacheKeys::contains) + val requestKey = requests.joinToString(separator = "|", transform = HomeCatalogDefinition::cacheKey) if (!force && activeRequestKey == requestKey && _uiState.value.isLoading) return - if (!force && requestKey == lastRequestKey && requestKeys.all(cachedSections::containsKey)) { + if ( + !force && + requestKey == completedRequestKey && + requestCacheKeys.all(cachedSections::containsKey) && + requestCacheKeys.any(::hasRenderableCachedSection) + ) { if (_uiState.value.sections.isEmpty() || _uiState.value.heroItems.isEmpty()) { applyCurrentSettings() } return } - lastRequestKey = requestKey activeRequestKey = requestKey if (requests.isEmpty()) { activeJob?.cancel() activeJob = null activeRequestKey = null + completedRequestKey = requestKey cachedSections = emptyMap() lastErrorMessage = null publishCurrentState( @@ -90,7 +93,7 @@ object HomeRepository { snapshot = HomeCatalogSettingsRepository.snapshot(), ) val pendingRequests = prioritizedRequests.filter { definition -> - force || cachedSections[definition.key] == null + force || cachedSections[definition.cacheKey] == null } if (pendingRequests.isEmpty()) { publishCurrentState( @@ -108,16 +111,20 @@ object HomeRepository { pendingRequests.chunked(HOME_CATALOG_FETCH_BATCH_SIZE).forEach { batch -> if (activeRequestKey != requestKey) return@launch val results = batch.map { request -> - async { runCatching { request.toSection() } } + async { request to runCatching { request.toSection() } } }.awaitAll() if (activeRequestKey != requestKey) return@launch - results.mapNotNull { it.getOrNull() }.forEach { section -> - loadedSections[section.key] = section + results.mapNotNull { (request, result) -> + result.getOrNull()?.let { section -> request.cacheKey to section } + }.forEach { (cacheKey, section) -> + loadedSections[cacheKey] = section } if (firstErrorMessage == null) { - firstErrorMessage = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } + firstErrorMessage = results.firstNotNullOfOrNull { (_, result) -> + result.exceptionOrNull()?.message + } } cachedSections = loadedSections.toMap() lastErrorMessage = firstErrorMessage @@ -134,6 +141,10 @@ object HomeRepository { cachedSections = loadedSections.toMap() lastErrorMessage = firstErrorMessage + if (cachedSections.values.any { section -> section.items.isNotEmpty() }) { + completedRequestKey = requestKey + } + activeRequestKey = null publishCurrentState( isLoading = false, requestKey = requestKey, @@ -149,12 +160,12 @@ object HomeRepository { fun applyCurrentSettings() { publishCurrentState( isLoading = _uiState.value.isLoading, - requestKey = activeRequestKey ?: lastRequestKey, + requestKey = activeRequestKey ?: completedRequestKey, ) ensureCollectionHeroFallback( addons = AddonRepository.uiState.value.addons.enabledAddons(), force = false, - requestKey = activeRequestKey ?: lastRequestKey, + requestKey = activeRequestKey ?: completedRequestKey, ) } @@ -162,7 +173,7 @@ object HomeRepository { activeJob?.cancel() activeJob = null activeRequestKey = null - lastRequestKey = null + completedRequestKey = null currentDefinitions = emptyList() cachedSections = emptyMap() cachedCollectionHeroItems = emptyList() @@ -174,6 +185,9 @@ object HomeRepository { _uiState.value = HomeUiState() } + private fun hasRenderableCachedSection(cacheKey: String): Boolean = + cachedSections[cacheKey]?.items?.isNotEmpty() == true + private fun publishCurrentState( isLoading: Boolean, requestKey: String?, @@ -190,7 +204,7 @@ object HomeRepository { val preference = preferences[definition.key] if (preference?.enabled == false) return@mapNotNull null - val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null + val section = cachedSections[definition.cacheKey]?.withReleaseFilter() ?: return@mapNotNull null if (section.items.isEmpty()) return@mapNotNull null val customTitle = preference?.customTitle.orEmpty() section.copy( @@ -202,7 +216,7 @@ object HomeRepository { val heroRandom = Random((requestKey?.hashCode() ?: 0).absoluteValue + 1) currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } - .mapNotNull { definition -> cachedSections[definition.key] } + .mapNotNull { definition -> cachedSections[definition.cacheKey] } .map { section -> section.withReleaseFilter() } .flatMap { section -> section.items } .distinctBy { item -> "${item.type}:${item.id}" } 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 917700ab..bd0ee9f0 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 @@ -9,11 +9,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp 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.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -39,13 +41,15 @@ import com.nuvio.app.features.home.components.HomeHeroReservedSpace import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow -import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap -import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watched.episodePlaybackId +import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache @@ -56,11 +60,13 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.continueWatchingEntries @@ -72,19 +78,18 @@ import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection -import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import com.nuvio.app.features.trakt.TraktEpisodeMappingService -import com.nuvio.app.features.home.components.ContinueWatchingLayout -import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight +import com.nuvio.app.features.home.components.continueWatchingHeroViewportReserveHeight import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.rememberContinueWatchingLayout import kotlinx.coroutines.CancellationException @@ -110,6 +115,10 @@ fun HomeScreen( ContinueWatchingPreferencesRepository.ensureLoaded() WatchedRepository.ensureLoaded() WatchProgressRepository.ensureLoaded() + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + WatchProgressSourceCoordinator.ensureStarted() + } } val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() @@ -119,20 +128,19 @@ fun HomeScreen( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() + val continueWatchingListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() + val effectiveWatchProgressSource by WatchProgressRepository.activeSourceState.collectAsStateWithLifecycle() val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val traktSettingsUiState by remember { TraktSettingsRepository.ensureLoaded() TraktSettingsRepository.uiState }.collectAsStateWithLifecycle() - val isTraktAuthenticated by remember { - TraktAuthRepository.ensureLoaded() - TraktAuthRepository.isAuthenticated - }.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } LaunchedEffect(scrollToTopRequests) { @@ -162,14 +170,10 @@ fun HomeScreen( } } - val isTraktProgressActive = remember( - isTraktAuthenticated, - traktSettingsUiState.watchProgressSource, - ) { - shouldUseTraktProgress( - isAuthenticated = isTraktAuthenticated, - source = traktSettingsUiState.watchProgressSource, - ) + val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT + + val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { + if (isTraktProgressActive) emptyList() else watchedUiState.items } val effectiveWatchProgressEntries = remember( @@ -192,7 +196,7 @@ fun HomeScreen( val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - watchedUiState.items, + nextUpWatchedItems, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { @@ -201,14 +205,9 @@ fun HomeScreen( } else { watchProgressUiState.entries } - val filteredWatchedItems = if (isTraktProgressActive) { - watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) } - } else { - watchedUiState.items - } buildHomeNextUpSeedCandidates( progressEntries = filteredEntries, - watchedItems = filteredWatchedItems, + watchedItems = nextUpWatchedItems, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), @@ -282,30 +281,45 @@ fun HomeScreen( } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 - val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle() + val cwCacheGeneration by ContinueWatchingEnrichmentCache.generation.collectAsStateWithLifecycle() + var hasUserScrolledContinueWatching by remember(activeProfileId) { mutableStateOf(false) } - var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf>>(emptyMap()) } - var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } + LaunchedEffect(activeProfileId, continueWatchingListState) { + snapshotFlow { continueWatchingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledContinueWatching = true + } + } - LaunchedEffect(activeProfileId, cwCacheClearVersion) { - if (cwCacheClearVersion == 0) return@LaunchedEffect + var nextUpItemsBySeries by remember(activeProfileId, effectiveWatchProgressSource) { + mutableStateOf>>(emptyMap()) + } + var processedNextUpContentIds by remember(activeProfileId, effectiveWatchProgressSource) { + mutableStateOf>(emptySet()) + } + + LaunchedEffect(activeProfileId, effectiveWatchProgressSource, cwCacheGeneration) { nextUpItemsBySeries = emptyMap() processedNextUpContentIds = emptySet() } - val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) { - ContinueWatchingEnrichmentCache.getSnapshots(activeProfileId) + val cachedSnapshots = remember(activeProfileId, effectiveWatchProgressSource, cwCacheGeneration) { + ContinueWatchingEnrichmentCache.getSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + ) } val shouldValidateMissingNextUpSeeds = remember( - isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + isTraktProgressActive, ) { - if (isTraktProgressActive) { - watchProgressUiState.hasLoadedRemoteProgress - } else { - watchedUiState.isLoaded - } + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) } val cachedNextUpItems = remember( cachedSnapshots.first, @@ -330,8 +344,16 @@ fun HomeScreen( val currentSeed = currentNextUpSeedByContentId[cached.contentId] if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed - val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode - if (seedChanged) return@mapNotNull null + if ( + hasHomeNextUpSeedChangedFromCache( + currentSeason = currentSeason, + currentEpisode = currentEpisode, + cachedSeason = cached.seedSeason, + cachedEpisode = cached.seedEpisode, + ) + ) { + return@mapNotNull null + } } if ( isTraktProgressActive && @@ -344,7 +366,7 @@ fun HomeScreen( if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { return@mapNotNull null } - if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { + if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { @@ -364,7 +386,7 @@ fun HomeScreen( if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { return@mapNotNull null } - cached.videoId to cached.toContinueWatchingItem() + cached.resolvedProgressKey() to cached.toContinueWatchingItem() }.toMap() } @@ -375,6 +397,7 @@ fun HomeScreen( activeNextUpSeedContentIds, currentNextUpSeedByContentId, shouldValidateMissingNextUpSeeds, + processedNextUpContentIds, ) { val liveNextUpItems = filterNextUpItemsByCurrentSeeds( nextUpItemsBySeries = nextUpItemsBySeries, @@ -388,14 +411,11 @@ fun HomeScreen( item.nextUpSeedEpisodeNumber, ) !in continueWatchingPreferences.dismissedNextUpKeys } - if (liveNextUpItems.isNotEmpty()) { - liveNextUpItems.mapValues { (contentId, pair) -> - val cachedItem = cachedNextUpItems[contentId]?.second - pair.first to pair.second.withFallbackMetadata(cachedItem) - } - } else { - cachedNextUpItems - } + mergeHomeNextUpItemsWithCache( + resolvedItems = liveNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = processedNextUpContentIds, + ) } val continueWatchingItems = remember( @@ -408,7 +428,7 @@ fun HomeScreen( ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, - cachedInProgressByVideoId = cachedInProgressItems, + cachedInProgressByProgressKey = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, @@ -416,12 +436,25 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } + LaunchedEffect(activeProfileId, continueWatchingItems.isNotEmpty(), hasUserScrolledContinueWatching) { + if (!hasUserScrolledContinueWatching && continueWatchingItems.isNotEmpty()) { + snapshotFlow { + continueWatchingListState.firstVisibleItemIndex to + continueWatchingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledContinueWatching && + !continueWatchingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + continueWatchingListState.scrollToItem(0) + } + } + } + } val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } - val isRefreshingEnabledAddons = remember(enabledAddons) { - enabledAddons.any { addon -> addon.isRefreshing } - } val availableManifests = remember(enabledAddons) { enabledAddons.mapNotNull { addon -> addon.manifest } } @@ -432,19 +465,30 @@ fun HomeScreen( .map { manifest -> manifest.transportUrl } .sorted() } - - val catalogRefreshKey = remember(availableManifests) { - availableManifests - .map { manifest -> - buildString { - append(manifest.transportUrl) - append(':') - append(manifest.catalogs.joinToString(separator = ",") { catalog -> - "${catalog.type}:${catalog.id}:${catalog.extra.count { it.isRequired }}" - }) - } + val metaProviderReadinessKey = remember(enabledAddons) { + enabledAddons + .sortedBy { addon -> addon.manifestUrl } + .joinToString(separator = "|") { addon -> + "${addon.manifestUrl}:${addon.manifest != null}:${addon.isRefreshing}:${addon.errorMessage.orEmpty()}" } - .sorted() + } + var nextUpResolutionRetryAttempt by remember( + activeProfileId, + effectiveWatchProgressSource, + completedSeriesCandidates, + metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + continueWatchingPreferences.dismissedNextUpKeys, + cwCacheGeneration, + ) { + mutableStateOf(0) + } + + val catalogRefreshKey = remember(enabledAddons) { + buildHomeCatalogRefreshSignature(enabledAddons) } LaunchedEffect(catalogRefreshKey) { @@ -464,25 +508,45 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + nextUpResolutionRetryAttempt, continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.upNextFromFurthestEpisode, - isRefreshingEnabledAddons, + continueWatchingPreferences.dismissedNextUpKeys, watchProgressSeedKey, - watchedUiState.items, + visibleContinueWatchingEntries, + nextUpWatchedItems, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + watchProgressUiState.hasLoadedRemoteProgress, activeProfileId, + effectiveWatchProgressSource, + cwCacheGeneration, ) { + if ( + !isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) + ) { + return@LaunchedEffect + } + if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() processedNextUpContentIds = emptySet() - return@LaunchedEffect - } - - if (!isTraktProgressActive && !watchedUiState.isLoaded) { - return@LaunchedEffect - } - - if (isRefreshingEnabledAddons) { + saveContinueWatchingSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = emptyMap(), + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = CurrentDateProvider.todayIsoDate(), + seedLastWatchedMap = emptyMap(), + ) return@LaunchedEffect } @@ -496,6 +560,9 @@ fun HomeScreen( ) { return@mapNotNull null } + if (!hasUsableHomeNextUpMetadata(item)) { + return@mapNotNull null + } candidate.content.id to cached }.toMap() val candidatesToResolve = completedSeriesCandidates.filter { candidate -> @@ -506,15 +573,20 @@ fun HomeScreen( val deferredResolutionCandidates = resolutionPlan.deferredCandidates val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { + val cachedResults = mergeHomeNextUpItemsWithCache( + resolvedItems = cachedResolvedNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = cachedResolvedNextUpItems.keys, + ) withContext(Dispatchers.Main) { - nextUpItemsBySeries = cachedResolvedNextUpItems - processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> - candidate.content.id - } + nextUpItemsBySeries = cachedResults + processedNextUpContentIds = cachedResolvedNextUpItems.keys } saveContinueWatchingSnapshots( profileId = activeProfileId, - nextUpItemsBySeries = cachedResolvedNextUpItems, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = cachedResults, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = CurrentDateProvider.todayIsoDate(), seedLastWatchedMap = seedLastWatchedMap, @@ -522,125 +594,154 @@ fun HomeScreen( return@withContext } - if (metaProviderKey.isEmpty()) { - return@withContext - } - val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) val freshResults = mutableMapOf>() val processedFreshContentIds = mutableSetOf() - val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) - for (batch in candidateBatches) { - val batchResults = batch.map { completedEntry -> - async { - semaphore.withPermit { - resolveHomeNextUpCandidate( - completedEntry = completedEntry, - watchProgressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, - todayIsoDate = todayIsoDate, - preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, - showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, - dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, - isTraktProgressActive = isTraktProgressActive, - ) + suspend fun resolveCandidatesStreaming( + candidates: List, + ) { + if (candidates.isEmpty()) return + + val results = Channel(Channel.UNLIMITED) + candidates.forEach { completedEntry -> + launch { + val attempt = try { + semaphore.withPermit { + resolveHomeNextUpCandidate( + completedEntry = completedEntry, + watchProgressEntries = watchProgressUiState.entries, + watchedItems = nextUpWatchedItems, + cachedFallbackItem = cachedNextUpItems[completedEntry.content.id]?.second, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, + dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, + isTraktProgressActive = isTraktProgressActive, + ) + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + HomeNextUpResolutionAttempt.transientFailure() + } + results.send( + HomeNextUpCandidateResolution( + candidate = completedEntry, + attempt = attempt, + ), + ) + } + } + + repeat(candidates.size) { + val resolution = results.receive() + if (resolution.attempt.isConclusive) { + processedFreshContentIds += resolution.candidate.content.id + } + + var changed = false + resolution.attempt.resolved?.let { (contentId, item) -> + if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { + val previous = freshResults.put(contentId, item) + changed = previous != item } } - }.awaitAll() - batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } - val resolvedBeforeBatch = freshResults.size - batchResults.filterNotNull().forEach { (contentId, item) -> - freshResults[contentId] = item - } - val batchResolvedCount = freshResults.size - resolvedBeforeBatch - if (batchResolvedCount > 0) { - val progressiveResults = cachedResolvedNextUpItems + freshResults - withContext(Dispatchers.Main) { - nextUpItemsBySeries = progressiveResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + if (changed || resolution.attempt.isConclusive) { + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val progressiveResults = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) + withContext(Dispatchers.Main) { + nextUpItemsBySeries = progressiveResults + processedNextUpContentIds = conclusiveContentIds + } + saveContinueWatchingSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = progressiveResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) } + yield() } - - if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) { - break - } + results.close() } - val results = cachedResolvedNextUpItems + freshResults + resolveCandidatesStreaming(candidates = resolutionCandidates) + + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val results = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = results - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, nextUpItemsBySeries = results, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = todayIsoDate, seedLastWatchedMap = seedLastWatchedMap, ) - if (deferredResolutionCandidates.isEmpty()) { - return@withContext + if (deferredResolutionCandidates.isNotEmpty()) { + resolveCandidatesStreaming( + candidates = deferredResolutionCandidates, + ) + + val deferredResolvedResults = cachedResolvedNextUpItems + freshResults + val deferredConclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val deferredResults = mergeHomeNextUpItemsWithCache( + resolvedItems = deferredResolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = deferredConclusiveContentIds, + ) + withContext(Dispatchers.Main) { + nextUpItemsBySeries = deferredResults + processedNextUpContentIds = deferredConclusiveContentIds + } + saveContinueWatchingSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = deferredResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) } - val deferredCandidateBatches = deferredResolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) - for (batch in deferredCandidateBatches) { - if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) { - break + val transientContentIds = candidatesToResolve + .asSequence() + .map { candidate -> candidate.content.id } + .filterNot { contentId -> contentId in processedFreshContentIds } + .toList() + if ( + transientContentIds.isNotEmpty() && + nextUpResolutionRetryAttempt < MAX_NEXT_UP_RESOLUTION_RETRIES && + networkStatusUiState.condition == NetworkCondition.Online + ) { + val retryDelayMs = NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS * + (1L shl nextUpResolutionRetryAttempt) + delay(retryDelayMs) + withContext(Dispatchers.Main) { + nextUpResolutionRetryAttempt += 1 } - - val batchResults = batch.map { completedEntry -> - async { - semaphore.withPermit { - resolveHomeNextUpCandidate( - completedEntry = completedEntry, - watchProgressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, - todayIsoDate = todayIsoDate, - preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, - showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, - dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, - isTraktProgressActive = isTraktProgressActive, - ) - } - } - }.awaitAll() - batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } - - val resolvedBeforeBatch = freshResults.size - batchResults.filterNotNull().forEach { (contentId, item) -> - if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { - freshResults[contentId] = item - } - } - if (freshResults.size > resolvedBeforeBatch) { - val progressiveResults = cachedResolvedNextUpItems + freshResults - withContext(Dispatchers.Main) { - nextUpItemsBySeries = progressiveResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() - } - saveContinueWatchingSnapshots( - profileId = activeProfileId, - nextUpItemsBySeries = progressiveResults, - visibleContinueWatchingEntries = visibleContinueWatchingEntries, - todayIsoDate = todayIsoDate, - seedLastWatchedMap = seedLastWatchedMap, - ) - } - yield() } } } @@ -671,6 +772,25 @@ fun HomeScreen( val enabledHomeItems = remember(homeSettingsUiState.items) { homeSettingsUiState.items.filter { it.enabled } } + val visibleSeriesPosterTargets = remember(enabledHomeItems, sectionsMap) { + enabledHomeItems + .filterNot { it.isCollection } + .mapNotNull { settingsItem -> sectionsMap[settingsItem.key] } + .flatMap { section -> section.items.take(HOME_CATALOG_PREVIEW_LIMIT) } + .filter { item -> item.type.isHomeSeriesLikeType() } + .distinctBy { item -> watchedItemKey(item.type, item.id) } + } + LaunchedEffect( + visibleSeriesPosterTargets, + watchedUiState.items, + watchProgressUiState.entries, + ) { + reconcileVisibleSeriesPosterBadges( + items = visibleSeriesPosterTargets, + watchedItems = watchedUiState.items, + progressEntries = watchProgressUiState.entries, + ) + } val hasRenderableCollectionRows = remember(enabledHomeItems, collectionsMap) { enabledHomeItems.any { item -> item.isCollection && collectionsMap[item.key] != null @@ -681,9 +801,6 @@ fun HomeScreen( val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value) val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value) val posterCardStyle = rememberPosterCardStyleUiState() - val continueWatchingCardHeight = remember(posterCardStyle.widthDp) { - continueWatchingLandscapeCardHeight(posterCardStyle.widthDp) - } val nativeBottomNavigationOverlayHeight = if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) { nuvioSafeBottomPadding() @@ -696,18 +813,24 @@ fun HomeScreen( continueWatchingPreferences.style, continueWatchingItems.isNotEmpty(), continueWatchingLayout, - continueWatchingCardHeight, + posterCardStyle.widthDp, + homeSettingsUiState.hideCatalogUnderline, nativeBottomNavigationOverlayHeight, ) { - heroMobileBelowSectionHeightHint( - maxWidthDp = maxWidth.value, - continueWatchingVisible = continueWatchingPreferences.isVisible, - hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), - continueWatchingStyle = continueWatchingPreferences.style, - continueWatchingLayout = continueWatchingLayout, - continueWatchingCardHeight = continueWatchingCardHeight, - bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight, - ) + if ( + maxWidth.value < 600f && + continueWatchingPreferences.isVisible && + continueWatchingItems.isNotEmpty() + ) { + continueWatchingHeroViewportReserveHeight( + style = continueWatchingPreferences.style, + layout = continueWatchingLayout, + basePosterWidthDp = posterCardStyle.widthDp, + showHeaderAccent = !homeSettingsUiState.hideCatalogUnderline, + ) + nativeBottomNavigationOverlayHeight + } else { + null + } } NuvioScreen( @@ -752,9 +875,10 @@ fun HomeScreen( style = continueWatchingPreferences.style, useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = 12.dp), + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, + listState = continueWatchingListState, onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, ) @@ -777,9 +901,10 @@ fun HomeScreen( style = continueWatchingPreferences.style, useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = 12.dp), + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, + listState = continueWatchingListState, onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, ) @@ -825,9 +950,10 @@ fun HomeScreen( style = continueWatchingPreferences.style, useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = 12.dp), + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, + listState = continueWatchingListState, onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, ) @@ -863,6 +989,7 @@ fun HomeScreen( null }, watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) @@ -883,7 +1010,58 @@ internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 -private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY +private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 +private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L + +private suspend fun reconcileVisibleSeriesPosterBadges( + items: List, + watchedItems: List, + progressEntries: List, +) { + if (items.isEmpty()) return + val watchedKeys = watchedItems.mapTo(linkedSetOf()) { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + val touchedSeriesIds = buildSet { + watchedItems.forEach { item -> + if (item.type.isHomeSeriesLikeType() && item.season != null && item.episode != null) { + add(item.id) + } + } + progressEntries.forEach { entry -> + if (entry.parentMetaType.isHomeSeriesLikeType() && entry.isEpisode && entry.isEffectivelyCompleted) { + add(entry.parentMetaId) + } + } + } + if (touchedSeriesIds.isEmpty()) return + val todayIsoDate = CurrentDateProvider.todayIsoDate() + withContext(Dispatchers.Default) { + items + .filter { item -> item.id in touchedSeriesIds } + .forEach { item -> + val meta = runCatching { + MetaDetailsRepository.fetch(type = item.type, id = item.id) + }.getOrNull() ?: return@forEach + WatchedRepository.reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeWatched = { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in watchedKeys + }, + isEpisodeCompleted = { episode -> + val playbackId = meta.episodePlaybackId(episode) + progressEntries.any { entry -> + entry.videoId == playbackId && entry.isEffectivelyCompleted + } + }, + ) + } + } +} + +private fun String.isHomeSeriesLikeType(): Boolean = + trim().lowercase() in setOf("series", "show", "tv", "tvshow") internal data class HomeNextUpResolutionPlan( val initialCandidates: List, @@ -946,12 +1124,16 @@ internal fun buildHomeNextUpSeedCandidates( } } .toList() - val watchedSeeds = watchedItems.filter { item -> - item.type.isSeriesTypeForContinueWatching() && - item.season != null && - item.episode != null && - item.season != 0 && - !isMalformedNextUpSeedContentId(item.id) + val watchedSeeds = if (isTraktProgressActive) { + emptyList() + } else { + watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) + } } return WatchingState.latestCompletedBySeries( @@ -991,16 +1173,103 @@ internal fun filterNextUpItemsByCurrentSeeds( item.nextUpSeedEpisodeNumber == currentSeed.second } +internal fun isHomeNextUpSeedSourceLoaded( + isTraktProgressActive: Boolean, + hasLoadedRemoteProgress: Boolean, + hasLoadedWatchedItems: Boolean, + hasLoadedRemoteWatchedItems: Boolean, +): Boolean = hasLoadedRemoteProgress && ( + isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) +) + +internal fun cachedNextUpHasAired( + cached: CachedNextUpItem, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Boolean = + com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs(cached.released) + ?.let { releaseEpochMs -> nowEpochMs >= releaseEpochMs } + ?: cached.hasAired + +internal fun hasHomeNextUpSeedChangedFromCache( + currentSeason: Int, + currentEpisode: Int, + cachedSeason: Int?, + cachedEpisode: Int?, +): Boolean { + if (cachedSeason == null || cachedEpisode == null) return false + return currentSeason != cachedSeason || currentEpisode != cachedEpisode +} + +internal fun hasUsableHomeNextUpMetadata(item: ContinueWatchingItem): Boolean { + val hasResolvedTitle = item.title.isNotBlank() && + !item.title.equals(item.parentMetaId, ignoreCase = true) + val hasArtwork = listOf( + item.imageUrl, + item.poster, + item.background, + item.episodeThumbnail, + ).any { value -> !value.isNullOrBlank() } + return hasResolvedTitle && hasArtwork +} + +internal fun mergeHomeNextUpItemsWithCache( + resolvedItems: Map>, + cachedItems: Map>, + conclusivelyProcessedContentIds: Set, +): Map> { + val retainedCachedItems = cachedItems.filterKeys { contentId -> + contentId !in conclusivelyProcessedContentIds || contentId in resolvedItems + } + val resolvedItemsWithCacheFallback = resolvedItems.mapValues { (contentId, pair) -> + pair.first to pair.second.withFallbackMetadata(cachedItems[contentId]?.second) + } + return retainedCachedItems + resolvedItemsWithCacheFallback +} + +internal enum class HomeNextUpCandidateMetadataOutcome { + Ready, + Dismissed, + Transient, +} + +internal data class HomeNextUpCandidateMetadataDecision( + val item: ContinueWatchingItem, + val outcome: HomeNextUpCandidateMetadataOutcome, +) + +internal fun classifyHomeNextUpCandidateMetadata( + freshItem: ContinueWatchingItem, + cachedFallbackItem: ContinueWatchingItem?, + dismissedNextUpKeys: Set, +): HomeNextUpCandidateMetadataDecision { + val mergedItem = freshItem.withFallbackMetadata(cachedFallbackItem) + val dismissKey = nextUpDismissKey( + mergedItem.parentMetaId, + mergedItem.nextUpSeedSeasonNumber, + mergedItem.nextUpSeedEpisodeNumber, + ) + val outcome = when { + dismissKey in dismissedNextUpKeys -> HomeNextUpCandidateMetadataOutcome.Dismissed + hasUsableHomeNextUpMetadata(mergedItem) -> HomeNextUpCandidateMetadataOutcome.Ready + else -> HomeNextUpCandidateMetadataOutcome.Transient + } + return HomeNextUpCandidateMetadataDecision( + item = mergedItem, + outcome = outcome, + ) +} + private suspend fun resolveHomeNextUpCandidate( completedEntry: CompletedSeriesCandidate, watchProgressEntries: List, watchedItems: List, + cachedFallbackItem: ContinueWatchingItem?, todayIsoDate: String, preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, isTraktProgressActive: Boolean, -): Pair>? { +): HomeNextUpResolutionAttempt { val contentId = completedEntry.content.id val meta = try { MetaDetailsRepository.fetch( @@ -1011,17 +1280,34 @@ private suspend fun resolveHomeNextUpCandidate( if (error is CancellationException) throw error null } - if (meta == null) return null + if (meta == null) { + return HomeNextUpResolutionAttempt.transientFailure() + } val resolvedProgressEntries = if (isTraktProgressActive) { remapTraktProgressEntries(watchProgressEntries, contentId) } else { watchProgressEntries } - val resolvedWatchedItems = if (isTraktProgressActive) { - remapTraktWatchedItems(watchedItems, contentId) - } else { - watchedItems + val resolvedWatchedItems = watchedItems + val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + + if (!isTraktProgressActive) { + WatchedRepository.reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeWatched = { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys + }, + isEpisodeCompleted = { episode -> + val playbackId = meta.episodePlaybackId(episode) + resolvedProgressEntries.any { entry -> + entry.videoId == playbackId && entry.isEffectivelyCompleted + } + }, + ) } val action = meta.seriesPrimaryAction( @@ -1032,15 +1318,29 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, ) - if (action == null) return null - if (action.resumePositionMs != null) return null + if (action == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (action.resumePositionMs != null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } val nextEpisode = meta.videoForSeriesAction(action) - if (nextEpisode == null) return null - val item = completedEntry.toContinueWatchingSeed(meta) - .toUpNextContinueWatchingItem(nextEpisode) - if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in dismissedNextUpKeys) { - return null + if (nextEpisode == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + val metadataDecision = classifyHomeNextUpCandidateMetadata( + freshItem = completedEntry.toContinueWatchingSeed(meta) + .toUpNextContinueWatchingItem(nextEpisode), + cachedFallbackItem = cachedFallbackItem, + dismissedNextUpKeys = dismissedNextUpKeys, + ) + val item = metadataDecision.item + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Dismissed) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Transient) { + return HomeNextUpResolutionAttempt.transientFailure() } val sortTimestamp = if (item.isReleaseAlert) { @@ -1048,7 +1348,9 @@ private suspend fun resolveHomeNextUpCandidate( } else { completedEntry.markedAtEpochMs } - return contentId to (sortTimestamp to item) + return HomeNextUpResolutionAttempt.success( + contentId to (sortTimestamp to item), + ) } private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { @@ -1088,29 +1390,9 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( return progress.lastUpdatedEpochMs >= latestCompletedAt } -private fun heroMobileBelowSectionHeightHint( - maxWidthDp: Float, - continueWatchingVisible: Boolean, - hasContinueWatchingItems: Boolean, - continueWatchingStyle: ContinueWatchingSectionStyle, - continueWatchingLayout: ContinueWatchingLayout, - continueWatchingCardHeight: Dp, - bottomNavigationOverlayHeight: Dp, -): Dp? { - if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null - - val sectionHeight = when (continueWatchingStyle) { - ContinueWatchingSectionStyle.Card -> continueWatchingCardHeight + 56.dp - ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp - ContinueWatchingSectionStyle.Poster -> - continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp - } - return sectionHeight + bottomNavigationOverlayHeight -} - internal fun buildHomeContinueWatchingItems( visibleEntries: List, - cachedInProgressByVideoId: Map = emptyMap(), + cachedInProgressByProgressKey: Map = emptyMap(), nextUpItemsBySeries: Map>, nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, @@ -1132,7 +1414,7 @@ internal fun buildHomeContinueWatchingItems( HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, item = liveItem - .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withFallbackMetadata(cachedInProgressByProgressKey[entry.resolvedProgressKey()]) .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) @@ -1220,8 +1502,42 @@ private data class HomeContinueWatchingCandidate( val isProgressEntry: Boolean, ) +private data class HomeNextUpCandidateResolution( + val candidate: CompletedSeriesCandidate, + val attempt: HomeNextUpResolutionAttempt, +) + +private data class HomeNextUpResolutionAttempt( + val resolved: Pair>?, + val isConclusive: Boolean, +) { + companion object { + fun success( + resolved: Pair>, + ): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = resolved, + isConclusive = true, + ) + + fun conclusiveNone(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = true, + ) + + fun transientFailure(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = false, + ) + } +} + private fun saveContinueWatchingSnapshots( profileId: Int, + source: WatchProgressSource, + cacheGeneration: Int, nextUpItemsBySeries: Map>, visibleContinueWatchingEntries: List, todayIsoDate: String, @@ -1254,33 +1570,64 @@ private fun saveContinueWatchingSnapshots( isNewSeasonRelease = item.isNewSeasonRelease, ) } - val inProgressCache = visibleContinueWatchingEntries.map { entry -> - CachedInProgressItem( - contentId = entry.parentMetaId, - contentType = entry.contentType, - name = entry.title, - poster = entry.poster, - backdrop = entry.background, - logo = entry.logo, - videoId = entry.videoId, - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - pauseDescription = entry.pauseDescription, - position = entry.lastPositionMs, - duration = entry.durationMs, - lastWatched = entry.lastUpdatedEpochMs, - progressPercent = entry.progressPercent, - ) - } + val inProgressCache = buildHomeInProgressCacheSnapshot( + visibleEntries = visibleContinueWatchingEntries, + cachedEntries = ContinueWatchingEnrichmentCache.getInProgressSnapshot( + profileId = profileId, + source = source, + ), + ) ContinueWatchingEnrichmentCache.saveSnapshots( profileId = profileId, + source = source, + generation = cacheGeneration, nextUp = nextUpCache, inProgress = inProgressCache, ) } +internal fun buildHomeInProgressCacheSnapshot( + visibleEntries: List, + cachedEntries: List, +): List { + val cachedByProgressKey = cachedEntries.associateBy(CachedInProgressItem::resolvedProgressKey) + return visibleEntries.map { entry -> + val item = entry + .toContinueWatchingItem() + .withFallbackMetadata( + cachedByProgressKey[entry.resolvedProgressKey()]?.toContinueWatchingItem(), + ) + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + progressKey = entry.resolvedProgressKey(), + ) + } +} + +internal fun effectiveContinueWatchingCacheSource( + isTraktProgressActive: Boolean, +): WatchProgressSource = + if (isTraktProgressActive) { + WatchProgressSource.TRAKT + } else { + WatchProgressSource.NUVIO_SYNC + } + private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) = WatchProgressEntry( contentType = content.type, @@ -1309,6 +1656,9 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { nextSeasonNumber = season, releasedIso = released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, parentMetaType = contentType, @@ -1319,16 +1669,16 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - released = released, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), + released = released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -1352,6 +1702,9 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem } else { 0f } + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, @@ -1363,15 +1716,15 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), isNextUp = false, nextUpSeedSeasonNumber = null, nextUpSeedEpisodeNumber = null, @@ -1385,29 +1738,35 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { - if (fallback == null) return this - val fallbackTitle = fallback.title - .takeIf { it.isNotBlank() } - ?.takeUnless { fallback.hasPlaceholderCloudTitle() } + val nonBlankFallbackTitle = fallback?.title?.takeIf { it.isNotBlank() } + val fallbackHasPlaceholderTitle = fallback?.hasPlaceholderHomeTitle() == true + val fallbackTitle = nonBlankFallbackTitle + ?.takeUnless { fallbackHasPlaceholderTitle } return copy( title = when { - title.isBlank() -> fallback.title - hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + title.isBlank() && nonBlankFallbackTitle != null -> nonBlankFallbackTitle + hasPlaceholderHomeTitle() && fallbackTitle != null -> fallbackTitle else -> title }, - subtitle = subtitle.ifBlank { fallback.subtitle }, - imageUrl = imageUrl ?: fallback.imageUrl, - logo = logo ?: fallback.logo, - poster = poster ?: fallback.poster, - background = background ?: fallback.background, - episodeTitle = episodeTitle ?: fallback.episodeTitle, - episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, - pauseDescription = pauseDescription ?: fallback.pauseDescription, - released = released ?: fallback.released, + subtitle = subtitle.takeIf { it.isNotBlank() } + ?: fallback?.subtitle?.takeIf { it.isNotBlank() }.orEmpty(), + imageUrl = imageUrl.orNonBlank(fallback?.imageUrl), + logo = logo.orNonBlank(fallback?.logo), + poster = poster.orNonBlank(fallback?.poster), + background = background.orNonBlank(fallback?.background), + episodeTitle = episodeTitle.orNonBlank(fallback?.episodeTitle), + episodeThumbnail = episodeThumbnail.orNonBlank(fallback?.episodeThumbnail), + pauseDescription = pauseDescription.orNonBlank(fallback?.pauseDescription), + released = released.orNonBlank(fallback?.released), ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + +private fun String?.orNonBlank(fallback: String?): String? = + nonBlankOrNull() ?: fallback.nonBlankOrNull() + private fun ContinueWatchingItem.withCloudLibraryMetadata( cloudLibraryUiState: CloudLibraryUiState?, ): ContinueWatchingItem { @@ -1426,11 +1785,10 @@ private fun ContinueWatchingItem.withCloudLibraryMetadata( ) } -private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { - if (!isCloudLibraryContinueWatchingItem()) return false +private fun ContinueWatchingItem.hasPlaceholderHomeTitle(): Boolean { val normalizedTitle = title.trim() return normalizedTitle.equals(parentMetaId, ignoreCase = true) || - normalizedTitle.equals(videoId, ignoreCase = true) + (isCloudLibraryContinueWatchingItem() && normalizedTitle.equals(videoId, ignoreCase = true)) } private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = @@ -1473,29 +1831,3 @@ private suspend fun remapTraktProgressEntries( } } } - -private suspend fun remapTraktWatchedItems( - items: List, - contentId: String, -): List { - return items.map { item -> - if (item.id != contentId) { - item - } else { - val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = item.id, - contentType = item.type ?: "series", - season = item.season, - episode = item.episode, - ) - if (mapping != null) { - item.copy( - season = mapping.season, - episode = mapping.episode, - ) - } else { - item - } - } - } -} 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 aecd6626..7f671d11 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 @@ -24,6 +24,7 @@ fun HomeCatalogRowSection( modifier: Modifier = Modifier, entries: List = section.items, watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), sectionPadding: Dp? = null, onViewAllClick: (() -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, @@ -34,6 +35,7 @@ fun HomeCatalogRowSection( section = section, entries = entries, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, onViewAllClick = onViewAllClick, @@ -46,6 +48,7 @@ fun HomeCatalogRowSection( section = section, entries = entries, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), onViewAllClick = onViewAllClick, @@ -61,6 +64,7 @@ private fun HomeCatalogRowSectionContent( section: HomeCatalogSection, entries: List, watchedKeys: Set, + fullyWatchedSeriesKeys: Set, modifier: Modifier, sectionPadding: Dp, onViewAllClick: (() -> Unit)?, @@ -90,6 +94,7 @@ private fun HomeCatalogRowSectionContent( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index fc5bb09f..2ad95679 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -26,7 +28,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor 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.blur @@ -45,8 +49,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.DisintegratingContainer +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioProgressBar +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.PosterLandscapeAspectRatio import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth @@ -67,6 +75,7 @@ private val ContinueWatchingStatusBadgeShape = RoundedCornerShape(4.dp) private val ContinueWatchingNewEpisodeBadgeColor = Color(0xFF1D4ED8) private val ContinueWatchingNewSeasonBadgeColor = Color(0xFFB45309) private const val ContinueWatchingLandscapeCardScale = 1.2f +internal val HomeContinueWatchingSectionBottomPadding = 12.dp internal fun continueWatchingLandscapeCardWidth(basePosterWidthDp: Int): Dp = (landscapePosterWidth(basePosterWidthDp).value * ContinueWatchingLandscapeCardScale).dp @@ -74,6 +83,45 @@ internal fun continueWatchingLandscapeCardWidth(basePosterWidthDp: Int): Dp = internal fun continueWatchingLandscapeCardHeight(basePosterWidthDp: Int): Dp = landscapePosterHeightForWidth(continueWatchingLandscapeCardWidth(basePosterWidthDp)) +internal fun continueWatchingSectionHeightEstimate( + style: ContinueWatchingSectionStyle, + layout: ContinueWatchingLayout, + basePosterWidthDp: Int, + showHeaderAccent: Boolean, +): Dp { + val headerHeight = NuvioTokens.Space.s40 + if (showHeaderAccent) { + NuvioTokens.Space.s6 + NuvioTokens.Space.s4 + } else { + 0.dp + } + val headerToRowGap = NuvioTokens.Space.s8 + NuvioTokens.Space.s2 + val rowHeight = when (style) { + ContinueWatchingSectionStyle.Card -> continueWatchingLandscapeCardHeight(basePosterWidthDp) + ContinueWatchingSectionStyle.Wide -> layout.wideCardHeight + ContinueWatchingSectionStyle.Poster -> layout.posterCardHeight + layout.posterTitleBlockHeight + } + return headerHeight + headerToRowGap + rowHeight + HomeContinueWatchingSectionBottomPadding +} + +internal fun continueWatchingHeroViewportReserveHeight( + style: ContinueWatchingSectionStyle, + layout: ContinueWatchingLayout, + basePosterWidthDp: Int, + showHeaderAccent: Boolean, +): Dp { + val bottomNavigationClearance = when (style) { + ContinueWatchingSectionStyle.Card, + ContinueWatchingSectionStyle.Wide -> NuvioTokens.Space.s24 + ContinueWatchingSectionStyle.Poster -> 0.dp + } + return continueWatchingSectionHeightEstimate( + style = style, + layout = layout, + basePosterWidthDp = basePosterWidthDp, + showHeaderAccent = showHeaderAccent, + ) + bottomNavigationClearance +} + private fun continueWatchingProgressPercent(progressFraction: Float): Int = (progressFraction * 100f).roundToInt().coerceIn(1, 99) @@ -189,6 +237,7 @@ internal fun HomeContinueWatchingSection( modifier: Modifier = Modifier, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, + listState: LazyListState = rememberLazyListState(), onItemClick: ((ContinueWatchingItem) -> Unit)? = null, onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null, ) { @@ -203,6 +252,7 @@ internal fun HomeContinueWatchingSection( modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, layout = layout, + listState = listState, onItemClick = onItemClick, onItemLongPress = onItemLongPress, ) @@ -216,6 +266,7 @@ internal fun HomeContinueWatchingSection( modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), + listState = listState, onItemClick = onItemClick, onItemLongPress = onItemLongPress, ) @@ -232,6 +283,7 @@ private fun HomeContinueWatchingSectionContent( modifier: Modifier, sectionPadding: Dp, layout: ContinueWatchingLayout, + listState: LazyListState, onItemClick: ((ContinueWatchingItem) -> Unit)?, onItemLongPress: ((ContinueWatchingItem) -> Unit)?, ) { @@ -240,44 +292,104 @@ private fun HomeContinueWatchingSectionContent( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() + val disintegration = remember { ContinueWatchingDisintegrationHolder() } + val displayEntries = disintegration.sync(items) + NuvioShelfSection( title = stringResource(Res.string.compose_settings_page_continue_watching), - entries = items, + entries = displayEntries, modifier = modifier, headerHorizontalPadding = sectionPadding, rowContentPadding = PaddingValues(horizontal = sectionPadding), itemSpacing = layout.itemGap, showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, - key = { item -> item.videoId }, - ) { item -> - when (style) { - ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( - item = item, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) + key = { entry -> entry.videoId }, + animatePlacement = true, + state = listState, + ) { entry -> + val item = entry.item + val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } + val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } } + DisintegratingContainer( + disintegrating = entry.exiting, + onDisintegrated = { disintegration.onExited(entry.videoId) }, + ) { + when (style) { + ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( + item = item, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + } } } } +private data class ContinueWatchingDisplayEntry( + val videoId: String, + val item: ContinueWatchingItem, + val exiting: Boolean, +) + +private class ContinueWatchingDisintegrationHolder { + private val exiting = LinkedHashMap>() + private var previous = LinkedHashMap>() + private var invalidations by mutableStateOf(0) + + fun onExited(videoId: String) { + if (exiting.remove(videoId) != null) invalidations++ + } + + fun sync(items: List): List { + @Suppress("UNUSED_EXPRESSION") + invalidations + + val current = LinkedHashMap>() + items.forEachIndexed { index, item -> current[item.videoId] = item to index } + + for ((videoId, info) in previous) { + if (videoId !in current && videoId !in exiting) { + exiting[videoId] = info + } + } + for (videoId in current.keys) { + exiting.remove(videoId) + } + previous = current + + val entries = ArrayList(items.size + exiting.size) + items.forEach { item -> + entries += ContinueWatchingDisplayEntry(item.videoId, item, exiting = false) + } + exiting.entries + .sortedBy { it.value.second } + .forEach { (videoId, info) -> + val insertAt = info.second.coerceIn(0, entries.size) + entries.add(insertAt, ContinueWatchingDisplayEntry(videoId, info.first, exiting = true)) + } + + return entries + } +} + @Composable fun ContinueWatchingStylePreview( style: ContinueWatchingSectionStyle, @@ -600,7 +712,16 @@ private fun ContinueWatchingCard( .aspectRatio(PosterLandscapeAspectRatio) .clip(RoundedCornerShape(cardMetrics.cornerRadius)) .background(MaterialTheme.colorScheme.surfaceVariant) - .posterCardClickable(onClick = onClick, onLongClick = onLongClick), + .nuvioCardDepth( + shape = RoundedCornerShape(cardMetrics.cornerRadius), + surface = NuvioCardDepthSurface.ContinueWatching, + ) + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = imageUrl, + zoomCornerRadius = cardMetrics.cornerRadius, + ), ) { if (imageUrl != null) { AsyncImage( @@ -891,6 +1012,7 @@ private fun ContinueWatchingPosterCard( onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { + val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails) Column( modifier = Modifier.width(layout.posterCardWidth), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -901,9 +1023,13 @@ private fun ContinueWatchingPosterCard( .height(layout.posterCardHeight) .clip(RoundedCornerShape(layout.cardRadius)) .background(MaterialTheme.colorScheme.surfaceVariant) - .posterCardClickable(onClick = onClick, onLongClick = onLongClick), + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = imageUrl, + zoomCornerRadius = layout.cardRadius, + ), ) { - val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails) val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp && diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 2cfebe19..30d73d55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -489,14 +489,24 @@ private fun mobileHeroHeight( val widthFallbackHeight = (maxWidthDp * 1.16f).dp val baseHeight = viewportDrivenHeight ?: widthFallbackHeight - val cappedHeight = if (viewportHeightDp != null && mobileBelowSectionHeightHintDp != null) { - val maxAllowedFromViewport = (viewportHeightDp - mobileBelowSectionHeightHintDp).dp + val maxAllowedFromViewportDp = if (viewportHeightDp != null && mobileBelowSectionHeightHintDp != null) { + viewportHeightDp - mobileBelowSectionHeightHintDp + } else { + null + } + val cappedHeight = if (maxAllowedFromViewportDp != null) { + val maxAllowedFromViewport = maxAllowedFromViewportDp.dp baseHeight.coerceAtMost(maxAllowedFromViewport) } else { baseHeight } + val minHeight = if (maxAllowedFromViewportDp != null) { + minOf(MOBILE_HERO_MIN_HEIGHT_DP, maxAllowedFromViewportDp.coerceAtLeast(0f)).dp + } else { + MOBILE_HERO_MIN_HEIGHT_DP.dp + } - return cappedHeight.coerceIn(MOBILE_HERO_MIN_HEIGHT_DP.dp, MOBILE_HERO_MAX_HEIGHT_DP.dp) + return cappedHeight.coerceIn(minHeight, MOBILE_HERO_MAX_HEIGHT_DP.dp) } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt new file mode 100644 index 00000000..2d48b5fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt @@ -0,0 +1,315 @@ +package com.nuvio.app.features.library + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.Job + +internal data class LibraryProfileToken( + val profileId: Int, + val generation: Long, +) + +internal data class LibraryLocalSnapshot( + val token: LibraryProfileToken, + val revision: Long, + val contentRevision: Long, + val hasLoaded: Boolean, + val isLoading: Boolean, + val items: List, + val hasPendingPush: Boolean, +) + +internal data class LibraryStateTransition( + val snapshot: LibraryLocalSnapshot, + val detachedPushJob: Job?, +) + +internal data class LibraryLocalMutation( + val snapshot: LibraryLocalSnapshot, + val affectedCount: Int, +) + +internal data class LibraryLocalToggleResult( + val snapshot: LibraryLocalSnapshot, + val isSaved: Boolean, +) + +internal data class LibraryServerItemsApplyResult( + val snapshot: LibraryLocalSnapshot, + val preservedLocalItems: Boolean, +) + +internal data class LibraryPushJobInstallResult( + val installed: Boolean, + val detachedPushJob: Job?, +) + +/** + * Owns the profile-scoped local-library state behind one lock. + * + * Callers only receive copied item lists, so sorting or serializing a snapshot never traverses + * the live mutable map while another thread replaces or edits it. + */ +internal class LibraryLocalState { + private val lock = SynchronizedObject() + + private var hasLoaded = false + private var currentProfileId = 1 + private var profileGeneration = 0L + private var revision = 0L + private var contentRevision = 0L + private var isLoading = false + private var itemsById: MutableMap = mutableMapOf() + private var hasPendingPush = false + private var pushJob: Job? = null + + fun snapshot(): LibraryLocalSnapshot = synchronized(lock) { + snapshotLocked() + } + + fun currentTokenIfLoaded(profileId: Int): LibraryProfileToken? = synchronized(lock) { + if (!hasLoaded || currentProfileId != profileId) { + null + } else { + tokenLocked() + } + } + + fun isCurrent(token: LibraryProfileToken): Boolean = synchronized(lock) { + isCurrentLocked(token) + } + + fun isCurrent(snapshot: LibraryLocalSnapshot): Boolean = synchronized(lock) { + isCurrentLocked(snapshot) + } + + fun isContentCurrent(snapshot: LibraryLocalSnapshot): Boolean = synchronized(lock) { + isContentCurrentLocked(snapshot) + } + + fun runIfCurrent(snapshot: LibraryLocalSnapshot, block: () -> Unit): Boolean = synchronized(lock) { + if (!isCurrentLocked(snapshot)) { + false + } else { + block() + true + } + } + + fun runIfContentCurrent(snapshot: LibraryLocalSnapshot, block: () -> Unit): Boolean = synchronized(lock) { + if (!isContentCurrentLocked(snapshot)) { + false + } else { + block() + true + } + } + + fun runIfTokenCurrent(token: LibraryProfileToken, block: () -> Unit): Boolean = synchronized(lock) { + if (!isCurrentLocked(token)) { + false + } else { + block() + true + } + } + + fun beginProfileLoad(profileId: Int): LibraryStateTransition = synchronized(lock) { + val detachedPushJob = pushJob + pushJob = null + currentProfileId = profileId + profileGeneration += 1L + revision += 1L + contentRevision += 1L + hasLoaded = false + isLoading = true + itemsById = mutableMapOf() + hasPendingPush = false + LibraryStateTransition( + snapshot = snapshotLocked(), + detachedPushJob = detachedPushJob, + ) + } + + fun completeProfileLoad( + token: LibraryProfileToken, + activeProfileId: Int, + items: Collection, + ): LibraryLocalSnapshot? = synchronized(lock) { + if (activeProfileId != token.profileId || !isCurrentLocked(token)) { + return@synchronized null + } + itemsById = items.associateByTo(mutableMapOf()) { libraryItemKey(it.id, it.type) } + hasLoaded = true + isLoading = false + revision += 1L + contentRevision += 1L + snapshotLocked() + } + + fun reset(): LibraryStateTransition = synchronized(lock) { + val detachedPushJob = pushJob + pushJob = null + currentProfileId = 1 + profileGeneration += 1L + revision += 1L + contentRevision += 1L + hasLoaded = false + isLoading = false + itemsById = mutableMapOf() + hasPendingPush = false + LibraryStateTransition( + snapshot = snapshotLocked(), + detachedPushJob = detachedPushJob, + ) + } + + fun markPullStarted(token: LibraryProfileToken): LibraryLocalSnapshot? = synchronized(lock) { + if (!isCurrentLocked(token)) return@synchronized null + snapshotLocked() + } + + fun applyServerItems( + pullSnapshot: LibraryLocalSnapshot, + serverItems: Collection, + ): LibraryServerItemsApplyResult? = synchronized(lock) { + if (!isCurrentLocked(pullSnapshot.token)) return@synchronized null + + val localContentChanged = contentRevision != pullSnapshot.contentRevision + val hasLocalChanges = pullSnapshot.hasPendingPush || hasPendingPush || localContentChanged + val preserveLocalItems = itemsById.isNotEmpty() && (serverItems.isEmpty() || hasLocalChanges) + if (!preserveLocalItems) { + itemsById = serverItems.associateByTo(mutableMapOf()) { libraryItemKey(it.id, it.type) } + contentRevision += 1L + hasPendingPush = false + } + hasLoaded = true + isLoading = false + revision += 1L + LibraryServerItemsApplyResult( + snapshot = snapshotLocked(), + preservedLocalItems = preserveLocalItems, + ) + } + + fun upsert(item: LibraryItem): LibraryLocalSnapshot = synchronized(lock) { + itemsById[libraryItemKey(item.id, item.type)] = item + revision += 1L + contentRevision += 1L + hasPendingPush = true + snapshotLocked() + } + + fun toggle(item: LibraryItem): LibraryLocalToggleResult = synchronized(lock) { + val key = libraryItemKey(item.id, item.type) + val isSaved = if (itemsById.remove(key) != null) { + false + } else { + itemsById[key] = item + true + } + revision += 1L + contentRevision += 1L + hasPendingPush = true + LibraryLocalToggleResult( + snapshot = snapshotLocked(), + isSaved = isSaved, + ) + } + + fun removeById(id: String): LibraryLocalMutation = synchronized(lock) { + val before = itemsById.size + itemsById.entries.removeAll { (_, item) -> item.id == id } + val affectedCount = before - itemsById.size + if (affectedCount > 0) { + revision += 1L + contentRevision += 1L + hasPendingPush = true + } + LibraryLocalMutation( + snapshot = snapshotLocked(), + affectedCount = affectedCount, + ) + } + + fun remove(id: String, type: String): LibraryLocalMutation = synchronized(lock) { + val affectedCount = if (itemsById.remove(libraryItemKey(id, type)) != null) 1 else 0 + if (affectedCount > 0) { + revision += 1L + contentRevision += 1L + hasPendingPush = true + } + LibraryLocalMutation( + snapshot = snapshotLocked(), + affectedCount = affectedCount, + ) + } + + fun contains(id: String, type: String): Boolean = synchronized(lock) { + itemsById.containsKey(libraryItemKey(id, type)) + } + + fun containsId(id: String): Boolean = synchronized(lock) { + itemsById.values.any { it.id == id } + } + + fun findById(id: String): LibraryItem? = synchronized(lock) { + itemsById.values.firstOrNull { it.id == id } + } + + fun installPushJob( + snapshot: LibraryLocalSnapshot, + job: Job, + ): LibraryPushJobInstallResult = synchronized(lock) { + if (!isContentCurrentLocked(snapshot)) { + LibraryPushJobInstallResult(installed = false, detachedPushJob = null) + } else { + val detachedPushJob = pushJob + pushJob = job + LibraryPushJobInstallResult(installed = true, detachedPushJob = detachedPushJob) + } + } + + fun clearPushJob(job: Job) { + synchronized(lock) { + if (pushJob === job) pushJob = null + } + } + + fun markPushCompleted(snapshot: LibraryLocalSnapshot) { + synchronized(lock) { + if (isContentCurrentLocked(snapshot)) { + hasPendingPush = false + } + } + } + + private fun tokenLocked(): LibraryProfileToken = + LibraryProfileToken( + profileId = currentProfileId, + generation = profileGeneration, + ) + + private fun snapshotLocked(): LibraryLocalSnapshot = + LibraryLocalSnapshot( + token = tokenLocked(), + revision = revision, + contentRevision = contentRevision, + hasLoaded = hasLoaded, + isLoading = isLoading, + items = itemsById.values.toList(), + hasPendingPush = hasPendingPush, + ) + + private fun isCurrentLocked(token: LibraryProfileToken): Boolean = + currentProfileId == token.profileId && profileGeneration == token.generation + + private fun isCurrentLocked(snapshot: LibraryLocalSnapshot): Boolean = + isCurrentLocked(snapshot.token) && revision == snapshot.revision + + private fun isContentCurrentLocked(snapshot: LibraryLocalSnapshot): Boolean = + isCurrentLocked(snapshot.token) && contentRevision == snapshot.contentRevision +} + +private fun libraryItemKey(id: String, type: String): String = + "${type.trim().lowercase()}:${id.trim()}" 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 e317b0a7..06c102f8 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 @@ -5,6 +5,7 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository @@ -17,9 +18,12 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -30,6 +34,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -79,13 +85,11 @@ object LibraryRepository { private val _uiState = MutableStateFlow(LibraryUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var hasLoaded = false - private var currentProfileId: Int = 1 - private var profileGeneration: Long = 0L - private var itemsById: MutableMap = mutableMapOf() - private var isPullingNuvioSyncFromServer = false - private var hasCompletedInitialNuvioSyncPull = false - private var pushJob: Job? = null + private val localState = LibraryLocalState() + private val loadLock = SynchronizedObject() + private val nuvioPullMutex = Mutex() + private val persistenceLock = SynchronizedObject() + private val lastPersistedContentRevisionByProfile = mutableMapOf() init { syncScope.launch { @@ -127,8 +131,12 @@ object LibraryRepository { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktLibraryRepository.ensureLoaded() - if (hasLoaded) return - loadFromDisk(ProfileRepository.activeProfileId) + while (true) { + val activeProfileId = ProfileRepository.activeProfileId + val snapshot = localState.snapshot() + if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break + loadFromDisk(activeProfileId) + } if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() if (isTraktLibrarySourceActive()) { @@ -138,12 +146,11 @@ object LibraryRepository { } fun onProfileChanged(profileId: Int) { - if (profileId == currentProfileId && hasLoaded) return - pushJob?.cancel() - isPullingNuvioSyncFromServer = false - hasCompletedInitialNuvioSyncPull = false + val current = localState.snapshot() + if (profileId == current.token.profileId && current.hasLoaded) return + TraktSettingsRepository.onProfileChanged() - loadFromDisk(profileId) + if (!loadFromDisk(profileId)) return TraktAuthRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged() if (TraktAuthRepository.isAuthenticated.value) { @@ -155,90 +162,125 @@ object LibraryRepository { } fun clearLocalState() { - hasLoaded = false - currentProfileId = 1 - profileGeneration += 1L - itemsById.clear() - pushJob?.cancel() - isPullingNuvioSyncFromServer = false - hasCompletedInitialNuvioSyncPull = false + val transition = synchronized(loadLock) { localState.reset() } + transition.detachedPushJob?.cancel() TraktAuthRepository.clearLocalState() TraktLibraryRepository.clearLocalState() _uiState.value = LibraryUiState() } - private fun loadFromDisk(profileId: Int) { - currentProfileId = profileId - profileGeneration += 1L - hasLoaded = true - itemsById.clear() + internal fun runAccountStorageWipe(wipeStorage: () -> Unit) { + synchronized(loadLock) { + val transition = localState.reset() + transition.detachedPushJob?.cancel() + synchronized(persistenceLock) { + try { + wipeStorage() + } finally { + lastPersistedContentRevisionByProfile.clear() + } + } + } + } - val payload = LibraryStorage.loadPayload(profileId).orEmpty().trim() - if (payload.isNotEmpty()) { - val items = runCatching { + private fun loadFromDisk(profileId: Int): Boolean { + var shouldPublish = false + val loaded = synchronized(loadLock) { + if (ProfileRepository.activeProfileId != profileId) return@synchronized false + val current = localState.snapshot() + if (current.hasLoaded && current.token.profileId == profileId) { + return@synchronized true + } + + val transition = localState.beginProfileLoad(profileId) + transition.detachedPushJob?.cancel() + shouldPublish = completeLoadFromDisk(transition.snapshot.token) + shouldPublish + } + if (shouldPublish) publish() + return loaded + } + + private fun completeLoadFromDisk(token: LibraryProfileToken): Boolean { + val payload = LibraryStorage.loadPayload(token.profileId).orEmpty().trim() + val items = if (payload.isNotEmpty()) { + runCatching { json.decodeFromString(payload).items }.getOrDefault(emptyList()) - itemsById = items.associateBy { libraryItemKey(it.id, it.type) }.toMutableMap() + } else { + emptyList() } - publish() + return localState.completeProfileLoad( + token = token, + activeProfileId = ProfileRepository.activeProfileId, + items = items, + ) != null } suspend fun pullFromServer(profileId: Int) { - val operationGeneration = activeOperationGeneration(profileId) ?: run { + val operationToken = activeOperationToken(profileId) ?: run { log.d { "Skipping library pull for inactive profile $profileId" } return } if (isTraktLibrarySourceActive()) { - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } - if (!isActiveOperation(profileId, operationGeneration)) return - hasCompletedInitialNuvioSyncPull = true + try { + TraktLibraryRepository.refreshNow() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to pull Trakt library" } + } + if (!isActiveOperation(operationToken)) return publish() return } - isPullingNuvioSyncFromServer = true - runCatching { - val serverItems = pullAllLibrarySyncItems(profileId) - if (!isActiveOperation(profileId, operationGeneration)) return@runCatching - if (serverItems.isEmpty() && itemsById.isNotEmpty()) { - log.w { "Remote library is empty while local has ${itemsById.size} entries; preserving local library" } - } else { - itemsById = serverItems - .map { it.toLibraryItem() } - .associateBy { libraryItemKey(it.id, it.type) } - .toMutableMap() - persist() + nuvioPullMutex.withLock { + val serializedToken = activeOperationToken(profileId) ?: return@withLock + val pullSnapshot = localState.markPullStarted(serializedToken) ?: return@withLock + + var appliedItems = false + try { + val serverItems = pullAllLibrarySyncItems(profileId).map { it.toLibraryItem() } + val applyResult = localState.applyServerItems(pullSnapshot, serverItems) + ?: return@withLock + appliedItems = true + if (applyResult.preservedLocalItems) { + log.w { + "Preserving ${applyResult.snapshot.items.size} local library items because the remote " + + "snapshot is empty or local changes are pending" + } + } else { + persist(applyResult.snapshot) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to pull library from server" } } - hasLoaded = true - publish() - }.onFailure { e -> - log.e(e) { "Failed to pull library from server" } - }.also { - hasCompletedInitialNuvioSyncPull = true - isPullingNuvioSyncFromServer = false + + if (appliedItems) publish() } } - private fun activeOperationGeneration(profileId: Int): Long? { + private fun activeOperationToken(profileId: Int): LibraryProfileToken? { if (ProfileRepository.activeProfileId != profileId) return null - if (!hasLoaded || currentProfileId != profileId) { - loadFromDisk(profileId) - } - return profileGeneration + if (!loadFromDisk(profileId)) return null + return localState.currentTokenIfLoaded(profileId) + ?.takeIf { ProfileRepository.activeProfileId == profileId } } - private fun isActiveOperation(profileId: Int, generation: Long): Boolean = - currentProfileId == profileId && - profileGeneration == generation && - ProfileRepository.activeProfileId == profileId + private fun isActiveOperation(token: LibraryProfileToken): Boolean = + localState.isCurrent(token) && ProfileRepository.activeProfileId == token.profileId fun toggleSaved(item: LibraryItem) { ensureLoaded() if (isTraktLibrarySourceActive()) { + val profileId = localState.snapshot().token.profileId + log.i { "toggleSaved routed to Trakt library source item=${item.id} type=${item.type} profile=$profileId" } syncScope.launch { runCatching { TraktLibraryRepository.toggleWatchlist(item) } .onFailure { e -> @@ -253,38 +295,60 @@ object LibraryRepository { return } - if (itemsById.containsKey(libraryItemKey(item.id, item.type))) { - remove(item.id, item.type) + val result = localState.toggle( + item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()), + ) + if (result.isSaved) { + log.i { + "Saving local library item item=${item.id} type=${item.type} " + + "profile=${result.snapshot.token.profileId}" + } } else { - save(item) + log.i { + "Removing local library item id=${item.id} type=${item.type} " + + "profile=${result.snapshot.token.profileId}" + } } + persist(result.snapshot) + publish() + pushToServer(result.snapshot) } fun save(item: LibraryItem) { ensureLoaded() - itemsById[libraryItemKey(item.id, item.type)] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) + val snapshot = localState.upsert(item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())) + log.i { + "Saving local library item item=${item.id} type=${item.type} profile=${snapshot.token.profileId}" + } + persist(snapshot) publish() - persist() - pushToServer() + pushToServer(snapshot) } fun remove(id: String) { ensureLoaded() - val before = itemsById.size - itemsById.entries.removeAll { (_, item) -> item.id == id } - if (itemsById.size != before) { + val result = localState.removeById(id) + if (result.affectedCount > 0) { + log.i { + "Removing local library item id=$id profile=${result.snapshot.token.profileId} " + + "removed=${result.affectedCount}" + } + persist(result.snapshot) publish() - persist() - pushToServer() + pushToServer(result.snapshot) } } private fun remove(id: String, type: String) { ensureLoaded() - if (itemsById.remove(libraryItemKey(id, type)) != null) { + val result = localState.remove(id, type) + if (result.affectedCount > 0) { + log.i { + "Removing local library item id=$id type=$type profile=${result.snapshot.token.profileId}" + } + persist(result.snapshot) publish() - persist() - pushToServer() + pushToServer(result.snapshot) } } @@ -303,9 +367,9 @@ object LibraryRepository { } return if (type != null) { - itemsById.containsKey(libraryItemKey(id, type)) + localState.contains(id, type) } else { - itemsById.values.any { it.id == id } + localState.containsId(id) } } @@ -316,7 +380,7 @@ object LibraryRepository { return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } } - return itemsById.values.firstOrNull { it.id == id } + return localState.findById(id) } fun libraryListTabs(): List { @@ -332,7 +396,7 @@ object LibraryRepository { suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() - val inLocal = itemsById.containsKey(libraryItemKey(item.id, item.type)) + val inLocal = localState.contains(item.id, item.type) if (TraktAuthRepository.isAuthenticated.value) { val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership return libraryMembershipWithLocal( @@ -346,7 +410,13 @@ object LibraryRepository { suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { ensureLoaded() val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true - val currentlyInLocal = itemsById.containsKey(libraryItemKey(item.id, item.type)) + val currentlyInLocal = localState.contains(item.id, item.type) + val profileId = localState.snapshot().token.profileId + log.i { + "Applying library membership item=${item.id} type=${item.type} profile=$profileId " + + "localDesired=$localDesired currentlyInLocal=$currentlyInLocal " + + "traktAuthenticated=${TraktAuthRepository.isAuthenticated.value}" + } if (localDesired != currentlyInLocal) { if (localDesired) { save(item) @@ -377,28 +447,62 @@ object LibraryRepository { applyMembershipChanges(item, desiredMembership) } - private fun pushToServer() { + private fun pushToServer(snapshot: LibraryLocalSnapshot) { val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return - if (isPullingNuvioSyncFromServer || !hasCompletedInitialNuvioSyncPull) return - - pushJob?.cancel() - val profileId = currentProfileId - pushJob = syncScope.launch { + val profileId = snapshot.token.profileId + val itemCount = snapshot.items.size + if (authState !is AuthState.Authenticated) { + log.w { "Skipping library push: auth state is ${authState::class.simpleName} profile=$profileId" } + return + } + if (authState.isAnonymous) { + log.w { "Skipping library push: anonymous auth user=${authState.userId} profile=$profileId" } + return + } + val pushJob = syncScope.launch(start = CoroutineStart.LAZY) { delay(500) - if (profileId != currentProfileId) return@launch + if (!localState.isContentCurrent(snapshot)) { + val current = localState.snapshot() + log.w { + "Skipping stale debounced library push: scheduled=${snapshot.token} " + + "current=${current.token} scheduledContentRevision=${snapshot.contentRevision} " + + "currentContentRevision=${current.contentRevision}" + } + return@launch + } runCatching { - val syncItems = itemsById.values.map { it.toSyncItem() } - if (syncItems.isEmpty()) return@runCatching + val syncItems = snapshot.items.map { it.toSyncItem() } + if (syncItems.isEmpty()) { + log.w { "Skipping library push: sync payload is empty profile=$profileId" } + return@runCatching false + } val params = buildJsonObject { put("p_profile_id", profileId) put("p_items", json.encodeToJsonElement(syncItems)) + putSyncOriginClientId() } + log.i { "Pushing library to server profile=$profileId itemCount=${syncItems.size}" } SupabaseProvider.client.postgrest.rpc("sync_push_library", params) + true + }.onSuccess { pushed -> + if (pushed) { + localState.markPushCompleted(snapshot) + log.i { "Library push completed profile=$profileId itemCount=$itemCount" } + } }.onFailure { e -> - log.e(e) { "Failed to push library to server" } + if (e is CancellationException) throw e + log.e(e) { "Failed to push library to server profile=$profileId itemCount=$itemCount" } } } + pushJob.invokeOnCompletion { localState.clearPushJob(pushJob) } + + val installResult = localState.installPushJob(snapshot, pushJob) + if (!installResult.installed) { + pushJob.cancel() + return + } + installResult.detachedPushJob?.cancel() + pushJob.start() } private suspend fun pullAllLibrarySyncItems(profileId: Int): List { @@ -423,6 +527,7 @@ object LibraryRepository { } private fun publish() { + val localSnapshot = localState.snapshot() if (isTraktLibrarySourceActive()) { val traktState = TraktLibraryRepository.uiState.value val sections = traktState.listTabs.mapNotNull { tab -> @@ -438,7 +543,7 @@ object LibraryRepository { } } - _uiState.value = LibraryUiState( + val newUiState = LibraryUiState( sourceMode = LibrarySourceMode.TRAKT, items = traktState.allItems, sections = sections, @@ -446,10 +551,13 @@ object LibraryRepository { isLoading = traktState.isLoading, errorMessage = traktState.errorMessage, ) + localState.runIfTokenCurrent(localSnapshot.token) { + _uiState.value = newUiState + } return } - val items = itemsById.values + val items = localSnapshot.items .sortedByDescending { it.savedAtEpochMs } val sections = items .groupBy { it.type } @@ -462,25 +570,34 @@ object LibraryRepository { } .sortedBy { it.displayTitle } - _uiState.value = LibraryUiState( + val newUiState = LibraryUiState( sourceMode = LibrarySourceMode.LOCAL, items = items, sections = sections, - isLoaded = true, - isLoading = false, + isLoaded = localSnapshot.hasLoaded, + isLoading = localSnapshot.isLoading, errorMessage = null, ) + localState.runIfCurrent(localSnapshot) { + _uiState.value = newUiState + } } - private fun persist() { - LibraryStorage.savePayload( - currentProfileId, - json.encodeToString( - StoredLibraryPayload( - items = itemsById.values.sortedByDescending { it.savedAtEpochMs }, - ), + private fun persist(snapshot: LibraryLocalSnapshot) { + val payload = json.encodeToString( + StoredLibraryPayload( + items = snapshot.items.sortedByDescending { it.savedAtEpochMs }, ), ) + synchronized(persistenceLock) { + val profileId = snapshot.token.profileId + val lastPersistedRevision = lastPersistedContentRevisionByProfile[profileId] ?: Long.MIN_VALUE + if (snapshot.contentRevision <= lastPersistedRevision) return@synchronized + localState.runIfContentCurrent(snapshot) { + LibraryStorage.savePayload(profileId, payload) + lastPersistedContentRevisionByProfile[profileId] = snapshot.contentRevision + } + } } private fun refreshTraktLibraryAsync() { @@ -569,9 +686,6 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem( addedAt = savedAtEpochMs, ) -private fun libraryItemKey(id: String, type: String): String = - "${type.trim().lowercase()}:${id.trim()}" - private fun String.toPosterShape(): PosterShape = when (trim().uppercase()) { "LANDSCAPE" -> PosterShape.Landscape 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 c64c449e..9e29c577 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 @@ -30,7 +30,7 @@ import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -58,6 +58,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.ui.DisintegratingContainer import com.nuvio.app.core.ui.NuvioDropdownChip import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioScreen @@ -171,6 +172,16 @@ fun LibraryScreen( } } + val disintegration = remember { LibraryDisintegrationHolder() } + val librarySectionsDisplay = if ( + sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty() + ) { + disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT) + } else { + disintegration.reset() + emptyList() + } + NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, @@ -299,12 +310,13 @@ fun LibraryScreen( else -> { librarySections( - sections = uiState.sections, + displaySections = librarySectionsDisplay, watchedKeys = watchedUiState.watchedKeys, showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, onPosterLongClick = onPosterLongClick, + onDisintegrated = disintegration::onExited, ) } } @@ -579,9 +591,8 @@ private fun LibraryChip( horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (loading) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, color = colorScheme.primary, ) } @@ -986,44 +997,153 @@ private enum class LibraryViewMode { } private fun LazyListScope.librarySections( - sections: List, + displaySections: List, watchedKeys: Set, showHeaderAccent: Boolean, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, + onDisintegrated: (String) -> Unit, ) { items( - items = sections, + items = displaySections, key = { section -> section.type }, ) { section -> - val previewItems = section.items.take(LIBRARY_SECTION_PREVIEW_LIMIT) NuvioShelfSection( title = section.displayTitle, - entries = previewItems, + entries = section.previewEntries, headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), showHeaderAccent = showHeaderAccent, - onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) { - onSectionViewAllClick?.let { { it(section) } } - } else { - null - }, + onViewAllClick = section.source + ?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT } + ?.let { source -> onSectionViewAllClick?.let { { it(source) } } }, viewAllPillSize = NuvioViewAllPillSize.Compact, - key = { item -> "${item.type}:${item.id}" }, - ) { item -> + key = { entry -> entry.globalKey }, + animatePlacement = true, + ) { entry -> + val item = entry.item val posterItem = item.toMetaPreview() - HomePosterCard( - item = posterItem, - isWatched = WatchingState.isPosterWatched( - watchedKeys = watchedKeys, + val entrySource = entry.section + DisintegratingContainer( + disintegrating = entry.exiting, + onDisintegrated = { onDisintegrated(entry.globalKey) }, + ) { + HomePosterCard( item = posterItem, - ), - onClick = onPosterClick?.let { { it(item) } }, - onLongClick = onPosterLongClick?.let { { it(item, section) } }, - ) + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = posterItem, + ), + onClick = if (entry.exiting) null else onPosterClick?.let { { it(item) } }, + onLongClick = if (entry.exiting || entrySource == null) { + null + } else { + onPosterLongClick?.let { { it(item, entrySource) } } + }, + ) + } } } } private const val LIBRARY_SECTION_PREVIEW_LIMIT = 18 + +private data class LibraryDisplayEntry( + val globalKey: String, + val item: LibraryItem, + val section: LibrarySection?, + val exiting: Boolean, +) + +private data class LibraryDisplaySection( + val source: LibrarySection?, + val type: String, + val displayTitle: String, + val previewEntries: List, +) + +private class LibraryExitingEntry( + val item: LibraryItem, + val sectionType: String, + val sectionTitle: String, + val index: Int, +) + +private fun libraryGlobalKey(sectionType: String, item: LibraryItem): String = + "$sectionType|${item.type}|${item.id}" + +private class LibraryDisintegrationHolder { + private val exiting = LinkedHashMap() + private var previous = LinkedHashMap() + private var invalidations by mutableStateOf(0) + + fun onExited(globalKey: String) { + if (exiting.remove(globalKey) != null) invalidations++ + } + + fun reset() { + exiting.clear() + previous = LinkedHashMap() + } + + fun sync(sections: List, previewLimit: Int): List { + @Suppress("UNUSED_EXPRESSION") + invalidations + + val current = LinkedHashMap() + sections.forEach { section -> + section.items.take(previewLimit).forEachIndexed { index, item -> + val key = libraryGlobalKey(section.type, item) + current[key] = LibraryExitingEntry(item, section.type, section.displayTitle, index) + } + } + for ((key, info) in previous) { + if (key !in current && key !in exiting) { + exiting[key] = info + } + } + for (key in current.keys) { + exiting.remove(key) + } + previous = current + + val exitingBySection = exiting.values.groupBy { it.sectionType } + val seenTypes = HashSet(sections.size) + val result = ArrayList(sections.size + 1) + + for (section in sections) { + seenTypes += section.type + val entries = ArrayList(previewLimit + 1) + section.items.take(previewLimit).forEach { item -> + entries += LibraryDisplayEntry( + globalKey = libraryGlobalKey(section.type, item), + item = item, + section = section, + exiting = false, + ) + } + exitingBySection[section.type]?.sortedBy { it.index }?.forEach { ex -> + val key = libraryGlobalKey(section.type, ex.item) + if (entries.none { it.globalKey == key }) { + entries.add( + ex.index.coerceIn(0, entries.size), + LibraryDisplayEntry(key, ex.item, section, exiting = true), + ) + } + } + result += LibraryDisplaySection(section, section.type, section.displayTitle, entries) + } + + for ((type, list) in exitingBySection) { + if (type in seenTypes) continue + val sorted = list.sortedBy { it.index } + val entries = sorted.map { ex -> + LibraryDisplayEntry(libraryGlobalKey(type, ex.item), ex.item, section = null, exiting = true) + } + result += LibraryDisplaySection(null, type, sorted.first().sectionTitle, entries) + } + + return result + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt index 51124197..5024ef94 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt @@ -1,39 +1,128 @@ package com.nuvio.app.features.player +import com.nuvio.app.features.player.skip.SkipInterval +import com.nuvio.app.features.player.skip.SkipIntroRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_external_downloading_subtitles +import nuvio.composeapp.generated.resources.player_external_loading_subtitles +import org.jetbrains.compose.resources.getString + +private const val SkipSegmentResolveTimeoutMs = 4_000L + +// Skip resolution runs on an app-lifetime scope rather than the caller's (often +// composition-bound) scope, so navigating to the external player cannot cancel an +// in-flight network lookup partway through. +private val skipResolveScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + /** * Orchestrates the full external player launch flow: * fetches subtitles if forwarding is enabled, downloads them to local cache, - * then returns an enriched request for the caller to dispatch. + * resolves intro/outro skip segments if enabled, then returns an enriched + * request for the caller to dispatch. */ suspend fun prepareExternalPlayerLaunch( request: ExternalPlayerPlaybackRequest, type: String, videoId: String, forwardSubtitles: Boolean, + sendSkipSegments: Boolean, preferredLanguage: String, secondaryLanguage: String?, onOverlayMessage: (String?) -> Unit, -): ExternalPlayerPlaybackRequest { - if (forwardSubtitles && !preferredLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true)) { - onOverlayMessage("Loading subtitles from addons...") +): ExternalPlayerPlaybackRequest = coroutineScope { + var result = request - val subtitles = SubtitleForwarder.fetchForExternalPlayer( - type = type, - videoId = videoId, - preferredLanguage = preferredLanguage, - secondaryLanguage = secondaryLanguage, - ) + val subtitlesDeferred = if (forwardSubtitles && !preferredLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true)) { + async { + onOverlayMessage(getString(Res.string.player_external_loading_subtitles)) - if (subtitles != null) { - onOverlayMessage("Downloading subtitles...") - val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles) - if (cachedSubtitles != null) { - return request.copy(subtitles = cachedSubtitles) + val subtitles = SubtitleForwarder.fetchForExternalPlayer( + type = type, + videoId = videoId, + preferredLanguage = preferredLanguage, + secondaryLanguage = secondaryLanguage, + ) + + if (subtitles != null) { + onOverlayMessage(getString(Res.string.player_external_downloading_subtitles)) + val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles) + // Fallback: use original URLs if caching fails + cachedSubtitles ?: subtitles + } else { + null } - // Fallback: use original URLs if caching fails - return request.copy(subtitles = subtitles) } + } else { + null } - return request + val skipSegmentsDeferred = if (sendSkipSegments) { + async { resolveSkipSegmentsJson(videoId, request.season, request.episode) } + } else { + null + } + + subtitlesDeferred?.await()?.let { subtitles -> + result = result.copy(subtitles = subtitles) + } + skipSegmentsDeferred?.await()?.let { skipSegmentsJson -> + result = result.copy(skipSegmentsJson = skipSegmentsJson) + } + + return@coroutineScope result } + +/** + * Resolves intro/outro skip segments for the given content and serializes them to the + * JSON contract understood by supporting external players: a JSON array of objects with + * `type` (String), `start` (seconds) and `end` (seconds). Returns null if nothing resolved. + * + * Bounded by a timeout so a slow provider never delays the player launch. Resolution is + * intentionally independent of the in-app skip-intro toggle (requireSkipIntroEnabled = false): + * this is its own opt-in setting. + */ +private suspend fun resolveSkipSegmentsJson(videoId: String, season: Int?, episode: Int?): String? { + val ep = episode ?: return null + val intervals = skipResolveScope.async { + withTimeoutOrNull(SkipSegmentResolveTimeoutMs) { + when { + videoId.startsWith("mal:") -> { + val malId = videoId.removePrefix("mal:").substringBefore(':') + SkipIntroRepository.getSkipIntervalsForMal(malId, ep, requireSkipIntroEnabled = false) + } + videoId.startsWith("kitsu:") -> { + val kitsuId = videoId.removePrefix("kitsu:").substringBefore(':') + SkipIntroRepository.getSkipIntervalsForKitsu(kitsuId, ep, requireSkipIntroEnabled = false) + } + else -> { + val imdbId = videoId.substringBefore(':').takeIf { it.startsWith("tt") } ?: return@withTimeoutOrNull null + val s = season ?: return@withTimeoutOrNull null + SkipIntroRepository.getSkipIntervals(imdbId, s, ep, requireSkipIntroEnabled = false) + } + } + } + }.await() + + if (intervals.isNullOrEmpty()) return null + return intervals.toSkipSegmentsJson() +} + +private fun List.toSkipSegmentsJson(): String = + buildJsonArray { + forEach { interval -> + addJsonObject { + put("type", interval.type) + put("start", interval.startTime) + put("end", interval.endTime) + } + } + }.toString() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt index 60bfd3f4..d2727938 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt @@ -21,6 +21,8 @@ data class ExternalPlayerPlaybackRequest( val season: Int? = null, val episode: Int? = null, val episodeTitle: String? = null, + // JSON array of intro/outro skip segments, passed to players that support auto-skipping. + val skipSegmentsJson: String? = null, ) { /** * Builds a display title for external players. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt index 06fe2fb5..59e99d3d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -private const val PARENTAL_GUIDE_BASE_URL = "https://api.imdbapi.dev" +private const val PARENTAL_GUIDE_BASE_URL = "https://api.tiffara.com" private val imdbIdPattern = Regex("tt\\d+") data class ParentalGuideResult( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 3c11d430..d7951031 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -34,7 +34,7 @@ import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.VideoLibrary -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider @@ -150,6 +150,7 @@ internal fun PlayerControlsShell( onParentalGuideAnimationComplete = onParentalGuideAnimationComplete, onLockToggle = onLockToggle, onVideoSettingsClick = onVideoSettingsClick, + onOpenInExternalPlayer = onOpenInExternalPlayer, onBack = onBack, modifier = Modifier .align(Alignment.TopStart) @@ -189,7 +190,6 @@ internal fun PlayerControlsShell( onAudioClick = onAudioClick, onSourcesClick = onSourcesClick, onEpisodesClick = onEpisodesClick, - onOpenInExternalPlayer = onOpenInExternalPlayer, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() @@ -218,6 +218,7 @@ private fun PlayerHeader( onParentalGuideAnimationComplete: () -> Unit, onLockToggle: () -> Unit, onVideoSettingsClick: (() -> Unit)?, + onOpenInExternalPlayer: (() -> Unit)?, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -317,6 +318,15 @@ private fun PlayerHeader( onClick = onSubmitIntroClick, ) } + if (onOpenInExternalPlayer != null) { + PlayerHeaderIconButton( + icon = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = stringResource(Res.string.streams_open_external_player), + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onOpenInExternalPlayer, + ) + } PlayerHeaderIconButton( icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, contentDescription = if (isLocked) { @@ -453,9 +463,8 @@ private fun PlayPauseControlButton( contentAlignment = Alignment.Center, ) { if (isBuffering) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = Color.White, - strokeWidth = 3.dp, modifier = Modifier.size(metrics.playIconSize), ) } else { @@ -487,7 +496,6 @@ private fun ProgressControls( onAudioClick: () -> Unit, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, - onOpenInExternalPlayer: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) @@ -569,13 +577,6 @@ private fun ProgressControls( onClick = onEpisodesClick, ) } - if (onOpenInExternalPlayer != null) { - PlayerActionPillButton( - label = stringResource(Res.string.streams_open_external_player), - icon = Icons.AutoMirrored.Rounded.OpenInNew, - onClick = onOpenInExternalPlayer, - ) - } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 44195362..92cbcc59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -29,6 +29,8 @@ interface PlayerEngineController { fun applySubtitleStyle(style: SubtitleStyleState) {} fun setSubtitleDelayMs(delayMs: Int) {} fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} + fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {} + fun clearNowPlayingInfo() {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 7a0af09c..74e3cfe2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -32,7 +32,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -576,9 +576,8 @@ private fun EpisodeStreamsSubView( .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = tokens.colors.accent, - strokeWidth = tokens.borders.medium, modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), ) } @@ -608,7 +607,7 @@ private fun EpisodeStreamsSubView( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> StreamCard( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 7f5efb6f..f4c21143 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -1,7 +1,6 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable -import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.player_ios_hardware_decoder_off import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_desc @@ -14,11 +13,6 @@ import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_de import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_label import org.jetbrains.compose.resources.stringResource -@Serializable -data class PlayerRoute( - val launchId: Long, -) - data class PlayerLaunch( val profileId: Int, val title: String, @@ -223,4 +217,12 @@ data class PlayerPlaybackSnapshot( val positionMs: Long = 0L, val bufferedPositionMs: Long = 0L, val playbackSpeed: Float = 1f, + val videoWidth: Int = 0, + val videoHeight: Int = 0, +) + +data class PlayerNowPlayingInfo( + val title: String, + val subtitle: String? = null, + val artworkUrl: String? = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index 6ff03c85..d3ae9b4b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -40,7 +40,7 @@ import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.FastForward import androidx.compose.material.icons.rounded.FastRewind import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -238,9 +238,8 @@ internal fun OpeningOverlay( }, ) } else { - CircularProgressIndicator( + NuvioLoadingIndicator( color = Color(0xFFE50914), - strokeWidth = 3.dp, modifier = Modifier.size(54.dp), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt index 024b0e50..1b8754ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt @@ -24,8 +24,11 @@ expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) @Composable expect fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) +@Composable +expect fun rememberIsInPictureInPicture(): Boolean + @Composable expect fun rememberPlayerGestureController(): PlayerGestureController? 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 5abffd25..c2232c34 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 @@ -20,6 +20,7 @@ fun PlayerScreen( pauseDescription: String? = null, onBack: () -> Unit, onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)? = null, + onOpenExternalUrl: ((String) -> Unit)? = null, modifier: Modifier = Modifier, logo: String? = null, poster: String? = null, @@ -58,6 +59,7 @@ fun PlayerScreen( pauseDescription = pauseDescription, onBack = onBack, onOpenInExternalPlayer = onOpenInExternalPlayer, + onOpenExternalUrl = onOpenExternalUrl, modifier = modifier, logo = logo, poster = poster, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt index 4ff97734..bc381ecc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -17,6 +17,7 @@ internal data class PlayerScreenArgs( val pauseDescription: String?, val onBack: () -> Unit, val onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)?, + val onOpenExternalUrl: ((String) -> Unit)?, val modifier: Modifier, val logo: String?, val poster: String?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt index bc1363e5..af2cab91 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.IntSize import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository @@ -138,7 +139,10 @@ internal fun PlayerScreenContent(args: PlayerScreenArgs) { EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) ManagePlayerPictureInPicture( isPlaying = runtime.playbackSnapshot.isPlaying, - playerSize = runtime.layoutSize, + videoSize = IntSize( + runtime.playbackSnapshot.videoWidth, + runtime.playbackSnapshot.videoHeight, + ), ) runtime.BindPlayerRuntimeEffects() runtime.RenderPlayerRuntimeUi() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 0b2d5f3e..81e1cf1b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -164,6 +164,23 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { playerController?.applySubtitleStyle(subtitleStyle) } + LaunchedEffect( + playerController, + playerControllerSourceUrl, + activeSourceUrl, + title, + activeStreamTitle, + activeSeasonNumber, + activeEpisodeNumber, + activeEpisodeTitle, + poster, + background, + ) { + val controller = playerController ?: return@LaunchedEffect + if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect + controller.updateNowPlayingMetadata(buildNowPlayingInfo()) + } + LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) { val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) { @@ -255,6 +272,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { DisposableEffect(Unit) { onDispose { + playerController?.clearNowPlayingInfo() P2pStreamingEngine.shutdown() PlayerStreamsRepository.clearAll() } @@ -469,6 +487,45 @@ private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() { } } +private fun PlayerScreenRuntime.buildNowPlayingInfo(): PlayerNowPlayingInfo { + val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + return PlayerNowPlayingInfo( + title = title.ifBlank { activeStreamTitle }, + subtitle = buildNowPlayingSubtitle( + isEpisode = isEpisode, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + ), + artworkUrl = firstNonBlankUrl(poster, background), + ) +} + +private fun buildNowPlayingSubtitle( + isEpisode: Boolean, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, +): String? { + if (!isEpisode) return null + + val episodeParts = buildList { + if (seasonNumber != null && episodeNumber != null) { + add("S${seasonNumber}E${episodeNumber}") + } + episodeTitle?.takeIf { it.isNotBlank() }?.let { add(it) } + } + + return when (episodeParts.size) { + 0 -> null + 1 -> episodeParts.first() + else -> "${episodeParts[0]} - ${episodeParts[1]}" + } +} + +private fun firstNonBlankUrl(vararg values: String?): String? = + values.firstOrNull { !it.isNullOrBlank() }?.trim() + internal fun PlayerScreenRuntime.removeFailedStreamFromCache() { val currentVideoId = activeVideoId ?: return val cacheKey = StreamLinkCacheRepository.contentKey( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 8f7be4f9..51654899 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -173,6 +173,7 @@ internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { } internal fun PlayerScreenRuntime.tryShowParentalGuide() { + if (!playerSettingsUiState.showParentalGuide) return if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { playbackStartedForParentalGuide = true controlsVisible = true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 43095cdc..86b44aed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -49,6 +49,17 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?) internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = stream.needsLocalDebridResolve && stream.p2pInfoHash != null +internal fun PlayerScreenRuntime.openExternalSourceUrl(stream: StreamItem): Boolean { + if (!stream.shouldOpenExternally) return false + val url = stream.externalOpenUrl ?: return false + val openExternalUrl = args.onOpenExternalUrl ?: return false + openExternalUrl(url) + showSourcesPanel = false + showEpisodesPanel = false + controlsVisible = true + return true +} + internal fun StreamItem.playerSourceIdentityKey(): String? { p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> return "torrent:$hash:${p2pFileIdx ?: -1}" @@ -240,6 +251,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { switchToP2pSourceStream(stream) return } + if (openExternalSourceUrl(stream)) return val url = stream.playableDirectUrl ?: return val sourceIdentityKey = stream.playerSourceIdentityKey() if (url == activeSourceUrl) { @@ -292,6 +304,7 @@ internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episo switchToP2pEpisodeStream(stream, episode) return } + if (openExternalSourceUrl(stream)) return val url = stream.playableDirectUrl ?: return resetEpisodePanelAndNextEpisodeState() flushWatchProgress() @@ -322,7 +335,12 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa fallbackVideoId = episode.id, ) val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId - val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + val epEntry = WatchProgressRepository.progressForVideo( + videoId = resolvedVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + ) ?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } @@ -426,7 +444,10 @@ private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: fallbackVideoId = epVideoId, ) val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + videoId = epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, )?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 42fdfd90..6c1350f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -20,6 +20,7 @@ import nuvio.composeapp.generated.resources.* @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this + val isInPip = rememberIsInPictureInPicture() val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback @@ -189,8 +190,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { @Composable private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) { + val isInPip = rememberIsInPictureInPicture() AnimatedVisibility( - visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, + visible = (controlsVisible || showParentalGuide) && !playerControlsLocked && !isInPip, enter = fadeIn(), exit = fadeOut(), ) { @@ -467,7 +469,7 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { parentMetaId = parentMetaId, activeSeasonNumber = activeSeasonNumber, activeEpisodeNumber = activeEpisodeNumber, - watchProgressByVideoId = watchProgressUiState.byVideoId, + watchProgressByVideoId = watchProgressUiState.byVideoIdForContent(parentMetaId), watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, episodeStreamsPanelState = episodeStreamsPanelState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 900553a3..c0f4c429 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -33,12 +33,14 @@ fun snapToAllowedTimeout(value: Int): Int { data class PlayerSettingsUiState( val showLoadingOverlay: Boolean = true, + val showParentalGuide: Boolean = true, val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, val holdToSpeedEnabled: Boolean = true, val holdToSpeedValue: Float = 2f, val touchGesturesEnabled: Boolean = true, val externalPlayerEnabled: Boolean = false, val externalPlayerForwardSubtitles: Boolean = false, + val externalPlayerSendSkipSegments: Boolean = false, val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(), val preferredAudioLanguage: String = AudioLanguageOption.DEVICE, val secondaryPreferredAudioLanguage: String? = null, @@ -97,12 +99,14 @@ object PlayerSettingsRepository { private var hasLoaded = false private var showLoadingOverlay = true + private var showParentalGuide = true private var resizeMode = PlayerResizeMode.Fit private var holdToSpeedEnabled = true private var holdToSpeedValue = 2f private var touchGesturesEnabled = true private var externalPlayerEnabled = false private var externalPlayerForwardSubtitles = false + private var externalPlayerSendSkipSegments = false private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId() private var preferredAudioLanguage = AudioLanguageOption.DEVICE private var secondaryPreferredAudioLanguage: String? = null @@ -166,12 +170,14 @@ object PlayerSettingsRepository { fun clearLocalState() { hasLoaded = false showLoadingOverlay = true + showParentalGuide = true resizeMode = PlayerResizeMode.Fit holdToSpeedEnabled = true holdToSpeedValue = 2f touchGesturesEnabled = true externalPlayerEnabled = false externalPlayerForwardSubtitles = false + externalPlayerSendSkipSegments = false externalPlayerId = ExternalPlayerPlatform.defaultPlayerId() preferredAudioLanguage = AudioLanguageOption.DEVICE secondaryPreferredAudioLanguage = null @@ -228,6 +234,7 @@ object PlayerSettingsRepository { private fun loadFromDisk() { hasLoaded = true showLoadingOverlay = PlayerSettingsStorage.loadShowLoadingOverlay() ?: true + showParentalGuide = PlayerSettingsStorage.loadShowParentalGuide() ?: true resizeMode = PlayerSettingsStorage.loadResizeMode() ?.let { runCatching { PlayerResizeMode.valueOf(it) }.getOrNull() } ?: PlayerResizeMode.Fit @@ -236,6 +243,7 @@ object PlayerSettingsRepository { touchGesturesEnabled = PlayerSettingsStorage.loadTouchGesturesEnabled() ?: true externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false + externalPlayerSendSkipSegments = PlayerSettingsStorage.loadExternalPlayerSendSkipSegments() ?: false externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId() ?: ExternalPlayerPlatform.defaultPlayerId() preferredAudioLanguage = @@ -366,6 +374,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveShowLoadingOverlay(enabled) } + fun setShowParentalGuide(enabled: Boolean) { + ensureLoaded() + if (showParentalGuide == enabled) return + showParentalGuide = enabled + publish() + PlayerSettingsStorage.saveShowParentalGuide(enabled) + } + fun setResizeMode(mode: PlayerResizeMode) { ensureLoaded() if (resizeMode == mode) return @@ -432,6 +448,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveExternalPlayerForwardSubtitles(enabled) } + fun setExternalPlayerSendSkipSegments(enabled: Boolean) { + ensureLoaded() + if (externalPlayerSendSkipSegments == enabled) return + externalPlayerSendSkipSegments = enabled + publish() + PlayerSettingsStorage.saveExternalPlayerSendSkipSegments(enabled) + } + fun setPreferredAudioLanguage(language: String) { ensureLoaded() val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE @@ -887,12 +911,14 @@ object PlayerSettingsRepository { private fun publish() { _uiState.value = PlayerSettingsUiState( showLoadingOverlay = showLoadingOverlay, + showParentalGuide = showParentalGuide, resizeMode = resizeMode, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, touchGesturesEnabled = touchGesturesEnabled, externalPlayerEnabled = externalPlayerEnabled, externalPlayerForwardSubtitles = externalPlayerForwardSubtitles, + externalPlayerSendSkipSegments = externalPlayerSendSkipSegments, externalPlayerId = externalPlayerId, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 8ca6050e..312a0764 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonObject internal expect object PlayerSettingsStorage { fun loadShowLoadingOverlay(): Boolean? fun saveShowLoadingOverlay(enabled: Boolean) + fun loadShowParentalGuide(): Boolean? + fun saveShowParentalGuide(enabled: Boolean) fun loadResizeMode(): String? fun saveResizeMode(mode: String) fun loadHoldToSpeedEnabled(): Boolean? @@ -17,6 +19,8 @@ internal expect object PlayerSettingsStorage { fun saveExternalPlayerEnabled(enabled: Boolean) fun loadExternalPlayerForwardSubtitles(): Boolean? fun saveExternalPlayerForwardSubtitles(enabled: Boolean) + fun loadExternalPlayerSendSkipSegments(): Boolean? + fun saveExternalPlayerSendSkipSegments(enabled: Boolean) fun loadExternalPlayerId(): String? fun saveExternalPlayerId(playerId: String?) fun loadPreferredAudioLanguage(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index b370fb3f..d3bafa5f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -176,9 +176,8 @@ fun PlayerSourcesPanel( .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = tokens.colors.accent, - strokeWidth = tokens.borders.medium, modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), ) } @@ -208,7 +207,7 @@ fun PlayerSourcesPanel( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> val isCurrent = isCurrentStream( stream = stream, @@ -272,9 +271,8 @@ internal fun AddonFilterChip( horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), ) { if (isLoading) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = tokens.colors.accent, - strokeWidth = tokens.borders.thin + NuvioTokens.Space.hairline, modifier = Modifier.size(NuvioTokens.Icon.xs), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 72a9bc50..8ce75e36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -129,7 +129,7 @@ object PlayerSubtitleCueParser { } private fun parseTtml(text: String): List = - Regex("""]*)>(.*?)

""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + Regex("""]*)>([\s\S]*?)

""", RegexOption.IGNORE_CASE) .findAll(text) .mapNotNull { match -> val attrs = match.groupValues[1] diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index 415f2e2d..2671b0ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CloudDownload -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -317,9 +317,8 @@ private fun AddonSubtitleList( .padding(40.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = colorScheme.primary, - strokeWidth = 2.dp, modifier = Modifier.size(32.dp), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt index 9e96d4aa..d99dc978 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.player.skip import com.nuvio.app.features.player.PlayerSettingsRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope object SkipIntroRepository { @@ -12,27 +14,156 @@ object SkipIntroRepository { private val introDbConfigured: Boolean get() = IntroDbConfig.URL.isNotBlank() - suspend fun getSkipIntervals(imdbId: String?, season: Int, episode: Int): List { - if (imdbId == null) return emptyList() + suspend fun getSkipIntervals( + imdbId: String?, + season: Int, + episode: Int, + requireSkipIntroEnabled: Boolean = true, + ): List = coroutineScope { + if (imdbId == null) return@coroutineScope emptyList() val settings = PlayerSettingsRepository.uiState.value - if (!settings.skipIntroEnabled) return emptyList() + if (requireSkipIntroEnabled && !settings.skipIntroEnabled) return@coroutineScope emptyList() val cacheKey = "$imdbId:$season:$episode" - cache[cacheKey]?.let { return it } + cache[cacheKey]?.let { return@coroutineScope it } - if (introDbConfigured) { - val result = fetchFromIntroDb(imdbId, season, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } + val introDbDeferred = async { + if (introDbConfigured) fetchFromIntroDb(imdbId, season, episode) else emptyList() } - - val entries = resolveImdbEntries(imdbId) + val entriesDeferred = async { resolveImdbEntries(imdbId) } + val entries = entriesDeferred.await() + val animeSkipDeferred = async { fetchAnimeSkipForEntries(entries, season, episode) } val malId = entries.getOrNull(season - 1)?.myanimelist?.toString() ?: entries.firstOrNull()?.myanimelist?.toString() - if (malId != null) { - val result = fetchFromAniSkip(malId, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } + val aniSkipDeferred = async { + if (malId != null) fetchFromAniSkip(malId, episode) else emptyList() } + return@coroutineScope mergeByPriority( + introDbDeferred.await(), + animeSkipDeferred.await(), + aniSkipDeferred.await(), + ).also { cache[cacheKey] = it } + } + + suspend fun getSkipIntervalsForMal( + malId: String, + episode: Int, + requireSkipIntroEnabled: Boolean = true, + ): List = coroutineScope { + val settings = PlayerSettingsRepository.uiState.value + if (requireSkipIntroEnabled && !settings.skipIntroEnabled) return@coroutineScope emptyList() + + val cacheKey = "mal:$malId:$episode" + cache[cacheKey]?.let { return@coroutineScope it } + + val aniSkipDeferred = async { fetchFromAniSkip(malId, episode) } + + val imdbIdDeferred = async { + try { + SkipIntroApi.resolveMalToImdb(malId)?.imdb + } catch (_: Exception) { null } + } + + var introDb = emptyList() + var animeSkip = emptyList() + val imdbId = imdbIdDeferred.await() + if (imdbId != null) { + val entries = resolveImdbEntries(imdbId) + val season = entries.indexOfFirst { it.myanimelist == malId.toIntOrNull() } + 1 + val introDbDeferred = async { + if (introDbConfigured) fetchFromIntroDb(imdbId, season, episode) else emptyList() + } + val animeSkipDeferred = async { fetchAnimeSkipForEntries(entries, season, episode) } + introDb = introDbDeferred.await() + animeSkip = animeSkipDeferred.await() + } else { + val anilistId = try { + SkipIntroApi.resolveMalToAnilist(malId)?.anilist?.toString() + } catch (_: Exception) { null } + if (anilistId != null) animeSkip = fetchFromAnimeSkip(anilistId, episode, season = null) + } + + return@coroutineScope mergeByPriority(introDb, animeSkip, aniSkipDeferred.await()).also { cache[cacheKey] = it } + } + + suspend fun getSkipIntervalsForKitsu( + kitsuId: String, + episode: Int, + requireSkipIntroEnabled: Boolean = true, + ): List = coroutineScope { + val settings = PlayerSettingsRepository.uiState.value + if (requireSkipIntroEnabled && !settings.skipIntroEnabled) return@coroutineScope emptyList() + + val cacheKey = "kitsu:$kitsuId:$episode" + cache[cacheKey]?.let { return@coroutineScope it } + + val malIdDeferred = async { + try { + SkipIntroApi.resolveKitsuToMal(kitsuId)?.myanimelist?.toString() + } catch (_: Exception) { null } + } + val imdbIdDeferred = async { + try { + SkipIntroApi.resolveKitsuToImdb(kitsuId)?.imdb + } catch (_: Exception) { null } + } + val aniSkipDeferred = async { + malIdDeferred.await()?.let { fetchFromAniSkip(it, episode) } ?: emptyList() + } + + var introDb = emptyList() + var animeSkip = emptyList() + val imdbId = imdbIdDeferred.await() + if (imdbId != null) { + val entries = resolveImdbEntries(imdbId) + val season = entries.indexOfFirst { it.kitsu == kitsuId.toIntOrNull() } + 1 + val introDbDeferred = async { + if (introDbConfigured) fetchFromIntroDb(imdbId, season, episode) else emptyList() + } + val animeSkipDeferred = async { fetchAnimeSkipForEntries(entries, season, episode) } + introDb = introDbDeferred.await() + animeSkip = animeSkipDeferred.await() + } else { + val anilistId = try { + SkipIntroApi.resolveKitsuToAnilist(kitsuId)?.anilist?.toString() + } catch (_: Exception) { null } + if (anilistId != null) animeSkip = fetchFromAnimeSkip(anilistId, episode, season = null) + } + + return@coroutineScope mergeByPriority(introDb, animeSkip, aniSkipDeferred.await()).also { cache[cacheKey] = it } + } + + /** + * Merge provider results into one best-of: fill each segment category (opening / ending / + * recap) from the highest-priority provider that has it. Arguments MUST be passed in priority + * order (IntroDB has the broadest coverage, then Anime-Skip, then AniSkip), so a partial + * result from one provider never shadows a complete segment from another. + */ + private fun mergeByPriority(vararg providerResults: List): List { + val chosen = LinkedHashMap() + for (result in providerResults) { + for (interval in result) { + val category = segmentCategory(interval.type) ?: continue + if (category !in chosen) chosen[category] = interval + } + } + return chosen.values.toList() + } + + private fun segmentCategory(type: String): String? = when (type.lowercase()) { + "intro", "op", "mixed-op" -> "opening" + "outro", "ed", "mixed-ed", "credits", "ending" -> "ending" + "recap" -> "recap" + else -> null + } + + // AnimeSkip: season-specific AniList ID first, then season-1 as a season-filtered fallback. + private suspend fun fetchAnimeSkipForEntries( + entries: List, + season: Int, + episode: Int + ): List { val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() for ((anilistId, seasonFilter) in listOfNotNull( @@ -40,104 +171,9 @@ object SkipIntroRepository { if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null )) { val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } + if (result.isNotEmpty()) return result } - - return emptyList().also { cache[cacheKey] = it } - } - - suspend fun getSkipIntervalsForMal(malId: String, episode: Int): List { - val settings = PlayerSettingsRepository.uiState.value - if (!settings.skipIntroEnabled) return emptyList() - - val cacheKey = "mal:$malId:$episode" - cache[cacheKey]?.let { return it } - - val aniSkipResult = fetchFromAniSkip(malId, episode) - if (aniSkipResult.isNotEmpty()) return aniSkipResult.also { cache[cacheKey] = it } - - val imdbId = try { - SkipIntroApi.resolveMalToImdb(malId)?.imdb - } catch (_: Exception) { null } - - if (imdbId != null) { - val entries = resolveImdbEntries(imdbId) - val season = entries.indexOfFirst { it.myanimelist == malId.toIntOrNull() } + 1 - - if (introDbConfigured) { - val result = fetchFromIntroDb(imdbId, season, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() - val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() - for ((anilistId, seasonFilter) in listOfNotNull( - seasonAnilistId?.let { it to null }, - if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null - )) { - val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - } else { - val anilistId = try { - SkipIntroApi.resolveMalToAnilist(malId)?.anilist?.toString() - } catch (_: Exception) { null } - if (anilistId != null) { - val result = fetchFromAnimeSkip(anilistId, episode, season = null) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - } - - return emptyList().also { cache[cacheKey] = it } - } - - suspend fun getSkipIntervalsForKitsu(kitsuId: String, episode: Int): List { - val settings = PlayerSettingsRepository.uiState.value - if (!settings.skipIntroEnabled) return emptyList() - - val cacheKey = "kitsu:$kitsuId:$episode" - cache[cacheKey]?.let { return it } - - val malId = try { - SkipIntroApi.resolveKitsuToMal(kitsuId)?.myanimelist?.toString() - } catch (_: Exception) { null } - - if (malId != null) { - val result = fetchFromAniSkip(malId, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - - val imdbId = try { - SkipIntroApi.resolveKitsuToImdb(kitsuId)?.imdb - } catch (_: Exception) { null } - - if (imdbId != null) { - val entries = resolveImdbEntries(imdbId) - val season = entries.indexOfFirst { it.kitsu == kitsuId.toIntOrNull() } + 1 - - if (introDbConfigured) { - val result = fetchFromIntroDb(imdbId, season, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() - val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() - for ((anilistId, seasonFilter) in listOfNotNull( - seasonAnilistId?.let { it to null }, - if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null - )) { - val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - } else { - val anilistId = try { - SkipIntroApi.resolveKitsuToAnilist(kitsuId)?.anilist?.toString() - } catch (_: Exception) { null } - if (anilistId != null) { - val result = fetchFromAnimeSkip(anilistId, episode, season = null) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - } - - return emptyList().also { cache[cacheKey] = it } + return emptyList() } private suspend fun fetchFromIntroDb(imdbId: String, season: Int, episode: Int): List { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt index 8be4314e..b173d239 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt @@ -28,7 +28,7 @@ import androidx.compose.material.icons.rounded.Replay import androidx.compose.material.icons.rounded.Send import androidx.compose.material.icons.rounded.StopCircle import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -228,10 +228,9 @@ fun SubmitIntroDialog( contentAlignment = Alignment.Center ) { if (isSubmitting) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(24.dp), - strokeWidth = 2.dp ) } else { Row( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index 32f0dc00..ac0a9ca4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -1,10 +1,11 @@ package com.nuvio.app.features.profiles import androidx.compose.ui.graphics.Color -import com.nuvio.app.core.network.SyncBackendRepository import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +const val MAX_PROFILES = 6 + @Serializable data class NuvioProfile( val id: String = "", @@ -78,7 +79,7 @@ val PROFILE_COLORS = listOf( ) fun avatarStorageUrl(storagePath: String): String = - SyncBackendRepository.selectedBackend.avatarStorageUrl(storagePath) + "${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath" fun normalizedAvatarUrl(url: String?): String? = url?.trim()?.takeIf { it.isValidAvatarUrl() } 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 cbc1c554..fd76b3fe 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 @@ -5,6 +5,7 @@ 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.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionRepository @@ -12,6 +13,7 @@ import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeRepository +import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository @@ -161,6 +163,7 @@ object ProfileRepository { } ThemeSettingsRepository.onProfileChanged() PosterCardStyleRepository.onProfileChanged() + CardDepthStyleRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() StreamBadgeSettingsRepository.onProfileChanged() P2pSettingsRepository.onProfileChanged() @@ -185,7 +188,9 @@ object ProfileRepository { } try { val params = buildJsonObject { + put("p_client_max_profiles", MAX_PROFILES) put("p_profiles", json.encodeToJsonElement(profiles)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_profiles", params) pullProfiles() @@ -203,7 +208,7 @@ object ProfileRepository { usesPrimaryAddons: Boolean = false, ) { val existing = _state.value.profiles - val nextIndex = ((1..4).toSet() - existing.map { it.profileIndex }.toSet()).minOrNull() ?: return + val nextIndex = ((1..MAX_PROFILES).toSet() - existing.map { it.profileIndex }.toSet()).minOrNull() ?: return val allPayloads = existing.map { profile -> ProfilePushPayload( @@ -276,7 +281,10 @@ object ProfileRepository { return } try { - val params = buildJsonObject { put("p_profile_id", profileIndex) } + val params = buildJsonObject { + put("p_profile_id", profileIndex) + putSyncOriginClientId() + } SupabaseProvider.client.postgrest.rpc("sync_delete_profile_data", params) pullProfiles() } catch (e: Throwable) { @@ -478,7 +486,7 @@ object ProfileRepository { private fun syncPinCache(profiles: List) { val profilesByIndex = profiles.associateBy { it.profileIndex } - for (profileIndex in 1..4) { + for (profileIndex in 1..MAX_PROFILES) { val profile = profilesByIndex[profileIndex] if (profile == null || !profile.pinEnabled) { ProfilePinCacheStorage.removePayload(profileIndex) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 25761a45..012fd406 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -147,7 +147,7 @@ fun ProfileSelectionScreen( Spacer(modifier = Modifier.height(if (isTabletLayout) 28.dp else 48.dp)) val profiles = profileState.profiles - val items = profiles.size + if (profiles.size < 4) 1 else 0 + val items = profiles.size + if (profiles.size < MAX_PROFILES) 1 else 0 if (isTabletLayout) { Box( 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 5e9b4795..d330bf12 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +30,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Backspace import androidx.compose.material.icons.rounded.Add @@ -269,6 +271,7 @@ fun ProfileSwitcherTab( Column(horizontalAlignment = Alignment.CenterHorizontally) { // Profile avatars row Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), verticalAlignment = Alignment.Top, ) { @@ -295,7 +298,7 @@ fun ProfileSwitcherTab( ) } - if (profiles.size < 4) { + if (profiles.size < MAX_PROFILES) { PopupAddProfileBubble( delayMs = profiles.size * 50, onClick = { @@ -458,6 +461,7 @@ fun NativeProfileSwitcherPopup( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), verticalAlignment = Alignment.Top, ) { @@ -473,7 +477,7 @@ fun NativeProfileSwitcherPopup( ) } - if (profiles.size < 4) { + if (profiles.size < MAX_PROFILES) { PopupAddProfileBubble( delayMs = profiles.size * 50, onClick = { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 2cf53cba..ed58b7f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -55,6 +55,7 @@ internal fun LazyListScope.discoverContent( onGenreSelected: (String?) -> Unit, onRetry: (() -> Unit)? = null, watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { @@ -117,6 +118,7 @@ internal fun LazyListScope.discoverContent( columns = columns, modifier = Modifier.padding(horizontal = 16.dp), watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) @@ -197,6 +199,7 @@ private fun DiscoverGridRow( columns: Int, modifier: Modifier = Modifier, watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { @@ -216,6 +219,7 @@ private fun DiscoverGridRow( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, @@ -248,7 +252,12 @@ private fun DiscoverPosterTile( .aspectRatio(item.posterShape.discoverAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) - .posterCardClickable(onClick = onClick, onLongClick = onLongClick), + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = item.poster, + zoomCornerRadius = cornerRadiusDp.dp, + ), ) { if (item.poster != null) { AsyncImage( @@ -315,10 +324,9 @@ private fun CatalogLoadingFooter(modifier: Modifier = Modifier) { .padding(vertical = 8.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(22.dp), color = MaterialTheme.colorScheme.primary, - strokeWidth = 2.dp, ) } } 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 c2603126..4520befe 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 @@ -111,6 +111,7 @@ fun SearchScreen( }.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var lastRequestedQuery by rememberSaveable { mutableStateOf(null) } @@ -305,6 +306,7 @@ fun SearchScreen( SearchRepository.refreshDiscover(addonsUiState.addons) }, watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) @@ -360,6 +362,7 @@ fun SearchScreen( section = section, modifier = Modifier.padding(bottom = 12.dp), watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) 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 7cc47203..77e06eb8 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 @@ -28,14 +28,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.build.AppFeaturePolicy -import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio -import com.nuvio.app.features.dev.DebugSyncBackendSwitch -import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel @@ -53,7 +50,6 @@ import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_ti import nuvio.composeapp.generated.resources.settings_account_status import nuvio.composeapp.generated.resources.settings_account_status_anonymous import nuvio.composeapp.generated.resources.settings_account_status_signed_in -import nuvio.composeapp.generated.resources.settings_account_sync_backend import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.accountSettingsContent( @@ -69,13 +65,11 @@ private fun AccountSettingsBody( isTablet: Boolean, ) { val authState by AuthRepository.state.collectAsStateWithLifecycle() - val syncBackendState by SyncBackendRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showSignOutConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } var isDeletingAccount by remember { mutableStateOf(false) } var deleteErrorMessage by remember { mutableStateOf(null) } - val syncBackendLabel = syncBackendState.selectedBackend.displayName val deleteAccountFallbackMessage = stringResource(Res.string.auth_account_deletion_failed) val canDeleteAccount = AppFeaturePolicy.accountDeletionEnabled && authState is AuthState.Authenticated @@ -116,20 +110,6 @@ private fun AccountSettingsBody( ) } } - - if (shouldShowDebugSyncBackendSwitch()) { - Spacer(modifier = Modifier.height(8.dp)) - DebugSyncBackendSwitch( - modifier = Modifier.fillMaxWidth(), - requireConfirmation = true, - ) - } else { - Spacer(modifier = Modifier.height(8.dp)) - AccountInfoRow( - label = stringResource(Res.string.settings_account_sync_backend), - value = syncBackendLabel, - ) - } } NuvioPrimaryButton( @@ -258,14 +238,14 @@ private fun AccountInfoRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - modifier = Modifier.weight(1f), text = value, + modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, color = valueColor, fontWeight = FontWeight.Medium, + textAlign = TextAlign.End, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.End, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt index e5967b23..7dc361ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt @@ -1,23 +1,62 @@ package com.nuvio.app.features.settings +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +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.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_subtitle import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile_description import nuvio.composeapp.generated.resources.settings_advanced_section_cache +import nuvio.composeapp.generated.resources.settings_advanced_section_diagnostics import nuvio.composeapp.generated.resources.settings_advanced_section_startup +import nuvio.composeapp.generated.resources.settings_advanced_sentry_reports +import nuvio.composeapp.generated.resources.settings_advanced_sentry_reports_subtitle +import nuvio.composeapp.generated.resources.sentry_disable_dialog_subtitle +import nuvio.composeapp.generated.resources.sentry_disable_dialog_title +import nuvio.composeapp.generated.resources.sentry_enable_dialog_subtitle +import nuvio.composeapp.generated.resources.sentry_enable_dialog_title +import nuvio.composeapp.generated.resources.sentry_help_body +import nuvio.composeapp.generated.resources.sentry_help_title +import nuvio.composeapp.generated.resources.sentry_keep_enabled +import nuvio.composeapp.generated.resources.sentry_not_sent_body +import nuvio.composeapp.generated.resources.sentry_not_sent_title +import nuvio.composeapp.generated.resources.sentry_sent_body +import nuvio.composeapp.generated.resources.sentry_sent_title +import nuvio.composeapp.generated.resources.sentry_turn_off +import nuvio.composeapp.generated.resources.sentry_turn_on import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.advancedSettingsContent( @@ -40,6 +79,43 @@ internal fun LazyListScope.advancedSettingsContent( } } } + if (SentrySettingsRepository.isSupported) { + item { + val sentryEnabledFlow = remember { + SentrySettingsRepository.ensureLoaded() + SentrySettingsRepository.enabled + } + val sentryEnabled by sentryEnabledFlow.collectAsStateWithLifecycle() + var showSentryDialog by rememberSaveable { mutableStateOf(false) } + + SettingsSection( + title = stringResource(Res.string.settings_advanced_section_diagnostics), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_advanced_sentry_reports), + description = stringResource(Res.string.settings_advanced_sentry_reports_subtitle), + checked = sentryEnabled, + isTablet = isTablet, + onCheckedChange = { showSentryDialog = true }, + ) + } + } + + if (showSentryDialog) { + SentrySettingsDialog( + enabled = sentryEnabled, + onConfirm = { + SentrySettingsRepository.setEnabled(!sentryEnabled) + }, + onDismiss = { + showSentryDialog = false + }, + ) + } + } + } item { SettingsSection( title = stringResource(Res.string.settings_advanced_section_cache), @@ -61,7 +137,7 @@ internal fun LazyListScope.advancedSettingsContent( ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId) cleared = true scope.launch { - WatchProgressRepository.forceSnapshotRefreshFromServer( + WatchProgressRepository.clearLocalAndForceSnapshotRefreshFromServer( ProfileRepository.activeProfileId, ) } @@ -72,3 +148,132 @@ internal fun LazyListScope.advancedSettingsContent( } } } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun SentrySettingsDialog( + enabled: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = tokens.colors.surfaceDialog, + shape = tokens.shapes.dialog, + ) { + Column( + modifier = Modifier.padding(tokens.spacing.dialogPadding), + ) { + Text( + text = stringResource( + if (enabled) { + Res.string.sentry_disable_dialog_title + } else { + Res.string.sentry_enable_dialog_title + }, + ), + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(tokens.spacing.controlGap)) + Text( + text = stringResource( + if (enabled) { + Res.string.sentry_disable_dialog_subtitle + } else { + Res.string.sentry_enable_dialog_subtitle + }, + ), + style = MaterialTheme.typography.bodyLarge, + color = tokens.colors.textMuted, + ) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s18)) + Column( + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s12), + ) { + SentryInfoSection( + title = stringResource(Res.string.sentry_help_title), + body = stringResource(Res.string.sentry_help_body), + ) + SentryInfoSection( + title = stringResource(Res.string.sentry_sent_title), + body = stringResource(Res.string.sentry_sent_body), + ) + SentryInfoSection( + title = stringResource(Res.string.sentry_not_sent_title), + body = stringResource(Res.string.sentry_not_sent_body), + ) + } + Spacer(modifier = Modifier.height(NuvioTokens.Space.s18)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = onDismiss, + shape = tokens.shapes.button, + colors = ButtonDefaults.buttonColors( + containerColor = tokens.colors.surfaceCard, + contentColor = tokens.colors.textPrimary, + ), + ) { + Text( + text = stringResource( + if (enabled) { + Res.string.sentry_keep_enabled + } else { + Res.string.action_cancel + }, + ), + ) + } + Spacer(modifier = Modifier.width(NuvioTokens.Space.s10)) + Button( + onClick = { + onConfirm() + onDismiss() + }, + shape = tokens.shapes.button, + ) { + Text( + text = stringResource( + if (enabled) { + Res.string.sentry_turn_off + } else { + Res.string.sentry_turn_on + }, + ), + ) + } + } + } + } + } +} + +@Composable +private fun SentryInfoSection( + title: String, + body: String, +) { + val tokens = MaterialTheme.nuvio + Column( + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + } +} 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 d5023573..d886af8c 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 @@ -1,20 +1,26 @@ package com.nuvio.app.features.settings import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.lang_bulgarian import nuvio.composeapp.generated.resources.lang_czech import nuvio.composeapp.generated.resources.lang_english import nuvio.composeapp.generated.resources.lang_french import nuvio.composeapp.generated.resources.lang_german import nuvio.composeapp.generated.resources.lang_greek +import nuvio.composeapp.generated.resources.lang_hungarian import nuvio.composeapp.generated.resources.lang_indonesian import nuvio.composeapp.generated.resources.lang_italian import nuvio.composeapp.generated.resources.lang_polish import nuvio.composeapp.generated.resources.lang_portuguese_brazil import nuvio.composeapp.generated.resources.lang_portuguese_portugal +import nuvio.composeapp.generated.resources.lang_romanian +import nuvio.composeapp.generated.resources.lang_slovak import nuvio.composeapp.generated.resources.lang_spanish import nuvio.composeapp.generated.resources.lang_turkish import nuvio.composeapp.generated.resources.lang_norwegian +import nuvio.composeapp.generated.resources.lang_dutch import nuvio.composeapp.generated.resources.lang_japanese +import nuvio.composeapp.generated.resources.lang_vietnamese import nuvio.composeapp.generated.resources.settings_appearance_app_language_device import org.jetbrains.compose.resources.StringResource @@ -23,20 +29,26 @@ enum class AppLanguage( val labelRes: StringResource, ) { DEVICE("device", Res.string.settings_appearance_app_language_device), + BULGARIAN("bg", Res.string.lang_bulgarian), CZECH("cs", Res.string.lang_czech), ENGLISH("en", Res.string.lang_english), FRENCH("fr", Res.string.lang_french), GERMAN("de", Res.string.lang_german), GREEK("el", Res.string.lang_greek), + HUNGARIAN("hu", Res.string.lang_hungarian), INDONESIAN("id", Res.string.lang_indonesian), ITALIAN("it", Res.string.lang_italian), POLISH("pl", Res.string.lang_polish), PORTUGUESE_BRAZIL("pt-BR", Res.string.lang_portuguese_brazil), PORTUGUESE("pt", Res.string.lang_portuguese_portugal), + ROMANIAN("ro", Res.string.lang_romanian), + SLOVAK("sk", Res.string.lang_slovak), SPANISH("es", Res.string.lang_spanish), TURKISH("tr", Res.string.lang_turkish), NORWEGIAN("nb", Res.string.lang_norwegian), + DUTCH("nl", Res.string.lang_dutch), JAPANESE("ja", Res.string.lang_japanese), + VIETNAMESE("vi", Res.string.lang_vietnamese), ; companion object { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 4b812d92..5452420f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -23,7 +23,7 @@ import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -72,6 +72,8 @@ import com.nuvio.app.features.debrid.DebridStreamSortCriterion import com.nuvio.app.features.debrid.DebridStreamSortDirection import com.nuvio.app.features.debrid.DebridStreamSortKey import com.nuvio.app.features.debrid.DebridStreamVisualTag +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TimeSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.delay @@ -210,6 +212,11 @@ import kotlinx.coroutines.runBlocking private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services" +// Upper bound for device-authorization polling when every redeem keeps throwing. Device codes +// expire server-side within minutes (TorBox/Premiumize), so this is comfortably beyond any code's +// lifetime — it only exists to stop an indefinitely-offline dialog from polling forever. +private val DEVICE_AUTH_MAX_POLL_DURATION = 15.minutes + internal fun LazyListScope.debridSettingsContent( isTablet: Boolean, settings: DebridSettings, @@ -1499,6 +1506,10 @@ private fun DebridDeviceAuthDialog( LaunchedEffect(session?.deviceCode, restartNonce, isConnected) { if (isConnected) return@LaunchedEffect val activeSession = session ?: return@LaunchedEffect + // Watchdog for the throwing path below: device codes expire server-side within minutes, + // so if every redeem keeps throwing past this deadline the failure is persistent (e.g. + // airplane mode / captive portal), not a brief background-network blip — give up then. + val pollDeadline = TimeSource.Monotonic.markNow() + DEVICE_AUTH_MAX_POLL_DURATION while (true) { delay(activeSession.intervalSeconds.coerceAtLeast(1) * 1_000L) isPolling = true @@ -1508,10 +1519,18 @@ private fun DebridDeviceAuthDialog( ?: DebridDeviceAuthorizationTokenResult.Unsupported }.getOrElse { error -> if (error is CancellationException) throw error - if (error.isCancelledHttpRequest()) { - DebridDeviceAuthorizationTokenResult.Pending - } else { + // A throwing redeem is almost always transient connectivity loss rather than a + // fatal error: aggressive ROMs (OxygenOS/MIUI/etc.) sever the backgrounded app's + // sockets + DNS while the user approves in the browser, so the in-flight poll + // throws UnknownHostException/IOException. Keep polling instead of terminating — + // terminating here on a transient error is what caused "Could not start sign-in" + // on OnePlus devices (issue #1409). A response-bearing expiry still surfaces via + // the Expired branch once requests reach the server; the pollDeadline watchdog + // bounds the loop when the failure is persistent and no response ever arrives. + if (pollDeadline.hasPassedNow()) { DebridDeviceAuthorizationTokenResult.Failed(null) + } else { + DebridDeviceAuthorizationTokenResult.Pending } } isPolling = false @@ -1566,7 +1585,7 @@ private fun DebridDeviceAuthDialog( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + NuvioLoadingIndicator(modifier = Modifier.size(18.dp)) Text( text = startingMessage, style = MaterialTheme.typography.bodyMedium, @@ -1614,7 +1633,7 @@ private fun DebridDeviceAuthDialog( verticalAlignment = Alignment.CenterVertically, ) { if (isPolling) { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + NuvioLoadingIndicator(modifier = Modifier.size(16.dp)) } Text( text = message, @@ -1700,14 +1719,6 @@ private fun clearPendingDeviceAuthorization(providerId: String) { DebridSettingsStorage.clearPendingDeviceAuthorization(providerId) } -private fun Throwable.isCancelledHttpRequest(): Boolean { - val text = listOfNotNull(message, toString()) - .joinToString(" ") - .lowercase() - return "code=-999" in text || - ("nsurlerrordomain" in text && ("cancelled" in text || "canceled" in text)) -} - private fun String?.toDeviceAuthStatusMessage(fallback: String): String { val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return fallback val lower = value.lowercase() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index ab7a57ee..70337c26 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -51,6 +51,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.TrailerPlaybackMode import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.features.details.MetaEpisodeCardStyle +import com.nuvio.app.features.details.MetaScreenBackgroundMode import com.nuvio.app.features.details.MetaScreenSectionItem import com.nuvio.app.features.details.MetaScreenSectionKey import com.nuvio.app.features.details.MetaScreenSettingsRepository @@ -64,8 +65,6 @@ import nuvio.composeapp.generated.resources.settings_meta_actions import nuvio.composeapp.generated.resources.settings_meta_actions_description import nuvio.composeapp.generated.resources.settings_meta_cast import nuvio.composeapp.generated.resources.settings_meta_cast_description -import nuvio.composeapp.generated.resources.settings_meta_cinematic_background -import nuvio.composeapp.generated.resources.settings_meta_cinematic_background_description import nuvio.composeapp.generated.resources.settings_meta_collection import nuvio.composeapp.generated.resources.settings_meta_collection_description import nuvio.composeapp.generated.resources.settings_meta_comments @@ -84,6 +83,14 @@ import nuvio.composeapp.generated.resources.settings_meta_episodes import nuvio.composeapp.generated.resources.settings_meta_episodes_description import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description +import nuvio.composeapp.generated.resources.settings_meta_background_mode +import nuvio.composeapp.generated.resources.settings_meta_background_mode_cinematic +import nuvio.composeapp.generated.resources.settings_meta_background_mode_cinematic_description +import nuvio.composeapp.generated.resources.settings_meta_background_mode_description +import nuvio.composeapp.generated.resources.settings_meta_background_mode_dominant +import nuvio.composeapp.generated.resources.settings_meta_background_mode_dominant_description +import nuvio.composeapp.generated.resources.settings_meta_background_mode_normal +import nuvio.composeapp.generated.resources.settings_meta_background_mode_normal_description import nuvio.composeapp.generated.resources.settings_meta_group_label import nuvio.composeapp.generated.resources.settings_meta_more_like_this import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description @@ -117,12 +124,10 @@ internal fun LazyListScope.metaScreenSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - SettingsSwitchRow( - title = stringResource(Res.string.settings_meta_cinematic_background), - description = stringResource(Res.string.settings_meta_cinematic_background_description), - checked = uiState.cinematicBackground, + MetaBackgroundModeSelector( isTablet = isTablet, - onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) }, + selectedMode = uiState.backgroundMode, + onModeSelected = MetaScreenSettingsRepository::setBackgroundMode, ) if (showHeroTrailerPlaybackSetting) { SettingsGroupDivider(isTablet = isTablet) @@ -181,6 +186,71 @@ internal fun LazyListScope.metaScreenSettingsContent( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MetaBackgroundModeSelector( + isTablet: Boolean, + selectedMode: MetaScreenBackgroundMode, + onModeSelected: (MetaScreenBackgroundMode) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = if (isTablet) 20.dp else 16.dp, + vertical = if (isTablet) 18.dp else 14.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(Res.string.settings_meta_background_mode), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(Res.string.settings_meta_background_mode_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MetaScreenBackgroundMode.entries.forEach { mode -> + FilterChip( + selected = selectedMode == mode, + onClick = { onModeSelected(mode) }, + label = { + Text( + text = stringResource(mode.labelRes), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selectedMode == mode, + borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f), + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + Text( + text = stringResource(selectedMode.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable private fun MetaSectionReorderableList( items: List, @@ -394,6 +464,20 @@ private fun TabGroupChip( ) } +private val MetaScreenBackgroundMode.labelRes: StringResource + get() = when (this) { + MetaScreenBackgroundMode.Normal -> Res.string.settings_meta_background_mode_normal + MetaScreenBackgroundMode.Cinematic -> Res.string.settings_meta_background_mode_cinematic + MetaScreenBackgroundMode.DominantColor -> Res.string.settings_meta_background_mode_dominant + } + +private val MetaScreenBackgroundMode.descriptionRes: StringResource + get() = when (this) { + MetaScreenBackgroundMode.Normal -> Res.string.settings_meta_background_mode_normal_description + MetaScreenBackgroundMode.Cinematic -> Res.string.settings_meta_background_mode_cinematic_description + MetaScreenBackgroundMode.DominantColor -> Res.string.settings_meta_background_mode_dominant_description + } + @Composable private fun MetaEpisodeCardStyleSelector( isTablet: Boolean, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 55506043..46b733c5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -334,6 +334,14 @@ private fun PlaybackSettingsSection( onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, ) SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_parental_guide), + description = stringResource(Res.string.settings_playback_parental_guide_description), + checked = autoPlayPlayerSettings.showParentalGuide, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setShowParentalGuide, + ) + SettingsGroupDivider(isTablet = isTablet) // Player preference picker: Internal / External SettingsNavigationRow( title = stringResource(Res.string.settings_playback_player_preference), @@ -368,6 +376,14 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setExternalPlayerForwardSubtitles, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_external_player_send_skip_segments), + description = stringResource(Res.string.settings_playback_external_player_send_skip_segments_description), + checked = autoPlayPlayerSettings.externalPlayerSendSkipSegments, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setExternalPlayerSendSkipSegments, + ) } SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( @@ -1857,7 +1873,7 @@ private fun ReuseCacheDurationDialog( onDurationSelected: (Int) -> Unit, onDismiss: () -> Unit, ) { - val options = listOf(1, 6, 12, 24, 48, 72, 168) + val options = listOf(1, 2, 3, 6, 12, 24, 48, 72, 168) BasicAlertDialog( onDismissRequest = onDismiss, @@ -3294,9 +3310,8 @@ private fun IntroDbApiKeyDialog( enabled = !isVerifying ) { if (isVerifying) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary ) } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt index f4f494d2..ccd3fdcf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -2,8 +2,12 @@ package com.nuvio.app.features.settings import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,6 +15,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,23 +24,81 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import com.nuvio.app.core.ui.CardDepthStyleRepository +import com.nuvio.app.core.ui.CardDepthStyleUiState +import com.nuvio.app.core.ui.DefaultCardDepthEdgeCoverage +import com.nuvio.app.core.ui.DefaultCardDepthEdgeStrength +import com.nuvio.app.core.ui.DefaultCardDepthSheenStrength import com.nuvio.app.core.ui.NuvioActionLabel +import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState +import com.nuvio.app.core.ui.cardDepthVisual import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.settings_card_depth_apply_to +import nuvio.composeapp.generated.resources.settings_card_depth_description +import nuvio.composeapp.generated.resources.settings_card_depth_edge +import nuvio.composeapp.generated.resources.settings_card_depth_edge_balanced +import nuvio.composeapp.generated.resources.settings_card_depth_edge_bold +import nuvio.composeapp.generated.resources.settings_card_depth_edge_subtle +import nuvio.composeapp.generated.resources.settings_card_depth_edge_value +import nuvio.composeapp.generated.resources.settings_card_depth_edge_coverage +import nuvio.composeapp.generated.resources.settings_card_depth_coverage_full +import nuvio.composeapp.generated.resources.settings_card_depth_coverage_half +import nuvio.composeapp.generated.resources.settings_card_depth_coverage_top +import nuvio.composeapp.generated.resources.settings_card_depth_coverage_value +import nuvio.composeapp.generated.resources.settings_card_depth_enabled +import nuvio.composeapp.generated.resources.settings_card_depth_fine_tune +import nuvio.composeapp.generated.resources.settings_card_depth_fine_tune_hint +import nuvio.composeapp.generated.resources.settings_card_depth_fine_tune_title +import nuvio.composeapp.generated.resources.settings_card_depth_pad_edge_axis +import nuvio.composeapp.generated.resources.settings_card_depth_pad_sheen_axis +import nuvio.composeapp.generated.resources.settings_card_depth_preview_meta +import nuvio.composeapp.generated.resources.settings_card_depth_preview_title +import nuvio.composeapp.generated.resources.settings_card_depth_sheen +import nuvio.composeapp.generated.resources.settings_card_depth_sheen_value +import nuvio.composeapp.generated.resources.settings_card_depth_sheen_bright +import nuvio.composeapp.generated.resources.settings_card_depth_sheen_off +import nuvio.composeapp.generated.resources.settings_card_depth_sheen_soft +import nuvio.composeapp.generated.resources.settings_card_depth_surface_cast +import nuvio.composeapp.generated.resources.settings_card_depth_surface_continue_watching +import nuvio.composeapp.generated.resources.settings_card_depth_surface_episodes +import nuvio.composeapp.generated.resources.settings_card_depth_surface_posters +import nuvio.composeapp.generated.resources.settings_card_depth_surface_trailers +import nuvio.composeapp.generated.resources.settings_card_depth_title import nuvio.composeapp.generated.resources.settings_poster_card_radius import nuvio.composeapp.generated.resources.settings_poster_card_style import nuvio.composeapp.generated.resources.settings_poster_card_width @@ -90,6 +154,451 @@ internal fun LazyListScope.posterCustomizationSettingsContent( } } } + item { + CardDepthStyleRepository.ensureLoaded() + val cardDepthState by CardDepthStyleRepository.uiState.collectAsState() + SettingsSection( + title = stringResource(Res.string.settings_card_depth_title), + isTablet = isTablet, + actions = { + NuvioActionLabel( + text = stringResource(Res.string.action_reset), + onClick = CardDepthStyleRepository::resetToDefaults, + ) + }, + ) { + SettingsGroup(isTablet = isTablet) { + CardDepthStyleControls( + isTablet = isTablet, + uiState = cardDepthState, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CardDepthStyleControls( + isTablet: Boolean, + uiState: CardDepthStyleUiState, +) { + var showFineTune by remember { mutableStateOf(false) } + val edgeOptions = listOf( + PresetOption(stringResource(Res.string.settings_card_depth_edge_subtle), 28), + PresetOption(stringResource(Res.string.settings_card_depth_edge_balanced), 42), + PresetOption(stringResource(Res.string.settings_card_depth_edge_bold), 56), + ) + val sheenOptions = listOf( + PresetOption(stringResource(Res.string.settings_card_depth_sheen_off), 0), + PresetOption(stringResource(Res.string.settings_card_depth_sheen_soft), 10), + PresetOption(stringResource(Res.string.settings_card_depth_sheen_bright), 16), + ) + val coverageOptions = listOf( + PresetOption(stringResource(Res.string.settings_card_depth_coverage_top), 0), + PresetOption(stringResource(Res.string.settings_card_depth_coverage_half), 50), + PresetOption(stringResource(Res.string.settings_card_depth_coverage_full), 100), + ) + val surfaceRows = listOf( + stringResource(Res.string.settings_card_depth_surface_posters) to NuvioCardDepthSurface.Posters, + stringResource(Res.string.settings_card_depth_surface_continue_watching) to NuvioCardDepthSurface.ContinueWatching, + stringResource(Res.string.settings_card_depth_surface_episodes) to NuvioCardDepthSurface.EpisodeCards, + stringResource(Res.string.settings_card_depth_surface_cast) to NuvioCardDepthSurface.Cast, + stringResource(Res.string.settings_card_depth_surface_trailers) to NuvioCardDepthSurface.Trailers, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 18.dp else 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = stringResource(Res.string.settings_card_depth_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PosterToggleRow( + title = stringResource(Res.string.settings_card_depth_enabled), + checked = uiState.enabled, + onCheckedChange = CardDepthStyleRepository::setEnabled, + ) + if (uiState.enabled) { + PosterStyleOptionRow( + title = stringResource(Res.string.settings_card_depth_edge), + selectedValue = uiState.edgeStrength, + options = edgeOptions, + onSelected = CardDepthStyleRepository::setEdgeStrength, + ) + PosterStyleOptionRow( + title = stringResource(Res.string.settings_card_depth_sheen), + selectedValue = uiState.sheenStrength, + options = sheenOptions, + onSelected = CardDepthStyleRepository::setSheenStrength, + ) + PosterStyleOptionRow( + title = stringResource(Res.string.settings_card_depth_edge_coverage), + selectedValue = uiState.edgeCoverage, + options = coverageOptions, + onSelected = CardDepthStyleRepository::setEdgeCoverage, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { showFineTune = true }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.settings_card_depth_fine_tune), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = stringResource(Res.string.settings_card_depth_apply_to), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + surfaceRows.forEach { (title, surface) -> + PosterToggleRow( + title = title, + checked = uiState.isSurfaceEnabled(surface), + onCheckedChange = { enabled -> + CardDepthStyleRepository.setSurfaceEnabled(surface, enabled) + }, + ) + } + } + } + + if (showFineTune) { + CardDepthFineTuneSheet( + initialEdgeStrength = uiState.edgeStrength, + initialSheenStrength = uiState.sheenStrength, + initialEdgeCoverage = uiState.edgeCoverage, + onDismiss = { showFineTune = false }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CardDepthFineTuneSheet( + initialEdgeStrength: Int, + initialSheenStrength: Int, + initialEdgeCoverage: Int, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var draftEdge by remember { mutableFloatStateOf(initialEdgeStrength.toFloat()) } + var draftSheen by remember { mutableFloatStateOf(initialSheenStrength.toFloat()) } + var draftCoverage by remember { mutableFloatStateOf(initialEdgeCoverage.toFloat()) } + + fun commitDraft() { + CardDepthStyleRepository.setEdgeStrength(draftEdge.roundToInt()) + CardDepthStyleRepository.setSheenStrength(draftSheen.roundToInt()) + CardDepthStyleRepository.setEdgeCoverage(draftCoverage.roundToInt()) + } + + NuvioModalBottomSheet( + onDismissRequest = { + commitDraft() + onDismiss() + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.settings_card_depth_fine_tune_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + NuvioActionLabel( + text = stringResource(Res.string.action_reset), + onClick = { + draftEdge = DefaultCardDepthEdgeStrength.toFloat() + draftSheen = DefaultCardDepthSheenStrength.toFloat() + draftCoverage = DefaultCardDepthEdgeCoverage.toFloat() + commitDraft() + }, + ) + } + Text( + text = stringResource(Res.string.settings_card_depth_fine_tune_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CardDepthPreviewCard( + edgeStrength = draftEdge, + sheenStrength = draftSheen, + edgeCoverage = draftCoverage, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${stringResource(Res.string.settings_card_depth_edge_value)}: ${formatDepthPercentage(draftEdge)}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "${stringResource(Res.string.settings_card_depth_sheen_value)}: ${formatDepthPercentage(draftSheen)}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + CardDepthTuningPad( + edgeStrength = draftEdge, + sheenStrength = draftSheen, + onChange = { edge, sheen -> + draftEdge = edge + draftSheen = sheen + }, + onCommit = { edge, sheen -> + CardDepthStyleRepository.setEdgeStrength(edge.roundToInt()) + CardDepthStyleRepository.setSheenStrength(sheen.roundToInt()) + }, + ) + Text( + text = "${stringResource(Res.string.settings_card_depth_coverage_value)}: ${formatDepthPercentage(draftCoverage)}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Slider( + value = draftCoverage, + onValueChange = { draftCoverage = it }, + onValueChangeFinished = { + CardDepthStyleRepository.setEdgeCoverage(draftCoverage.roundToInt()) + }, + valueRange = 0f..100f, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +private fun formatDepthPercentage(value: Float): String { + val tenths = (value.coerceIn(0f, 100f) * 10f).roundToInt() + return "${tenths / 10}.${tenths % 10}%" +} + +@Composable +private fun CardDepthPreviewCard( + edgeStrength: Float, + sheenStrength: Float, + edgeCoverage: Float, +) { + val shape = RoundedCornerShape(14.dp) + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(shape) + .background( + Brush.linearGradient( + colors = listOf( + Color(0xFF33415C), + Color(0xFF232D42), + Color(0xFF141A28), + ), + ), + ) + .cardDepthVisual( + shape = shape, + edgeStrength = edgeStrength, + sheenStrength = sheenStrength, + edgeCoverage = edgeCoverage, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 0.42f to Color.Transparent, + 0.56f to Color.Black.copy(alpha = 0.20f), + 0.70f to Color.Black.copy(alpha = 0.45f), + 0.84f to Color.Black.copy(alpha = 0.68f), + 1f to Color.Black.copy(alpha = 0.92f), + ), + ), + ) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(Res.string.settings_card_depth_preview_meta), + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.78f), + ) + Text( + text = stringResource(Res.string.settings_card_depth_preview_title), + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + ) + } + } +} + +@Composable +private fun CardDepthTuningPad( + edgeStrength: Float, + sheenStrength: Float, + onChange: (Float, Float) -> Unit, + onCommit: (Float, Float) -> Unit, +) { + val maxEdge = 70f + val maxSheen = 25f + val shape = RoundedCornerShape(16.dp) + val thumbColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + val currentEdge by rememberUpdatedState(edgeStrength) + val currentSheen by rememberUpdatedState(sheenStrength) + val currentOnChange by rememberUpdatedState(onChange) + val currentOnCommit by rememberUpdatedState(onCommit) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + shape = shape, + ) + .pointerInput(Unit) { + fun valuesAt(position: Offset): Pair { + val x = (position.x / size.width).coerceIn(0f, 1f) + val y = 1f - (position.y / size.height).coerceIn(0f, 1f) + return (x * maxEdge) to (y * maxSheen) + } + detectDragGestures( + onDragStart = { position -> + val (edge, sheen) = valuesAt(position) + currentOnChange(edge, sheen) + }, + onDrag = { change, _ -> + change.consume() + val (edge, sheen) = valuesAt(change.position) + currentOnChange(edge, sheen) + }, + onDragEnd = { + currentOnCommit(currentEdge, currentSheen) + }, + onDragCancel = { + currentOnCommit(currentEdge, currentSheen) + }, + ) + } + .pointerInput(Unit) { + fun valuesAt(position: Offset): Pair { + val x = (position.x / size.width).coerceIn(0f, 1f) + val y = 1f - (position.y / size.height).coerceIn(0f, 1f) + return (x * maxEdge) to (y * maxSheen) + } + detectTapGestures { position -> + val (edge, sheen) = valuesAt(position) + currentOnChange(edge, sheen) + currentOnCommit(edge, sheen) + } + }, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + for (step in 1..3) { + val x = size.width * step / 4f + val y = size.height * step / 4f + drawLine( + color = gridColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = 1.dp.toPx(), + ) + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1.dp.toPx(), + ) + } + val thumbX = size.width * (edgeStrength / maxEdge).coerceIn(0f, 1f) + val thumbY = size.height * (1f - (sheenStrength / maxSheen).coerceIn(0f, 1f)) + drawLine( + color = thumbColor.copy(alpha = 0.35f), + start = Offset(thumbX, 0f), + end = Offset(thumbX, size.height), + strokeWidth = 1.dp.toPx(), + ) + drawLine( + color = thumbColor.copy(alpha = 0.35f), + start = Offset(0f, thumbY), + end = Offset(size.width, thumbY), + strokeWidth = 1.dp.toPx(), + ) + drawCircle( + color = thumbColor.copy(alpha = 0.25f), + radius = 16.dp.toPx(), + center = Offset(thumbX, thumbY), + ) + drawCircle( + color = thumbColor, + radius = 9.dp.toPx(), + center = Offset(thumbX, thumbY), + ) + drawCircle( + color = Color.White, + radius = 4.dp.toPx(), + center = Offset(thumbX, thumbY), + ) + } + Text( + text = stringResource(Res.string.settings_card_depth_pad_sheen_axis), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp), + ) + Text( + text = stringResource(Res.string.settings_card_depth_pad_edge_axis), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(10.dp), + ) + } } @OptIn(ExperimentalLayoutApi::class) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsRepository.kt new file mode 100644 index 00000000..31961b30 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsRepository.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.settings + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal object SentrySettingsRepository { + val isSupported: Boolean + get() = SentrySettingsPlatform.crashReportsSupported + + private val _enabled = MutableStateFlow(true) + val enabled: StateFlow = _enabled.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + private fun loadFromDisk() { + hasLoaded = true + _enabled.value = SentrySettingsStorage.loadEnabled() ?: true + } + + fun setEnabled(enabled: Boolean) { + ensureLoaded() + if (_enabled.value == enabled) return + _enabled.value = enabled + SentrySettingsStorage.saveEnabled(enabled) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.kt new file mode 100644 index 00000000..d10301ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.kt @@ -0,0 +1,10 @@ +package com.nuvio.app.features.settings + +internal expect object SentrySettingsPlatform { + val crashReportsSupported: Boolean +} + +internal expect object SentrySettingsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) +} 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 351bcc08..6f42f03e 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 @@ -80,6 +80,7 @@ import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState +import com.nuvio.app.navigation.LocalUseNativeNavigation import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_root import kotlinx.coroutines.delay @@ -99,13 +100,26 @@ private fun SettingsPage.isEnabledByPolicy(): Boolean = else -> true } +@Composable +private fun settingsPageTitles(): Map { + val titles = mutableMapOf() + for (page in SettingsPage.entries) { + titles[page] = stringResource(page.titleRes) + } + return titles +} + @Composable fun SettingsScreen( modifier: Modifier = Modifier, rootActionRequests: Flow = emptyFlow(), + initialPageName: String = SettingsPage.Root.name, requestedPageName: String? = null, onRequestedPageConsumed: () -> Unit = {}, rootActionsEnabled: Boolean = true, + onNavigatePage: ((pageName: String, title: String) -> Unit)? = null, + onExternalBack: (() -> Unit)? = null, + showInternalHeader: Boolean = true, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onMetaScreenClick: () -> Unit = {}, @@ -135,7 +149,10 @@ fun SettingsScreen( val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() - val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } + val useNativeNavigation = LocalUseNativeNavigation.current + val liquidGlassNativeTabBarSupported = remember(useNativeNavigation) { + !useNativeNavigation && isLiquidGlassNativeTabBarSupported() + } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() @@ -219,8 +236,15 @@ fun SettingsScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } + val initialPage = remember(initialPageName) { + runCatching { SettingsPage.valueOf(initialPageName) } + .getOrDefault(SettingsPage.Root) + .takeIf { it.isEnabledByPolicy() } + ?: SettingsPage.Root + } + var currentPage by rememberSaveable(initialPageName) { mutableStateOf(initialPage.name) } val scrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val pageTitles = settingsPageTitles() val page = remember(currentPage) { runCatching { SettingsPage.valueOf(currentPage) } .getOrDefault(SettingsPage.Root) @@ -229,6 +253,73 @@ fun SettingsScreen( } val previousPage = page.previousPage() + fun openPage(targetPage: SettingsPage) { + if (!targetPage.isEnabledByPolicy()) return + val externalNavigator = onNavigatePage + if (externalNavigator == null) { + currentPage = targetPage.name + return + } + if (targetPage == SettingsPage.Root && onExternalBack != null) { + onExternalBack() + return + } + externalNavigator( + targetPage.name, + pageTitles.getValue(targetPage), + ) + } + + fun navigateBack() { + val parentPage = previousPage ?: return + if (onNavigatePage != null && onExternalBack != null) { + onExternalBack() + } else { + currentPage = parentPage.name + } + } + + val openHomescreen = if (onNavigatePage != null) { + { openPage(SettingsPage.Homescreen) } + } else { + onHomescreenClick + } + val openMetaScreen = if (onNavigatePage != null) { + { openPage(SettingsPage.MetaScreen) } + } else { + onMetaScreenClick + } + val openContinueWatching = if (onNavigatePage != null) { + { openPage(SettingsPage.ContinueWatching) } + } else { + onContinueWatchingClick + } + val openAddons = if (onNavigatePage != null) { + { openPage(SettingsPage.Addons) } + } else { + onAddonsClick + } + val openPlugins = if (onNavigatePage != null) { + { openPage(SettingsPage.Plugins) } + } else { + onPluginsClick + } + val openAccount = if (onNavigatePage != null) { + { openPage(SettingsPage.Account) } + } else { + onAccountClick + } + val openSupportersContributors = if (onNavigatePage != null) { + { openPage(SettingsPage.SupportersContributors) } + } else { + onSupportersContributorsClick + } + val openLicensesAttributions = if (onNavigatePage != null) { + { openPage(SettingsPage.LicensesAttributions) } + } else { + onLicensesAttributionsClick + } + LaunchedEffect(page, currentPage) { if (page.name != currentPage) { currentPage = page.name @@ -240,7 +331,7 @@ fun SettingsScreen( if (!rootActionsEnabled) return@collect val pageToOpen = page.previousPage() if (pageToOpen != null) { - currentPage = pageToOpen.name + navigateBack() } else { scrollToTopRequests.tryEmit(Unit) } @@ -255,20 +346,22 @@ fun SettingsScreen( return@LaunchedEffect } if (!rootActionsEnabled) return@LaunchedEffect - currentPage = targetPage.name + openPage(targetPage) onRequestedPageConsumed() } PlatformBackHandler( - enabled = rootActionsEnabled && previousPage != null, - onBack = { previousPage?.let { currentPage = it.name } }, + enabled = previousPage != null && (rootActionsEnabled || onExternalBack != null), + onBack = ::navigateBack, ) if (maxWidth >= 768.dp) { TabletSettingsScreen( page = page, scrollToTopRequests = scrollToTopRequests, - onPageChange = { currentPage = it.name }, + onPageChange = ::openPage, + onNavigateBack = ::navigateBack, + showInternalHeader = showInternalHeader, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, @@ -314,8 +407,8 @@ fun SettingsScreen( posterCardStyleUiState = posterCardStyleUiState, onSwitchProfile = onSwitchProfile, onDownloadsClick = onDownloadsClick, - onSupportersContributorsClick = onSupportersContributorsClick, - onLicensesAttributionsClick = onLicensesAttributionsClick, + onSupportersContributorsClick = openSupportersContributors, + onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsClick, ) @@ -323,7 +416,9 @@ fun SettingsScreen( MobileSettingsScreen( page = page, scrollToTopRequests = scrollToTopRequests, - onPageChange = { currentPage = it.name }, + onPageChange = ::openPage, + onNavigateBack = ::navigateBack, + showInternalHeader = showInternalHeader, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, @@ -368,15 +463,15 @@ fun SettingsScreen( continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, posterCardStyleUiState = posterCardStyleUiState, onSwitchProfile = onSwitchProfile, - onHomescreenClick = onHomescreenClick, - onMetaScreenClick = onMetaScreenClick, - onContinueWatchingClick = onContinueWatchingClick, - onAddonsClick = onAddonsClick, - onPluginsClick = onPluginsClick, + onHomescreenClick = openHomescreen, + onMetaScreenClick = openMetaScreen, + onContinueWatchingClick = openContinueWatching, + onAddonsClick = openAddons, + onPluginsClick = openPlugins, onDownloadsClick = onDownloadsClick, - onAccountClick = onAccountClick, - onSupportersContributorsClick = onSupportersContributorsClick, - onLicensesAttributionsClick = onLicensesAttributionsClick, + onAccountClick = openAccount, + onSupportersContributorsClick = openSupportersContributors, + onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsClick, ) @@ -389,6 +484,8 @@ private fun MobileSettingsScreen( page: SettingsPage, scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, + onNavigateBack: () -> Unit, + showInternalHeader: Boolean, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, @@ -521,12 +618,16 @@ private fun MobileSettingsScreen( modifier = Modifier.nestedScroll(rootSearchRevealConnection), listState = listState, ) { - stickyHeader { - val previousPage = page.previousPage() - NuvioScreenHeader( - title = stringResource(page.titleRes), - onBack = previousPage?.let { { onPageChange(it) } }, - ) + if (showInternalHeader) { + stickyHeader { + val previousPage = page.previousPage() + NuvioScreenHeader( + title = stringResource(page.titleRes), + onBack = previousPage?.let { { onNavigateBack() } }, + ) + } + } else { + item { Spacer(modifier = Modifier.height(44.dp)) } } when (page) { @@ -733,6 +834,8 @@ private fun TabletSettingsScreen( page: SettingsPage, scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, + onNavigateBack: () -> Unit, + showInternalHeader: Boolean, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, @@ -911,21 +1014,23 @@ private fun TabletSettingsScreen( ), verticalArrangement = Arrangement.spacedBy(18.dp), ) { - item { - val previousPage = page.previousPage() - TabletPageHeader( - title = if (page == SettingsPage.Root) { - if (settingsSearchQuery.isBlank()) { - stringResource(activeCategory.labelRes) + if (showInternalHeader) { + item { + val previousPage = page.previousPage() + TabletPageHeader( + title = if (page == SettingsPage.Root) { + if (settingsSearchQuery.isBlank()) { + stringResource(activeCategory.labelRes) + } else { + stringResource(Res.string.compose_settings_page_root) + } } else { - stringResource(Res.string.compose_settings_page_root) - } - } else { - stringResource(page.titleRes) - }, - showBack = previousPage != null, - onBack = { previousPage?.let(onPageChange) }, - ) + stringResource(page.titleRes) + }, + showBack = previousPage != null, + onBack = onNavigateBack, + ) + } } when (page) { SettingsPage.Root -> { 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 index 6924857b..419d5567 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -406,6 +406,18 @@ internal fun settingsSearchEntries( category = advancedCategory, icon = Icons.Rounded.Tune, ) + if (SentrySettingsRepository.isSupported) { + addRow( + page = SettingsPage.Advanced, + key = "sentry-crash-reports", + title = stringResource(Res.string.settings_advanced_sentry_reports), + description = stringResource(Res.string.settings_advanced_sentry_reports_subtitle), + pageLabel = advancedPage, + section = stringResource(Res.string.settings_advanced_section_diagnostics), + category = advancedCategory, + icon = Icons.Rounded.Tune, + ) + } addRow( page = SettingsPage.Advanced, key = "clear-cw-cache", @@ -698,6 +710,23 @@ internal fun settingsSearchEntries( ) } + val cardDepthSection = stringResource(Res.string.settings_card_depth_title) + listOf( + PlaybackSearchRow("card-depth-effect", cardDepthSection, stringResource(Res.string.settings_card_depth_description)), + PlaybackSearchRow("card-depth-edge", stringResource(Res.string.settings_card_depth_edge)), + PlaybackSearchRow("card-depth-sheen", stringResource(Res.string.settings_card_depth_sheen)), + ).forEach { row -> + addRow( + page = SettingsPage.PosterCustomization, + key = "poster-${row.key}", + title = row.title, + description = row.description, + pageLabel = posterStylePage, + section = cardDepthSection, + 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)), @@ -719,7 +748,7 @@ internal fun settingsSearchEntries( 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-background-mode", stringResource(Res.string.settings_meta_background_mode), stringResource(Res.string.settings_meta_background_mode_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)), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt index 38117df1..360d492a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -21,7 +21,7 @@ import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -306,8 +306,7 @@ private fun BadgeUrlManagerDialog( }, ) { if (isImporting) { - CircularProgressIndicator( - strokeWidth = tokens.borders.medium, + NuvioLoadingIndicator( modifier = Modifier.size(tokens.icons.sm), color = tokens.colors.onAccent, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 2299e120..84074daa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -27,7 +27,7 @@ import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -59,6 +59,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @@ -208,8 +209,7 @@ private object SupportersContributorsRepository { val progress = donationsResponse.monthlyGoal ?.progressPercent - ?.toInt() - ?.coerceIn(0, 100) + ?.toDonationProgressPercent() ?.let { percent -> DonationProgress(progressPercent = percent) } SupportersResult( @@ -240,6 +240,15 @@ private object SupportersContributorsRepository { val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE return year * 10_000L + month * 100L + day } + + private fun Double.toDonationProgressPercent(): Int? { + if (isNaN()) return null + return when { + this >= 100.0 -> 100 + this <= 0.0 -> 0 + else -> roundToInt().coerceIn(0, 99) + } + } } @Composable @@ -630,13 +639,27 @@ private fun DonationProgressSection( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - text = stringResource(Res.string.community_donation_progress_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, + Row( modifier = Modifier.fillMaxWidth(), - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.community_donation_progress_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + if (progress != null && errorMessage == null) { + Text( + text = "$percent%", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + } if (isLoading && progress == null) { LinearProgressIndicator( @@ -835,7 +858,7 @@ private fun LoadingState( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - CircularProgressIndicator(strokeWidth = 2.dp) + NuvioLoadingIndicator() Text( text = label, style = MaterialTheme.typography.bodyMedium, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index ac981558..4f8420de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -18,7 +18,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -27,6 +27,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler @@ -38,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState @@ -50,6 +52,8 @@ import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.traktBrandPainter +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator +import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.settings_playback_dialog_close @@ -157,6 +161,7 @@ private fun TraktFeatureRows( var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) } var statusMessage by rememberSaveable { mutableStateOf(null) } + val scope = rememberCoroutineScope() val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) @@ -234,11 +239,20 @@ private fun TraktFeatureRows( WatchProgressSourceDialog( selectedSource = settingsUiState.watchProgressSource, onSourceSelected = { source -> - TraktSettingsRepository.setWatchProgressSource(source) - statusMessage = if (source == WatchProgressSource.TRAKT) { - traktProgressSelectedMessage - } else { - nuvioProgressSelectedMessage + scope.launch { + val result = WatchProgressSourceCoordinator.selectSource( + profileId = ProfileRepository.activeProfileId, + source = source, + ) + statusMessage = if (result.succeeded) { + if (result.requestedSource == WatchProgressSource.TRAKT) { + traktProgressSelectedMessage + } else { + nuvioProgressSelectedMessage + } + } else { + null + } } showWatchProgressDialog = false }, @@ -704,9 +718,8 @@ private fun TraktConnectionCard( ), ) { if (uiState.isLoading) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = MaterialTheme.colorScheme.onSurface, - strokeWidth = 2.dp, modifier = Modifier.size(18.dp), ) } else { @@ -774,9 +787,8 @@ private fun TraktConnectionCard( enabled = uiState.credentialsConfigured && !uiState.isLoading, ) { if (uiState.isLoading) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, modifier = Modifier.size(18.dp), ) } else { 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 a16a172b..87349ffa 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 @@ -41,16 +41,26 @@ data class StreamItem( get() = description val directPlaybackUrl: String? - get() = url ?: externalUrl + get() = url?.trim()?.takeIf { it.isNotEmpty() } /** * First URL that can be handed directly to a player or HTTP consumer. - * `magnet:` and `torrent://` URLs are filtered out, falling back to - * [externalUrl] when [url] carries one of those schemes. + * `magnet:` and `torrent://` URLs are filtered out. `externalUrl` is not + * a media URL in the Stremio SDK contract and must be opened externally. */ val playableDirectUrl: String? - get() = listOfNotNull(url, externalUrl) - .firstOrNull { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } + get() = directPlaybackUrl?.takeIf { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } + + val externalOpenUrl: String? + get() = externalUrl + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.isMagnetLink() && !it.isTorrentSchemeUrl() } + + val shouldOpenExternally: Boolean + get() = url.isNullOrBlank() && + infoHash.isNullOrBlank() && + clientResolve == null && + externalOpenUrl != null val torrentMagnetUri: String? get() = listOfNotNull(url, externalUrl) @@ -166,6 +176,7 @@ private fun String?.extractBtihInfoHash(): String? { fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = playableDirectUrl != null || + shouldOpenExternally || (AppFeaturePolicy.p2pEnabled && needsLocalDebridResolve && p2pInfoHash != null) || (debridEnabled && isAddonDebridCandidate) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index ab1df959..a9566bed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -29,7 +29,7 @@ object StreamParser { val externalUrl = obj.string("externalUrl") val clientResolve = obj.objectValue("clientResolve")?.toClientResolve() - // Must have at least one playable source + // Must have at least one source or external target. if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null val hintsObj = obj["behaviorHints"] as? JsonObject 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 bdf8f0ec..fd9f2615 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 @@ -35,7 +35,6 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew @@ -43,7 +42,7 @@ import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.SearchOff -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -61,6 +60,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -90,6 +90,7 @@ import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.navigation.LocalUseNativeNavigation import kotlinx.coroutines.launch import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* @@ -127,6 +128,7 @@ fun StreamsScreen( onBack: () -> Unit, modifier: Modifier = Modifier, ) { + val useNativeNavigation = LocalUseNativeNavigation.current val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val playerSettings by remember { PlayerSettingsRepository.ensureLoaded() @@ -155,7 +157,12 @@ fun StreamsScreen( val storedProgress = if (startFromBeginning) { null } else { - watchProgressUiState.byVideoId[videoId] + watchProgressUiState.progressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } val storedProgressFraction = storedProgress ?.takeIf { it.isResumable } @@ -205,6 +212,16 @@ fun StreamsScreen( } else { background ?: poster } + val reloadStreams: () -> Unit = { + StreamsRepository.reload( + type = type, + videoId = videoId, + parentMetaId = parentMetaId, + season = seasonNumber, + episode = episodeNumber, + manualSelection = manualSelection, + ) + } BoxWithConstraints( modifier = modifier @@ -233,6 +250,7 @@ fun StreamsScreen( onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, + onRefresh = reloadStreams, ) } else { MobileStreamsLayout( @@ -252,6 +270,7 @@ fun StreamsScreen( onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, + onRefresh = reloadStreams, ) } @@ -259,7 +278,7 @@ fun StreamsScreen( modifier = Modifier .align(Alignment.TopStart) .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) - .padding(start = 12.dp, top = 8.dp), + .padding(start = 12.dp, top = if (useNativeNavigation) 52.dp else 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { NuvioBackButton( @@ -269,35 +288,6 @@ fun StreamsScreen( containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), contentColor = MaterialTheme.colorScheme.onBackground, ) - - Box( - modifier = Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), - shape = CircleShape, - ) - .clickable( - onClick = { - StreamsRepository.reload( - type = type, - videoId = videoId, - parentMetaId = parentMetaId, - season = seasonNumber, - episode = episodeNumber, - manualSelection = manualSelection, - ) - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Refresh, - contentDescription = stringResource(Res.string.streams_refresh), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp), - ) - } } AnimatedVisibility( @@ -336,10 +326,9 @@ fun StreamsScreen( modifier = Modifier.padding(horizontal = 24.dp), ) } - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(32.dp), color = Color.White, - strokeWidth = 2.5.dp, ) Text( text = uiState.overlayMessage @@ -356,7 +345,7 @@ fun StreamsScreen( externalPlayerEnabled = playerSettings.externalPlayerEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> - val directUrl = stream.playableDirectUrl + val directUrl = stream.playableDirectUrl ?: stream.externalOpenUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) NuvioToastController.show(streamLinkCopiedText) @@ -471,6 +460,7 @@ private fun MobileStreamsLayout( resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -543,6 +533,7 @@ private fun MobileStreamsLayout( groups = uiState.groups, selectedFilter = uiState.selectedFilter, onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) }, + onRefresh = onRefresh, ) StreamList( @@ -752,10 +743,10 @@ internal fun ProviderFilterRow( groups: List, selectedFilter: String?, onFilterSelected: (String?) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { val addonGroups = groups.filter { it.streams.isNotEmpty() || it.isLoading } - if (addonGroups.isEmpty()) return Row( modifier = modifier @@ -764,6 +755,12 @@ internal fun ProviderFilterRow( .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + FilterChip( + icon = Icons.Rounded.Refresh, + contentDescription = stringResource(Res.string.streams_refresh), + isSelected = false, + onClick = onRefresh, + ) // "All" chip FilterChip( label = stringResource(Res.string.collections_tab_all), @@ -782,7 +779,9 @@ internal fun ProviderFilterRow( @Composable private fun FilterChip( - label: String, + label: String? = null, + icon: ImageVector? = null, + contentDescription: String? = null, isSelected: Boolean, onClick: () -> Unit, ) { @@ -817,6 +816,7 @@ private fun FilterChip( scaleX = scale scaleY = scale } + .height(36.dp) .clip(RoundedCornerShape(16.dp)) .background(containerColor) .clickable( @@ -824,18 +824,34 @@ private fun FilterChip( indication = null, onClick = onClick, ) - .padding(horizontal = 14.dp, vertical = 8.dp), + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 14.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold, - letterSpacing = 0.1.sp, - ), - color = contentColor, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + } + if (label != null) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold, + letterSpacing = 0.1.sp, + ), + color = contentColor, + maxLines = 1, + ) + } + } } } @@ -987,7 +1003,7 @@ private fun LazyListScope.streamSection( } }, onLongClick = { - if (stream.playableDirectUrl != null || stream.isAddonDebridCandidate) { + if (stream.playableDirectUrl != null || stream.shouldOpenExternally || stream.isAddonDebridCandidate) { onStreamLongPress(stream) } }, @@ -1015,6 +1031,10 @@ internal fun streamCardRenderKey( append(itemIndex) append(':') append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel) + stream.externalUrl?.let { + append(':') + append(it) + } } // --------------------------------------------------------------------------- @@ -1044,9 +1064,8 @@ private fun StreamSectionHeader( ) AnimatedVisibility(visible = isLoading, enter = fadeIn(), exit = fadeOut()) { Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(6.dp)) @@ -1210,7 +1229,7 @@ private fun LoadingStateBlock(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - CircularProgressIndicator( + NuvioLoadingIndicator( color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp), ) @@ -1295,9 +1314,8 @@ private fun FooterLoadingBlock(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 98c225a6..1091ba7f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.isIos +import dev.chrisbanes.haze.HazeInputScale import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -66,6 +67,7 @@ internal fun TabletStreamsLayout( resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { val hazeState = rememberHazeState() @@ -172,8 +174,11 @@ internal fun TabletStreamsLayout( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(24.dp)) - .hazeEffect(state = hazeState) - .background(Color.Black.copy(alpha = 0.22f)), + .hazeEffect(state = hazeState) { + inputScale = HazeInputScale.Fixed(0.66f) + blurRadius = 56.dp + } + .background(Color.Black.copy(alpha = 0.36f)), ) { Column( modifier = Modifier @@ -192,6 +197,7 @@ internal fun TabletStreamsLayout( groups = uiState.groups, selectedFilter = uiState.selectedFilter, onFilterSelected = { addonId -> StreamsRepository.selectFilter(addonId) }, + onRefresh = onRefresh, ) ActiveScrapersStatusBlock( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index cba36b92..9e940ee9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -65,6 +65,24 @@ object TraktAuthRepository { return _uiState.value } + internal fun currentStateForSync(): TraktAuthState { + ensureLoaded() + return authState + } + + internal fun replaceStateFromSync(state: TraktAuthState): Boolean { + ensureLoaded() + val syncedState = state.copy( + pendingAuthorizationState = null, + pendingAuthorizationStartedAtMillis = null, + ) + if (authState.syncSignature() == syncedState.syncSignature()) return false + authState = syncedState + persist() + publish(statusMessage = null, errorMessage = null) + return true + } + fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() @@ -281,6 +299,7 @@ object TraktAuthRepository { ) persist() refreshUserSettings() + TraktCredentialSync.pushCurrentToRemote() publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -312,6 +331,7 @@ object TraktAuthRepository { } } + TraktCredentialSync.deleteRemote() authState = TraktAuthState() persist() publish( @@ -347,11 +367,21 @@ object TraktAuthRepository { }.onFailure { error -> if (error is CancellationException) throw error log.w { "Trakt token refresh failed: ${error.message}" } - }.getOrNull() ?: return false + }.getOrNull() + + if (response == null) { + if (recoverFromRemoteCredentials(refreshToken)) return true + return false + } val parsed = runCatching { json.decodeFromString(response) - }.getOrNull() ?: return false + }.getOrNull() + + if (parsed == null) { + if (recoverFromRemoteCredentials(refreshToken)) return true + return false + } authState = authState.copy( accessToken = parsed.accessToken, @@ -361,6 +391,7 @@ object TraktAuthRepository { expiresIn = parsed.expiresIn, ) persist() + TraktCredentialSync.pushCurrentToRemote() publish() return true } @@ -443,6 +474,23 @@ object TraktAuthRepository { val nowSeconds = TraktPlatformClock.nowEpochMs() / 1_000L return nowSeconds >= (expiresAtSeconds - 60) } + + private suspend fun recoverFromRemoteCredentials(staleRefreshToken: String): Boolean { + val pulled = TraktCredentialSync.pullFromRemote() + if (!pulled) return false + return authState.isAuthenticated && authState.refreshToken != staleRefreshToken + } + + private fun TraktAuthState.syncSignature(): String = + listOf( + accessToken.orEmpty(), + refreshToken.orEmpty(), + tokenType.orEmpty(), + createdAt?.toString().orEmpty(), + expiresIn?.toString().orEmpty(), + username.orEmpty(), + userSlug.orEmpty(), + ).joinToString("|") } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt new file mode 100644 index 00000000..b2574647 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt @@ -0,0 +1,165 @@ +package com.nuvio.app.features.trakt + +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.core.sync.putSyncOriginClientId +import com.nuvio.app.features.profiles.ProfileRepository +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.rpc +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +private const val TRAKT_PROVIDER = "trakt" +private const val TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS = 86_400 + +@Serializable +private data class ProviderCredentialRow( + val provider: String, + @SerialName("credential_json") val credentialJson: JsonObject, + @SerialName("updated_at") val updatedAt: String? = null, +) + +object TraktCredentialSync { + private val log = Logger.withTag("TraktCredentialSync") + private val mutex = Mutex() + + suspend fun pushCurrentToRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = + mutex.withLock { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false + + val state = TraktAuthRepository.currentStateForSync() + val credentialJson = state.toCredentialJson() ?: return@withLock false + runCatching { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_credentials", buildJsonArray { + addJsonObject { + put("provider", TRAKT_PROVIDER) + put("credential_json", credentialJson) + } + }) + putSyncOriginClientId() + } + SupabaseProvider.client.postgrest.rpc("sync_push_provider_credentials", params) + true + }.getOrElse { error -> + log.e(error) { "pushCurrentToRemote(profileId=$profileId) failed" } + false + } + } + + suspend fun pullFromRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = + try { + pullFromRemoteOrThrow(profileId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "pullFromRemote(profileId=$profileId) failed" } + false + } + + internal suspend fun pullFromRemoteOrThrow( + profileId: Int = ProfileRepository.activeProfileId, + ): Boolean = mutex.withLock { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false + val accountId = authState.userId + if (ProfileRepository.activeProfileId != profileId) return@withLock false + + val params = buildJsonObject { + put("p_profile_id", profileId) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params) + val currentAuthState = AuthRepository.state.value + if ( + currentAuthState !is AuthState.Authenticated || + currentAuthState.isAnonymous || + currentAuthState.userId != accountId || + ProfileRepository.activeProfileId != profileId + ) { + throw CancellationException("Trakt credential pull target changed") + } + val rows = result.decodeList() + val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) } + ?: return@withLock false + val remoteState = row.credentialJson.toTraktAuthState() + ?: error("Remote Trakt credential payload is invalid") + TraktAuthRepository.replaceStateFromSync(remoteState) + } + + suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = + mutex.withLock { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false + + runCatching { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_provider", TRAKT_PROVIDER) + putSyncOriginClientId() + } + SupabaseProvider.client.postgrest.rpc("sync_delete_provider_credentials", params) + true + }.getOrElse { error -> + log.e(error) { "deleteRemote(profileId=$profileId) failed" } + false + } + } +} + +private fun TraktAuthState.toCredentialJson(): JsonObject? { + val accessTokenValue = accessToken?.takeIf { it.isNotBlank() } ?: return null + val refreshTokenValue = refreshToken?.takeIf { it.isNotBlank() } ?: return null + return buildJsonObject { + put("access_token", accessTokenValue) + put("refresh_token", refreshTokenValue) + put("token_type", tokenType ?: "bearer") + put("created_at", createdAt ?: (TraktPlatformClock.nowEpochMs() / 1_000L)) + put("expires_in", normalizeTraktTokenLifetime(expiresIn ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS)) + username?.takeIf { it.isNotBlank() }?.let { put("username", it) } + userSlug?.takeIf { it.isNotBlank() }?.let { put("user_slug", it) } + } +} + +private fun JsonObject.toTraktAuthState(): TraktAuthState? { + val accessTokenValue = stringValue("access_token")?.takeIf { it.isNotBlank() } ?: return null + val refreshTokenValue = stringValue("refresh_token")?.takeIf { it.isNotBlank() } ?: return null + return TraktAuthState( + accessToken = accessTokenValue, + refreshToken = refreshTokenValue, + tokenType = stringValue("token_type") ?: "bearer", + createdAt = longValue("created_at") ?: (TraktPlatformClock.nowEpochMs() / 1_000L), + expiresIn = normalizeTraktTokenLifetime(intValue("expires_in") ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS), + username = stringValue("username"), + userSlug = stringValue("user_slug"), + ) +} + +private fun normalizeTraktTokenLifetime(expiresIn: Int): Int { + if (expiresIn <= 0) return TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS + return expiresIn.coerceAtMost(TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS) +} + +private fun JsonObject.stringValue(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull + +private fun JsonObject.longValue(key: String): Long? = + this[key]?.jsonPrimitive?.longOrNull + +private fun JsonObject.intValue(key: String): Int? = + this[key]?.jsonPrimitive?.intOrNull 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 ff3d8c8f..8da4ec19 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 @@ -3,6 +3,7 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -11,19 +12,22 @@ import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching +import com.nuvio.app.features.watchprogress.shouldReplaceProgressSnapshotEntry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred 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.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore @@ -37,20 +41,24 @@ import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlin.math.abs private const val BASE_URL = "https://api.trakt.tv" private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f private const val HISTORY_PAGE_LIMIT = 1000 private const val HISTORY_MAX_PAGES = 5 private const val HISTORY_MAX_PAGES_ALL = 20 +private const val WATCHED_PAGE_LIMIT = 250 +private const val WATCHED_MAX_PAGES = 1_000 +private const val WATCHED_SHOWS_EXTENDED = "progress" private const val MAX_RECENT_EPISODE_HISTORY_ENTRIES = 300 private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 private const val METADATA_HYDRATION_LIMIT = 110 private const val REFRESH_BASE_INTERVAL_MS = 60L * 1000L -private const val REFRESH_MAX_INTERVAL_MS = 15L * 60L * 1000L private const val EPISODE_PROGRESS_CACHE_TTL_MS = 30L * 60L * 1000L private const val EPISODE_PROGRESS_FETCH_THROTTLE_MS = 60L * 1000L +private const val OPTIMISTIC_PROGRESS_TTL_MS = 3L * 60L * 1000L private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val AMBIGUOUS_ID_MARKER = "__ambiguous__" @@ -61,7 +69,17 @@ data class TraktProgressUiState( val hasLoadedRemoteProgress: Boolean = false, ) +private data class TraktMetadataHydrationResult( + val entries: List, + val meta: MetaDetails?, +) + object TraktProgressRepository { + private data class OptimisticProgressEntry( + val progress: WatchProgressEntry, + val expiresAtMs: Long, + ) + private val log = Logger.withTag("TraktProgress") private val json = Json { ignoreUnknownKeys = true } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -71,13 +89,15 @@ object TraktProgressRepository { val uiState: StateFlow = _uiState.asStateFlow() private val hiddenProgressShowIds = MutableStateFlow>(emptySet()) + private val optimisticProgress = MutableStateFlow>(emptyMap()) private var hasLoaded = false private var refreshRequestId: Long = 0L private val refreshJobMutex = Mutex() private var inFlightRefresh: Deferred? = null + private var metadataHydrationJob: Job? = null + private var remoteEntriesSnapshot: List = emptyList() private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS - private var consecutiveRefreshFailures = 0 private var lastKnownMoviesWatchedAt: String? = null private var lastKnownEpisodeActivityFingerprint: String? = null private var lastKnownActivityFingerprint: String? = null @@ -100,17 +120,17 @@ object TraktProgressRepository { source = TraktSettingsRepository.uiState.value.watchProgressSource, ) ) { - updateRefreshBackoff(success = true) + resetRefreshInterval() continue } - val success = runCatching { + runCatching { refreshIfActivityChanged() }.onFailure { error -> if (error is CancellationException) throw error log.w { "Periodic Trakt activity refresh failed: ${error.message}" } - }.isSuccess - updateRefreshBackoff(success = success) + } + resetRefreshInterval() } } } @@ -137,6 +157,8 @@ object TraktProgressRepository { fun onProfileChanged() { invalidateInFlightRefreshes() hasLoaded = false + remoteEntriesSnapshot = emptyList() + optimisticProgress.value = emptyMap() hiddenProgressShowIds.value = emptySet() resetActivitySnapshot() resetShowProgressCaches() @@ -147,6 +169,8 @@ object TraktProgressRepository { fun clearLocalState() { invalidateInFlightRefreshes() hasLoaded = false + remoteEntriesSnapshot = emptyList() + optimisticProgress.value = emptyMap() hiddenProgressShowIds.value = emptySet() resetActivitySnapshot() resetShowProgressCaches() @@ -178,6 +202,18 @@ object TraktProgressRepository { } } + suspend fun invalidateAndRefresh() { + ensureLoaded() + invalidateInFlightRefreshes() + resetActivitySnapshot() + episodeProgressMutex.withLock { + episodeProgressFetchedAtMsByContentId.clear() + episodeProgressLastAttemptAtMsByContentId.clear() + inFlightEpisodeProgressContentIds.clear() + } + refreshNow() + } + suspend fun refreshEpisodeProgress( contentId: String, forceRefresh: Boolean = false, @@ -207,14 +243,14 @@ object TraktProgressRepository { try { val entries = fetchEpisodeProgressEntries(headers = headers, contentId = normalizedContentId) - val existingEntries = _uiState.value.entries - val merged = mergeNewestByVideoId(existingEntries + entries) - _uiState.value = _uiState.value.copy(entries = merged.sortedByDescending { it.lastUpdatedEpochMs }) + remoteEntriesSnapshot = mergeNewestByVideoId(remoteEntriesSnapshot + entries) + reconcileOptimisticProgress(remoteEntriesSnapshot) + publishProgressState(entries = remoteEntriesSnapshot) episodeProgressMutex.withLock { episodeProgressFetchedAtMsByContentId[cacheKey] = TraktPlatformClock.nowEpochMs() } if (entries.isNotEmpty()) { - launchHydration(requestId = refreshRequestId, entries = entries) + launchHydration(requestId = refreshRequestId, entries = _uiState.value.entries) } } finally { episodeProgressMutex.withLock { @@ -232,11 +268,13 @@ object TraktProgressRepository { private suspend fun hasActivityChanged(headers: Map): Boolean { val activities = runCatching { + val endpoint = "$BASE_URL/sync/last_activities" + val payload = httpGetTextWithHeaders( + url = endpoint, + headers = headers, + ) json.decodeFromString( - httpGetTextWithHeaders( - url = "$BASE_URL/sync/last_activities", - headers = headers, - ), + payload, ) }.onFailure { error -> if (error is CancellationException) throw error @@ -269,23 +307,14 @@ object TraktProgressRepository { return changed } - private fun updateRefreshBackoff(success: Boolean) { - if (success) { - consecutiveRefreshFailures = 0 - refreshIntervalMs = REFRESH_BASE_INTERVAL_MS - return - } - - consecutiveRefreshFailures += 1 - refreshIntervalMs = (REFRESH_BASE_INTERVAL_MS shl (consecutiveRefreshFailures - 1)) - .coerceAtMost(REFRESH_MAX_INTERVAL_MS) + private fun resetRefreshInterval() { + refreshIntervalMs = REFRESH_BASE_INTERVAL_MS } private fun resetActivitySnapshot() { lastKnownMoviesWatchedAt = null lastKnownEpisodeActivityFingerprint = null lastKnownActivityFingerprint = null - consecutiveRefreshFailures = 0 refreshIntervalMs = REFRESH_BASE_INTERVAL_MS } @@ -303,11 +332,13 @@ object TraktProgressRepository { val requestId = nextRefreshRequestId() val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { + remoteEntriesSnapshot = emptyList() + optimisticProgress.value = emptyMap() _uiState.value = TraktProgressUiState() return } - _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + publishProgressState(isLoading = true, errorMessage = null) val playbackEntries = runCatching { fetchPlaybackEntries(headers) @@ -329,14 +360,14 @@ object TraktProgressRepository { // Merge new playback entries into the existing state rather than replacing it wholesale. // This prevents the CW list from briefly losing "next up" seeds (like One Piece) for the // ~2.8s gap between fetchPlaybackEntries completing and the full sync finishing. - val existingEntries = _uiState.value.entries - val mergedWithPlayback = if (existingEntries.isEmpty()) { + val existingEntries = remoteEntriesSnapshot + remoteEntriesSnapshot = if (existingEntries.isEmpty()) { playbackEntries } else { mergeNewestByVideoId(existingEntries + playbackEntries) } - _uiState.value = _uiState.value.copy( - entries = mergedWithPlayback, + publishProgressState( + entries = remoteEntriesSnapshot, isLoading = true, errorMessage = null, hasLoadedRemoteProgress = false, @@ -359,7 +390,7 @@ object TraktProgressRepository { if (completedEntries == null) { if (!isLatestRefreshRequest(requestId)) return - _uiState.value = _uiState.value.copy( + publishProgressState( isLoading = false, errorMessage = null, hasLoadedRemoteProgress = false, @@ -369,18 +400,19 @@ object TraktProgressRepository { if (!isLatestRefreshRequest(requestId)) return - val merged = mergeNewestByVideoId(playbackEntries + completedEntries) - val sortedMerged = merged.sortedByDescending { it.lastUpdatedEpochMs } + remoteEntriesSnapshot = mergeNewestByVideoId(playbackEntries + completedEntries) + reconcileOptimisticProgress(remoteEntriesSnapshot) - _uiState.value = _uiState.value.copy( - entries = sortedMerged, + publishProgressState( + entries = remoteEntriesSnapshot, isLoading = false, errorMessage = null, hasLoadedRemoteProgress = true, ) - - if (sortedMerged.isNotEmpty()) { - launchHydration(requestId = requestId, entries = sortedMerged) + + val visibleEntries = _uiState.value.entries + if (visibleEntries.isNotEmpty()) { + launchHydration(requestId = requestId, entries = visibleEntries) } } @@ -388,37 +420,115 @@ object TraktProgressRepository { requestId: Long, entries: List, ) { - scope.launch { - val hydrated = runCatching { - hydrateEntriesFromAddonMeta(entries) + metadataHydrationJob?.cancel() + metadataHydrationJob = scope.launch { + runCatching { + hydrateEntriesFromAddonMeta( + requestId = requestId, + entries = entries, + ) }.onFailure { error -> if (error is CancellationException) throw error log.w { "Failed to hydrate Trakt metadata: ${error.message}" } - }.getOrNull() ?: return@launch - - if (!isLatestRefreshRequest(requestId)) return@launch - - val merged = mergeEntriesPreferRichMetadata( - current = _uiState.value.entries, - hydrated = hydrated, - ) - val sortedMerged = merged.sortedByDescending { it.lastUpdatedEpochMs } - _uiState.value = _uiState.value.copy( - entries = sortedMerged, - isLoading = false, - errorMessage = null, - ) + } } } + private fun publishProgressState( + entries: List = remoteEntriesSnapshot, + isLoading: Boolean = _uiState.value.isLoading, + errorMessage: String? = _uiState.value.errorMessage, + hasLoadedRemoteProgress: Boolean = _uiState.value.hasLoadedRemoteProgress, + ) { + val publishedEntries = mergeWithActiveOptimistic(entries) + _uiState.value = TraktProgressUiState( + entries = publishedEntries, + isLoading = isLoading, + errorMessage = errorMessage, + hasLoadedRemoteProgress = hasLoadedRemoteProgress, + ) + } + + private fun mergeWithActiveOptimistic(entries: List): List { + val optimisticEntries = activeOptimisticEntries() + if (optimisticEntries.isEmpty()) { + return entries.sortedByDescending { it.lastUpdatedEpochMs } + } + return mergeNewestByVideoId(entries + optimisticEntries) + } + + private fun activeOptimisticEntries( + now: Long = TraktPlatformClock.nowEpochMs(), + ): List { + val current = optimisticProgress.value + if (current.isEmpty()) return emptyList() + val active = current.filterValues { entry -> entry.expiresAtMs > now } + if (active.size != current.size) { + optimisticProgress.value = active + } + return active.values.map { it.progress } + } + + private fun putOptimisticProgress(entry: WatchProgressEntry) { + val now = TraktPlatformClock.nowEpochMs() + optimisticProgress.update { current -> + val active = current + .filterValues { optimistic -> optimistic.expiresAtMs > now } + .toMutableMap() + val existing = active[entry.videoId]?.progress + if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) { + active[entry.videoId] = OptimisticProgressEntry( + progress = entry, + expiresAtMs = now + OPTIMISTIC_PROGRESS_TTL_MS, + ) + } + active + } + } + + private fun removeOptimisticProgress( + shouldRemove: (WatchProgressEntry) -> Boolean, + ) { + optimisticProgress.update { current -> + current.filterValues { optimistic -> !shouldRemove(optimistic.progress) } + } + } + + private fun reconcileOptimisticProgress(remoteEntries: List) { + if (remoteEntries.isEmpty() || optimisticProgress.value.isEmpty()) return + val remoteByVideoId = remoteEntries.associateBy { it.videoId } + val now = TraktPlatformClock.nowEpochMs() + optimisticProgress.update { current -> + current.filter { (videoId, optimistic) -> + if (optimistic.expiresAtMs <= now) return@filter false + val remoteEntry = remoteByVideoId[videoId] ?: return@filter true + !remoteConfirmsOptimisticEntry( + remote = remoteEntry, + optimistic = optimistic.progress, + ) + } + } + } + + private fun remoteConfirmsOptimisticEntry( + remote: WatchProgressEntry, + optimistic: WatchProgressEntry, + ): Boolean { + val normalizedRemote = remote.normalizedCompletion() + val normalizedOptimistic = optimistic.normalizedCompletion() + val remoteNewEnough = normalizedRemote.lastUpdatedEpochMs >= normalizedOptimistic.lastUpdatedEpochMs - 60_000L + if (normalizedOptimistic.isEffectivelyCompleted) { + return normalizedRemote.isEffectivelyCompleted && remoteNewEnough + } + + val closeEnough = abs(normalizedRemote.progressFraction - normalizedOptimistic.progressFraction) <= 0.03f + return closeEnough && remoteNewEnough + } + fun applyOptimisticProgress(entry: WatchProgressEntry) { if (!TraktAuthRepository.isAuthenticated.value) return - val current = _uiState.value.entries.associateBy { it.videoId }.toMutableMap() val normalizedEntry = entry.normalizedCompletion() - val existing = current[normalizedEntry.videoId] - if (existing == null || normalizedEntry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) { - current[normalizedEntry.videoId] = normalizedEntry - } + putOptimisticProgress(normalizedEntry) if (normalizedEntry.isCompleted && normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) { optimisticallyAddWatchedEpisode( contentId = normalizedEntry.parentMetaId, @@ -426,14 +536,15 @@ object TraktProgressRepository { episode = normalizedEntry.episodeNumber, ) } - _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) + publishProgressState() } fun applyOptimisticRemoval(videoId: String) { if (!TraktAuthRepository.isAuthenticated.value) return if (videoId.isBlank()) return - val filtered = _uiState.value.entries.filterNot { it.videoId == videoId } - _uiState.value = _uiState.value.copy(entries = filtered) + removeOptimisticProgress { it.videoId == videoId } + remoteEntriesSnapshot = remoteEntriesSnapshot.filterNot { it.videoId == videoId } + publishProgressState() } fun applyOptimisticRemoval( @@ -444,15 +555,16 @@ object TraktProgressRepository { if (!TraktAuthRepository.isAuthenticated.value) return val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return - val filtered = _uiState.value.entries.filterNot { entry -> - if (entry.parentMetaId != normalizedContentId) { - false - } else if (seasonNumber != null && episodeNumber != null) { - entry.seasonNumber == seasonNumber && entry.episodeNumber == episodeNumber - } else { - true - } + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return + val shouldRemove: (WatchProgressEntry) -> Boolean = { entry -> + entry.matchesTraktRemovalContext( + contentId = normalizedContentId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } + removeOptimisticProgress(shouldRemove) + remoteEntriesSnapshot = remoteEntriesSnapshot.filterNot(shouldRemove) if (seasonNumber != null && episodeNumber != null) { optimisticallyRemoveWatchedEpisode( contentId = normalizedContentId, @@ -460,7 +572,7 @@ object TraktProgressRepository { episode = episodeNumber, ) } - _uiState.value = _uiState.value.copy(entries = filtered) + publishProgressState() } suspend fun removeProgress( @@ -470,6 +582,7 @@ object TraktProgressRepository { ) { val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val headers = TraktAuthRepository.authorizedHeaders() ?: return applyOptimisticRemoval( @@ -547,9 +660,10 @@ object TraktProgressRepository { var page = 1 val limit = 1000 while (true) { + val endpoint = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit" val payload = runCatching { httpGetTextWithHeaders( - url = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit", + url = endpoint, headers = headers, ) }.getOrNull() ?: break @@ -572,16 +686,18 @@ object TraktProgressRepository { } private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { + val moviesEndpoint = "$BASE_URL/sync/playback/movies" + val episodesEndpoint = "$BASE_URL/sync/playback/episodes" val payloads = coroutineScope { val moviesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/movies", + url = moviesEndpoint, headers = headers, ) } val episodesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/episodes", + url = episodesEndpoint, headers = headers, ) } @@ -677,10 +793,11 @@ object TraktProgressRepository { cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) } } val payload = httpGetTextWithHeaders(url = url, headers = headers) - return json.decodeFromString>(payload) + val mapped = json.decodeFromString>(payload) .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs } .distinctBy { entry -> entry.videoId } + return mapped } private suspend fun fetchWatchedShowSeedEntries( @@ -688,11 +805,7 @@ object TraktProgressRepository { ): List = withContext(Dispatchers.Default) { ContinueWatchingPreferencesRepository.ensureLoaded() val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode - val payload = httpGetTextWithHeaders( - url = "$BASE_URL/sync/watched/shows", - headers = headers, - ) - val watchedShows = json.decodeFromString>(payload) + val watchedShows = fetchWatchedShowPages(headers) updateWatchedShowCaches(watchedShows) val mapped = fixAmbiguousWatchedShowSeeds( watchedShows.mapNotNull { item -> @@ -706,6 +819,34 @@ object TraktProgressRepository { mapped } + private suspend fun fetchWatchedShowPages( + headers: Map, + ): List { + val items = mutableListOf() + var page = 1 + while (page <= WATCHED_MAX_PAGES) { + val response = httpRequestRaw( + method = "GET", + url = "$BASE_URL/sync/watched/shows?page=$page&limit=$WATCHED_PAGE_LIMIT&extended=$WATCHED_SHOWS_EXTENDED", + headers = headers, + body = "", + ) + if (response.status !in 200..299) { + error("Trakt watched shows request failed: ${response.status}") + } + val pageItems = json.decodeFromString>(response.body) + if (pageItems.isEmpty()) break + items.addAll(pageItems) + val pageCount = response.headerInt("x-pagination-page-count") + if (pageCount != null && page >= pageCount) break + page += 1 + } + if (page > WATCHED_MAX_PAGES) { + error("Trakt watched shows exceeded max pages") + } + return items + } + private fun updateWatchedShowCaches(items: List) { val siblingsMap = mutableMapOf>() @@ -980,18 +1121,6 @@ object TraktProgressRepository { .sortedByDescending { it.lastUpdatedEpochMs } } - private fun shouldReplaceProgressSnapshotEntry( - existing: WatchProgressEntry, - candidate: WatchProgressEntry, - ): Boolean { - val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching() - val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching() - if (existingInProgress != candidateInProgress) { - return candidateInProgress - } - return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs - } - private fun mergeEntriesPreferRichMetadata( current: List, hydrated: List, @@ -1033,41 +1162,120 @@ object TraktProgressRepository { refreshRequestId += 1L inFlightRefresh?.cancel() inFlightRefresh = null + metadataHydrationJob?.cancel() + metadataHydrationJob = null } private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId private suspend fun hydrateEntriesFromAddonMeta( + requestId: Long, entries: List, - ): List = coroutineScope { - if (entries.isEmpty()) return@coroutineScope entries + ) = coroutineScope { + if (entries.isEmpty()) return@coroutineScope - val uniqueContent = entries - .map { entry -> entry.parentMetaType to entry.parentMetaId } - .distinct() + val entriesByContent = entries + .groupBy { entry -> entry.parentMetaType to entry.parentMetaId } + .entries .take(METADATA_HYDRATION_LIMIT) val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY) - val metadataByContent = uniqueContent - .map { (metaType, metaId) -> - async { - semaphore.withPermit { - val normalizedType = when (metaType.lowercase()) { - "movie", "film" -> "movie" - else -> "series" - } - val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) { - MetaDetailsRepository.fetch(type = normalizedType, id = metaId) - } - (metaType to metaId) to meta - } + val results = Channel(Channel.UNLIMITED) + entriesByContent.forEach { (key, contentEntries) -> + launch { + val (metaType, metaId) = key + val meta = semaphore.withPermit { + fetchAddonMetaForHydration( + metaType = metaType, + metaId = metaId, + ) + } + results.send( + TraktMetadataHydrationResult( + entries = contentEntries, + meta = meta, + ), + ) + } + } + + repeat(entriesByContent.size) { + val result = results.receive() + if (!isLatestRefreshRequest(requestId)) { + throw CancellationException("Superseded Trakt metadata hydration request $requestId") + } + val meta = result.meta ?: return@repeat + val hydrated = hydrateEntriesWithAddonMeta( + entries = result.entries, + meta = meta, + ) + if (hydrated.isEmpty()) return@repeat + + val hydratedByVideoId = hydrated.associateBy { entry -> entry.videoId } + val remoteVideoIds = remoteEntriesSnapshot.mapTo(mutableSetOf()) { entry -> entry.videoId } + val remoteHydrated = hydrated.filter { entry -> entry.videoId in remoteVideoIds } + var changed = false + if (remoteHydrated.isNotEmpty()) { + val merged = mergeEntriesPreferRichMetadata( + current = remoteEntriesSnapshot, + hydrated = remoteHydrated, + ) + if (merged != remoteEntriesSnapshot) { + remoteEntriesSnapshot = merged + changed = true } } - .awaitAll() - .toMap() + optimisticProgress.update { current -> + val updated = current.mapValues { (videoId, optimistic) -> + val hydratedEntry = hydratedByVideoId[videoId] ?: return@mapValues optimistic + val normalizedHydrated = hydratedEntry.normalizedCompletion() + if (shouldReplaceEntry(existing = optimistic.progress, candidate = normalizedHydrated)) { + optimistic.copy(progress = normalizedHydrated) + } else { + optimistic + } + } + if (updated != current) { + changed = true + } + updated + } + + if (changed) { + publishProgressState( + isLoading = false, + errorMessage = null, + ) + } + } + results.close() + } + + private suspend fun fetchAddonMetaForHydration( + metaType: String, + metaId: String, + ): MetaDetails? { + val normalizedType = when (metaType.lowercase()) { + "movie", "film" -> "movie" + else -> "series" + } + return try { + withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) { + MetaDetailsRepository.fetch(type = normalizedType, id = metaId) + } + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } + } + + private suspend fun hydrateEntriesWithAddonMeta( + entries: List, + meta: MetaDetails, + ): List = entries.map { entry -> - val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry var resolvedSeason = entry.seasonNumber var resolvedEpisode = entry.episodeNumber @@ -1105,19 +1313,18 @@ object TraktProgressRepository { entry.copy( title = entry.title.takeIf { it.isNotBlank() } ?: meta.name, - logo = entry.logo ?: meta.logo, - poster = entry.poster ?: meta.poster, - background = entry.background ?: meta.background, + logo = entry.logo?.takeIf(String::isNotBlank) ?: meta.logo, + poster = entry.poster?.takeIf(String::isNotBlank) ?: meta.poster, + background = entry.background?.takeIf(String::isNotBlank) ?: meta.background, seasonNumber = if (entry.isCompleted) entry.seasonNumber else (resolvedSeason ?: entry.seasonNumber), episodeNumber = if (entry.isCompleted) entry.episodeNumber else (resolvedEpisode ?: entry.episodeNumber), - episodeTitle = entry.episodeTitle ?: episode?.title, - episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, - pauseDescription = entry.pauseDescription - ?: episode?.overview - ?: meta.description, + episodeTitle = entry.episodeTitle?.takeIf(String::isNotBlank) ?: episode?.title, + episodeThumbnail = entry.episodeThumbnail?.takeIf(String::isNotBlank) ?: episode?.thumbnail, + pauseDescription = entry.pauseDescription?.takeIf(String::isNotBlank) + ?: episode?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank), ) } - } private fun mapPlaybackMovie(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { val movie = item.movie ?: return null @@ -1371,6 +1578,25 @@ object TraktProgressRepository { } } +internal fun hasCompleteTraktEpisodeContext( + seasonNumber: Int?, + episodeNumber: Int?, +): Boolean = (seasonNumber == null) == (episodeNumber == null) + +internal fun WatchProgressEntry.matchesTraktRemovalContext( + contentId: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): Boolean { + val normalizedContentId = contentId.trim() + if (normalizedContentId.isBlank()) return false + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return false + if (parentMetaId != normalizedContentId) return false + return seasonNumber == null || ( + this.seasonNumber == seasonNumber && this.episodeNumber == episodeNumber + ) +} + @Serializable private data class TraktPlaybackItem( @SerialName("id") val id: Long? = null, 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 102d7590..36f6ba59 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 @@ -237,7 +237,7 @@ internal object TraktScrobbleRepository { ) if (action == "stop") { - runCatching { TraktProgressRepository.refreshNow() } + runCatching { TraktProgressRepository.invalidateAndRefresh() } .onFailure { error -> if (error is CancellationException) throw error log.w { "Failed to refresh Trakt progress after stop: ${error.message}" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index 3b22d9dc..c696b2ac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -1,6 +1,9 @@ package com.nuvio.app.features.trakt +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -94,11 +97,23 @@ object TraktSettingsRepository { _uiState.value = TraktSettingsUiState() } - fun setWatchProgressSource(source: WatchProgressSource) { + internal fun setWatchProgressSource( + source: WatchProgressSource, + profileId: Int = ProfileRepository.activeProfileId, + ) { ensureLoaded() if (_uiState.value.watchProgressSource == source) return - _uiState.value = _uiState.value.copy(watchProgressSource = source) - persist() + val nextState = _uiState.value.copy(watchProgressSource = source) + persist(nextState) + val authState = AuthRepository.state.value + if (authState is AuthState.Authenticated && !authState.isAnonymous) { + ProfileSettingsWatchSourceOutbox.record( + accountId = authState.userId, + profileId = profileId, + source = source, + ) + } + _uiState.value = nextState } fun setContinueWatchingDaysCap(days: Int) { @@ -148,14 +163,14 @@ object TraktSettingsRepository { } } - private fun persist() { + private fun persist(state: TraktSettingsUiState = _uiState.value) { TraktSettingsStorage.savePayload( json.encodeToString( StoredTraktSettings( - watchProgressSource = _uiState.value.watchProgressSource.name, - continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, - librarySourceMode = _uiState.value.librarySourceMode.name, - moreLikeThisSource = _uiState.value.moreLikeThisSource.name, + watchProgressSource = state.watchProgressSource.name, + continueWatchingDaysCap = state.continueWatchingDaysCap, + librarySourceMode = state.librarySourceMode.name, + moreLikeThisSource = state.moreLikeThisSource.name, ), ), ) @@ -174,6 +189,20 @@ fun shouldUseTraktProgress( source: WatchProgressSource, ): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT +fun effectiveWatchProgressSource( + isTraktAuthenticated: Boolean, + requestedSource: WatchProgressSource, +): WatchProgressSource = + if (shouldUseTraktProgress( + isAuthenticated = isTraktAuthenticated, + source = requestedSource, + ) + ) { + WatchProgressSource.TRAKT + } else { + WatchProgressSource.NUVIO_SYNC + } + fun effectiveLibrarySourceMode( isAuthenticated: Boolean, source: LibrarySourceMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt index f1302794..f2ddcbf7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt @@ -3,4 +3,7 @@ package com.nuvio.app.features.trakt internal expect object TraktSettingsStorage { fun loadPayload(): String? fun savePayload(payload: String) + fun loadPendingWatchProgressSourcePayload(profileId: Int): String? + fun savePendingWatchProgressSourcePayload(profileId: Int, payload: String) + fun clearPendingWatchProgressSourcePayload(profileId: Int) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/WatchProgressSourceSettingsOutbox.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/WatchProgressSourceSettingsOutbox.kt new file mode 100644 index 00000000..4c98f471 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/WatchProgressSourceSettingsOutbox.kt @@ -0,0 +1,86 @@ +package com.nuvio.app.features.trakt + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +internal data class PendingWatchProgressSourceChange( + val accountId: String, + val profileId: Int, + val source: WatchProgressSource, +) + +internal class WatchProgressSourceSettingsOutbox( + private val loadPayload: (profileId: Int) -> String?, + private val savePayload: (profileId: Int, payload: String) -> Unit, + private val clearPayload: (profileId: Int) -> Unit, +) { + private val lock = SynchronizedObject() + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun record(change: PendingWatchProgressSourceChange) = synchronized(lock) { + savePayload(change.profileId, json.encodeToString(change)) + } + + fun pendingFor(accountId: String, profileId: Int): PendingWatchProgressSourceChange? = + synchronized(lock) { + val payload = loadPayload(profileId).orEmpty().trim() + if (payload.isEmpty()) return@synchronized null + + val change = runCatching { + json.decodeFromString(payload) + }.getOrNull() + if (change == null || change.accountId != accountId || change.profileId != profileId) { + clearPayload(profileId) + return@synchronized null + } + change + } + + fun clearIfMatches(change: PendingWatchProgressSourceChange): Boolean = synchronized(lock) { + val current = loadPayload(change.profileId) + ?.let { payload -> + runCatching { + json.decodeFromString(payload) + }.getOrNull() + } + if (current != change) return@synchronized false + clearPayload(change.profileId) + true + } +} + +internal object ProfileSettingsWatchSourceOutbox { + private val delegate = WatchProgressSourceSettingsOutbox( + loadPayload = TraktSettingsStorage::loadPendingWatchProgressSourcePayload, + savePayload = TraktSettingsStorage::savePendingWatchProgressSourcePayload, + clearPayload = TraktSettingsStorage::clearPendingWatchProgressSourcePayload, + ) + + fun record( + accountId: String, + profileId: Int, + source: WatchProgressSource, + ) { + delegate.record( + PendingWatchProgressSourceChange( + accountId = accountId, + profileId = profileId, + source = source, + ), + ) + } + + fun pendingFor(accountId: String, profileId: Int): PendingWatchProgressSourceChange? = + delegate.pendingFor(accountId = accountId, profileId = profileId) + + fun clearIfMatches(change: PendingWatchProgressSourceChange): Boolean = + delegate.clearIfMatches(change) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 75427d6a..6cfe9245 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -15,7 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -488,9 +488,8 @@ fun AppUpdaterHost( verticalAlignment = Alignment.CenterVertically, ) { if (state.isChecking) { - CircularProgressIndicator( + NuvioLoadingIndicator( modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, ) Spacer(modifier = Modifier.width(10.dp)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index 99389a70..f634aa20 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -100,4 +100,5 @@ private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode = title = title, thumbnail = thumbnail, releasedDate = released, + available = available, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 3f778d81..4aae9a59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -22,6 +22,7 @@ data class WatchedUiState( val items: List = emptyList(), val watchedKeys: Set = emptySet(), val isLoaded: Boolean = false, + val hasLoadedRemoteItems: Boolean = false, ) fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 9d1fcdd9..27ee2076 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -1,7 +1,10 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktSettingsRepository @@ -11,8 +14,12 @@ import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedDeltaEvent import com.nuvio.app.features.watching.sync.WatchedSyncAdapter +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,9 +33,11 @@ import kotlinx.serialization.json.Json @Serializable private data class StoredWatchedPayload( val items: List = emptyList(), + val fullyWatchedSeriesKeys: Set = emptySet(), val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyWatchedKeys: Set = emptySet(), ) internal enum class WatchedTraktHistorySync { @@ -41,13 +50,58 @@ internal fun shouldMirrorWatchedMarkToTraktHistory( isTraktAuthenticated: Boolean, ): Boolean = sync == WatchedTraktHistorySync.Mirror && isTraktAuthenticated +internal data class WatchedSourceOperation( + val source: WatchProgressSource, + val generation: Long, +) + +internal fun isWatchedSourceOperationCurrent( + operation: WatchedSourceOperation, + activeSource: WatchProgressSource, + activeGeneration: Long, +): Boolean = operation.source == activeSource && operation.generation == activeGeneration + +internal fun watchedItemsForSource( + source: WatchProgressSource, + nuvioItems: Collection, + traktItems: Collection, +): Collection = when (source) { + WatchProgressSource.NUVIO_SYNC -> nuvioItems + WatchProgressSource.TRAKT -> traktItems +} + +internal fun shouldPersistWatchedSource(source: WatchProgressSource): Boolean = + source == WatchProgressSource.NUVIO_SYNC + +internal fun replaceWatchedItemsForSource( + source: WatchProgressSource, + nuvioItems: MutableMap, + traktItems: MutableMap, + replacement: Map, +) { + val target = when (source) { + WatchProgressSource.NUVIO_SYNC -> nuvioItems + WatchProgressSource.TRAKT -> traktItems + } + target.clear() + target.putAll(replacement) +} + object WatchedRepository { + private data class WatchedRefreshOperation( + val profileId: Int, + val profileGeneration: Long, + val sourceOperation: WatchedSourceOperation, + ) + private const val watchedItemsPageSize = 900 private const val watchedItemsDeltaPageSize = 900 private const val watchedDeltaOperationUpsert = "upsert" private const val watchedDeltaOperationDelete = "delete" - private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val accountScopeLock = SynchronizedObject() + private var accountScopeJob: Job = SupervisorJob() + private var accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default) private val log = Logger.withTag("WatchedRepository") private val json = Json { ignoreUnknownKeys = true @@ -56,19 +110,41 @@ object WatchedRepository { private val _uiState = MutableStateFlow(WatchedUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _fullyWatchedSeriesKeys = MutableStateFlow>(emptySet()) + val fullyWatchedSeriesKeys: StateFlow> = _fullyWatchedSeriesKeys.asStateFlow() private var hasLoaded = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L - private var itemsByKey: MutableMap = mutableMapOf() + private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC + private var sourceGeneration: Long = 0L + private var nuvioItemsByKey: MutableMap = mutableMapOf() + private var traktItemsByKey: MutableMap = mutableMapOf() + private var nuvioFullyWatchedSeriesKeys: Set = emptySet() + private var traktFullyWatchedSeriesKeys: Set = emptySet() + private var nuvioHasLoaded: Boolean = false + private var traktHasLoaded: Boolean = false + private var nuvioHasLoadedRemote: Boolean = false + private var traktHasLoadedRemote: Boolean = false + private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter + internal var traktSyncAdapter: WatchedSyncAdapter = TraktWatchedSyncAdapter fun ensureLoaded() { - if (hasLoaded) return - loadFromDisk(ProfileRepository.activeProfileId) + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + if (!hasLoaded) { + loadFromDisk(ProfileRepository.activeProfileId) + activateEffectiveSource( + effectiveWatchedSource( + requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + ), + ) + } } fun onProfileChanged(profileId: Int) { @@ -77,21 +153,49 @@ object WatchedRepository { } fun clearLocalState() { + val previousAccountJob = synchronized(accountScopeLock) { + accountScopeJob.also { + accountScopeJob = SupervisorJob() + accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default) + } + } + previousAccountJob.cancel() hasLoaded = false currentProfileId = 1 profileGeneration += 1L - itemsByKey.clear() + activeSource = WatchProgressSource.NUVIO_SYNC + sourceGeneration += 1L + nuvioItemsByKey.clear() + traktItemsByKey.clear() + nuvioFullyWatchedSeriesKeys = emptySet() + traktFullyWatchedSeriesKeys = emptySet() + nuvioHasLoaded = false + traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + _fullyWatchedSeriesKeys.value = emptySet() _uiState.value = WatchedUiState() } private fun loadFromDisk(profileId: Int) { currentProfileId = profileId profileGeneration += 1L + activeSource = WatchProgressSource.NUVIO_SYNC + sourceGeneration += 1L hasLoaded = true - itemsByKey.clear() + nuvioItemsByKey.clear() + traktItemsByKey.clear() + nuvioFullyWatchedSeriesKeys = emptySet() + traktFullyWatchedSeriesKeys = emptySet() + nuvioHasLoaded = true + traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { @@ -101,123 +205,261 @@ object WatchedRepository { lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs deltaCursorEventId = storedPayload.deltaCursorEventId deltaInitialized = storedPayload.deltaInitialized - itemsByKey = storedPayload.items + nuvioItemsByKey = storedPayload.items .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + nuvioDirtyWatchedKeys = storedPayload.dirtyWatchedKeys + .filterTo(mutableSetOf()) { key -> key in nuvioItemsByKey } + nuvioFullyWatchedSeriesKeys = storedPayload.fullyWatchedSeriesKeys } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + nuvioDirtyWatchedKeys.clear() + nuvioFullyWatchedSeriesKeys = emptySet() } publish() } - private fun activeOperationGeneration(profileId: Int): Long? { - if (ProfileRepository.activeProfileId != profileId) return null - if (!hasLoaded || currentProfileId != profileId) { - loadFromDisk(profileId) + internal fun activateSource(source: WatchProgressSource): WatchProgressSource { + if (!hasLoaded) { + loadFromDisk(ProfileRepository.activeProfileId) } - return profileGeneration + return activateEffectiveSource(source) } - private fun isActiveOperation(profileId: Int, generation: Long): Boolean = - currentProfileId == profileId && - profileGeneration == generation && - ProfileRepository.activeProfileId == profileId + private fun activateEffectiveSource(source: WatchProgressSource): WatchProgressSource { + if (activeSource == source) return source + if (source == WatchProgressSource.TRAKT) { + traktItemsByKey.clear() + traktFullyWatchedSeriesKeys = emptySet() + traktHasLoaded = false + traktHasLoadedRemote = false + } else { + nuvioHasLoadedRemote = false + } + activeSource = source + sourceGeneration += 1L + publish() + return source + } + + private fun newRefreshOperation(profileId: Int): WatchedRefreshOperation? { + if (ProfileRepository.activeProfileId != profileId) return null + if (!hasLoaded || currentProfileId != profileId) return null + return WatchedRefreshOperation( + profileId = profileId, + profileGeneration = profileGeneration, + sourceOperation = WatchedSourceOperation( + source = activeSource, + generation = sourceGeneration, + ), + ) + } + + private fun isActiveOperation(operation: WatchedRefreshOperation): Boolean = + currentProfileId == operation.profileId && + profileGeneration == operation.profileGeneration && + ProfileRepository.activeProfileId == operation.profileId && + isWatchedSourceOperationCurrent( + operation = operation.sourceOperation, + activeSource = activeSource, + activeGeneration = sourceGeneration, + ) suspend fun pullFromServer(profileId: Int) { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() - val operationGeneration = activeOperationGeneration(profileId) ?: run { - log.d { "Skipping watched pull for inactive profile $profileId" } - return + refreshForSource( + profileId = profileId, + source = effectiveWatchedSource( + requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + ), + forceSnapshot = false, + ) + } + + suspend fun forceSnapshotRefreshFromServer(profileId: Int) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + refreshForSource( + profileId = profileId, + source = effectiveWatchedSource( + requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + ), + forceSnapshot = true, + ) + } + + internal suspend fun refreshForSource( + profileId: Int, + source: WatchProgressSource, + forceSnapshot: Boolean = true, + ): Boolean { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + if (ProfileRepository.activeProfileId != profileId) { + log.d { "Skipping watched refresh for inactive profile $profileId" } + return false } - val pullStartedEpochMs = WatchedClock.nowEpochMs() - val localBeforePull = itemsByKey.values - .map(WatchedItem::normalizedMarkedAt) - .toList() - val lastPushEpochMs = lastSuccessfulPushEpochMs - runCatching { - if (shouldUseTraktWatchedSync()) { - pullFullFromAdapter( - adapter = TraktWatchedSyncAdapter, + if (!hasLoaded || currentProfileId != profileId) { + loadFromDisk(profileId) + } + + val effectiveSource = activateEffectiveSource(source) + val operation = newRefreshOperation(profileId) ?: return false + if (effectiveSource == WatchProgressSource.NUVIO_SYNC) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // Local watched state is authoritative when this account has no Nuvio upstream. + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + publish() + return true + } + } + return try { + if (effectiveSource == WatchProgressSource.TRAKT) { + pullSnapshotFromAdapter( + adapter = traktSyncAdapter, + operation = operation, profileId = profileId, - localBeforePull = localBeforePull, - lastPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, - operationGeneration = operationGeneration, + ) + } else if (forceSnapshot) { + refreshNuvioSnapshot( + operation = operation, + profileId = profileId, ) } else { pullSupabaseDeltaFromServer( + operation = operation, profileId = profileId, - localBeforePull = localBeforePull, - lastPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - operationGeneration = operationGeneration, ) } - }.onFailure { e -> - log.e(e) { "Failed to pull watched items from server" } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to refresh watched items from $effectiveSource" } + false } } - private suspend fun pullFullFromAdapter( - adapter: WatchedSyncAdapter, + private suspend fun refreshNuvioSnapshot( + operation: WatchedRefreshOperation, + profileId: Int, + ): Boolean { + val cursorBeforeSnapshot = try { + syncAdapter.getDeltaCursor(profileId) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } + if (!isActiveOperation(operation)) return false + + val applied = pullSnapshotFromAdapter( + adapter = syncAdapter, + operation = operation, + profileId = profileId, + resetDeltaState = cursorBeforeSnapshot == null, + ) + if (!applied || !isActiveOperation(operation)) return false + if (cursorBeforeSnapshot != null) { + deltaCursorEventId = cursorBeforeSnapshot + deltaInitialized = true + persistNuvio() + } + return true + } + + private suspend fun pullSnapshotFromAdapter( + adapter: WatchedSyncAdapter, + operation: WatchedRefreshOperation, profileId: Int, - localBeforePull: List, - lastPushEpochMs: Long, - pullStartedEpochMs: Long, resetDeltaState: Boolean, - operationGeneration: Long, - ) { + ): Boolean { val serverItems = adapter.pull( profileId = profileId, pageSize = watchedItemsPageSize, ) - if (!isActiveOperation(profileId, operationGeneration)) return + if (!isActiveOperation(operation)) return false + val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() - itemsByKey = mergeWatchedItemsPreservingUnsynced( + val mergedSnapshot = mergeWatchedSnapshot( serverItems = serverItems, - localItems = localBeforePull, - lastSuccessfulPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ).toMutableMap() - if (resetDeltaState) { - deltaCursorEventId = 0L - deltaInitialized = false + localItems = localAtApply, + dirtyKeys = if (operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys + } else { + emptySet() + }, + ) + replaceWatchedItemsForSource( + source = operation.sourceOperation.source, + nuvioItems = nuvioItemsByKey, + traktItems = traktItemsByKey, + replacement = mergedSnapshot.items, + ) + when (operation.sourceOperation.source) { + WatchProgressSource.NUVIO_SYNC -> { + nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + if (resetDeltaState) { + deltaCursorEventId = 0L + deltaInitialized = false + } + } + WatchProgressSource.TRAKT -> { + traktHasLoaded = true + traktHasLoadedRemote = true + } } - hasLoaded = true publish() - persist() + if (shouldPersistWatchedSource(operation.sourceOperation.source)) { + persistNuvio() + } + return true } private suspend fun pullSupabaseDeltaFromServer( + operation: WatchedRefreshOperation, profileId: Int, - localBeforePull: List, - lastPushEpochMs: Long, - pullStartedEpochMs: Long, - operationGeneration: Long, - ) { - if (!isActiveOperation(profileId, operationGeneration)) return + ): Boolean { + if (!isActiveOperation(operation)) return false if (!deltaInitialized) { - val cursorBeforeSnapshot = syncAdapter.getDeltaCursor(profileId) ?: return - pullFullFromAdapter( + val cursorBeforeSnapshot = try { + syncAdapter.getDeltaCursor(profileId) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } + if (!isActiveOperation(operation)) return false + if (cursorBeforeSnapshot == null) { + return pullSnapshotFromAdapter( + adapter = syncAdapter, + operation = operation, + profileId = profileId, + resetDeltaState = true, + ) + } + val applied = pullSnapshotFromAdapter( adapter = syncAdapter, + operation = operation, profileId = profileId, - localBeforePull = localBeforePull, - lastPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, - operationGeneration = operationGeneration, ) - if (!isActiveOperation(profileId, operationGeneration)) return + if (!applied || !isActiveOperation(operation)) return false deltaCursorEventId = cursorBeforeSnapshot deltaInitialized = true - persist() - return + persistNuvio() + return true } var cursor = deltaCursorEventId @@ -229,13 +471,13 @@ object WatchedRepository { sinceEventId = cursor, limit = watchedItemsDeltaPageSize, ) - if (!isActiveOperation(profileId, operationGeneration)) return + if (!isActiveOperation(operation)) return false if (events.isEmpty()) break applyWatchedDeltaEvents( + targetItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, events = events, - lastPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, ) cursor = maxOf(cursor, events.maxOf { it.eventId }) deltaCursorEventId = cursor @@ -245,23 +487,38 @@ object WatchedRepository { if (events.size < watchedItemsDeltaPageSize) break } - hasLoaded = true - if (changed) { + if (!isActiveOperation(operation)) return false + nuvioHasLoaded = true + val remoteReadinessChanged = !nuvioHasLoadedRemote + nuvioHasLoadedRemote = true + if (changed || remoteReadinessChanged) { publish() - persist() } + if (changed) { + persistNuvio() + } + return true } private fun applyWatchedDeltaEvents( + targetItems: MutableMap, + dirtyKeys: MutableSet, events: Collection, - lastPushEpochMs: Long, - pullStartedEpochMs: Long, ) { + var upsertCount = 0 + var deleteCount = 0 + var removedCount = 0 + var removedByFallbackKeyCount = 0 + var preservedDirtyCount = 0 + var acknowledgedDirtyCount = 0 + var ignoredCount = 0 + events.forEach { event -> val key = watchedItemKey(event.contentType, event.contentId, event.season, event.episode) when (event.operation.lowercase()) { watchedDeltaOperationUpsert -> { - itemsByKey[key] = WatchedItem( + upsertCount += 1 + val remoteItem = WatchedItem( id = event.contentId, type = event.contentType, name = event.title, @@ -269,22 +526,114 @@ object WatchedRepository { episode = event.episode, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(event.watchedAt), ) + val localItem = targetItems[key]?.normalizedMarkedAt() + if ( + key in dirtyKeys && + localItem != null && + remoteItem.markedAtEpochMs < localItem.markedAtEpochMs + ) { + preservedDirtyCount += 1 + } else { + targetItems[key] = remoteItem + if (dirtyKeys.remove(key)) { + acknowledgedDirtyCount += 1 + } + } } watchedDeltaOperationDelete -> { - val localItem = itemsByKey[key] - if (localItem != null && shouldPreserveLocalWatchedItem(localItem, lastPushEpochMs, pullStartedEpochMs)) { + deleteCount += 1 + val matchingKey = if (key in targetItems) { + key + } else { + findWatchedItemStableDeleteKey( + targetItems = targetItems, + contentId = event.contentId, + contentType = event.contentType, + season = event.season, + episode = event.episode, + ) + } + if (matchingKey == null) { return@forEach } - itemsByKey.remove(key) + if (matchingKey in dirtyKeys) { + preservedDirtyCount += 1 + return@forEach + } + val removedItem = targetItems.remove(matchingKey) + if (removedItem != null) { + removedCount += 1 + if (matchingKey != key) { + removedByFallbackKeyCount += 1 + } + } + } + else -> { + ignoredCount += 1 } } } + + log.i { + "Applied watched delta events total=${events.size} upserts=$upsertCount deletes=$deleteCount " + + "removed=$removedCount removedByFallbackKey=$removedByFallbackKeyCount " + + "preservedDirty=$preservedDirtyCount acknowledgedDirty=$acknowledgedDirtyCount " + + "ignored=$ignoredCount" + } } + private fun findWatchedItemStableDeleteKey( + targetItems: Map, + contentId: String, + contentType: String, + season: Int?, + episode: Int?, + ): String? = targetItems.entries.firstOrNull { (_, item) -> + item.id == contentId && + watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && + item.season == season && + item.episode == episode + }?.key + + private fun watchedDeleteTypesCompatible(remoteType: String, localType: String): Boolean { + if (remoteType.equals(localType, ignoreCase = true)) return true + return remoteType.isSeriesLikeWatchedType() && localType.isSeriesLikeWatchedType() + } + + private fun itemsForSource(source: WatchProgressSource): MutableMap = + when (source) { + WatchProgressSource.NUVIO_SYNC -> nuvioItemsByKey + WatchProgressSource.TRAKT -> traktItemsByKey + } + + private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set = + when (source) { + WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys + WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys + } + + private fun setFullyWatchedSeriesKeysForSource( + source: WatchProgressSource, + keys: Set, + ) { + when (source) { + WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys = keys + WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys = keys + } + } + + private fun hasLoadedSource(source: WatchProgressSource): Boolean = + when (source) { + WatchProgressSource.NUVIO_SYNC -> nuvioHasLoaded + WatchProgressSource.TRAKT -> traktHasLoaded + } + fun toggleWatched(item: WatchedItem) { ensureLoaded() + val source = activeSource + val targetItems = itemsForSource(source) val key = watchedItemKey(item.type, item.id, item.season, item.episode) - if (itemsByKey.containsKey(key)) { + if (targetItems.containsKey(key)) { unmarkWatched(item) } else { markWatched(item) @@ -310,18 +659,29 @@ object WatchedRepository { ) { ensureLoaded() if (items.isEmpty()) return + val source = activeSource + val targetItems = itemsForSource(source) val markedAt = WatchedClock.nowEpochMs() val timestampedItems = items.map { watchedItem -> watchedItem.copy(markedAtEpochMs = markedAt) } timestampedItems.forEach { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) - itemsByKey[key] = watchedItem + targetItems[key] = watchedItem + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys += key + } } publish() - persist() + if (shouldPersistWatchedSource(source)) { + persistNuvio() + } if (syncRemote) { - pushMarksToServer(timestampedItems, traktHistorySync) + pushMarksToServer( + items = timestampedItems, + traktHistorySync = traktHistorySync, + source = source, + ) } } @@ -352,13 +712,22 @@ object WatchedRepository { fun unmarkWatched(items: Collection) { ensureLoaded() if (items.isEmpty()) return + val source = activeSource + val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> - itemsByKey.remove(watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)) + val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) + targetItems.remove(key)?.also { + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys -= key + } + } } if (removedItems.isNotEmpty()) { publish() - persist() - pushDeleteToServer(removedItems) + if (shouldPersistWatchedSource(source)) { + persistNuvio() + } + pushDeleteToServer(items = removedItems, source = source) } } @@ -369,7 +738,7 @@ object WatchedRepository { episode: Int? = null, ): Boolean { ensureLoaded() - return itemsByKey.containsKey(watchedItemKey(type, id, season, episode)) + return itemsForSource(activeSource).containsKey(watchedItemKey(type, id, season, episode)) } fun reconcileSeriesWatchedState( @@ -380,14 +749,11 @@ object WatchedRepository { if (!meta.type.isSeriesLikeWatchedType()) return ensureLoaded() - val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode -> - isWatched( - id = meta.id, - type = meta.type, - season = episode.season, - episode = episode.episode, - ) || isEpisodeCompleted(episode) - } + val shouldMarkSeriesWatched = reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeCompleted = isEpisodeCompleted, + ) val seriesWatchedItem = meta.toSeriesWatchedItem() val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type) if (shouldMarkSeriesWatched) { @@ -399,21 +765,82 @@ object WatchedRepository { } } + fun reconcileFullyWatchedSeriesState( + meta: MetaDetails, + todayIsoDate: String, + isEpisodeWatched: (MetaVideo) -> Boolean = { episode -> + isWatched( + id = meta.id, + type = meta.type, + season = episode.season, + episode = episode.episode, + ) + }, + isEpisodeCompleted: (MetaVideo) -> Boolean = { false }, + ): Boolean { + if (!meta.type.isSeriesLikeWatchedType()) return false + + ensureLoaded() + val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode -> + isEpisodeWatched(episode) || isEpisodeCompleted(episode) + } + updateFullyWatchedSeriesKey( + key = watchedItemKey(meta.type, meta.id), + isFullyWatched = shouldMarkSeriesWatched, + ) + return shouldMarkSeriesWatched + } + + fun updateFullyWatchedSeries( + id: String, + type: String, + isFullyWatched: Boolean, + ) { + if (!type.isSeriesLikeWatchedType()) return + ensureLoaded() + updateFullyWatchedSeriesKey( + key = watchedItemKey(type, id), + isFullyWatched = isFullyWatched, + ) + } + + private fun updateFullyWatchedSeriesKey( + key: String, + isFullyWatched: Boolean, + ) { + val source = activeSource + val current = fullyWatchedSeriesKeysForSource(source) + val updated = if (isFullyWatched) current + key else current - key + if (updated == current) return + setFullyWatchedSeriesKeysForSource(source = source, keys = updated) + publish() + if (shouldPersistWatchedSource(source)) { + persistNuvio() + } + } + private fun pushMarksToServer( items: Collection, traktHistorySync: WatchedTraktHistorySync, + source: WatchProgressSource, ) { val profileId = currentProfileId - syncScope.launch { + val operationGeneration = profileGeneration + accountScopeSnapshot().launch { runCatching { if (items.isEmpty()) return@runCatching - val pushed = pushToActiveTargets( + val pushed = pushToTargetsForSource( profileId = profileId, items = items, traktHistorySync = traktHistorySync, + source = source, ) - if (pushed) { - recordSuccessfulPush(profileId = profileId, items = items) + if (pushed && shouldPersistWatchedSource(source)) { + recordSuccessfulPush( + profileId = profileId, + operationGeneration = operationGeneration, + items = items, + ) } }.onFailure { e -> log.e(e) { "Failed to push watched items" } @@ -421,12 +848,19 @@ object WatchedRepository { } } - private fun pushDeleteToServer(items: Collection) { + private fun pushDeleteToServer( + items: Collection, + source: WatchProgressSource, + ) { val profileId = currentProfileId - syncScope.launch { + accountScopeSnapshot().launch { runCatching { if (items.isEmpty()) return@runCatching - deleteFromActiveTargets(profileId = profileId, items = items) + deleteFromTargetsForSource( + profileId = profileId, + items = items, + source = source, + ) }.onFailure { e -> log.e(e) { "Failed to push watched item delete" } } @@ -434,124 +868,176 @@ object WatchedRepository { } private fun publish() { - val items = itemsByKey.values + val items = watchedItemsForSource( + source = activeSource, + nuvioItems = nuvioItemsByKey.values, + traktItems = traktItemsByKey.values, + ) .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs } + _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeysForSource(activeSource) _uiState.value = WatchedUiState( items = items, watchedKeys = items.mapTo(linkedSetOf()) { watchedItemKey(it.type, it.id, it.season, it.episode) }, - isLoaded = true, + isLoaded = hasLoadedSource(activeSource), + hasLoadedRemoteItems = when (activeSource) { + WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote + WatchProgressSource.TRAKT -> traktHasLoadedRemote + }, ) } - private fun persist() { + private fun persistNuvio() { WatchedStorage.savePayload( currentProfileId, json.encodeToString( StoredWatchedPayload( - items = itemsByKey.values + items = nuvioItemsByKey.values .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs }, + fullyWatchedSeriesKeys = nuvioFullyWatchedSeriesKeys, lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyWatchedKeys = nuvioDirtyWatchedKeys.toSet(), ), ), ) } - private fun recordSuccessfulPush(profileId: Int, items: Collection) { - if (profileId != currentProfileId) return + private fun recordSuccessfulPush( + profileId: Int, + operationGeneration: Long, + items: Collection, + ) { + if (profileId != currentProfileId || operationGeneration != profileGeneration) return + val acknowledgedDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, + pushedItems = items, + ) val latestPushed = items .asSequence() .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) } .maxOrNull() ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed - persist() + val updatedLastSuccessfulPushEpochMs = maxOf(lastSuccessfulPushEpochMs, latestPushed) + if ( + acknowledgedDirtyKeys == nuvioDirtyWatchedKeys && + updatedLastSuccessfulPushEpochMs == lastSuccessfulPushEpochMs + ) { + return + } + nuvioDirtyWatchedKeys = acknowledgedDirtyKeys.toMutableSet() + lastSuccessfulPushEpochMs = updatedLastSuccessfulPushEpochMs + persistNuvio() } - private fun shouldUseTraktWatchedSync(): Boolean = - shouldUseTraktWatchedSync( - isAuthenticated = TraktAuthRepository.isAuthenticated.value, - source = TraktSettingsRepository.uiState.value.watchProgressSource, - ) - - private suspend fun pushToActiveTargets( + private suspend fun pushToTargetsForSource( profileId: Int, items: Collection, traktHistorySync: WatchedTraktHistorySync, + source: WatchProgressSource, ): Boolean { val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory( sync = traktHistorySync, isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, ) - if (shouldUseTraktWatchedSync()) { + if (source == WatchProgressSource.TRAKT) { if (!shouldMirrorToTrakt) return false - TraktWatchedSyncAdapter.push(profileId = profileId, items = items) + traktSyncAdapter.push(profileId = profileId, items = items) return true } syncAdapter.push(profileId = profileId, items = items) if (shouldMirrorToTrakt) { - TraktWatchedSyncAdapter.push(profileId = profileId, items = items) + traktSyncAdapter.push(profileId = profileId, items = items) } return true } - private suspend fun deleteFromActiveTargets( + private suspend fun deleteFromTargetsForSource( profileId: Int, items: Collection, + source: WatchProgressSource, ) { - if (shouldUseTraktWatchedSync()) { - TraktWatchedSyncAdapter.delete(profileId = profileId, items = items) + if (source == WatchProgressSource.TRAKT) { + traktSyncAdapter.delete(profileId = profileId, items = items) return } syncAdapter.delete(profileId = profileId, items = items) if (TraktAuthRepository.isAuthenticated.value) { - TraktWatchedSyncAdapter.delete(profileId = profileId, items = items) + traktSyncAdapter.delete(profileId = profileId, items = items) } } + + private fun accountScopeSnapshot(): CoroutineScope = + synchronized(accountScopeLock) { + accountScope + } } -internal fun mergeWatchedItemsPreservingUnsynced( +internal data class WatchedSnapshotMerge( + val items: Map, + val dirtyKeys: Set, +) + +internal fun mergeWatchedSnapshot( serverItems: Collection, localItems: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, -): Map { - val merged = serverItems + dirtyKeys: Set, +): WatchedSnapshotMerge { + val remoteByKey = serverItems .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() - - localItems + val localByKey = localItems .map(WatchedItem::normalizedMarkedAt) - .forEach { localItem -> - val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode) - if (key in merged) return@forEach - if (shouldPreserveLocalWatchedItem(localItem, lastSuccessfulPushEpochMs, pullStartedEpochMs)) { - merged[key] = localItem - } - } + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + val remainingDirtyKeys = dirtyKeys + .filterTo(mutableSetOf()) { key -> key in localByKey } - return merged + remainingDirtyKeys.toList().forEach { key -> + val localItem = localByKey.getValue(key) + val remoteItem = remoteByKey[key] + if (remoteItem == null || remoteItem.markedAtEpochMs < localItem.markedAtEpochMs) { + remoteByKey[key] = localItem + } else { + remainingDirtyKeys -= key + } + } + + return WatchedSnapshotMerge( + items = remoteByKey, + dirtyKeys = remainingDirtyKeys, + ) } -internal fun shouldPreserveLocalWatchedItem( - localItem: WatchedItem, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, -): Boolean { - val markedAt = localItem.markedAtEpochMs - val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs - val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs - return wasMarkedAfterLastPush || wasMarkedDuringPull +internal fun acknowledgeSuccessfulWatchedPush( + currentItems: Map, + dirtyKeys: Set, + pushedItems: Collection, +): Set { + val remainingDirtyKeys = dirtyKeys.toMutableSet() + pushedItems + .map(WatchedItem::normalizedMarkedAt) + .forEach { pushedItem -> + val key = watchedItemKey( + type = pushedItem.type, + id = pushedItem.id, + season = pushedItem.season, + episode = pushedItem.episode, + ) + val currentItem = currentItems[key]?.normalizedMarkedAt() + if (currentItem == null || currentItem.markedAtEpochMs <= pushedItem.markedAtEpochMs) { + remainingDirtyKeys -= key + } + } + return remainingDirtyKeys } internal fun shouldUseTraktWatchedSync( @@ -562,5 +1048,15 @@ internal fun shouldUseTraktWatchedSync( source = source, ) +internal fun effectiveWatchedSource( + requestedSource: WatchProgressSource, + isTraktAuthenticated: Boolean, +): WatchProgressSource = + if (shouldUseTraktWatchedSync(isAuthenticated = isTraktAuthenticated, source = requestedSource)) { + WatchProgressSource.TRAKT + } else { + WatchProgressSource.NUVIO_SYNC + } + private fun String.isSeriesLikeWatchedType(): Boolean = trim().lowercase() in setOf("series", "show", "tv", "tvshow") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index 984dfbec..6977e02c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -36,6 +36,11 @@ object WatchingActions { if (meta == null) { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(preview.toWatchedItem(markedAtEpochMs = 0L)) + WatchedRepository.updateFullyWatchedSeries( + id = preview.id, + type = preview.type, + isFullyWatched = false, + ) } return } @@ -45,6 +50,11 @@ object WatchingActions { if (releasedMainEpisodes.isEmpty()) { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(meta.toSeriesWatchedItem()) + WatchedRepository.updateFullyWatchedSeries( + id = meta.id, + type = meta.type, + isFullyWatched = false, + ) } return } @@ -55,10 +65,25 @@ object WatchingActions { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(seriesItems) + WatchProgressRepository.clearProgress( + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) + WatchedRepository.updateFullyWatchedSeries( + id = meta.id, + type = meta.type, + isFullyWatched = false, + ) } else { WatchedRepository.markWatched(seriesItems) + WatchedRepository.updateFullyWatchedSeries( + id = meta.id, + type = meta.type, + isFullyWatched = true, + ) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) } } @@ -71,9 +96,16 @@ object WatchingActions { val watchedItem = meta.toEpisodeWatchedItem(episode) if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItem) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } @@ -112,7 +144,12 @@ object WatchingActions { meta = meta, todayIsoDate = todayIsoDate, isEpisodeCompleted = { episode -> - WatchProgressRepository.progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true + WatchProgressRepository.progressForVideo( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + )?.isCompleted == true }, ) } @@ -153,9 +190,16 @@ object WatchingActions { val watchedItems = episodes.map(meta::toEpisodeWatchedItem) if (areCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItems) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index c0f1474f..f098c65f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -18,7 +18,12 @@ object WatchingState { fun isPosterWatched( watchedKeys: Set, item: MetaPreview, - ): Boolean = watchedKeys.contains(watchedItemKey(item.type, item.id)) + fullyWatchedSeriesKeys: Set = emptySet(), + ): Boolean { + val posterKey = watchedItemKey(item.type, item.id) + if (watchedKeys.contains(posterKey)) return true + return item.type.isSeriesLikePosterType() && fullyWatchedSeriesKeys.contains(posterKey) + } fun isEpisodeWatched( watchedKeys: Set, @@ -82,6 +87,9 @@ object WatchingState { ): List = progressEntries.continueWatchingEntries() } +private fun String.isSeriesLikePosterType(): Boolean = + trim().lowercase() in setOf("series", "show", "tv", "tvshow") + private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( 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 d3c2bc37..3e1671c6 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 @@ -6,25 +6,56 @@ import com.nuvio.app.core.i18n.localizedUpNextLabel const val DefaultContinueWatchingLimit = 20 +private val watchingProgressRecencyComparator = + compareBy { record -> record.lastUpdatedEpochMs } + .thenBy { record -> record.seasonNumber ?: 0 } + .thenBy { record -> record.episodeNumber ?: 0 } + .thenBy(WatchingProgressRecord::lastPositionMs) + .thenBy(WatchingProgressRecord::isCompleted) + .thenBy(WatchingProgressRecord::identityKey) + .thenBy(WatchingProgressRecord::videoId) + +internal fun String?.isSeriesLikeWatchingContentType(): Boolean { + val type = this?.trim() ?: return false + return type.equals("series", ignoreCase = true) || + type.equals("tv", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tvshow", ignoreCase = true) +} + +private fun WatchingContentRef.matchesWatchingContent(content: WatchingContentRef): Boolean { + val bothSeriesLike = type.isSeriesLikeWatchingContentType() && + content.type.isSeriesLikeWatchingContentType() + return if (bothSeriesLike) { + id.trim() == content.id.trim() + } else { + this == content + } +} + fun resumeProgressForSeries( content: WatchingContentRef, progressRecords: List, ): WatchingProgressRecord? = progressRecords - .filter { record -> record.content == content && !record.isCompleted } - .maxByOrNull { record -> record.lastUpdatedEpochMs } + .filter { record -> record.content.matchesWatchingContent(content) } + .maxWithOrNull(watchingProgressRecencyComparator) + ?.takeUnless { record -> record.isCompleted } fun continueWatchingProgressEntries( progressRecords: List, limit: Int = DefaultContinueWatchingLimit, ): List { - val inProgress = progressRecords.filterNot { record -> record.isCompleted } - val (episodes, nonEpisodes) = inProgress.partition { record -> - record.seasonNumber != null && record.episodeNumber != null + val (seriesEntries, nonSeriesEntries) = progressRecords.partition { record -> + record.content.type.isSeriesLikeWatchingContentType() || + (record.seasonNumber != null && record.episodeNumber != null) } - val latestPerSeries = episodes - .sortedByDescending { record -> record.lastUpdatedEpochMs } - .distinctBy { record -> record.content.id } - return (nonEpisodes + latestPerSeries) + val latestPerSeries = seriesEntries + .groupBy { record -> record.content.id.trim() } + .values + .mapNotNull { entries -> entries.maxWithOrNull(watchingProgressRecencyComparator) } + + return (nonSeriesEntries + latestPerSeries) + .filterNot { record -> record.isCompleted } .sortedByDescending { record -> record.lastUpdatedEpochMs } .take(limit) } @@ -78,6 +109,7 @@ fun nextReleasedEpisodeAfter( todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, showUnairedNextUp = showUnairedNextUp, + available = episode.available, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } @@ -119,7 +151,13 @@ fun decideSeriesPrimaryAction( } else { val sorted = episodes .sortedWith(compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 })) - val released = sorted.filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) } + val released = sorted.filter { episode -> + isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = episode.releasedDate, + available = episode.available, + ) + } released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } ?: released.firstOrNull() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt index 6040e2d7..0430ddb9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt @@ -28,6 +28,7 @@ data class WatchingProgressRecord( val isCompleted: Boolean = false, val episodeTitle: String? = null, val episodeThumbnail: String? = null, + val identityKey: String = videoId, ) data class WatchingReleasedEpisode( @@ -37,6 +38,7 @@ data class WatchingReleasedEpisode( val title: String? = null, val thumbnail: String? = null, val releasedDate: String? = null, + val available: Boolean = true, ) data class WatchingCompletedEpisode( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 60320f56..49e38623 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -30,7 +30,9 @@ fun isProgressComplete( fun isReleasedBy( todayIsoDate: String, releasedDate: String?, + available: Boolean = true, ): Boolean { + if (!available) return false val isoDate = releasedDate ?.substringBefore('T') ?.takeIf { it.length == 10 } @@ -44,8 +46,17 @@ internal fun shouldSurfaceNextEpisode( todayIsoDate: String, releasedDate: String?, showUnairedNextUp: Boolean, + available: Boolean = true, ): Boolean { val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber) + if (!available) { + val daysUntilRelease = daysUntilExplicitRelease( + todayIsoDate = todayIsoDate, + releasedDate = releasedDate, + ) ?: return false + if (!showUnairedNextUp || daysUntilRelease <= 0) return false + return !isSeasonRollover || daysUntilRelease <= UpcomingNextSeasonWindowDays + } if (!isSeasonRollover) { if (showUnairedNextUp) return true return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate) @@ -117,7 +128,11 @@ fun releasedEpisodes( episodes: List, todayIsoDate: String, ): List = episodes.filter { episode -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) + isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = episode.releasedDate, + available = episode.available, + ) } fun releasedMainSeasonEpisodes( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt index dfb8d48e..d47ff2b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -11,15 +11,16 @@ data class ProgressSyncRecord( val position: Long = 0L, val duration: Long = 0L, val lastWatched: Long = 0L, + val progressKey: String = "", ) data class ProgressDeltaEvent( val eventId: Long, val operation: String, val progressKey: String, - val contentId: String, - val contentType: String, - val videoId: String, + val contentId: String = "", + val contentType: String = "", + val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0L, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 8521e4cd..9b9b00de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -1,7 +1,9 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.resolvedProgressKey import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.serialization.SerialName @@ -72,6 +74,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { val serverEntries = result.decodeList() return serverEntries.map { entry -> ProgressSyncRecord( + progressKey = entry.progressKey, contentId = entry.contentId, contentType = entry.contentType, videoId = entry.videoId, @@ -98,12 +101,13 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { position = entry.lastPositionMs, duration = entry.durationMs, lastWatched = entry.lastUpdatedEpochMs, - progressKey = progressKeyForEntry(entry), + progressKey = entry.resolvedProgressKey(), ) } val params = buildJsonObject { put("p_profile_id", profileId) put("p_entries", json.encodeToJsonElement(syncEntries)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params) } @@ -112,26 +116,15 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { profileId: Int, entries: Collection, ) { - val progressKeys = entries.map { entry -> - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } - } + val progressKeys = entries.map { entry -> entry.resolvedProgressKey() } val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(progressKeys)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } - private fun progressKeyForEntry(entry: WatchProgressEntry): String = - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } } @Serializable @@ -152,9 +145,9 @@ private data class WatchProgressDeltaSyncEntry( @SerialName("event_id") val eventId: Long, val operation: String, @SerialName("progress_key") val progressKey: String, - @SerialName("content_id") val contentId: String, - @SerialName("content_type") val contentType: String, - @SerialName("video_id") val videoId: String, + @SerialName("content_id") val contentId: String = "", + @SerialName("content_type") val contentType: String = "", + @SerialName("video_id") val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt index a9b52395..76f8a5c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import io.github.jan.supabase.postgrest.postgrest @@ -102,6 +103,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { val params = buildJsonObject { put("p_profile_id", profileId) put("p_items", json.encodeToJsonElement(syncItems)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_watched_items", params) } @@ -120,6 +122,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(keys)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_delete_watched_items", params) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index f3c82591..b2490f75 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.watching.sync import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.httpGetTextWithHeaders +import com.nuvio.app.features.addons.RawHttpResponse import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository @@ -18,6 +18,9 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" +private const val WATCHED_PAGE_LIMIT = 250 +private const val WATCHED_MAX_PAGES = 1_000 +private const val WATCHED_SHOWS_EXTENDED = "progress" object TraktWatchedSyncAdapter : WatchedSyncAdapter { @@ -35,30 +38,16 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ): List { val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() - val (moviesPayload, showsPayload) = coroutineScope { + val (movieItems, showItems) = coroutineScope { val movies = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/watched/movies", - headers = headers, - ) + fetchWatchedMoviePages(headers) } val shows = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/watched/shows", - headers = headers, - ) + fetchWatchedShowPages(headers) } movies.await() to shows.await() } - val movieItems = runCatching { - json.decodeFromString>(moviesPayload) - }.getOrDefault(emptyList()) - - val showItems = runCatching { - json.decodeFromString>(showsPayload) - }.getOrDefault(emptyList()) - val result = mutableListOf() movieItems.forEach { item -> @@ -122,6 +111,64 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { return remappedResult } + private suspend fun fetchWatchedMoviePages(headers: Map): List { + val items = mutableListOf() + var page = 1 + while (page <= WATCHED_MAX_PAGES) { + val response = httpRequestRaw( + method = "GET", + url = "$BASE_URL/sync/watched/movies?page=$page&limit=$WATCHED_PAGE_LIMIT", + headers = headers, + body = "", + ) + if (response.status !in 200..299) { + error("Trakt watched movies request failed: ${response.status}") + } + val pageItems = json.decodeFromString>(response.body) + if (pageItems.isEmpty()) break + items.addAll(pageItems) + val pageCount = response.headerInt("x-pagination-page-count") + if (pageCount != null && page >= pageCount) break + page += 1 + } + if (page > WATCHED_MAX_PAGES) { + error("Trakt watched movies exceeded max pages") + } + return items + } + + private suspend fun fetchWatchedShowPages(headers: Map): List { + val items = mutableListOf() + var page = 1 + while (page <= WATCHED_MAX_PAGES) { + val response = httpRequestRaw( + method = "GET", + url = "$BASE_URL/sync/watched/shows?page=$page&limit=$WATCHED_PAGE_LIMIT&extended=$WATCHED_SHOWS_EXTENDED", + headers = headers, + body = "", + ) + if (response.status !in 200..299) { + error("Trakt watched shows request failed: ${response.status}") + } + val pageItems = json.decodeFromString>(response.body) + if (pageItems.isEmpty()) break + items.addAll(pageItems) + val pageCount = response.headerInt("x-pagination-page-count") + if (pageCount != null && page >= pageCount) break + page += 1 + } + if (page > WATCHED_MAX_PAGES) { + error("Trakt watched shows exceeded max pages") + } + return items + } + + private fun RawHttpResponse.headerInt(name: String): Int? = + headers[name.lowercase()] + ?.substringBefore(",") + ?.trim() + ?.toIntOrNull() + // ── push (add to history) ─────────────────────────────────────────── override suspend fun push( profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 13e2ebd5..51980b2c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -59,7 +59,7 @@ fun parseReleaseDateToEpochMs(raw: String?): Long? { if (epochMs != null) return epochMs val datePart = isoCalendarDateOrNull(trimmed) ?: return null - return parseTraktIsoDateTimeToEpochMs("${datePart}T00:00:00Z") + return CurrentDateProvider.localStartOfDayEpochMs(datePart) } class ReleaseAlertState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 0ba3b0c5..2082b4a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -1,6 +1,9 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.features.trakt.WatchProgressSource +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -50,8 +53,17 @@ data class CachedInProgressItem( val duration: Long, val lastWatched: Long, val progressPercent: Float? = null, + val progressKey: String? = null, ) +internal fun CachedInProgressItem.resolvedProgressKey(): String = + progressKey?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + @Serializable private data class CachedEnrichmentPayload( val nextUp: List = emptyList(), @@ -65,18 +77,28 @@ internal object ContinueWatchingEnrichmentCache { } private const val storageKey = "cw_enrichment_cache" - private val lastPayloadHashByProfile = mutableMapOf() - private val _cacheCleared = MutableStateFlow(0) - val cacheCleared: StateFlow = _cacheCleared.asStateFlow() + private val cacheLock = SynchronizedObject() + private val lastPayloadHashByScope = mutableMapOf() + private val _generation = MutableStateFlow(0) + val generation: StateFlow = _generation.asStateFlow() - fun getNextUpSnapshot(profileId: Int): List = - loadPayload(profileId)?.nextUp ?: emptyList() + fun getNextUpSnapshot( + profileId: Int, + source: WatchProgressSource, + ): List = + loadPayload(profileId = profileId, source = source)?.nextUp ?: emptyList() - fun getInProgressSnapshot(profileId: Int): List = - loadPayload(profileId)?.inProgress ?: emptyList() + fun getInProgressSnapshot( + profileId: Int, + source: WatchProgressSource, + ): List = + loadPayload(profileId = profileId, source = source)?.inProgress ?: emptyList() - fun getSnapshots(profileId: Int): Pair, List> { - val payload = loadPayload(profileId) + fun getSnapshots( + profileId: Int, + source: WatchProgressSource, + ): Pair, List> { + val payload = loadPayload(profileId = profileId, source = source) val nextUp = payload?.nextUp ?: emptyList() val inProgress = payload?.inProgress ?: emptyList() return nextUp to inProgress @@ -84,43 +106,111 @@ internal object ContinueWatchingEnrichmentCache { fun saveSnapshots( profileId: Int, + source: WatchProgressSource, + generation: Int, nextUp: List, inProgress: List, force: Boolean = false, - ) { + ): Boolean = synchronized(cacheLock) { + if (generation != _generation.value) return@synchronized false + + removeLegacyPayload(profileId) val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress) val payloadHash = payload.hashCode() - if (!force && lastPayloadHashByProfile[profileId] == payloadHash) { - return + val scope = CacheScope(profileId = profileId, source = source) + if (!force && lastPayloadHashByScope[scope] == payloadHash) { + return@synchronized true } val encoded = runCatching { json.encodeToString(payload) - }.getOrNull() ?: return - ContinueWatchingEnrichmentStorage.savePayload(profileScopedStorageKey(profileId), encoded) - lastPayloadHashByProfile[profileId] = payloadHash + }.getOrNull() ?: return@synchronized false + ContinueWatchingEnrichmentStorage.savePayload( + continueWatchingEnrichmentStorageKey(profileId = profileId, source = source), + encoded, + ) + lastPayloadHashByScope[scope] = payloadHash + true } - fun clearAll(profileId: Int) { - ContinueWatchingEnrichmentStorage.removePayload(profileScopedStorageKey(profileId)) - lastPayloadHashByProfile.remove(profileId) - _cacheCleared.value += 1 + fun invalidate( + profileId: Int, + source: WatchProgressSource, + ) = synchronized(cacheLock) { + ContinueWatchingEnrichmentStorage.removePayload( + continueWatchingEnrichmentStorageKey(profileId = profileId, source = source), + ) + removeLegacyPayload(profileId) + lastPayloadHashByScope.remove(CacheScope(profileId = profileId, source = source)) + advanceGeneration() } - fun onProfileChanged() { - _cacheCleared.value += 1 + fun clearAll(profileId: Int) = synchronized(cacheLock) { + WatchProgressSource.entries.forEach { source -> + ContinueWatchingEnrichmentStorage.removePayload( + continueWatchingEnrichmentStorageKey(profileId = profileId, source = source), + ) + lastPayloadHashByScope.remove(CacheScope(profileId = profileId, source = source)) + } + removeLegacyPayload(profileId) + advanceGeneration() } - private fun loadPayload(profileId: Int): CachedEnrichmentPayload? { - val raw = ContinueWatchingEnrichmentStorage.loadPayload(profileScopedStorageKey(profileId)) - ?: return null - return runCatching { + fun clearLocalState() = synchronized(cacheLock) { + lastPayloadHashByScope.clear() + advanceGeneration() + } + + fun onProfileChanged() = synchronized(cacheLock) { + advanceGeneration() + } + + private fun loadPayload( + profileId: Int, + source: WatchProgressSource, + ): CachedEnrichmentPayload? = synchronized(cacheLock) { + removeLegacyPayload(profileId) + val scope = CacheScope(profileId = profileId, source = source) + val raw = ContinueWatchingEnrichmentStorage.loadPayload( + continueWatchingEnrichmentStorageKey(profileId = profileId, source = source), + ) ?: run { + lastPayloadHashByScope.remove(scope) + return@synchronized null + } + runCatching { json.decodeFromString(raw) }.getOrNull()?.also { payload -> - lastPayloadHashByProfile[profileId] = payload.hashCode() + lastPayloadHashByScope[scope] = payload.hashCode() + } ?: run { + lastPayloadHashByScope.remove(scope) + ContinueWatchingEnrichmentStorage.removePayload( + continueWatchingEnrichmentStorageKey(profileId = profileId, source = source), + ) + null } } - private fun profileScopedStorageKey(profileId: Int): String = + private fun removeLegacyPayload(profileId: Int) { + ContinueWatchingEnrichmentStorage.removePayload(legacyStorageKey(profileId)) + } + + private fun advanceGeneration() { + _generation.value += 1 + } + + private data class CacheScope( + val profileId: Int, + val source: WatchProgressSource, + ) + + internal fun continueWatchingEnrichmentStorageKey( + profileId: Int, + source: WatchProgressSource, + ): String = ProfileScopedKey.of( + baseKey = "${storageKey}_${source.name.lowercase()}", + profileId = profileId, + ) + + internal fun legacyStorageKey(profileId: Int): String = ProfileScopedKey.of(storageKey, profileId) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt index 83091292..72bdc7be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt @@ -2,5 +2,5 @@ package com.nuvio.app.features.watchprogress expect object CurrentDateProvider { fun todayIsoDate(): String + fun localStartOfDayEpochMs(isoDate: String): Long? } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt new file mode 100644 index 00000000..c66d53d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt @@ -0,0 +1,190 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import com.nuvio.app.features.watching.sync.ProgressSyncRecord + +/** + * Stable storage/sync identity for a progress row. + * + * [WatchProgressEntry.videoId] identifies the playable video and is not unique + * across metadata aliases. The server's progress key identifies the logical + * row and must therefore be used for snapshot merges and delta deletes. + */ +internal fun buildWatchProgressKey( + contentId: String, + seasonNumber: Int?, + episodeNumber: Int?, +): String = + if (seasonNumber != null && episodeNumber != null) { + "${contentId}_s${seasonNumber}e${episodeNumber}" + } else { + contentId + } + +internal fun WatchProgressEntry.resolvedProgressKey(): String = + progressKey + ?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) + +internal fun WatchProgressEntry.withResolvedProgressKey(): WatchProgressEntry { + val resolved = resolvedProgressKey() + return if (progressKey == resolved) this else copy(progressKey = resolved) +} + +internal fun ProgressSyncRecord.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal fun ProgressDeltaEvent.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal val watchProgressEntryFreshnessComparator: Comparator = + compareBy { entry -> entry.lastUpdatedEpochMs } + .thenBy { entry -> entry.lastPositionMs } + .thenBy { entry -> entry.durationMs } + .thenBy { entry -> entry.videoId } + .thenBy { entry -> entry.parentMetaId } + .thenBy { entry -> entry.contentType } + .thenBy { entry -> entry.seasonNumber ?: Int.MIN_VALUE } + .thenBy { entry -> entry.episodeNumber ?: Int.MIN_VALUE } + .thenBy(WatchProgressEntry::isCompleted) + .thenBy { entry -> entry.normalizedProgressPercent ?: Float.NEGATIVE_INFINITY } + .thenBy(WatchProgressEntry::source) + .thenBy(WatchProgressEntry::parentMetaType) + .thenBy(WatchProgressEntry::title) + .thenBy { entry -> entry.logo.orEmpty() } + .thenBy { entry -> entry.poster.orEmpty() } + .thenBy { entry -> entry.background.orEmpty() } + .thenBy { entry -> entry.episodeTitle.orEmpty() } + .thenBy { entry -> entry.episodeThumbnail.orEmpty() } + .thenBy { entry -> entry.providerName.orEmpty() } + .thenBy { entry -> entry.providerAddonId.orEmpty() } + .thenBy { entry -> entry.lastStreamTitle.orEmpty() } + .thenBy { entry -> entry.lastStreamSubtitle.orEmpty() } + .thenBy { entry -> entry.pauseDescription.orEmpty() } + .thenBy { entry -> entry.lastSourceUrl.orEmpty() } + .thenBy { entry -> entry.progressKey.orEmpty() } + +internal fun WatchProgressEntry.isFresherThan(other: WatchProgressEntry): Boolean = + watchProgressEntryFreshnessComparator.compare(this, other) > 0 + +/** Keeps one newest row per logical progress key, independent of input order. */ +internal fun Collection.newestByProgressKey(): Map { + val result = linkedMapOf() + forEach { rawEntry -> + val entry = rawEntry.withResolvedProgressKey() + val key = entry.resolvedProgressKey() + val existing = result[key] + if (existing == null || entry.isFresherThan(existing)) { + result[key] = entry + } + } + return result +} + +/** + * Reuses an existing opaque server key when playback updates the same logical + * content row. Exact playback-id matches win; freshness breaks remaining ties. + */ +internal fun Collection.resolveIdentityForUpsert( + candidate: WatchProgressEntry, +): WatchProgressEntry { + val logicalMatches = filter { existing -> + existing.parentMetaId == candidate.parentMetaId && + existing.seasonNumber == candidate.seasonNumber && + existing.episodeNumber == candidate.episodeNumber + } + val existing = logicalMatches + .filter { entry -> entry.videoId == candidate.videoId } + .maxWithOrNull(watchProgressEntryFreshnessComparator) + ?: logicalMatches.maxWithOrNull(watchProgressEntryFreshnessComparator) + val progressKey = existing?.resolvedProgressKey() ?: candidate.resolvedProgressKey() + return candidate.copy(progressKey = progressKey) +} + +internal fun Collection.resolveProgressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): WatchProgressEntry? { + val candidates = asSequence() + .filter { entry -> entry.videoId == videoId } + .filter { entry -> parentMetaId == null || entry.parentMetaId == parentMetaId } + .filter { entry -> seasonNumber == null || entry.seasonNumber == seasonNumber } + .filter { entry -> episodeNumber == null || entry.episodeNumber == episodeNumber } + .toList() + if (candidates.isEmpty()) return null + val logicalIdentities = candidates.mapTo(mutableSetOf()) { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + if (logicalIdentities.size > 1) return null + return candidates.maxWithOrNull(watchProgressEntryFreshnessComparator) +} + +internal data class WatchProgressIdentityReconciliation( + val entries: List, + val migratedKeys: Map, +) + +/** + * Migrates a legacy synthetic local key to the server's sole opaque key for + * the same logical content row. Exact keys always win, and ambiguous server + * identities are intentionally left untouched. + */ +internal fun reconcileLocalProgressKeysWithSnapshot( + serverEntries: Collection, + localEntries: Collection, +): WatchProgressIdentityReconciliation { + val serverKeys = serverEntries.mapTo(mutableSetOf(), ProgressSyncRecord::resolvedProgressKey) + val uniqueServerKeyByLogicalIdentity = serverEntries + .groupBy { record -> + Triple(record.contentId, record.season, record.episode) + } + .mapNotNull { (identity, records) -> + records.map(ProgressSyncRecord::resolvedProgressKey) + .distinct() + .singleOrNull() + ?.let { key -> identity to key } + } + .toMap() + val migrations = linkedMapOf() + val reconciled = localEntries.map { entry -> + val localKey = entry.resolvedProgressKey() + if (localKey in serverKeys) return@map entry + + val syntheticKey = buildWatchProgressKey( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + val serverKey = uniqueServerKeyByLogicalIdentity[ + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + ] + if (localKey == syntheticKey && serverKey != null && serverKey != localKey) { + migrations[localKey] = serverKey + entry.copy(progressKey = serverKey) + } else { + entry + } + } + return WatchProgressIdentityReconciliation( + entries = reconciled, + migratedKeys = migrations, + ) +} 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 06978237..aca40d7f 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 @@ -52,6 +52,8 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, val source: String = WatchProgressSourceLocal, + /** Stable server/storage identity. [videoId] remains the playback identity. */ + val progressKey: String? = null, ) { val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -81,10 +83,11 @@ data class WatchProgressEntry( fun normalizedCompletion(): WatchProgressEntry { val completed = isEffectivelyCompleted - val normalizedPositionMs = when { - completed && durationMs > 0L -> durationMs - else -> lastPositionMs.coerceAtLeast(0L) - } + // Preserve the upstream position. Completion is a state derived at the + // 90% threshold, not evidence that playback reached the exact duration. + // Rewriting it to duration made a pulled 94% row oscillate between 94% + // and 100% across reloads. + val normalizedPositionMs = lastPositionMs.coerceAtLeast(0L) val normalizedPercent = when { normalizedProgressPercent != null -> normalizedProgressPercent completed && durationMs <= 0L -> 100f @@ -123,8 +126,35 @@ data class WatchProgressUiState( val entries: List = emptyList(), val hasLoadedRemoteProgress: Boolean = false, ) { + val byProgressKey: Map + get() = entries.newestByProgressKey() + + /** Secondary compatibility lookup; multiple server rows may share a video id. */ val byVideoId: Map - get() = entries.associateBy { it.videoId } + get() = entries + .groupBy(WatchProgressEntry::videoId) + .mapNotNull { (videoId, candidates) -> + candidates.resolveProgressForVideo(videoId)?.let { entry -> videoId to entry } + } + .toMap() + + fun byVideoIdForContent(parentMetaId: String): Map = + entries + .filter { entry -> entry.parentMetaId == parentMetaId } + .groupBy(WatchProgressEntry::videoId) + .mapValues { (_, candidates) -> candidates.maxWith(watchProgressEntryFreshnessComparator) } + + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? = entries.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) val continueWatchingEntries: List get() = entries.continueWatchingEntries(limit = ContinueWatchingLimit) @@ -205,7 +235,10 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() - val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl().nonBlankOrNull() + val resolvedPoster = normalizedEntry.poster.nonBlankOrNull() ?: cloudPosterUrl + val resolvedBackground = normalizedEntry.background.nonBlankOrNull() + val resolvedEpisodeThumbnail = normalizedEntry.episodeThumbnail.nonBlankOrNull() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -220,15 +253,15 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, - logo = normalizedEntry.logo, - poster = normalizedEntry.poster ?: cloudPosterUrl, - background = normalizedEntry.background, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackground ?: resolvedPoster, + logo = normalizedEntry.logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, - episodeTitle = normalizedEntry.episodeTitle, - episodeThumbnail = normalizedEntry.episodeThumbnail, - pauseDescription = normalizedEntry.pauseDescription, + episodeTitle = normalizedEntry.episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = normalizedEntry.pauseDescription.nonBlankOrNull(), released = null, isNextUp = false, nextUpSeedSeasonNumber = null, @@ -261,6 +294,10 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextSeasonNumber = nextEpisode.season, releasedIso = nextEpisode.released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackground = background.nonBlankOrNull() + val resolvedCurrentEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() + val resolvedNextEpisodeThumbnail = nextEpisode.thumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -276,16 +313,19 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeNumber = nextEpisode.episode, episodeTitle = nextEpisode.title, ), - imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, - logo = logo, - poster = poster, - background = background, + imageUrl = resolvedNextEpisodeThumbnail + ?: resolvedCurrentEpisodeThumbnail + ?: resolvedBackground + ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = nextEpisode.season, episodeNumber = nextEpisode.episode, - episodeTitle = nextEpisode.title, - episodeThumbnail = nextEpisode.thumbnail, - pauseDescription = nextEpisode.overview, - released = nextEpisode.released, + episodeTitle = nextEpisode.title.nonBlankOrNull(), + episodeThumbnail = resolvedNextEpisodeThumbnail, + pauseDescription = nextEpisode.overview.nonBlankOrNull(), + released = nextEpisode.released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seasonNumber, nextUpSeedEpisodeNumber = episodeNumber, @@ -298,6 +338,8 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + internal fun buildContinueWatchingEpisodeSubtitle( seasonNumber: Int?, episodeNumber: Int?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 36b9ac40..578f0d83 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -14,24 +14,24 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.effectiveWatchProgressSource import com.nuvio.app.features.trakt.isTraktCompatibleId import com.nuvio.app.features.trakt.resolveEffectiveContentId -import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressDeltaEvent import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart 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.coroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,34 +39,121 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withTimeoutOrNull private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4 private const val WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT = 64 +private const val WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS = 3 +private const val WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS = 750L private const val WATCH_PROGRESS_DELTA_PAGE_SIZE = 900 private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( - val key: Pair, val entries: List, val meta: MetaDetails?, ) private data class MetadataProviderReadiness( val providers: List, - val isRefreshing: Boolean, ) { val fingerprint: String get() = providers.map(AddonManifest::transportUrl).sorted().joinToString(separator = "|") val isReady: Boolean - get() = providers.isNotEmpty() && !isRefreshing + get() = providers.isNotEmpty() +} - val isSettledWithoutProviders: Boolean - get() = !isRefreshing && providers.isEmpty() +internal class MetadataResolutionRetryCoordinator { + private val lock = SynchronizedObject() + private var generation = 0L + private var activeGeneration: Long? = null + private var activeProviderFingerprint: String? = null + private var lastRequestedProviderFingerprint: String? = null + private var pendingProviderFingerprint: String? = null + + fun reset() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + lastRequestedProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun invalidateActiveResolution() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun requestForProviders(providerFingerprint: String): Boolean = + synchronized(lock) { + if (activeGeneration != null) { + if (providerFingerprint != activeProviderFingerprint) { + pendingProviderFingerprint = providerFingerprint + } + return@synchronized false + } + if (providerFingerprint == lastRequestedProviderFingerprint) { + return@synchronized false + } + + lastRequestedProviderFingerprint = providerFingerprint + true + } + + fun beginResolution(providerFingerprint: String?): Long = + synchronized(lock) { + generation += 1L + activeGeneration = generation + activeProviderFingerprint = providerFingerprint + pendingProviderFingerprint = null + if (providerFingerprint != null) { + lastRequestedProviderFingerprint = providerFingerprint + } + generation + } + + fun providersObservedBeforeFetch( + resolutionGeneration: Long, + providerFingerprint: String, + ) { + synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized + activeProviderFingerprint = providerFingerprint + lastRequestedProviderFingerprint = providerFingerprint + if (pendingProviderFingerprint == providerFingerprint) { + pendingProviderFingerprint = null + } + } + } + + fun finishResolution( + resolutionGeneration: Long, + currentProviderFingerprint: String?, + ): Boolean = synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized false + + activeGeneration = null + val shouldRetry = currentProviderFingerprint != null && + currentProviderFingerprint != activeProviderFingerprint && + (pendingProviderFingerprint != null || + currentProviderFingerprint != lastRequestedProviderFingerprint) + activeProviderFingerprint = null + pendingProviderFingerprint = null + if (shouldRetry) { + lastRequestedProviderFingerprint = currentProviderFingerprint + } + shouldRetry + } } private data class WatchProgressDeltaApplyResult( @@ -76,61 +163,78 @@ private data class WatchProgressDeltaApplyResult( val changed: Boolean, ) +internal enum class WatchProgressDeltaDecisionType { + UPSERT, + DELETE, + PRESERVE_LOCAL, + IGNORE, +} + +internal data class WatchProgressDeltaDecision( + val type: WatchProgressDeltaDecisionType, + val updatedEntry: WatchProgressEntry? = null, + val clearsDirtyProgress: Boolean = false, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val accountScopeLock = SynchronizedObject() + private var accountScopeJob: Job = SupervisorJob() + private var accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default) private val log = Logger.withTag("WatchProgressRepository") private val _uiState = MutableStateFlow(WatchProgressUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var hasLoaded = false + private var hasLoadedNuvioRemoteProgress = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L + private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC + private val _activeSourceState = MutableStateFlow(activeSource) + internal val activeSourceState: StateFlow = _activeSourceState.asStateFlow() private val entriesLock = SynchronizedObject() - private var entriesByVideoId: MutableMap = mutableMapOf() + private var entriesByProgressKey: MutableMap = mutableMapOf() + private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null - private var isPullingNuvioSyncFromServer = false + private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() + private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L private var deltaInitialized = false - private var lastAddonMetadataReadyFingerprint: String? = null internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { - syncScope.launch { - TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> - if (shouldUseTraktProgressSource( - isAuthenticated = authenticated, - source = TraktSettingsRepository.uiState.value.watchProgressSource, - ) - ) { - runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> - if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } - } - } - publish() - } - } - - syncScope.launch { - TraktSettingsRepository.uiState.collectLatest { settings -> - if (shouldUseTraktProgressSource( - isAuthenticated = TraktAuthRepository.isAuthenticated.value, - source = settings.watchProgressSource, - ) - ) { - runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> - if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } - } - } - publish() - } - } - syncScope.launch { TraktProgressRepository.uiState.collectLatest { if (shouldUseTraktProgress()) { @@ -151,29 +255,44 @@ object WatchProgressRepository { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktProgressRepository.ensureLoaded() - if (hasLoaded) return - loadFromDisk(ProfileRepository.activeProfileId) - if (shouldUseTraktProgress()) { - TraktProgressRepository.refreshAsync() + if (!hasLoaded) { + updateActiveSource( + effectiveWatchProgressSource( + isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + ), + ) + loadFromDisk(ProfileRepository.activeProfileId) } } fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return TraktSettingsRepository.onProfileChanged() + updateActiveSource( + effectiveWatchProgressSource( + isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + ), + ) loadFromDisk(profileId) TraktProgressRepository.onProfileChanged() - if (shouldUseTraktProgress()) { - TraktProgressRepository.refreshAsync() - } } fun clearLocalState() { - metadataResolutionJob?.cancel() + val previousAccountJob = synchronized(accountScopeLock) { + accountScopeJob.also { + accountScopeJob = SupervisorJob() + accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default) + } + } + previousAccountJob.cancel() + cancelMetadataResolution(resetProviderHistory = true) hasLoaded = false + hasLoadedNuvioRemoteProgress = false currentProfileId = 1 profileGeneration += 1L - lastAddonMetadataReadyFingerprint = null + updateActiveSource(WatchProgressSource.NUVIO_SYNC) clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -184,11 +303,11 @@ object WatchProgressRepository { } private fun loadFromDisk(profileId: Int) { - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) currentProfileId = profileId profileGeneration += 1L hasLoaded = true - lastAddonMetadataReadyFingerprint = null + hasLoadedNuvioRemoteProgress = false clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -198,6 +317,7 @@ object WatchProgressRepository { deltaCursorEventId = storedPayload.deltaCursorEventId deltaInitialized = storedPayload.deltaInitialized replaceLocalEntries(storedPayload.entries) + replaceDirtyProgressKeys(storedPayload.dirtyProgressKeys) } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -225,88 +345,185 @@ object WatchProgressRepository { ProfileRepository.activeProfileId == profileId suspend fun pullFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktProgressRepository.ensureLoaded() - val operationGeneration = activeOperationGeneration(profileId) ?: run { - log.d { "Skipping watch progress pull for inactive profile $profileId" } - return - } - - val useTraktProgress = shouldUseTraktProgress() - - if (!useTraktProgress && isPullingNuvioSyncFromServer) { - log.d { "Skipping watch progress pull for profile $profileId because a Nuvio sync pull is already running" } - return - } - if (!useTraktProgress) { - isPullingNuvioSyncFromServer = true - } - - try { - if (useTraktProgress) { - log.d { "Pulling Trakt watch progress for profile $profileId" } - runCatching { TraktProgressRepository.refreshNow() } - .onFailure { e -> - if (e is CancellationException) throw e - log.e(e) { "Failed to pull Trakt progress" } - } - if (isActiveOperation(profileId, operationGeneration)) { - publish() - } - return - } - - runCatching { - log.d { "Pulling Nuvio watch progress for profile $profileId" } - pullSupabaseDeltaFromServer( - profileId = profileId, - pullStartedEpochMs = WatchProgressClock.nowEpochMs(), - operationGeneration = operationGeneration, - ) - }.onFailure { e -> - if (e is CancellationException) throw e - log.e(e) { "Failed to pull watch progress from server" } - } - } finally { - if (!useTraktProgress) { - isPullingNuvioSyncFromServer = false - } - } + refreshForSource( + profileId = profileId, + source = activeSource, + sourceChanged = false, + force = false, + ) } suspend fun forceSnapshotRefreshFromServer(profileId: Int) { - ensureLoaded() - if (currentProfileId != profileId) { - loadFromDisk(profileId) - } + refreshForSource( + profileId = profileId, + source = activeSource, + sourceChanged = false, + force = true, + ) + } - if (shouldUseTraktProgress()) { - log.d { "Force refreshing Trakt watch progress for profile $profileId" } - runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to force refresh Trakt progress" } - } + suspend fun selectWatchProgressSource(profileId: Int, source: WatchProgressSource) { + WatchProgressSourceCoordinator.selectSource(profileId = profileId, source = source) + } + + suspend fun clearLocalAndForceSnapshotRefreshFromServer(profileId: Int) { + ContinueWatchingEnrichmentCache.clearAll(profileId) + WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) + } + + internal fun activateSource(source: WatchProgressSource) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + TraktProgressRepository.ensureLoaded() + if (!hasLoaded) { + loadFromDisk(ProfileRepository.activeProfileId) + } + if (activeSource == source) { publish() return } - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) { - log.d { "Skipping force watch progress refresh because Nuvio Sync is not authenticated" } - return + updateActiveSource(source) + cancelMetadataResolution(resetProviderHistory = false) + if (source == WatchProgressSource.TRAKT) { + TraktProgressRepository.clearLocalState() + } else { + hasLoadedNuvioRemoteProgress = false + } + publish() + if (source == WatchProgressSource.NUVIO_SYNC) { + resolveRemoteMetadata() + } + } + + internal suspend fun refreshForSource( + profileId: Int, + source: WatchProgressSource, + sourceChanged: Boolean, + force: Boolean, + ): Boolean { + ensureLoaded() + if (currentProfileId != profileId) { + loadFromDisk(profileId) + } + val operationGeneration = activeOperationGeneration(profileId) ?: run { + log.d { "Skipping watch progress refresh for inactive profile $profileId" } + return false } - deltaCursorEventId = 0L - deltaInitialized = false - persist() - pullFromServer(profileId) + activateSource(source) + return when (source) { + WatchProgressSource.TRAKT -> refreshTraktSource( + profileId = profileId, + operationGeneration = operationGeneration, + sourceChanged = sourceChanged, + force = force, + ) + + WatchProgressSource.NUVIO_SYNC -> refreshNuvioSource( + profileId = profileId, + operationGeneration = operationGeneration, + force = force, + ) + } + } + + private suspend fun refreshTraktSource( + profileId: Int, + operationGeneration: Long, + sourceChanged: Boolean, + force: Boolean, + ): Boolean { + if (!TraktAuthRepository.isAuthenticated.value) { + log.d { "Skipping Trakt progress refresh because Trakt is not authenticated" } + return false + } + + return try { + if (force || sourceChanged) { + TraktProgressRepository.invalidateAndRefresh() + } else { + TraktProgressRepository.refreshNow() + } + if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.TRAKT) { + publish() + } + val state = TraktProgressRepository.uiState.value + state.hasLoadedRemoteProgress && state.errorMessage == null + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to refresh Trakt watch progress" } + false + } + } + + private suspend fun refreshNuvioSource( + profileId: Int, + operationGeneration: Long, + force: Boolean, + ): Boolean { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // There is no upstream source for this account, so local state is authoritative. + hasLoadedNuvioRemoteProgress = true + publish() + return true + } + + return nuvioPullMutex.withLock { + try { + if (force) { + pullNuvioSnapshotFromServer( + profileId = profileId, + operationGeneration = operationGeneration, + ) + } else { + pullSupabaseDeltaFromServer( + profileId = profileId, + operationGeneration = operationGeneration, + ) + } + true + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to refresh Nuvio watch progress" } + false + } + } + } + + private suspend fun pullNuvioSnapshotFromServer( + profileId: Int, + operationGeneration: Long, + ) { + val cursorBeforeSnapshot = try { + syncAdapter.getDeltaCursor(profileId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Watch progress cursor unavailable during snapshot refresh: ${error.message}" } + null + } + + pullFullFromAdapter( + profileId = profileId, + resetDeltaState = cursorBeforeSnapshot == null, + operationGeneration = operationGeneration, + preserveLocalEntries = true, + ) + if (!isActiveOperation(profileId, operationGeneration)) return + + if (cursorBeforeSnapshot != null) { + deltaCursorEventId = cursorBeforeSnapshot + deltaInitialized = true + persist() + } } private suspend fun pullSupabaseDeltaFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { if (!isActiveOperation(profileId, operationGeneration)) return @@ -328,7 +545,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor unavailable for profile $profileId; using snapshot fallback" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -338,7 +554,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor before snapshot for profile $profileId is $cursorBeforeSnapshot" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, operationGeneration = operationGeneration, ) @@ -358,6 +573,7 @@ object WatchProgressRepository { var totalUpserts = 0 var totalDeletes = 0 var preservedLocalItems = false + var cursorAdvanced = false var page = 1 while (true) { @@ -374,7 +590,6 @@ object WatchProgressRepository { log.w { "Watch progress delta pull unavailable, falling back to full pull: ${error.message}" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -395,15 +610,14 @@ object WatchProgressRepository { "first=$firstEvent last=$lastEvent upserts=$eventUpserts deletes=$eventDeletes" } - val pageResult = applyWatchProgressDeltaEvents( - events = events, - pullStartedEpochMs = pullStartedEpochMs, - ) + val pageResult = applyWatchProgressDeltaEvents(events = events) changed = pageResult.changed || changed totalUpserts += pageResult.appliedUpserts totalDeletes += pageResult.appliedDeletes preservedLocalItems = preservedLocalItems || pageResult.preservedLocalItems + val previousCursor = cursor cursor = maxOf(cursor, events.maxOf { it.eventId }) + cursorAdvanced = cursorAdvanced || cursor > previousCursor deltaCursorEventId = cursor deltaInitialized = true log.d { @@ -417,9 +631,15 @@ object WatchProgressRepository { } hasLoaded = true - if (changed) { + val remoteReadinessChanged = !hasLoadedNuvioRemoteProgress + hasLoadedNuvioRemoteProgress = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed || cursorAdvanced) { persist() + } + if (changed) { resolveRemoteMetadata() } log.d { @@ -431,29 +651,53 @@ object WatchProgressRepository { private suspend fun pullFullFromAdapter( profileId: Int, - pullStartedEpochMs: Long, resetDeltaState: Boolean, operationGeneration: Long, + preserveLocalEntries: Boolean = true, ) { val serverEntries = syncAdapter.pull(profileId = profileId) if (!isActiveOperation(profileId, operationGeneration)) return log.d { "Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " + - "resetDeltaState=$resetDeltaState" + "resetDeltaState=$resetDeltaState preserveLocalEntries=$preserveLocalEntries" } - replaceLocalEntries( - mergeWatchProgressEntriesPreservingUnsynced( + val localBeforePull = localEntriesSnapshot() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( serverEntries = serverEntries, - localEntries = localEntriesSnapshot(), - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ), + localEntries = localBeforePull, + ) + migrateDirtyProgressKeys(reconciliation.migratedKeys) + val dirtyBeforeApply = dirtyProgressKeysSnapshot() + val updatedEntries = if (preserveLocalEntries) { + mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = serverEntries, + localEntries = reconciliation.entries, + dirtyProgressKeys = dirtyBeforeApply, + ) + } else { + val newestRemoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = null) + val existing = newestRemoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + newestRemoteByKey[key] = candidate + } + } + newestRemoteByKey + } + replaceLocalEntries(updatedEntries) + acknowledgeDirtyProgressFromSnapshot( + serverEntries = serverEntries, + localEntriesBeforeApply = reconciliation.entries, + dirtyKeysBeforeApply = dirtyBeforeApply, ) if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false } hasLoaded = true + hasLoadedNuvioRemoteProgress = true publish() persist() resolveRemoteMetadata() @@ -465,42 +709,56 @@ object WatchProgressRepository { private fun applyWatchProgressDeltaEvents( events: Collection, - pullStartedEpochMs: Long, ): WatchProgressDeltaApplyResult { var changed = false var appliedUpserts = 0 var appliedDeletes = 0 var preservedLocalItems = false - events.forEach { event -> - if (event.videoId.isBlank()) return@forEach + val latestEventByProgressKey = linkedMapOf() + events.sortedBy(ProgressDeltaEvent::eventId).forEach { event -> + val progressKey = event.resolvedProgressKey() + if (progressKey.isBlank()) { + return@forEach + } when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { + latestEventByProgressKey[progressKey] = event + } WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { - val current = localEntry(event.videoId) - val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) - if (current != updated) { - upsertLocalEntry(updated) - changed = true - appliedUpserts += 1 + if (event.videoId.isNotBlank()) { + latestEventByProgressKey[progressKey] = event } } - WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { - val localEntry = localEntry(event.videoId) - if ( - localEntry != null && - shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - ) { - preservedLocalItems = true - return@forEach - } - if (removeLocalEntry(event.videoId) != null) { + else -> Unit + } + } + + latestEventByProgressKey.forEach { (progressKey, event) -> + val current = localEntry(progressKey) + val decision = decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = progressKey in dirtyProgressKeysSnapshot(), + ) + when (decision.type) { + WatchProgressDeltaDecisionType.UPSERT -> { + upsertLocalEntry(requireNotNull(decision.updatedEntry)) + changed = true + appliedUpserts += 1 + } + WatchProgressDeltaDecisionType.DELETE -> { + if (removeLocalEntry(progressKey) != null) { changed = true appliedDeletes += 1 } } + WatchProgressDeltaDecisionType.PRESERVE_LOCAL -> { + preservedLocalItems = true + } + WatchProgressDeltaDecisionType.IGNORE -> Unit + } + if (decision.clearsDirtyProgress) { + clearProgressDirty(progressKey) } } return WatchProgressDeltaApplyResult( @@ -511,6 +769,49 @@ object WatchProgressRepository { ) } + internal fun decideWatchProgressDeltaEvent( + current: WatchProgressEntry?, + event: ProgressDeltaEvent, + isLocalDirty: Boolean, + ): WatchProgressDeltaDecision = when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { + if (event.videoId.isBlank()) { + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } else { + val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) + when { + current == null -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + isLocalDirty && current.isFresherThan(updated) -> + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + current == updated -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.IGNORE, + clearsDirtyProgress = true, + ) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + } + } + } + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> when { + current == null -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + isLocalDirty -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.DELETE, + clearsDirtyProgress = true, + ) + } + else -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = WatchProgressEntry( contentType = contentType, @@ -535,10 +836,12 @@ object WatchProgressRepository { pauseDescription = cached?.pauseDescription, lastSourceUrl = cached?.lastSourceUrl, isCompleted = isWatchProgressComplete(position, duration, false), + progressKey = resolvedProgressKey(), ) private fun ProgressDeltaEvent.toProgressSyncRecord(): ProgressSyncRecord = ProgressSyncRecord( + progressKey = progressKey, contentId = contentId, contentType = contentType, videoId = videoId, @@ -549,44 +852,40 @@ object WatchProgressRepository { lastWatched = lastWatched, ) - private fun mergeWatchProgressEntriesPreservingUnsynced( + internal fun mergeWatchProgressEntriesPreservingUnsynced( serverEntries: Collection, localEntries: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, + dirtyProgressKeys: Set, ): Map { - val localByVideoId = localEntries.associateBy { entry -> entry.videoId } - val merged = serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = localByVideoId[record.videoId]) - }.toMutableMap() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localEntries, + ) + val effectiveDirtyKeys = dirtyProgressKeys.mapTo(mutableSetOf()) { key -> + reconciliation.migratedKeys[key] ?: key + } + val localByProgressKey = reconciliation.entries.newestByProgressKey() + val merged = linkedMapOf() + serverEntries.forEach { record -> + val progressKey = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByProgressKey[progressKey]) + val existing = merged[progressKey] + if (existing == null || candidate.isFresherThan(existing)) { + merged[progressKey] = candidate + } + } - localByVideoId.forEach { (videoId, localEntry) -> - val remoteEntry = merged[videoId] - val shouldPreserve = shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - if (!shouldPreserve) return@forEach - if (remoteEntry == null || localEntry.lastUpdatedEpochMs > remoteEntry.lastUpdatedEpochMs) { - merged[videoId] = localEntry + localByProgressKey.forEach { (progressKey, localEntry) -> + val remoteEntry = merged[progressKey] + if (progressKey !in effectiveDirtyKeys) return@forEach + if (remoteEntry == null || localEntry.isFresherThan(remoteEntry)) { + merged[progressKey] = localEntry } } return merged } - private fun shouldPreserveLocalWatchProgressEntry( - localEntry: WatchProgressEntry, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - ): Boolean { - val updatedAt = localEntry.lastUpdatedEpochMs - val wasUpdatedAfterLastPush = lastSuccessfulPushEpochMs > 0L && updatedAt > lastSuccessfulPushEpochMs - val wasUpdatedDuringPull = pullStartedEpochMs > 0L && updatedAt >= pullStartedEpochMs - return wasUpdatedAfterLastPush || wasUpdatedDuringPull - } - private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { if (!hasLoaded || shouldUseTraktProgress()) return @@ -594,18 +893,25 @@ object WatchProgressRepository { if (!readiness.isReady) return val fingerprint = readiness.fingerprint - if (fingerprint == lastAddonMetadataReadyFingerprint) return - lastAddonMetadataReadyFingerprint = fingerprint - - if (metadataResolutionJob?.isActive == true) return + if (!metadataResolutionRetryCoordinator.requestForProviders(fingerprint)) return resolveRemoteMetadata() } + private fun cancelMetadataResolution(resetProviderHistory: Boolean) { + if (resetProviderHistory) { + metadataResolutionRetryCoordinator.reset() + } else { + metadataResolutionRetryCoordinator.invalidateActiveResolution() + } + metadataResolutionJob?.cancel() + metadataResolutionJob = null + } + private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration val missingMetadataEntries = localEntriesSnapshot() - .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } + .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) @@ -614,77 +920,75 @@ object WatchProgressRepository { if (needsResolution.isEmpty()) return + val providersAtStart = AddonRepository.uiState.value.metadataProviderReadiness() + val resolutionGeneration = metadataResolutionRetryCoordinator.beginResolution( + providerFingerprint = providersAtStart.fingerprint.takeIf { providersAtStart.isReady }, + ) metadataResolutionJob?.cancel() - metadataResolutionJob = syncScope.launch { - val providerReadiness = awaitReadyMetadataProviders() ?: return@launch - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint - - val supportedNeedsResolution = needsResolution.filter { (key, _) -> - val (metaId, metaType) = key - providerReadiness.providers.any { provider -> - provider.supportsMetaRequest(type = metaType, id = metaId) + metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { + try { + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + AddonRepository.initialize() + val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + if (providerReadiness.isReady) { + metadataResolutionRetryCoordinator.providersObservedBeforeFetch( + resolutionGeneration = resolutionGeneration, + providerFingerprint = providerReadiness.fingerprint, + ) } - } - if (supportedNeedsResolution.isEmpty()) return@launch - - var resolvedEntries = 0 - val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) - val resolutionResults = coroutineScope { - supportedNeedsResolution.map { (key, entries) -> - async { - semaphore.withPermit { + val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) + val resolutionResults = Channel(Channel.UNLIMITED) + needsResolution.forEach { (key, entries) -> + launch { + val result = semaphore.withPermit { fetchRemoteMetadataGroup(key = key, entries = entries) } + resolutionResults.send(result) } - }.awaitAll() - } - - for (result in resolutionResults) { - ensureActive() - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - val meta = result.meta - if (meta == null) { - continue } - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.videoId) ?: continue - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.find { v -> - v.season == current.seasonNumber && v.episode == current.episodeNumber - } - } else null + var resolvedEntries = 0 + repeat(needsResolution.size) { + val result = resolutionResults.receive() + ensureActive() + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + val meta = result.meta + if (meta == null) { + return@repeat + } - upsertLocalEntry( - current.copy( - title = meta.name, - poster = meta.poster, - background = meta.background, - logo = meta.logo, - episodeTitle = episodeVideo?.title ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview - ?: meta.description - ?: current.pauseDescription, - ), - ) - appliedEntries += 1 - } - if (appliedEntries == 0) { - continue - } + var appliedEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedEntries += 1 + } + if (appliedEntries == 0) return@repeat - resolvedEntries += appliedEntries - } - if (resolvedEntries > 0) { - if (isActiveOperation(targetProfileId, targetGeneration)) { - publish() + resolvedEntries += appliedEntries + + if (isActiveOperation(targetProfileId, targetGeneration)) { + publish() + } + } + resolutionResults.close() + if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { persist() } + } finally { + val currentReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + val shouldRetry = metadataResolutionRetryCoordinator.finishResolution( + resolutionGeneration = resolutionGeneration, + currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady }, + ) + if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) { + resolveRemoteMetadata() + } } } + metadataResolutionJob?.start() } private suspend fun fetchRemoteMetadataGroup( @@ -692,34 +996,28 @@ object WatchProgressRepository { entries: List, ): RemoteMetadataResolutionResult { val (metaId, metaType) = key - val meta = try { - MetaDetailsRepository.fetch(metaType, metaId) - } catch (error: CancellationException) { - throw error - } catch (_: Throwable) { - null + var meta: MetaDetails? = null + for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { + if (attempt > 1) { + val retryDelayMs = WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS * + (1L shl (attempt - 2)) + delay(retryDelayMs) + } + meta = try { + MetaDetailsRepository.fetch(metaType, metaId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + null + } + if (meta != null) break } return RemoteMetadataResolutionResult( - key = key, entries = entries, meta = meta, ) } - private suspend fun awaitReadyMetadataProviders(): MetadataProviderReadiness? { - val current = AddonRepository.uiState.value.metadataProviderReadiness() - if (current.isReady) return current - if (current.isSettledWithoutProviders) return null - - val settled = withTimeoutOrNull(30_000L) { - AddonRepository.uiState.first { state -> - val readiness = state.metadataProviderReadiness() - readiness.isReady || readiness.isSettledWithoutProviders - }.metadataProviderReadiness() - } - return settled?.takeIf { it.isReady } - } - fun upsertPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, @@ -738,19 +1036,39 @@ object WatchProgressRepository { upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote) } - fun clearProgress(videoId: String) { - clearProgress(listOf(videoId)) + fun clearProgress(videoId: String, parentMetaId: String? = null) { + clearProgress(videoIds = listOf(videoId), parentMetaId = parentMetaId) } - fun clearProgress(videoIds: Collection) { + fun clearProgress( + videoIds: Collection, + parentMetaId: String? = null, + ) { ensureLoaded() if (videoIds.isEmpty()) return val useTraktProgress = shouldUseTraktProgress() if (useTraktProgress) { - val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds } + val entriesToRemove = currentEntries().filter { entry -> + entry.videoId in videoIds && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + if (parentMetaId == null) { + videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + } else { + entriesToRemove + .distinctBy { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + .forEach { entry -> + TraktProgressRepository.applyOptimisticRemoval( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } + } if (locallyRemovedEntries.isNotEmpty()) { persist() } @@ -775,9 +1093,10 @@ object WatchProgressRepository { return } - val removedEntries = videoIds.mapNotNull { videoId -> - removeLocalEntry(videoId) - } + val removedEntries = removeLocalEntriesForVideoIds( + videoIds = videoIds, + parentMetaId = parentMetaId, + ) if (removedEntries.isNotEmpty()) { publish() persist() @@ -837,20 +1156,30 @@ object WatchProgressRepository { } entriesToRemove.forEach { entry -> - removeLocalEntry(entry.videoId) + removeLocalEntry(entry.resolvedProgressKey()) } publish() persist() pushDeleteToServer(entriesToRemove) } - fun progressForVideo(videoId: String): WatchProgressEntry? { + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? { ensureLoaded() return if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.entries } else { localEntriesSnapshot() - }.firstOrNull { it.videoId == videoId } + }.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } fun resumeEntryForSeries(metaId: String): WatchProgressEntry? { @@ -908,7 +1237,7 @@ object WatchProgressRepository { session.parentMetaId } - val entry = WatchProgressEntry( + val candidateEntry = WatchProgressEntry( contentType = session.contentType, parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, @@ -934,8 +1263,10 @@ object WatchProgressRepository { ).normalizedCompletion() if (targetProfileId != currentProfileId || ProfileRepository.activeProfileId != targetProfileId) { - if (persist) { - upsertStoredProfileProgress(profileId = targetProfileId, entry = entry) + val entry = if (persist) { + upsertStoredProfileProgress(profileId = targetProfileId, entry = candidateEntry) + } else { + resolveStoredProfileProgressIdentity(profileId = targetProfileId, entry = candidateEntry) } if (syncRemote) { pushScrobbleToServer(entry = entry, profileId = targetProfileId) @@ -943,17 +1274,20 @@ object WatchProgressRepository { return } + val entry = localEntriesSnapshot().resolveIdentityForUpsert(candidateEntry) + if (entry.parentMetaType.equals("series", ignoreCase = true)) { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } upsertLocalEntry(entry) + markProgressDirty(entry) if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) } publish() if (persist) persist() - if (entry.poster.isNullOrBlank() || entry.background.isNullOrBlank()) { + if (entry.needsRemoteMetadataEnrichment()) { resolveRemoteMetadata() } if (syncRemote) { @@ -964,15 +1298,20 @@ object WatchProgressRepository { } } - private fun upsertStoredProfileProgress(profileId: Int, entry: WatchProgressEntry) { + private fun upsertStoredProfileProgress( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() val storedPayload = if (payload.isNotEmpty()) { WatchProgressCodec.decodePayload(payload) } else { StoredWatchProgressPayload() } + val resolvedEntry = storedPayload.entries.resolveIdentityForUpsert(entry) + val progressKey = resolvedEntry.resolvedProgressKey() val updatedEntries = storedPayload.entries - .filterNot { it.videoId == entry.videoId } + entry + .filterNot { it.resolvedProgressKey() == progressKey } + resolvedEntry WatchProgressStorage.savePayload( profileId, WatchProgressCodec.encodePayload( @@ -980,15 +1319,35 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs, deltaCursorEventId = storedPayload.deltaCursorEventId, deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = storedPayload.dirtyProgressKeys + progressKey, ), ) + return resolvedEntry + } + + private fun resolveStoredProfileProgressIdentity( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + val storedEntries = if (payload.isEmpty()) { + emptyList() + } else { + WatchProgressCodec.decodePayload(payload).entries + } + return storedEntries.resolveIdentityForUpsert(entry) } private fun pushScrobbleToServer(entry: WatchProgressEntry, profileId: Int) { - syncScope.launch { + val operationGeneration = profileGeneration.takeIf { profileId == currentProfileId } + accountScopeSnapshot().launch { runCatching { syncAdapter.push(profileId = profileId, entries = listOf(entry)) - recordSuccessfulPush(profileId = profileId, entries = listOf(entry)) + recordSuccessfulPush( + profileId = profileId, + operationGeneration = operationGeneration, + entries = listOf(entry), + ) }.onFailure { e -> log.e(e) { "Failed to push watch progress scrobble" } } @@ -998,7 +1357,7 @@ object WatchProgressRepository { private fun pushDeleteToServer(entries: Collection) { if (shouldUseTraktProgress()) return val profileId = currentProfileId - syncScope.launch { + accountScopeSnapshot().launch { runCatching { if (entries.isEmpty()) return@runCatching syncAdapter.delete(profileId = profileId, entries = entries) @@ -1014,7 +1373,7 @@ object WatchProgressRepository { val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.hasLoadedRemoteProgress } else { - hasLoaded + hasLoadedNuvioRemoteProgress } _uiState.value = WatchProgressUiState( entries = sortedEntries, @@ -1030,36 +1389,113 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeysSnapshot(), ), ) } - private fun recordSuccessfulPush(profileId: Int, entries: Collection) { - if (profileId != currentProfileId) return + private fun recordSuccessfulPush( + profileId: Int, + operationGeneration: Long?, + entries: Collection, + ) { + if (profileId != currentProfileId) { + acknowledgeStoredProfilePush(profileId = profileId, pushedEntries = entries) + return + } + if (operationGeneration != profileGeneration) return + val dirtyChanged = acknowledgeCurrentProfilePush(entries) val latestPushed = entries .asSequence() .map { entry -> entry.lastUpdatedEpochMs } .maxOrNull() - ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed - persist() + ?: 0L + val watermarkChanged = latestPushed > lastSuccessfulPushEpochMs + if (watermarkChanged) { + lastSuccessfulPushEpochMs = latestPushed + } + if (dirtyChanged || watermarkChanged) persist() + } + + private fun acknowledgeCurrentProfilePush(entries: Collection): Boolean = + synchronized(entriesLock) { + var changed = false + entries.forEach { pushed -> + val key = pushed.resolvedProgressKey() + val current = entriesByProgressKey[key] + if ( + (current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs) && + dirtyProgressKeys.remove(key) + ) { + changed = true + } + } + changed + } + + private fun acknowledgeStoredProfilePush( + profileId: Int, + pushedEntries: Collection, + ) { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + if (payload.isEmpty()) return + val storedPayload = WatchProgressCodec.decodePayload(payload) + val storedByKey = storedPayload.entries.newestByProgressKey() + val acknowledgedKeys = pushedEntries.mapNotNullTo(mutableSetOf()) { pushed -> + val key = pushed.resolvedProgressKey() + val current = storedByKey[key] + key.takeIf { current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs } + } + val remainingDirtyKeys = storedPayload.dirtyProgressKeys - acknowledgedKeys + val latestPushed = pushedEntries.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L + if ( + remainingDirtyKeys == storedPayload.dirtyProgressKeys && + latestPushed <= storedPayload.lastSuccessfulPushEpochMs + ) { + return + } + WatchProgressStorage.savePayload( + profileId, + WatchProgressCodec.encodePayload( + entries = storedPayload.entries, + lastSuccessfulPushEpochMs = maxOf(storedPayload.lastSuccessfulPushEpochMs, latestPushed), + deltaCursorEventId = storedPayload.deltaCursorEventId, + deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = remainingDirtyKeys, + ), + ) } private fun shouldUseTraktProgress(): Boolean = - shouldUseTraktProgressSource( - isAuthenticated = TraktAuthRepository.isAuthenticated.value, - source = TraktSettingsRepository.uiState.value.watchProgressSource, - ) + activeSource == WatchProgressSource.TRAKT + + private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { + accountScope + } + + private fun updateActiveSource(source: WatchProgressSource) { + activeSource = source + _activeSourceState.value = source + } private fun WatchProgressEntry.shouldAttemptTraktPlaybackDelete(): Boolean = isTraktCompatibleId(parentMetaId) private fun removeStoredLocalEntries(entries: Collection): List = synchronized(entriesLock) { - entries.mapNotNull { entry -> - entriesByVideoId.remove(entry.videoId) - } + val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } + val keysToRemove = entriesByProgressKey + .filterValues { localEntry -> + localEntry.resolvedProgressKey() in targetKeys || entries.any { target -> + localEntry.parentMetaId == target.parentMetaId && + localEntry.seasonNumber == target.seasonNumber && + localEntry.episodeNumber == target.episodeNumber + } + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } private fun currentEntries(): List { @@ -1074,10 +1510,10 @@ object WatchProgressRepository { if (localNonTraktItems.isEmpty()) { traktItems } else { - val traktKeys = traktItems.map { it.videoId }.toSet() + val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } val merged = traktItems.toMutableList() localNonTraktItems.forEach { localItem -> - if (localItem.videoId !in traktKeys) { + if (localItem.resolvedProgressKey() !in traktKeys) { merged.add(localItem) } } @@ -1090,48 +1526,129 @@ object WatchProgressRepository { private fun localEntriesSnapshot(): List = synchronized(entriesLock) { - entriesByVideoId.values.toList() + entriesByProgressKey.values.toList() } - private fun localEntry(videoId: String): WatchProgressEntry? = + private fun localEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId[videoId] + entriesByProgressKey[progressKey] } private fun localEntryCount(): Int = synchronized(entriesLock) { - entriesByVideoId.size + entriesByProgressKey.size } private fun clearLocalEntries() { synchronized(entriesLock) { - entriesByVideoId.clear() + entriesByProgressKey.clear() + dirtyProgressKeys.clear() + } + } + + private fun dirtyProgressKeysSnapshot(): Set = + synchronized(entriesLock) { + dirtyProgressKeys.toSet() + } + + private fun replaceDirtyProgressKeys(keys: Collection) { + synchronized(entriesLock) { + dirtyProgressKeys = keys + .filterTo(mutableSetOf()) { key -> key in entriesByProgressKey } + } + } + + private fun markProgressDirty(entry: WatchProgressEntry) { + synchronized(entriesLock) { + dirtyProgressKeys += entry.resolvedProgressKey() + } + } + + private fun clearProgressDirty(progressKey: String) { + synchronized(entriesLock) { + dirtyProgressKeys -= progressKey + } + } + + private fun migrateDirtyProgressKeys(migrations: Map) { + if (migrations.isEmpty()) return + synchronized(entriesLock) { + migrations.forEach { (oldKey, newKey) -> + if (dirtyProgressKeys.remove(oldKey)) { + dirtyProgressKeys += newKey + } + } + } + } + + private fun acknowledgeDirtyProgressFromSnapshot( + serverEntries: Collection, + localEntriesBeforeApply: Collection, + dirtyKeysBeforeApply: Set, + ) { + if (dirtyKeysBeforeApply.isEmpty()) return + val localByKey = localEntriesBeforeApply.newestByProgressKey() + val remoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByKey[key]) + val existing = remoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + remoteByKey[key] = candidate + } + } + synchronized(entriesLock) { + dirtyKeysBeforeApply.forEach { key -> + val local = localByKey[key] + val remote = remoteByKey[key] + if (remote != null && (local == null || !local.isFresherThan(remote))) { + dirtyProgressKeys -= key + } + } } } private fun replaceLocalEntries(entries: Collection) { synchronized(entriesLock) { - entriesByVideoId = entries - .associateBy { it.videoId } - .toMutableMap() + entriesByProgressKey = entries.newestByProgressKey().toMutableMap() } } private fun replaceLocalEntries(entries: Map) { synchronized(entriesLock) { - entriesByVideoId = entries.toMutableMap() + entriesByProgressKey = entries.values.newestByProgressKey().toMutableMap() } } private fun upsertLocalEntry(entry: WatchProgressEntry) { synchronized(entriesLock) { - entriesByVideoId[entry.videoId] = entry + val resolvedEntry = entry.withResolvedProgressKey() + entriesByProgressKey[resolvedEntry.resolvedProgressKey()] = resolvedEntry } } - private fun removeLocalEntry(videoId: String): WatchProgressEntry? = + private fun removeLocalEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId.remove(videoId) + dirtyProgressKeys -= progressKey + entriesByProgressKey.remove(progressKey) + } + + private fun removeLocalEntriesForVideoIds( + videoIds: Collection, + parentMetaId: String?, + ): List = + synchronized(entriesLock) { + if (videoIds.isEmpty()) return@synchronized emptyList() + val ids = videoIds.toSet() + val keysToRemove = entriesByProgressKey + .filterValues { entry -> + entry.videoId in ids && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } fun isDroppedShow(contentId: String): Boolean { @@ -1145,17 +1662,10 @@ object WatchProgressRepository { .filter { manifest -> manifest.hasMetaResource() } return MetadataProviderReadiness( providers = providers, - isRefreshing = enabled.any { addon -> addon.isRefreshing }, ) } private fun AddonManifest.hasMetaResource(): Boolean = resources.any { resource -> resource.name == "meta" } - private fun AddonManifest.supportsMetaRequest(type: String, id: String): Boolean = - resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { prefix -> id.startsWith(prefix) }) - } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index bcaedb77..f58b7807 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries import com.nuvio.app.features.watching.domain.isProgressComplete +import com.nuvio.app.features.watching.domain.isSeriesLikeWatchingContentType import com.nuvio.app.features.watching.domain.resumeProgressForSeries import com.nuvio.app.features.watching.domain.shouldStoreProgress import kotlinx.serialization.Serializable @@ -13,6 +14,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json internal const val ContinueWatchingLimit = DefaultContinueWatchingLimit +private const val WatchProgressSnapshotConflictToleranceMs = 1_000L @Serializable internal data class StoredWatchProgressPayload( @@ -20,6 +22,7 @@ internal data class StoredWatchProgressPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyProgressKeys: Set = emptySet(), ) internal object WatchProgressCodec { @@ -34,8 +37,16 @@ internal object WatchProgressCodec { fun decodePayload(payload: String): StoredWatchProgressPayload = runCatching { json.decodeFromString(payload).let { storedPayload -> + val migratedEntries = storedPayload.entries + .map { entry -> entry.normalizedCompletion().withResolvedProgressKey() } + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()) storedPayload.copy( - entries = storedPayload.entries.map(WatchProgressEntry::normalizedCompletion), + entries = migratedEntries, + dirtyProgressKeys = storedPayload.dirtyProgressKeys.intersect( + migratedEntries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() }, + ), ) } }.getOrDefault(StoredWatchProgressPayload()) @@ -53,13 +64,18 @@ internal object WatchProgressCodec { lastSuccessfulPushEpochMs: Long, deltaCursorEventId: Long, deltaInitialized: Boolean, + dirtyProgressKeys: Set = emptySet(), ): String = json.encodeToString( StoredWatchProgressPayload( - entries = entries.toList().sortedByDescending { it.lastUpdatedEpochMs }, + entries = entries + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()), lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeys, ), ) } @@ -79,26 +95,41 @@ internal fun isWatchProgressComplete( isEnded = isEnded, ) -internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? = - firstOrNull { entry -> entry.parentMetaId == metaId }?.let { seed -> - resumeProgressForSeries( - content = WatchingContentRef(type = seed.parentMetaType, id = metaId), - progressRecords = map(WatchProgressEntry::toDomainProgressRecord), - )?.let { record -> - firstOrNull { entry -> entry.videoId == record.videoId } - } +internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? { + val candidates = newestByProgressKey().values.toList() + val normalizedMetaId = metaId.trim() + if (normalizedMetaId.isEmpty()) return null + val contentCandidates = candidates.filter { entry -> + entry.parentMetaId.trim() == normalizedMetaId } + val seed = contentCandidates.firstOrNull() ?: return null + val hasSeriesCandidate = contentCandidates.any { entry -> + entry.parentMetaType.isSeriesLikeWatchingContentType() + } + val requestedType = if (hasSeriesCandidate) "series" else seed.parentMetaType + + return resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = normalizedMetaId), + progressRecords = candidates.map(WatchProgressEntry::toDomainProgressRecord), + )?.let { record -> + candidates.firstOrNull { entry -> entry.resolvedProgressKey() == record.identityKey } + } +} internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { - val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } + val selectionEntries = filter { entry -> + entry.isEffectivelyCompleted || entry.shouldTreatAsInProgressForContinueWatching() + }.newestByProgressKey().values.toList() val domainEntries = continueWatchingProgressEntries( - progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord), + progressRecords = selectionEntries.map(WatchProgressEntry::toDomainProgressRecord), limit = limit, ) - val ids = domainEntries.map { record -> record.videoId }.toSet() - return inProgressEntries.filter { entry -> entry.videoId in ids } + val identityKeys = domainEntries.map { record -> record.identityKey }.toSet() + return selectionEntries + .filter { entry -> entry.resolvedProgressKey() in identityKeys } + .filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } .sortedByDescending { it.lastUpdatedEpochMs } } @@ -124,6 +155,24 @@ internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): B return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold } +internal fun shouldReplaceProgressSnapshotEntry( + existing: WatchProgressEntry, + candidate: WatchProgressEntry, +): Boolean { + val normalizedExisting = existing.normalizedCompletion() + val normalizedCandidate = candidate.normalizedCompletion() + val existingInProgress = normalizedExisting.shouldTreatAsInProgressForContinueWatching() + val candidateInProgress = normalizedCandidate.shouldTreatAsInProgressForContinueWatching() + if (existingInProgress != candidateInProgress) { + val inProgressEntry = if (candidateInProgress) normalizedCandidate else normalizedExisting + val completedEntry = if (candidateInProgress) normalizedExisting else normalizedCandidate + val inProgressIsCurrentEnough = + inProgressEntry.lastUpdatedEpochMs >= completedEntry.lastUpdatedEpochMs - WatchProgressSnapshotConflictToleranceMs + return if (candidateInProgress) inProgressIsCurrentEnough else !inProgressIsCurrentEnough + } + return normalizedCandidate.lastUpdatedEpochMs > normalizedExisting.lastUpdatedEpochMs +} + internal fun shouldCascadeCompletedProgressToWatchedHistory( entry: WatchProgressEntry, isUsingTraktProgress: Boolean, @@ -144,17 +193,18 @@ internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( - content = WatchingContentRef( - type = entry.parentMetaType, - id = entry.parentMetaId, - ), - videoId = entry.videoId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - lastPositionMs = entry.lastPositionMs, - isCompleted = entry.isCompleted, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - ) + content = WatchingContentRef( + type = entry.parentMetaType, + id = entry.parentMetaId, + ), + videoId = entry.videoId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + lastUpdatedEpochMs = entry.lastUpdatedEpochMs, + lastPositionMs = entry.lastPositionMs, + isCompleted = entry.isEffectivelyCompleted, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + identityKey = entry.resolvedProgressKey(), + ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt new file mode 100644 index 00000000..69678ca0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -0,0 +1,485 @@ +package com.nuvio.app.features.watchprogress + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.trakt.DEFAULT_WATCH_PROGRESS_SOURCE +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.effectiveWatchProgressSource +import com.nuvio.app.features.watched.WatchedRepository +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class WatchProgressSourceTransitionState( + val profileId: Int? = null, + val requestedSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, + val effectiveSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC, + val isRefreshing: Boolean = false, + val lastRefreshSucceeded: Boolean? = null, +) + +data class WatchProgressSourceTransitionResult( + val requestedSource: WatchProgressSource, + val effectiveSource: WatchProgressSource, + val progressRefreshed: Boolean, + val watchedHistoryRefreshed: Boolean, +) { + val succeeded: Boolean + get() = progressRefreshed && watchedHistoryRefreshed +} + +internal data class WatchProgressSourceContext( + val profileId: Int, + val requestedSource: WatchProgressSource, + val effectiveSource: WatchProgressSource, + val isNuvioAuthenticated: Boolean, +) + +internal fun resolveSerializedWatchProgressContext( + queuedContext: WatchProgressSourceContext, + currentContext: WatchProgressSourceContext, +): WatchProgressSourceContext? = currentContext.takeIf { + it.profileId == queuedContext.profileId +} + +internal class WatchProgressSourceTransitionRunner( + private val currentAppliedSource: () -> WatchProgressSource? = { null }, + private val invalidateCache: (profileId: Int, source: WatchProgressSource) -> Unit, + private val activateProgressSource: (WatchProgressSource) -> Unit, + private val activateWatchedSource: (WatchProgressSource) -> Unit, + private val refreshProgress: suspend ( + profileId: Int, + source: WatchProgressSource, + sourceChanged: Boolean, + force: Boolean, + ) -> Boolean, + private val refreshWatched: suspend ( + profileId: Int, + source: WatchProgressSource, + force: Boolean, + ) -> Boolean, +) { + private val transitionMutex = Mutex() + private val lifecycleLock = SynchronizedObject() + private var lifecycleGeneration: Long = 0L + private var lastAppliedContext: WatchProgressSourceContext? = null + + fun currentGeneration(): Long = synchronized(lifecycleLock) { + lifecycleGeneration + } + + suspend fun transition( + context: WatchProgressSourceContext, + refreshIfUnchanged: Boolean, + forceSnapshot: Boolean, + transitionGeneration: Long = currentGeneration(), + ): WatchProgressSourceTransitionResult = transitionMutex.withLock { + val (previousContext, sourceChanged) = synchronized(lifecycleLock) { + ensureCurrentGeneration(transitionGeneration) + val previous = lastAppliedContext + val currentSourceDiffers = currentAppliedSource() + ?.let { appliedSource -> appliedSource != context.effectiveSource } + ?: false + val lastSuccessfulContextDiffers = previous?.let { successfulContext -> + successfulContext.profileId != context.profileId || + successfulContext.effectiveSource != context.effectiveSource + } ?: false + val changed = currentSourceDiffers || lastSuccessfulContextDiffers + + if (changed) { + invalidateCache(context.profileId, context.effectiveSource) + } + activateWatchedSource(context.effectiveSource) + activateProgressSource(context.effectiveSource) + previous to changed + } + + if (!refreshIfUnchanged && !sourceChanged && previousContext == context) { + return@withLock WatchProgressSourceTransitionResult( + requestedSource = context.requestedSource, + effectiveSource = context.effectiveSource, + progressRefreshed = true, + watchedHistoryRefreshed = true, + ) + } + + val (progressRefreshed, watchedHistoryRefreshed) = coroutineScope { + val progress = synchronized(lifecycleLock) { + ensureCurrentGeneration(transitionGeneration) + async(start = CoroutineStart.UNDISPATCHED) { + runRefresh { + refreshProgress( + context.profileId, + context.effectiveSource, + sourceChanged, + forceSnapshot, + ) + } + } + } + val watched = synchronized(lifecycleLock) { + ensureCurrentGeneration(transitionGeneration) + async(start = CoroutineStart.UNDISPATCHED) { + runRefresh { + refreshWatched( + context.profileId, + context.effectiveSource, + forceSnapshot, + ) + } + } + } + progress.await() to watched.await() + } + + if (progressRefreshed && watchedHistoryRefreshed) { + synchronized(lifecycleLock) { + ensureCurrentGeneration(transitionGeneration) + lastAppliedContext = context + } + } else { + synchronized(lifecycleLock) { + ensureCurrentGeneration(transitionGeneration) + } + } + WatchProgressSourceTransitionResult( + requestedSource = context.requestedSource, + effectiveSource = context.effectiveSource, + progressRefreshed = progressRefreshed, + watchedHistoryRefreshed = watchedHistoryRefreshed, + ) + } + + fun reset() = synchronized(lifecycleLock) { + lifecycleGeneration += 1L + lastAppliedContext = null + } + + private fun ensureCurrentGeneration(expectedGeneration: Long) { + if (expectedGeneration != lifecycleGeneration) { + throw CancellationException("Watch progress source transition belongs to a cleared account") + } + } + + private suspend fun runRefresh(block: suspend () -> Boolean): Boolean = + try { + block() + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + false + } +} + +object WatchProgressSourceCoordinator { + private val log = Logger.withTag("ProgressSourceCoordinator") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val startLock = SynchronizedObject() + private val transitionStateMutex = Mutex() + private var observeJob: Job? = null + private var lifecycleGeneration: Long = 0L + private val automaticTransitionPauseCount = atomic(0) + + private val _uiState = MutableStateFlow(WatchProgressSourceTransitionState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val runner = WatchProgressSourceTransitionRunner( + currentAppliedSource = { WatchProgressRepository.activeSourceState.value }, + invalidateCache = ContinueWatchingEnrichmentCache::invalidate, + activateProgressSource = WatchProgressRepository::activateSource, + activateWatchedSource = { source -> WatchedRepository.activateSource(source) }, + refreshProgress = WatchProgressRepository::refreshForSource, + refreshWatched = WatchedRepository::refreshForSource, + ) + + fun ensureStarted() { + val expectedGeneration = synchronized(startLock) { lifecycleGeneration } + ensureStartedForGeneration(expectedGeneration) + } + + private fun ensureStartedForGeneration(expectedGeneration: Long) { + val generationIsCurrent = synchronized(startLock) { + expectedGeneration == lifecycleGeneration + } + if (!generationIsCurrent) return + + ensureSourceStateLoaded() + + synchronized(startLock) { + if (expectedGeneration != lifecycleGeneration) return + if (observeJob?.isActive == true) return + observeJob = scope.launch { + combine( + TraktSettingsRepository.uiState, + TraktAuthRepository.isAuthenticated, + AuthRepository.state, + ProfileRepository.state, + ) { settings, isTraktAuthenticated, authState, profileState -> + buildContext( + profileId = profileState.activeProfile?.profileIndex + ?: ProfileRepository.activeProfileId, + requestedSource = settings.watchProgressSource, + isTraktAuthenticated = isTraktAuthenticated, + authState = authState, + ) + } + .distinctUntilChanged() + .collectLatest { context -> + if (automaticTransitionPauseCount.value > 0) return@collectLatest + runTransition( + context = context, + refreshIfUnchanged = false, + forceSnapshot = true, + ) + } + } + } + } + + private fun ensureSourceStateLoaded() { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + } + + suspend fun selectSource( + profileId: Int, + source: WatchProgressSource, + ): WatchProgressSourceTransitionResult { + val operationGeneration = synchronized(startLock) { lifecycleGeneration } + ensureSourceStateLoadedForGeneration(operationGeneration) + synchronized(startLock) { + ensureCoordinatorGeneration(operationGeneration) + TraktSettingsRepository.setWatchProgressSource(source, profileId) + } + val context = currentContext(profileId) + return try { + runTransition( + context = context, + refreshIfUnchanged = false, + forceSnapshot = true, + expectedCoordinatorGeneration = operationGeneration, + ) + } finally { + ensureStartedForGeneration(operationGeneration) + } + } + + suspend fun refreshActiveSource( + profileId: Int, + force: Boolean = true, + ): WatchProgressSourceTransitionResult { + val operationGeneration = synchronized(startLock) { lifecycleGeneration } + ensureSourceStateLoadedForGeneration(operationGeneration) + val context = currentContext(profileId) + return try { + runTransition( + context = context, + refreshIfUnchanged = true, + forceSnapshot = force, + expectedCoordinatorGeneration = operationGeneration, + ) + } finally { + ensureStartedForGeneration(operationGeneration) + } + } + + private fun ensureSourceStateLoadedForGeneration(expectedGeneration: Long) { + synchronized(startLock) { + ensureCoordinatorGeneration(expectedGeneration) + } + ensureSourceStateLoaded() + synchronized(startLock) { + ensureCoordinatorGeneration(expectedGeneration) + } + } + + private fun ensureCoordinatorGeneration(expectedGeneration: Long) { + if (expectedGeneration != lifecycleGeneration) { + throw CancellationException("Watch progress source operation belongs to a cleared account") + } + } + + fun clearLocalState() { + synchronized(startLock) { + observeJob?.cancel() + observeJob = null + lifecycleGeneration += 1L + runner.reset() + automaticTransitionPauseCount.value = 0 + _uiState.value = WatchProgressSourceTransitionState() + } + } + + internal fun pauseAutomaticTransitions() { + automaticTransitionPauseCount.incrementAndGet() + } + + internal fun resumeAutomaticTransitions() { + while (true) { + val current = automaticTransitionPauseCount.value + if (current == 0) return + if (automaticTransitionPauseCount.compareAndSet(current, current - 1)) { + if (current == 1) { + scope.launch { + val profileId = ProfileRepository.state.value.activeProfile?.profileIndex + ?: ProfileRepository.activeProfileId + try { + runTransition( + context = currentContext(profileId), + refreshIfUnchanged = false, + forceSnapshot = true, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to replay source transition after profile sync" } + } + } + } + return + } + } + } + + private suspend fun runTransition( + context: WatchProgressSourceContext, + refreshIfUnchanged: Boolean, + forceSnapshot: Boolean, + expectedCoordinatorGeneration: Long? = null, + ): WatchProgressSourceTransitionResult { + currentCoroutineContext().ensureActive() + val transitionToken = synchronized(startLock) { + if ( + expectedCoordinatorGeneration != null && + expectedCoordinatorGeneration != lifecycleGeneration + ) { + throw CancellationException("Watch progress source operation belongs to a cleared account") + } + TransitionToken( + coordinatorGeneration = lifecycleGeneration, + runnerGeneration = runner.currentGeneration(), + ) + } + + return transitionStateMutex.withLock { + currentCoroutineContext().ensureActive() + val activeProfileId = ProfileRepository.state.value.activeProfile?.profileIndex + ?: ProfileRepository.activeProfileId + val resolvedContext = resolveSerializedWatchProgressContext( + queuedContext = context, + currentContext = currentContext(activeProfileId), + ) ?: throw CancellationException("Watch progress source transition belongs to an inactive profile") + val started = synchronized(startLock) { + if (transitionToken.coordinatorGeneration != lifecycleGeneration) { + false + } else { + _uiState.value = WatchProgressSourceTransitionState( + profileId = resolvedContext.profileId, + requestedSource = resolvedContext.requestedSource, + effectiveSource = resolvedContext.effectiveSource, + isRefreshing = true, + lastRefreshSucceeded = null, + ) + true + } + } + if (!started) { + throw CancellationException("Watch progress source transition belongs to a cleared account") + } + + val result = try { + runner.transition( + context = resolvedContext, + refreshIfUnchanged = refreshIfUnchanged, + forceSnapshot = forceSnapshot, + transitionGeneration = transitionToken.runnerGeneration, + ) + } catch (error: Throwable) { + synchronized(startLock) { + if (transitionToken.coordinatorGeneration == lifecycleGeneration) { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + lastRefreshSucceeded = false, + ) + } + } + throw error + } + val completed = synchronized(startLock) { + if (transitionToken.coordinatorGeneration != lifecycleGeneration) { + false + } else { + _uiState.value = _uiState.value.copy( + profileId = resolvedContext.profileId, + requestedSource = result.requestedSource, + effectiveSource = result.effectiveSource, + isRefreshing = false, + lastRefreshSucceeded = result.succeeded, + ) + true + } + } + if (!completed) { + throw CancellationException("Watch progress source transition belongs to a cleared account") + } + if (!result.succeeded) { + log.w { + "Source refresh incomplete for profile ${resolvedContext.profileId}: " + + "source=${resolvedContext.effectiveSource} progress=${result.progressRefreshed} " + + "watched=${result.watchedHistoryRefreshed}" + } + } + result + } + } + + private data class TransitionToken( + val coordinatorGeneration: Long, + val runnerGeneration: Long, + ) + + private fun buildContext( + profileId: Int, + requestedSource: WatchProgressSource, + isTraktAuthenticated: Boolean, + authState: AuthState, + ): WatchProgressSourceContext = WatchProgressSourceContext( + profileId = profileId, + requestedSource = requestedSource, + effectiveSource = effectiveWatchProgressSource( + isTraktAuthenticated = isTraktAuthenticated, + requestedSource = requestedSource, + ), + isNuvioAuthenticated = authState is AuthState.Authenticated && !authState.isAnonymous, + ) + + private fun currentContext(profileId: Int): WatchProgressSourceContext = buildContext( + profileId = profileId, + requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + authState = AuthRepository.state.value, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NativeNavigation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NativeNavigation.kt new file mode 100644 index 00000000..133e29cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NativeNavigation.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.navigation + +import androidx.compose.runtime.staticCompositionLocalOf + +/** True when SwiftUI owns the current iOS tab and navigation stack. */ +val LocalUseNativeNavigation = staticCompositionLocalOf { false } + +/** True for immersive routes that intentionally keep their Compose-owned exit controls. */ +val LocalNativeNavigationBarHidden = staticCompositionLocalOf { false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt new file mode 100644 index 00000000..7320a800 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt @@ -0,0 +1,79 @@ +package com.nuvio.app.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import kotlin.reflect.KClass + +internal class NuvioNavigator( + private val backStack: NavBackStack, + private val onExternalNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, + private val onExternalBack: (() -> Unit)? = null, + private val onExternalReplace: ((AppRoute) -> Unit)? = null, + private val onRouteRemoved: (AppRoute) -> Unit = {}, +) { + val currentRoute: AppRoute? + get() = backStack.lastOrNull() as? AppRoute + + val routes: List + get() = backStack.filterIsInstance() + + fun navigate(route: AppRoute, options: NuvioNavigateOptions.() -> Unit = {}) { + val resolvedOptions = NuvioNavigateOptions().apply(options) + + if (resolvedOptions.launchSingleTop && currentRoute == route) return + + val popUpToRoute = resolvedOptions.popUpToRoute + if (popUpToRoute != null) { + val targetIndex = backStack.indexOfLast { popUpToRoute.isInstance(it) } + if (targetIndex >= 0) { + if ( + onExternalReplace != null && + resolvedOptions.popUpToInclusive && + targetIndex == backStack.lastIndex + ) { + onExternalReplace(route) + return + } + + val firstRemovedIndex = if (resolvedOptions.popUpToInclusive) targetIndex else targetIndex + 1 + if (firstRemovedIndex <= backStack.lastIndex) { + val removedRoutes = backStack + .subList(firstRemovedIndex, backStack.size) + .filterIsInstance() + .toList() + backStack.subList(firstRemovedIndex, backStack.size).clear() + removedRoutes.forEach(onRouteRemoved) + } + } + } + + if (resolvedOptions.launchSingleTop && currentRoute == route) return + onExternalNavigate?.invoke(route, resolvedOptions.launchSingleTop) ?: backStack.add(route) + } + + fun popBackStack(expectedRoute: AppRoute? = null): Boolean { + if (expectedRoute != null && currentRoute != expectedRoute) return false + if (backStack.size > 1) { + (backStack.removeAt(backStack.lastIndex) as? AppRoute)?.let(onRouteRemoved) + return true + } + onExternalBack?.invoke() + return onExternalBack != null + } +} + +internal class NuvioNavigateOptions { + var launchSingleTop: Boolean = false + internal var popUpToRoute: KClass? = null + internal var popUpToInclusive: Boolean = false + + inline fun popUpTo(noinline options: NuvioPopUpToOptions.() -> Unit = {}) { + val resolved = NuvioPopUpToOptions().apply(options) + popUpToRoute = T::class + popUpToInclusive = resolved.inclusive + } +} + +internal class NuvioPopUpToOptions { + var inclusive: Boolean = false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/Routes.kt new file mode 100644 index 00000000..df098063 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/Routes.kt @@ -0,0 +1,149 @@ +package com.nuvio.app.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface AppRoute : NavKey { + val title: String? + get() = null + + val subtitle: String? + get() = null + + /** Full-screen destinations such as the video player keep native navigation chrome hidden. */ + val hidesNavigationBar: Boolean + get() = false + + /** Stable enough to apply Navigation 3 launchSingleTop semantics in SwiftUI. */ + val navigationIdentity: String + get() = toString() + + /** Lets an explicitly cross-tab route select its native SwiftUI stack. */ + val preferredTabName: String? + get() = null +} + +@Serializable +sealed interface SettingsDestinationRoute : AppRoute { + override val preferredTabName: String + get() = "Settings" +} + +@Serializable +data object TabsRoute : AppRoute + +@Serializable +data class DetailRoute( + val type: String, + val id: String, + override val title: String? = null, +) : AppRoute + +@Serializable +data class PersonDetailRoute( + val personId: Int, + val personName: String, + val personPhoto: String? = null, + val castAvatarTransitionKey: String? = null, + val preferCrew: Boolean = false, +) : AppRoute { + override val title: String + get() = personName +} + +@Serializable +data class EntityBrowseRoute( + val entityKind: String, + val entityId: Int, + val entityName: String, + val sourceType: String = "tv", +) : AppRoute { + override val title: String + get() = entityName +} + +/** A settings leaf promoted from the former in-screen page state machine. */ +@Serializable +data class SettingsPageRoute( + val pageName: String, + override val title: String, +) : SettingsDestinationRoute + +@Serializable +data class HomescreenSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class MetaScreenSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class ContinueWatchingSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class DownloadsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class DownloadShowRoute( + val showId: String, + override val title: String, +) : AppRoute + +@Serializable +data class AddonsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class PluginsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class AccountSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class SupportersContributorsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class LicensesAttributionsSettingsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class CollectionsRoute(override val title: String = "") : SettingsDestinationRoute + +@Serializable +data class CollectionEditorRoute( + val collectionId: String? = null, + override val title: String = "", +) : AppRoute + +@Serializable +data class CollectionEditorPageRoute( + val collectionId: String? = null, + val pageName: String, + override val title: String, +) : AppRoute + +@Serializable +data class FolderDetailRoute( + val collectionId: String, + val folderId: String, + override val title: String = "", +) : AppRoute + +@Serializable +data class StreamRoute( + val launchId: Long, + override val title: String = "", +) : AppRoute + +@Serializable +data class CatalogRoute( + val launchId: Long, + override val title: String = "", + override val subtitle: String? = null, +) : AppRoute + +@Serializable +data class PlayerRoute( + val launchId: Long, + override val title: String = "", +) : AppRoute { + override val hidesNavigationBar: Boolean + get() = true +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/deeplink/AppUrlBridgeTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/deeplink/AppUrlBridgeTest.kt new file mode 100644 index 00000000..47a406de --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/deeplink/AppUrlBridgeTest.kt @@ -0,0 +1,66 @@ +package com.nuvio.app.core.deeplink + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AppUrlBridgeTest { + + @Test + fun `parses existing notification meta deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tt0944947"), + parseAppDeepLink("nuvio://meta?type=series&id=tt0944947"), + ) + } + + @Test + fun `parses direct nuvio addon install deeplink`() { + assertEquals( + AppDeepLink.AddonInstall("https://free.nebulapro.xyz/sports/i/free/manifest.json"), + parseAppDeepLink("nuvio://free.nebulapro.xyz/sports/i/free/manifest.json"), + ) + } + + @Test + fun `parses stremio addon install deeplink`() { + assertEquals( + AppDeepLink.AddonInstall("https://free.nebulapro.xyz/sports/i/free/manifest.json"), + parseAppDeepLink("stremio://free.nebulapro.xyz/sports/i/free/manifest.json"), + ) + } + + @Test + fun `parses direct imdb detail deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tt0944947"), + parseAppDeepLink("nuvio://series/tt0944947"), + ) + } + + @Test + fun `parses provider imdb detail deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tt0944947"), + parseAppDeepLink("nuvio://imdb/series/tt0944947"), + ) + } + + @Test + fun `parses provider tmdb detail deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tmdb:1399"), + parseAppDeepLink("nuvio://tmdb/tv/1399"), + ) + } + + @Test + fun `does not treat reserved auth link as addon install`() { + assertNull(parseAppDeepLink("nuvio://auth/trakt?code=abc")) + } + + @Test + fun `does not treat non-host stremio link as addon install`() { + assertNull(parseAppDeepLink("stremio://detail/series/tt0944947")) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt new file mode 100644 index 00000000..7eb617ac --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -0,0 +1,184 @@ +package com.nuvio.app.core.sync + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SyncManagerTest { + + @Test + fun `source prerequisites finish before source dependent pulls`() = runBlocking { + val events = mutableListOf() + var profileSettingsApplied = false + var traktCredentialsApplied = false + + runOrderedProfileSync( + profileId = 7, + pluginsEnabled = true, + operations = ProfileSyncOperations( + pullAddons = { events += "addons" }, + pullPlugins = { events += "plugins" }, + pullProfileSettings = { + events += "settings:start" + yield() + profileSettingsApplied = true + events += "settings:end" + }, + pullTraktCredentials = { + events += "credentials:start" + yield() + traktCredentialsApplied = true + events += "credentials:end" + }, + pullLibrary = { + assertTrue(profileSettingsApplied) + assertTrue(traktCredentialsApplied) + events += "library" + }, + refreshActiveWatchSource = { + assertTrue(profileSettingsApplied) + assertTrue(traktCredentialsApplied) + events += "active-watch-source" + }, + pullCollections = { events += "collections" }, + pullHomeCatalogSettings = { events += "home-settings" }, + ), + onFailure = { _, error -> throw error }, + ) + + val lastPrerequisite = maxOf( + events.indexOf("settings:end"), + events.indexOf("credentials:end"), + ) + assertTrue(events.indexOf("library") > lastPrerequisite) + assertTrue(events.indexOf("active-watch-source") > lastPrerequisite) + assertEquals(1, events.count { it == "active-watch-source" }) + } + + @Test + fun `disabled plugins are skipped without changing sync ordering`() = runBlocking { + val events = mutableListOf() + + runOrderedProfileSync( + profileId = 2, + pluginsEnabled = false, + operations = recordingOperations(events), + onFailure = { _, error -> throw error }, + ) + + assertTrue("plugins" !in events) + assertTrue(events.indexOf("settings") < events.indexOf("library")) + assertTrue(events.indexOf("credentials") < events.indexOf("active-watch-source")) + } + + @Test + fun `duplicate active request for one profile is coalesced`() = runBlocking { + val gate = ProfileSyncRequestGate() + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + var runCount = 0 + + val first = gate.launch(this, profileId = 4) { + runCount += 1 + firstStarted.complete(Unit) + releaseFirst.await() + } + firstStarted.await() + + val duplicate = gate.launch(this, profileId = 4) { + runCount += 1 + } + + assertEquals(ProfileSyncRequestResult.Started, first) + assertEquals(ProfileSyncRequestResult.Coalesced, duplicate) + assertEquals(1, runCount) + + releaseFirst.complete(Unit) + yield() + gate.cancel() + } + + @Test + fun `new profile replaces stale in flight request`() = runBlocking { + val gate = ProfileSyncRequestGate() + val firstStarted = CompletableDeferred() + val firstCancelled = CompletableDeferred() + val secondCompleted = CompletableDeferred() + + gate.launch(this, profileId = 1) { + firstStarted.complete(Unit) + try { + CompletableDeferred().await() + } finally { + firstCancelled.complete(Unit) + } + } + firstStarted.await() + + val replacement = gate.launch(this, profileId = 2) { + secondCompleted.complete(Unit) + } + + assertEquals(ProfileSyncRequestResult.Replaced, replacement) + firstCancelled.await() + secondCompleted.await() + gate.cancel() + } + + @Test + fun `failed step is reported by ordered sync result`() = runBlocking { + val result = runOrderedProfileSync( + profileId = 3, + pluginsEnabled = false, + operations = recordingOperations(mutableListOf()).copy( + refreshActiveWatchSource = { error("source refresh failed") }, + ), + ) + + assertFalse(result.succeeded) + assertEquals(setOf(ProfileSyncStep.ActiveWatchSource), result.failedSteps) + } + + @Test + fun `realtime invalidation queued during active sync runs once afterwards`() = runBlocking { + val gate = ProfileSyncRequestGate() + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val replayCompleted = CompletableDeferred() + var runCount = 0 + + gate.launch(this, profileId = 1) { + runCount += 1 + firstStarted.complete(Unit) + releaseFirst.await() + } + firstStarted.await() + + val queued = gate.launch(this, profileId = 1, queueIfCoalesced = true) { + runCount += 1 + replayCompleted.complete(Unit) + } + + assertEquals(ProfileSyncRequestResult.Coalesced, queued) + releaseFirst.complete(Unit) + replayCompleted.await() + assertEquals(2, runCount) + gate.cancel() + } + + private fun recordingOperations(events: MutableList): ProfileSyncOperations = + ProfileSyncOperations( + pullAddons = { events += "addons" }, + pullPlugins = { events += "plugins" }, + pullProfileSettings = { events += "settings" }, + pullTraktCredentials = { events += "credentials" }, + pullLibrary = { events += "library" }, + refreshActiveWatchSource = { events += "active-watch-source" }, + pullCollections = { events += "collections" }, + pullHomeCatalogSettings = { events += "home-settings" }, + ) +} 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 5f83cd99..04836f29 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 @@ -93,6 +93,41 @@ class CollectionSourceSerializationTest { assertTrue(source.hasInvalidTraktListId()) } + @Test + fun importModelRejectsDuplicateCollectionIds() { + val collections = listOf( + Collection(id = "collection-1", title = "One"), + Collection(id = "collection-1", title = "Two"), + ) + + assertEquals( + CollectionImportModelError.DuplicateCollectionId("collection-1"), + validateImportModel(collections), + ) + } + + @Test + fun importModelRejectsDuplicateFolderIdsWithinCollection() { + val collections = listOf( + Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder(id = "folder-1", title = "One"), + CollectionFolder(id = "folder-1", title = "Two"), + ), + ), + ) + + assertEquals( + CollectionImportModelError.DuplicateFolderId( + folderId = "folder-1", + collectionTitle = "Favorites", + ), + validateImportModel(collections), + ) + } + @Test fun legacyAddonCatalogSourcesRemainCompatible() { val payload = """ diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index 7a30963e..b46f2960 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.details import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class MetaDetailsParserTest { @@ -29,4 +31,38 @@ class MetaDetailsParserTest { assertEquals("series", result.type) assertEquals("The Fragrant Flower Blooms with Dignity", result.name) } + + @Test + fun `parse preserves explicit video availability`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "mal:52991", + "type": "series", + "name": "Show", + "videos": [ + { + "id": "show:3:1", + "title": "Episode 1", + "season": 3, + "episode": 1, + "released": null, + "available": false + }, + { + "id": "show:1:1", + "title": "Episode 1", + "season": 1, + "episode": 1 + } + ] + } + } + """.trimIndent(), + ) + + assertFalse(result.videos[0].available) + assertTrue(result.videos[1].available) + } } 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 faff7f84..437c308d 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 @@ -156,4 +156,16 @@ class SeriesPlaybackResolverTest { assertEquals(2, nextEpisode.episode) assertEquals("s2e2", nextEpisode.id) } + + @Test + fun filterUnavailableFutureSeasons_removes_explicitly_unavailable_season_without_release_date() { + val episodes = listOf( + MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-01"), + MetaVideo(id = "s3e1", title = "Episode 1", season = 3, episode = 1, released = null, available = false), + ) + + val filtered = episodes.filterUnavailableFutureSeasons(todayIsoDate = "2026-07-05") + + assertEquals(listOf("s1e1"), filtered.map(MetaVideo::id)) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 1f189673..20df1e2e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -7,17 +7,40 @@ import com.nuvio.app.features.cloud.CloudLibraryProviderState import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.watchprogress.CachedInProgressItem +import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs +import com.nuvio.app.features.watchprogress.resolvedProgressKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class HomeScreenTest { + @Test + fun `continue watching cache uses the effective progress source`() { + assertEquals( + WatchProgressSource.TRAKT, + effectiveContinueWatchingCacheSource(isTraktProgressActive = true), + ) + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveContinueWatchingCacheSource(isTraktProgressActive = false), + ) + } + @Test fun `home trakt continue watching candidate limits match TV`() { assertEquals(300, HomeContinueWatchingMaxRecentProgressItems) @@ -184,6 +207,13 @@ class HomeScreenTest { videoId = "show:1:4", title = "Show", lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "", + background = "\t", + episodeTitle = " ", + episodeThumbnail = "", + pauseDescription = " ", ) val cached = ContinueWatchingItem( parentMetaId = "show", @@ -208,13 +238,133 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(progress), - cachedInProgressByVideoId = mapOf(progress.videoId to cached), + cachedInProgressByProgressKey = mapOf(progress.resolvedProgressKey() to cached), nextUpItemsBySeries = emptyMap(), ) assertEquals("https://example.test/cached.jpg", result.single().imageUrl) assertEquals("https://example.test/logo.png", result.single().logo) + assertEquals("https://example.test/poster.jpg", result.single().poster) + assertEquals("https://example.test/backdrop.jpg", result.single().background) + assertEquals("Cached Episode", result.single().episodeTitle) assertEquals("https://example.test/thumb.jpg", result.single().episodeThumbnail) + assertEquals("Cached description", result.single().pauseDescription) + } + + @Test + fun `continue watching artwork selection skips blank values`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "https://example.test/poster.jpg", + background = "\t", + episodeThumbnail = "", + ) + + val item = progress.toContinueWatchingItem() + + assertEquals("https://example.test/poster.jpg", item.imageUrl) + assertEquals("https://example.test/poster.jpg", item.poster) + assertNull(item.logo) + assertNull(item.background) + assertNull(item.episodeThumbnail) + } + + @Test + fun `home in progress snapshot preserves cached metadata through serialization round trip`() { + val progress = progressEntry( + videoId = "show:1:4", + title = " ", + lastUpdatedEpochMs = 500L, + ).copy( + progressKey = "opaque-progress-key", + logo = "", + poster = " ", + background = "\t", + episodeTitle = "", + episodeThumbnail = " ", + pauseDescription = "", + ) + val cached = CachedInProgressItem( + contentId = "show", + contentType = "series", + name = "Cached Show", + poster = "https://example.test/poster.jpg", + backdrop = "https://example.test/backdrop.jpg", + logo = "https://example.test/logo.png", + videoId = "show:1:4", + season = 1, + episode = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + position = 10L, + duration = 20L, + lastWatched = 30L, + progressPercent = 50f, + progressKey = "opaque-progress-key", + ) + + val snapshot = buildHomeInProgressCacheSnapshot( + visibleEntries = listOf(progress), + cachedEntries = listOf(cached), + ).single() + val encoded = Json.encodeToString(CachedInProgressItem.serializer(), snapshot) + val restored = Json.decodeFromString(CachedInProgressItem.serializer(), encoded) + + assertEquals("Cached Show", restored.name) + assertEquals("https://example.test/poster.jpg", restored.poster) + assertEquals("https://example.test/backdrop.jpg", restored.backdrop) + assertEquals("https://example.test/logo.png", restored.logo) + assertEquals("Cached Episode", restored.episodeTitle) + assertEquals("https://example.test/thumb.jpg", restored.episodeThumbnail) + assertEquals("Cached description", restored.pauseDescription) + assertEquals(progress.lastPositionMs, restored.position) + assertEquals(progress.durationMs, restored.duration) + assertEquals(progress.lastUpdatedEpochMs, restored.lastWatched) + assertEquals("opaque-progress-key", restored.progressKey) + assertNull(restored.progressPercent) + } + + @Test + fun `cached artwork does not cross aliases that share a playback video id`() { + val firstProgress = progressEntry( + videoId = "shared-video", + title = "First", + lastUpdatedEpochMs = 500L, + ).copy( + parentMetaId = "show-a", + progressKey = "opaque-a", + ) + val secondProgress = firstProgress.copy( + parentMetaId = "show-b", + title = "Second", + lastUpdatedEpochMs = 400L, + progressKey = "opaque-b", + ) + val firstCached = firstProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/a.jpg", + poster = "https://example.test/a.jpg", + ) + val secondCached = secondProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/b.jpg", + poster = "https://example.test/b.jpg", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(firstProgress, secondProgress), + cachedInProgressByProgressKey = mapOf( + "opaque-a" to firstCached, + "opaque-b" to secondCached, + ), + nextUpItemsBySeries = emptyMap(), + ).associateBy(ContinueWatchingItem::parentMetaId) + + assertEquals("https://example.test/a.jpg", result.getValue("show-a").imageUrl) + assertEquals("https://example.test/b.jpg", result.getValue("show-b").imageUrl) } @Test @@ -339,6 +489,34 @@ class HomeScreenTest { assertEquals(14, result.single().episodeNumber) } + @Test + fun `Trakt next up seeds ignore watched items from the separate watched sync`() { + val traktProgress = progressEntry( + videoId = "show:1:2", + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ).copy(source = WatchProgressSourceTraktHistory) + val watchedItem = watchedItem( + id = "other-show", + season = 3, + episode = 8, + markedAtEpochMs = 3_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(traktProgress), + watchedItems = listOf(watchedItem), + isTraktProgressActive = true, + preferFurthestEpisode = true, + nowEpochMs = 4_000L, + ) + + assertEquals(listOf("show"), result.map { it.content.id }) + } + @Test fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( @@ -358,6 +536,270 @@ class HomeScreenTest { assertTrue(result.isEmpty()) } + @Test + fun `home next up waits for the selected seed source before resolving or clearing cache`() { + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = false, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = false, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = true, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = false, + ), + ) + } + + @Test + fun `home next up progressively merges live results with unprocessed cache`() { + val cachedResolved = continueWatchingItem( + videoId = "show:1:2", + subtitle = "Next Up • S1E2 • Cached", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-show.jpg", + ) + val cachedUnprocessed = continueWatchingItem( + videoId = "deferred:1:2", + subtitle = "Next Up • S1E2 • Deferred", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-deferred.jpg", + ) + val liveResolved = continueWatchingItem( + videoId = "show:1:3", + subtitle = "Next Up • S1E3 • Live", + seedEpisodeNumber = 1, + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = mapOf("show" to (300L to liveResolved)), + cachedItems = mapOf( + "show" to (100L to cachedResolved), + "deferred" to (90L to cachedUnprocessed), + ), + conclusivelyProcessedContentIds = setOf("show"), + ) + + assertEquals(setOf("show", "deferred"), result.keys) + assertEquals("show:1:3", result.getValue("show").second.videoId) + assertEquals("https://example.test/cached-show.jpg", result.getValue("show").second.imageUrl) + assertEquals("deferred:1:2", result.getValue("deferred").second.videoId) + } + + @Test + fun `home next up drops conclusive null while retaining transient cache`() { + val conclusive = continueWatchingItem( + videoId = "conclusive:1:2", + subtitle = "Next Up • S1E2", + ) + val transient = continueWatchingItem( + videoId = "transient:1:2", + subtitle = "Next Up • S1E2", + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = emptyMap(), + cachedItems = mapOf( + "conclusive" to (100L to conclusive), + "transient" to (90L to transient), + ), + conclusivelyProcessedContentIds = setOf("conclusive"), + ) + + assertEquals(setOf("transient"), result.keys) + } + + @Test + fun `cached next up recalculates aired state from release timestamp`() { + val released = "2026-07-12T00:00:00Z" + val releaseEpochMs = requireNotNull(parseReleaseDateToEpochMs(released)) + + assertTrue( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = false), + nowEpochMs = releaseEpochMs + 1L, + ), + ) + assertFalse( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = true), + nowEpochMs = releaseEpochMs - 1L, + ), + ) + } + + @Test + fun `cached next up invalidates whenever the authoritative seed changes`() { + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 2, + currentEpisode = 1, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 11, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 9, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertFalse( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 10, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + } + + @Test + fun `home next up does not treat placeholder cache as enriched metadata`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = " ", + logo = null, + episodeThumbnail = null, + ).copy( + title = "show", + poster = "", + background = "\t", + ) + + assertFalse(hasUsableHomeNextUpMetadata(placeholder)) + } + + @Test + fun `home next up accepts cache with resolved title and artwork`() { + val enriched = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/show.jpg", + ).copy(title = "Resolved Show") + + assertTrue(hasUsableHomeNextUpMetadata(enriched)) + } + + @Test + fun `home next up combines fresh title with cached artwork before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Fresh Show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/cached-show.jpg", + ).copy(title = "show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Fresh Show", decision.item.title) + assertEquals("https://example.test/cached-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up combines fresh artwork with cached title before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/fresh-show.jpg", + ).copy(title = "show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Cached Show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Cached Show", decision.item.title) + assertEquals("https://example.test/fresh-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up does not count logo alone as card artwork`() { + val logoOnly = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + logo = "https://example.test/show-logo.png", + ).copy(title = "Resolved Show") + + assertFalse(hasUsableHomeNextUpMetadata(logoOnly)) + } + + @Test + fun `dismissed placeholder next up is conclusive instead of transient`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + seasonNumber = 1, + episodeNumber = 2, + seedSeasonNumber = 1, + seedEpisodeNumber = 1, + ).copy(title = "show") + val dismissKey = nextUpDismissKey("show", 1, 1) + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = placeholder, + cachedFallbackItem = null, + dismissedNextUpKeys = setOf(dismissKey), + ) + + assertFalse(hasUsableHomeNextUpMetadata(decision.item)) + assertEquals(HomeNextUpCandidateMetadataOutcome.Dismissed, decision.outcome) + } + private fun progressEntry( videoId: String, title: String, @@ -428,6 +870,25 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun cachedNextUpItem( + released: String?, + hasAired: Boolean, + ): CachedNextUpItem = + CachedNextUpItem( + contentId = "show", + contentType = "series", + name = "Show", + videoId = "show:1:2", + season = 1, + episode = 2, + released = released, + hasAired = hasAired, + lastWatched = 1_000L, + sortTimestamp = 1_000L, + seedSeason = 1, + seedEpisode = 1, + ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = CompletedSeriesCandidate( content = WatchingContentRef(type = "series", id = "show-$index"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/HomeHeroSectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/HomeHeroSectionTest.kt index 95b42b16..fe3c5028 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/HomeHeroSectionTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/HomeHeroSectionTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.home.components +import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlin.test.Test import kotlin.test.assertEquals @@ -26,4 +27,41 @@ class HomeHeroSectionTest { assertEquals(true, layout.isTablet) assertEquals(386.4f, layout.heroHeight.value, 0.001f) } -} \ No newline at end of file + + @Test + fun `mobile hero height leaves room for continue watching card section`() { + val viewportHeight = 844f + val continueWatchingLayout = rememberContinueWatchingLayout(maxWidthDp = 390f) + val continueWatchingHeight = continueWatchingSectionHeightEstimate( + style = ContinueWatchingSectionStyle.Card, + layout = continueWatchingLayout, + basePosterWidthDp = 110, + showHeaderAccent = true, + ) + val reserveHeight = continueWatchingHeroViewportReserveHeight( + style = ContinueWatchingSectionStyle.Card, + layout = continueWatchingLayout, + basePosterWidthDp = 110, + showHeaderAccent = true, + ) + val layout = homeHeroLayout( + maxWidthDp = 390f, + viewportHeightDp = viewportHeight, + mobileBelowSectionHeightHintDp = reserveHeight.value, + ) + + assertEquals(24f, viewportHeight - layout.heroHeight.value - continueWatchingHeight.value, 0.001f) + } + + @Test + fun `mobile hero can shrink below default minimum to fit short viewport`() { + val layout = homeHeroLayout( + maxWidthDp = 390f, + viewportHeightDp = 568f, + mobileBelowSectionHeightHintDp = 300f, + ) + + assertEquals(false, layout.isTablet) + assertEquals(268f, layout.heroHeight.value, 0.001f) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index 2f658b97..0a5add23 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -7,6 +7,10 @@ import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktListType import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class LibraryRepositoryTest { @@ -94,4 +98,193 @@ class LibraryRepositoryTest { membership, ) } + + @Test + fun `local snapshot remains attached to its profile after replacement`() { + val state = LibraryLocalState() + val profileOneLoad = state.beginProfileLoad(profileId = 1).snapshot + assertFalse(profileOneLoad.hasLoaded) + assertTrue(profileOneLoad.isLoading) + + val profileOneSnapshot = assertNotNull( + state.completeProfileLoad( + token = profileOneLoad.token, + activeProfileId = 1, + items = listOf(libraryItem(id = "profile-one", savedAtEpochMs = 1L)), + ), + ) + assertTrue(profileOneSnapshot.hasLoaded) + assertFalse(profileOneSnapshot.isLoading) + + val profileTwoToken = state.beginProfileLoad(profileId = 2).snapshot.token + state.completeProfileLoad( + token = profileTwoToken, + activeProfileId = 2, + items = listOf(libraryItem(id = "profile-two", savedAtEpochMs = 2L)), + ) + val profileTwoSnapshot = state.snapshot() + + assertEquals(1, profileOneSnapshot.token.profileId) + assertEquals(listOf("profile-one"), profileOneSnapshot.items.map { it.id }) + assertEquals(2, profileTwoSnapshot.token.profileId) + assertEquals(listOf("profile-two"), profileTwoSnapshot.items.map { it.id }) + } + + @Test + fun `stale profile load cannot replace current profile items`() { + val state = LibraryLocalState() + val staleToken = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = staleToken, + activeProfileId = 1, + items = listOf(libraryItem(id = "old", savedAtEpochMs = 1L)), + ) + val staleSnapshot = state.snapshot() + + val currentToken = state.beginProfileLoad(profileId = 2).snapshot.token + state.completeProfileLoad( + token = currentToken, + activeProfileId = 2, + items = listOf(libraryItem(id = "current", savedAtEpochMs = 2L)), + ) + + val staleCommit = state.completeProfileLoad( + token = staleToken, + activeProfileId = 1, + items = listOf(libraryItem(id = "stale", savedAtEpochMs = 3L)), + ) + + assertNull(staleCommit) + var stalePersistenceAccepted = false + assertFalse( + state.runIfContentCurrent(staleSnapshot) { + stalePersistenceAccepted = true + }, + ) + assertFalse(stalePersistenceAccepted) + assertEquals(2, state.snapshot().token.profileId) + assertEquals(listOf("current"), state.snapshot().items.map { it.id }) + } + + @Test + fun `stale publication revision is rejected after same profile mutation`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "first", savedAtEpochMs = 1L)), + ) + val staleSnapshot = state.snapshot() + state.upsert(libraryItem(id = "second", savedAtEpochMs = 2L)) + + var committed = false + val accepted = state.runIfCurrent(staleSnapshot) { + committed = true + } + + assertFalse(accepted) + assertFalse(committed) + assertEquals(setOf("first", "second"), state.snapshot().items.map { it.id }.toSet()) + } + + @Test + fun `pull lifecycle does not invalidate pending local push`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = emptyList(), + ) + val localSnapshot = state.upsert(libraryItem(id = "local", savedAtEpochMs = 1L)) + + state.markPullStarted(token) + + assertTrue(state.isContentCurrent(localSnapshot)) + } + + @Test + fun `server pull preserves local items awaiting push`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ), + ) + + assertTrue(result.preservedLocalItems) + assertEquals( + setOf("remote-old", "local-new"), + result.snapshot.items.map { it.id }.toSet(), + ) + } + + @Test + fun `server pull preserves a local mutation made while pulling`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ), + ) + + assertTrue(result.preservedLocalItems) + assertEquals( + setOf("remote-old", "local-new"), + result.snapshot.items.map { it.id }.toSet(), + ) + } + + @Test + fun `server pull applies after local push completes`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + val localSnapshot = state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + state.markPushCompleted(localSnapshot) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-new", savedAtEpochMs = 3L)), + ), + ) + + assertFalse(result.preservedLocalItems) + assertEquals(listOf("remote-new"), result.snapshot.items.map { it.id }) + } + + private fun libraryItem(id: String, savedAtEpochMs: Long): LibraryItem = + LibraryItem( + id = id, + type = "movie", + name = id, + savedAtEpochMs = savedAtEpochMs, + ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 000d00da..c057e414 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -169,6 +169,27 @@ class StreamAutoPlaySelectorTest { assertEquals(directDebrid, selected) } + @Test + fun `first stream mode does not auto select external url browser link`() { + val external = stream( + addonName = "External Addon", + externalUrl = "https://example.com/watch", + name = "Watch on site", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(external), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("External Addon"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + ) + + assertNull(selected) + } + @Test fun `timeout evaluation keeps pending regex debrid candidate open`() { val pending = stream( @@ -252,6 +273,7 @@ class StreamAutoPlaySelectorTest { private fun stream( addonName: String, url: String? = null, + externalUrl: String? = null, name: String? = null, bingeGroup: String? = null, directDebrid: Boolean = false, @@ -261,6 +283,7 @@ class StreamAutoPlaySelectorTest { ): StreamItem = StreamItem( name = name, url = url, + externalUrl = externalUrl, infoHash = infoHash, addonName = addonName, addonId = "addon:$addonName", diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt index d0241a33..b3630109 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt @@ -101,13 +101,27 @@ class StreamModelsTest { } @Test - fun `torrent url falls through to http externalUrl`() { + fun `plain http externalUrl is surfaced only for external opening`() { + val url = "https://example.com/watch" + val stream = stream(externalUrl = url) + + assertNull(stream.playableDirectUrl) + assertEquals(url, stream.externalOpenUrl) + assertTrue(stream.shouldOpenExternally) + assertTrue(stream.isSelectableForPlayback(debridEnabled = false)) + } + + @Test + fun `torrent url does not fall through to http externalUrl as playable`() { val httpUrl = "https://cdn.example.com/video.mp4" val stream = stream( url = "torrent://$hexHash", externalUrl = httpUrl, ) - assertEquals(httpUrl, stream.playableDirectUrl) + + assertNull(stream.playableDirectUrl) + assertEquals(httpUrl, stream.externalOpenUrl) + assertFalse(stream.shouldOpenExternally) } // ----------------------------------------------------------------------- @@ -269,6 +283,30 @@ class StreamModelsTest { assertNull(stream.p2pInfoHash) } + @Test + fun `parser keeps externalUrl as external target instead of playable URL`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "externalUrl": "https://megogo.net/ua/search-extended?query=Barbie", + "name": "Watch on Megogo" + } + ] + } + """.trimIndent(), + addonName = "Ukrainian Streams", + addonId = "addon.ukrainian.streams", + ) + + val stream = streams.single() + assertNull(stream.playableDirectUrl) + assertEquals("https://megogo.net/ua/search-extended?query=Barbie", stream.externalOpenUrl) + assertTrue(stream.shouldOpenExternally) + assertTrue(stream.isSelectableForPlayback(debridEnabled = false)) + } + // ----------------------------------------------------------------------- // Helper // ----------------------------------------------------------------------- diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt new file mode 100644 index 00000000..b1e20c98 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktProgressRemovalTest { + + @Test + fun `episode removal matches only the exact logical episode`() { + val target = entry(contentId = "show", season = 2, episode = 3) + val otherEpisode = entry(contentId = "show", season = 2, episode = 4) + val otherParent = entry(contentId = "other-show", season = 2, episode = 3) + + assertTrue(target.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherEpisode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherParent.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + } + + @Test + fun `incomplete episode context never widens removal to the whole show`() { + val episode = entry(contentId = "show", season = 2, episode = 3) + + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = 2, episodeNumber = null)) + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = null, episodeNumber = 3)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = null)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = null, episodeNumber = 3)) + } + + @Test + fun `show removal matches only entries from the requested parent`() { + assertTrue(entry(contentId = "show").matchesTraktRemovalContext(" show ")) + assertFalse(entry(contentId = "other-show").matchesTraktRemovalContext("show")) + } + + private fun entry( + contentId: String, + season: Int = 1, + episode: Int = 1, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = contentId, + parentMetaType = "series", + videoId = "$contentId:$season:$episode", + title = "Show", + seasonNumber = season, + episodeNumber = episode, + lastPositionMs = 100L, + durationMs = 1_000L, + lastUpdatedEpochMs = 10L, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt index c051244a..24fb3553 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -4,10 +4,37 @@ import com.nuvio.app.features.library.LibrarySourceMode import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class TraktSettingsRepositoryTest { + @Test + fun `watch source outbox survives restart and a stale push cannot clear a newer choice`() { + val disk = mutableMapOf() + fun newOutbox() = WatchProgressSourceSettingsOutbox( + loadPayload = disk::get, + savePayload = disk::set, + clearPayload = disk::remove, + ) + val traktChoice = PendingWatchProgressSourceChange( + accountId = "account-a", + profileId = 2, + source = WatchProgressSource.TRAKT, + ) + val nuvioChoice = traktChoice.copy(source = WatchProgressSource.NUVIO_SYNC) + + newOutbox().record(traktChoice) + val afterProcessRestart = newOutbox() + assertEquals(traktChoice, afterProcessRestart.pendingFor("account-a", 2)) + + afterProcessRestart.record(nuvioChoice) + assertFalse(afterProcessRestart.clearIfMatches(traktChoice)) + assertEquals(nuvioChoice, afterProcessRestart.pendingFor("account-a", 2)) + assertTrue(afterProcessRestart.clearIfMatches(nuvioChoice)) + assertNull(afterProcessRestart.pendingFor("account-a", 2)) + } + @Test fun `watch progress source defaults to Trakt for unset or invalid storage`() { assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null)) @@ -62,6 +89,31 @@ class TraktSettingsRepositoryTest { assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT)) } + @Test + fun `effective progress source falls back to Nuvio when Trakt is unavailable`() { + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchProgressSource( + isTraktAuthenticated = false, + requestedSource = WatchProgressSource.TRAKT, + ), + ) + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchProgressSource( + isTraktAuthenticated = true, + requestedSource = WatchProgressSource.NUVIO_SYNC, + ), + ) + assertEquals( + WatchProgressSource.TRAKT, + effectiveWatchProgressSource( + isTraktAuthenticated = true, + requestedSource = WatchProgressSource.TRAKT, + ), + ) + } + @Test fun `effective library source uses Trakt only when authenticated and selected`() { assertEquals( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 34645728..1857f3d9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.trakt.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -47,56 +48,91 @@ class WatchedRepositoryTest { } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { - val serverItem = WatchedItem( + fun fullyWatchedSeries_ignores_explicitly_unavailable_main_episodes() { + val meta = MetaDetails( id = "show", type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) - val unsyncedLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 2", - season = 1, - episode = 2, - markedAtEpochMs = 3_000L, - ) - - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = listOf(serverItem), - localItems = listOf(serverItem, unsyncedLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, + name = "Show", + videos = listOf( + MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "s3e1", title = "Episode 1", season = 3, episode = 1, released = null, available = false), + ), ) assertEquals( - setOf("series:show:1:1", "series:show:1:2"), - merged.keys, + listOf("s1e1"), + meta.releasedMainSeasonEpisodes(todayIsoDate = "2026-07-05").map(MetaVideo::id), ) + + val result = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate = "2026-07-05") { episode -> + episode.id == "s1e1" + } + + assertTrue(result) } @Test - fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() { - val oldLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) + fun snapshot_dropsRemoteLoadedLocalMissingFromServerDespiteLegacyZeroPushWatermark() { + val remoteLoadedLocalItem = watchedItem(id = "remote-loaded", markedAtEpochMs = 1_000L) - val merged = mergeWatchedItemsPreservingUnsynced( + val merged = mergeWatchedSnapshot( serverItems = emptyList(), - localItems = listOf(oldLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, + localItems = listOf(remoteLoadedLocalItem), + dirtyKeys = emptySet(), ) - assertTrue(merged.isEmpty()) + assertTrue(merged.items.isEmpty()) + assertTrue(merged.dirtyKeys.isEmpty()) + } + + @Test + fun snapshot_preservesGenuinePendingLocalMarkMissingFromServer() { + val pendingLocalItem = watchedItem(id = "pending", markedAtEpochMs = 1_000L) + val pendingKey = watchedItemKey(pendingLocalItem.type, pendingLocalItem.id) + + val merged = mergeWatchedSnapshot( + serverItems = emptyList(), + localItems = listOf(pendingLocalItem), + dirtyKeys = setOf(pendingKey), + ) + + assertEquals(mapOf(pendingKey to pendingLocalItem), merged.items) + assertEquals(setOf(pendingKey), merged.dirtyKeys) + } + + @Test + fun snapshot_acknowledgesOnlyDirtyKeyWithEqualOrNewerRemoteItem() { + val acknowledgedLocal = watchedItem(id = "acknowledged", markedAtEpochMs = 1_000L) + val stillPendingLocal = watchedItem(id = "still-pending", markedAtEpochMs = 2_000L) + val acknowledgedRemote = acknowledgedLocal.copy(name = "server copy") + val acknowledgedKey = watchedItemKey(acknowledgedLocal.type, acknowledgedLocal.id) + val stillPendingKey = watchedItemKey(stillPendingLocal.type, stillPendingLocal.id) + + val merged = mergeWatchedSnapshot( + serverItems = listOf(acknowledgedRemote), + localItems = listOf(acknowledgedLocal, stillPendingLocal), + dirtyKeys = setOf(acknowledgedKey, stillPendingKey), + ) + + assertEquals(acknowledgedRemote, merged.items[acknowledgedKey]) + assertEquals(stillPendingLocal, merged.items[stillPendingKey]) + assertEquals(setOf(stillPendingKey), merged.dirtyKeys) + } + + @Test + fun successfulPush_acknowledgesOnlyPushedDirtyKey() { + val pushedItem = watchedItem(id = "pushed", markedAtEpochMs = 1_000L) + val pendingItem = watchedItem(id = "pending", markedAtEpochMs = 2_000L) + val pushedKey = watchedItemKey(pushedItem.type, pushedItem.id) + val pendingKey = watchedItemKey(pendingItem.type, pendingItem.id) + + val remainingDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = mapOf(pushedKey to pushedItem, pendingKey to pendingItem), + dirtyKeys = setOf(pushedKey, pendingKey), + pushedItems = listOf(pushedItem), + ) + + assertEquals(setOf(pendingKey), remainingDirtyKeys) } @Test @@ -120,4 +156,119 @@ class WatchedRepositoryTest { ), ) } + + @Test + fun watchedItemsForSource_keepsNuvioAndTraktSnapshotsIsolated() { + val nuvioItem = watchedItem(id = "nuvio", markedAtEpochMs = 1_000L) + val traktItem = watchedItem(id = "trakt", markedAtEpochMs = 2_000L) + + assertEquals( + listOf(nuvioItem), + watchedItemsForSource( + source = WatchProgressSource.NUVIO_SYNC, + nuvioItems = listOf(nuvioItem), + traktItems = listOf(traktItem), + ), + ) + assertEquals( + listOf(traktItem), + watchedItemsForSource( + source = WatchProgressSource.TRAKT, + nuvioItems = listOf(nuvioItem), + traktItems = listOf(traktItem), + ), + ) + } + + @Test + fun onlyNuvioWatchedStateIsPersisted() { + assertTrue(shouldPersistWatchedSource(WatchProgressSource.NUVIO_SYNC)) + assertFalse(shouldPersistWatchedSource(WatchProgressSource.TRAKT)) + } + + @Test + fun replacingTraktSnapshot_doesNotOverwriteNuvioSnapshot() { + val nuvioItem = watchedItem(id = "nuvio", markedAtEpochMs = 1_000L) + val previousTraktItem = watchedItem(id = "old-trakt", markedAtEpochMs = 2_000L) + val refreshedTraktItem = watchedItem(id = "new-trakt", markedAtEpochMs = 3_000L) + val nuvioItems = mutableMapOf("nuvio" to nuvioItem) + val traktItems = mutableMapOf("old-trakt" to previousTraktItem) + + replaceWatchedItemsForSource( + source = WatchProgressSource.TRAKT, + nuvioItems = nuvioItems, + traktItems = traktItems, + replacement = mapOf("new-trakt" to refreshedTraktItem), + ) + + assertEquals(mapOf("nuvio" to nuvioItem), nuvioItems) + assertEquals(mapOf("new-trakt" to refreshedTraktItem), traktItems) + } + + @Test + fun effectiveWatchedSource_fallsBackToNuvioWhenTraktIsUnavailable() { + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchedSource( + requestedSource = WatchProgressSource.TRAKT, + isTraktAuthenticated = false, + ), + ) + assertEquals( + WatchProgressSource.TRAKT, + effectiveWatchedSource( + requestedSource = WatchProgressSource.TRAKT, + isTraktAuthenticated = true, + ), + ) + } + + @Test + fun sourceOperationGuard_rejectsResultFromSourceActiveBeforeSwitch() { + val traktOperation = WatchedSourceOperation( + source = WatchProgressSource.TRAKT, + generation = 4L, + ) + + assertTrue( + isWatchedSourceOperationCurrent( + operation = traktOperation, + activeSource = WatchProgressSource.TRAKT, + activeGeneration = 4L, + ), + ) + assertFalse( + isWatchedSourceOperationCurrent( + operation = traktOperation, + activeSource = WatchProgressSource.NUVIO_SYNC, + activeGeneration = 5L, + ), + ) + } + + @Test + fun sourceOperationGuard_rejectsOlderRefreshAfterSwitchingAwayAndBack() { + val firstTraktOperation = WatchedSourceOperation( + source = WatchProgressSource.TRAKT, + generation = 7L, + ) + + assertFalse( + isWatchedSourceOperationCurrent( + operation = firstTraktOperation, + activeSource = WatchProgressSource.TRAKT, + activeGeneration = 9L, + ), + ) + } + + private fun watchedItem( + id: String, + markedAtEpochMs: Long, + ): WatchedItem = WatchedItem( + id = id, + type = "movie", + name = id, + markedAtEpochMs = markedAtEpochMs, + ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index e615cbc6..afcf8f73 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -28,7 +28,7 @@ class WatchingStateTest { } @Test - fun `visible continue watching keeps active resume when newer episode is completed`() { + fun `visible continue watching drops stale resume when newer episode is completed`() { val resume = entry( videoId = "show:1:4", seasonNumber = 1, @@ -52,7 +52,7 @@ class WatchingStateTest { latestCompletedBySeries = latestCompleted, ) - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) } @Test 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 eda075e4..e619ae24 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 @@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class SeriesContinuityTest { private val show = WatchingContentRef(type = "series", id = "show") @@ -12,6 +13,130 @@ class SeriesContinuityTest { WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 1, episodeNumber = 3, title = "Episode 3", releasedDate = "2026-03-15"), ) + @Test + fun continueWatchingProgressEntries_drops_older_resume_when_latest_series_progress_is_completed() { + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ), + ), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun continueWatchingProgressEntries_keeps_newer_resume_and_independent_movies() { + val movieA = WatchingContentRef(type = "movie", id = "movie-a") + val movieB = WatchingContentRef(type = "movie", id = "movie-b") + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 400L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieA, + videoId = "movie-a", + lastUpdatedEpochMs = 300L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieB, + videoId = "movie-b", + lastUpdatedEpochMs = 200L, + lastPositionMs = 10_000L, + ), + ), + ) + + assertEquals(listOf("show:1:5", "movie-a", "movie-b"), result.map { it.videoId }) + } + + @Test + fun resumeProgressForSeries_coalesces_series_type_variants_independent_of_input_order() { + val staleResume = WatchingProgressRecord( + content = WatchingContentRef(type = "SeRiEs", id = "show"), + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val completed = WatchingProgressRecord( + content = WatchingContentRef(type = "tV", id = "show"), + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + val inputOrders = listOf( + listOf(staleResume, completed), + listOf(completed, staleResume), + ) + + listOf("series", "TV", "Show", "TvShow").forEach { requestedType -> + inputOrders.forEach { progressRecords -> + assertNull( + resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = "show"), + progressRecords = progressRecords, + ), + ) + } + } + } + + @Test + fun resumeProgressForSeries_keeps_non_series_content_types_exact() { + val movieResume = WatchingProgressRecord( + content = WatchingContentRef(type = "movie", id = "shared"), + videoId = "shared", + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val differentlyCasedMovie = movieResume.copy( + content = WatchingContentRef(type = "Movie", id = "shared"), + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + + assertEquals( + movieResume, + resumeProgressForSeries( + content = WatchingContentRef(type = "movie", id = "shared"), + progressRecords = listOf(differentlyCasedMovie, movieResume), + ), + ) + } + @Test fun decideSeriesPrimaryAction_prefers_up_next_when_completed_is_newer_than_resume() { val action = decideSeriesPrimaryAction( @@ -121,6 +246,45 @@ class SeriesContinuityTest { assertEquals("s2e2", nextEpisode.videoId) } + @Test + fun nextReleasedEpisodeAfter_skips_explicitly_unavailable_phantom_episode() { + val episodesWithPhantom = listOf( + WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-01"), + WatchingReleasedEpisode(videoId = "s3e1", seasonNumber = 3, episodeNumber = 1, title = "Episode 1", releasedDate = null, available = false), + ) + + val nextEpisode = nextReleasedEpisodeAfter( + content = show, + episodes = episodesWithPhantom, + seasonNumber = 1, + episodeNumber = 1, + todayIsoDate = "2026-07-05", + showUnairedNextUp = true, + ) + + assertNull(nextEpisode) + } + + @Test + fun nextReleasedEpisodeAfter_surfaces_dated_unavailable_upcoming_episode_when_enabled() { + val episodes = listOf( + WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-07-01"), + WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-07-12", available = false), + ) + + val nextEpisode = nextReleasedEpisodeAfter( + content = show, + episodes = episodes, + seasonNumber = 1, + episodeNumber = 1, + todayIsoDate = "2026-07-05", + showUnairedNextUp = true, + ) + + assertNotNull(nextEpisode) + assertEquals("s1e2", nextEpisode.videoId) + } + @Test fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() { val specialsOnly = listOf( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt index 5f1dc7cc..f9942e7f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.domain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -25,6 +26,68 @@ class WatchingPoliciesTest { assertTrue(result) } + @Test + fun hasWatchedAllMainSeasonEpisodes_ignores_explicitly_unavailable_episodes() { + val episodes = listOf( + WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, releasedDate = "2026-03-01"), + WatchingReleasedEpisode(videoId = "phantom", seasonNumber = 3, episodeNumber = 1, releasedDate = null, available = false), + ) + + assertEquals( + listOf("ep1"), + releasedMainSeasonEpisodes( + episodes = episodes, + todayIsoDate = "2026-07-05", + ).map(WatchingReleasedEpisode::videoId), + ) + + val result = hasWatchedAllMainSeasonEpisodes( + episodes = episodes, + todayIsoDate = "2026-07-05", + isEpisodeWatched = { episode -> episode.videoId == "ep1" }, + ) + + assertTrue(result) + } + + @Test + fun shouldSurfaceNextEpisode_allows_dated_unavailable_upcoming_episode_only_when_enabled() { + assertTrue( + shouldSurfaceNextEpisode( + watchedSeasonNumber = 1, + candidateSeasonNumber = 1, + todayIsoDate = "2026-07-05", + releasedDate = "2026-07-12", + showUnairedNextUp = true, + available = false, + ), + ) + assertFalse( + shouldSurfaceNextEpisode( + watchedSeasonNumber = 1, + candidateSeasonNumber = 1, + todayIsoDate = "2026-07-05", + releasedDate = "2026-07-12", + showUnairedNextUp = false, + available = false, + ), + ) + } + + @Test + fun shouldSurfaceNextEpisode_keeps_unavailable_phantom_episode_hidden() { + assertFalse( + shouldSurfaceNextEpisode( + watchedSeasonNumber = 1, + candidateSeasonNumber = 3, + todayIsoDate = "2026-07-05", + releasedDate = null, + showUnairedNextUp = true, + available = false, + ), + ) + } + @Test fun latestCompletedSeriesEpisode_prefers_newer_manual_watch_marker() { val latestCompleted = latestCompletedSeriesEpisode( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt new file mode 100644 index 00000000..0e0d012f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt @@ -0,0 +1,78 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.trakt.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ContinueWatchingEnrichmentCacheTest { + + @Test + fun `storage keys are scoped by profile and effective source`() { + val traktKey = ContinueWatchingEnrichmentCache.continueWatchingEnrichmentStorageKey( + profileId = 2, + source = WatchProgressSource.TRAKT, + ) + val nuvioKey = ContinueWatchingEnrichmentCache.continueWatchingEnrichmentStorageKey( + profileId = 2, + source = WatchProgressSource.NUVIO_SYNC, + ) + + assertEquals("cw_enrichment_cache_trakt_2", traktKey) + assertEquals("cw_enrichment_cache_nuvio_sync_2", nuvioKey) + assertNotEquals(traktKey, nuvioKey) + assertEquals("cw_enrichment_cache_2", ContinueWatchingEnrichmentCache.legacyStorageKey(profileId = 2)) + } + + @Test + fun `stale resolver generation cannot write snapshots after invalidation`() { + val profileId = 4 + val staleGeneration = ContinueWatchingEnrichmentCache.generation.value + + ContinueWatchingEnrichmentCache.invalidate( + profileId = profileId, + source = WatchProgressSource.TRAKT, + ) + + assertFalse( + ContinueWatchingEnrichmentCache.saveSnapshots( + profileId = profileId, + source = WatchProgressSource.TRAKT, + generation = staleGeneration, + nextUp = emptyList(), + inProgress = emptyList(), + ), + ) + assertTrue( + ContinueWatchingEnrichmentCache.saveSnapshots( + profileId = profileId, + source = WatchProgressSource.TRAKT, + generation = ContinueWatchingEnrichmentCache.generation.value, + nextUp = emptyList(), + inProgress = emptyList(), + ), + ) + + ContinueWatchingEnrichmentCache.clearAll(profileId) + } + + @Test + fun `account clear invalidates resolver writes from the previous account`() { + val staleGeneration = ContinueWatchingEnrichmentCache.generation.value + + ContinueWatchingEnrichmentCache.clearLocalState() + + assertFalse( + ContinueWatchingEnrichmentCache.saveSnapshots( + profileId = 1, + source = WatchProgressSource.NUVIO_SYNC, + generation = staleGeneration, + nextUp = emptyList(), + inProgress = emptyList(), + ), + ) + ContinueWatchingEnrichmentCache.clearAll(profileId = 1) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt new file mode 100644 index 00000000..39003816 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -0,0 +1,516 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.watching.sync.ProgressSyncRecord +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchProgressIdentityTest { + + @Test + fun `provider change during metadata batch schedules one follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + + assertTrue(coordinator.requestForProviders("provider-a")) + val firstResolution = coordinator.beginResolution("provider-a") + assertTrue(!coordinator.requestForProviders("provider-b")) + + assertTrue(coordinator.finishResolution(firstResolution, "provider-b")) + assertTrue(!coordinator.requestForProviders("provider-b")) + + val followUpResolution = coordinator.beginResolution("provider-b") + assertTrue(!coordinator.finishResolution(followUpResolution, "provider-b")) + } + + @Test + fun `provider observed before metadata fetch does not schedule redundant follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + val resolution = coordinator.beginResolution(providerFingerprint = null) + + assertTrue(!coordinator.requestForProviders("provider-a")) + coordinator.providersObservedBeforeFetch( + resolutionGeneration = resolution, + providerFingerprint = "provider-a", + ) + + assertTrue(!coordinator.finishResolution(resolution, "provider-a")) + assertTrue(!coordinator.requestForProviders("provider-a")) + } + + @Test + fun `completion from replaced metadata batch cannot schedule retry`() { + val coordinator = MetadataResolutionRetryCoordinator() + val replacedResolution = coordinator.beginResolution("provider-a") + val activeResolution = coordinator.beginResolution("provider-b") + + assertTrue(!coordinator.finishResolution(replacedResolution, "provider-c")) + assertTrue(!coordinator.finishResolution(activeResolution, "provider-b")) + } + + @Test + fun `legacy payload derives episode progress key and preserves completed position`() { + val decoded = WatchProgressCodec.decodeEntries( + """ + { + "entries": [{ + "contentType": "series", + "parentMetaId": "show", + "parentMetaType": "series", + "videoId": "show:1:2", + "title": "Show", + "seasonNumber": 1, + "episodeNumber": 2, + "lastPositionMs": 940, + "durationMs": 1000, + "lastUpdatedEpochMs": 100 + }] + } + """.trimIndent(), + ) + + assertEquals("show_s1e2", decoded.single().progressKey) + assertEquals(940L, decoded.single().lastPositionMs) + assertTrue(decoded.single().isCompleted) + } + + @Test + fun `custom progress key round trips byte for byte`() { + val customKey = " Remote/KEY:S1E2 " + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries( + listOf(entry(progressKey = customKey)), + ), + ) + + assertEquals(customKey, decoded.single().progressKey) + assertEquals(customKey, decoded.single().resolvedProgressKey()) + } + + @Test + fun `key-only delta delete preserves its opaque identity`() { + val event = ProgressDeltaEvent( + eventId = 7L, + operation = "delete", + progressKey = " Remote/Delete-Key ", + ) + + assertEquals(" Remote/Delete-Key ", event.resolvedProgressKey()) + assertEquals("", event.videoId) + } + + @Test + fun `codec keeps aliases sharing video id and deduplicates only equal keys`() { + val sharedVideoId = "shared-video" + val olderSameKey = entry( + parentMetaId = "show-a", + videoId = sharedVideoId, + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + lastPositionMs = 100L, + ) + val newerSameKey = olderSameKey.copy( + lastUpdatedEpochMs = 20L, + lastPositionMs = 200L, + ) + val otherAlias = entry( + parentMetaId = "show-b", + videoId = sharedVideoId, + progressKey = "opaque-b", + lastUpdatedEpochMs = 15L, + ) + + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries(listOf(olderSameKey, otherAlias, newerSameKey)), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), decoded.map { it.resolvedProgressKey() }.toSet()) + assertEquals(200L, decoded.single { it.resolvedProgressKey() == "opaque-a" }.lastPositionMs) + } + + @Test + fun `same-key completion tie is independent of input order`() { + val incomplete = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 900L, + ) + val completed = incomplete.copy(isCompleted = true) + + val forward = listOf(incomplete, completed).newestByProgressKey().getValue("opaque") + val reversed = listOf(completed, incomplete).newestByProgressKey().getValue("opaque") + + assertTrue(forward.isCompleted) + assertEquals(forward, reversed) + } + + @Test + fun `partial metadata enrichment keeps existing nonblank artwork`() { + val current = entry().copy( + title = "Existing title", + logo = "existing-logo", + poster = "existing-poster", + background = null, + ) + + val enriched = enrichWatchProgressEntry( + current = current, + meta = MetaDetails( + id = "show", + type = "series", + name = "", + logo = null, + poster = "", + background = "new-background", + ), + ) + + assertEquals("Existing title", enriched.title) + assertEquals("existing-logo", enriched.logo) + assertEquals("existing-poster", enriched.poster) + assertEquals("new-background", enriched.background) + } + + @Test + fun `placeholder id title still requests metadata when artwork exists`() { + val placeholder = entry().copy( + title = "show", + poster = "poster", + background = "background", + ) + val enriched = placeholder.copy(title = "Resolved Show") + + assertTrue(placeholder.needsRemoteMetadataEnrichment()) + assertTrue(!enriched.needsRemoteMetadataEnrichment()) + } + + @Test + fun `video id compatibility projection rejects ambiguous aliases and contextual lookup resolves them`() { + val older = entry( + parentMetaId = "show-a", + videoId = "shared-video", + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + ) + val newer = entry( + parentMetaId = "show-b", + videoId = "shared-video", + progressKey = "opaque-b", + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(older, newer)) + + assertTrue("shared-video" !in state.byVideoId) + assertEquals( + "opaque-a", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show-a", + )?.resolvedProgressKey(), + ) + assertEquals(setOf("opaque-a", "opaque-b"), state.byProgressKey.keys) + } + + @Test + fun `contextual lookup rejects episodes that remain ambiguous within the same parent`() { + val episodeOne = entry( + parentMetaId = "show", + videoId = "shared-video", + progressKey = "episode-one", + lastUpdatedEpochMs = 10L, + ).copy(episodeNumber = 1) + val episodeTwo = episodeOne.copy( + progressKey = "episode-two", + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(episodeOne, episodeTwo)) + + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + ), + ) + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + ), + ) + assertEquals( + "episode-two", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 2, + )?.resolvedProgressKey(), + ) + } + + @Test + fun `optimistic update reuses exact opaque key before a fresher playback alias`() { + val exactPlayback = entry( + videoId = "provider-video-a", + progressKey = "remote-exact-key", + lastUpdatedEpochMs = 10L, + ) + val otherPlayback = exactPlayback.copy( + videoId = "provider-video-b", + progressKey = "remote-other-key", + lastUpdatedEpochMs = 20L, + ) + val candidate = exactPlayback.copy( + progressKey = null, + lastUpdatedEpochMs = 30L, + ) + + val resolved = listOf(otherPlayback, exactPlayback).resolveIdentityForUpsert(candidate) + + assertEquals("remote-exact-key", resolved.progressKey) + } + + @Test + fun `snapshot keeps distinct keys that share a playback video id`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record(contentId = "show-a", progressKey = "opaque-a", lastWatched = 10L), + record(contentId = "show-b", progressKey = "opaque-b", lastWatched = 20L), + ), + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), merged.keys) + assertEquals(2, merged.values.count { it.videoId == "shared-video" }) + } + + @Test + fun `snapshot same-key winner is newest and independent of input order`() { + val older = record( + contentId = "show", + progressKey = "opaque", + videoId = "older-video", + lastWatched = 10L, + position = 100L, + ) + val newer = older.copy( + videoId = "newer-video", + lastWatched = 20L, + position = 200L, + ) + + val forward = mergeSnapshot(listOf(older, newer)).getValue("opaque") + val reversed = mergeSnapshot(listOf(newer, older)).getValue("opaque") + + assertEquals("newer-video", forward.videoId) + assertEquals(forward, reversed) + } + + @Test + fun `snapshot preserves unsynced local position when timestamps tie`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + ), + localEntries = listOf( + entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 200L, + ), + ), + dirtyProgressKeys = setOf("opaque"), + ) + + assertEquals(200L, merged.getValue("opaque").lastPositionMs) + } + + @Test + fun `delta upsert with equal timestamp and higher position wins`() { + val current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 100L, + ) + val event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 200L, + ) + + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(200L, decision.updatedEntry?.lastPositionMs) + } + + @Test + fun `delta delete preserves a local update made during the pull`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 300L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = true, + ) + + assertEquals(WatchProgressDeltaDecisionType.PRESERVE_LOCAL, decision.type) + } + + @Test + fun `delta delete removes a clean remote-loaded entry even without a push watermark`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 100L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.DELETE, decision.type) + } + + @Test + fun `causally newer clean delta applies an authoritative rewind`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 200L, + lastPositionMs = 800L, + ), + event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(100L, decision.updatedEntry?.lastPositionMs) + assertTrue(decision.clearsDirtyProgress) + } + + @Test + fun `authoritative snapshot drops a clean cached row when watermark is zero`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = emptyList(), + localEntries = listOf(entry(progressKey = "opaque")), + dirtyProgressKeys = emptySet(), + ) + + assertTrue(merged.isEmpty()) + } + + @Test + fun `snapshot migrates dirty legacy identity to sole opaque server key`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "server/opaque", + lastWatched = 10L, + ), + ), + localEntries = listOf( + entry( + parentMetaId = "show", + progressKey = "show_s1e2", + lastUpdatedEpochMs = 20L, + ), + ), + dirtyProgressKeys = setOf("show_s1e2"), + ) + + assertEquals(setOf("server/opaque"), merged.keys) + assertEquals(20L, merged.getValue("server/opaque").lastUpdatedEpochMs) + } + + private fun mergeSnapshot(records: List): Map = + WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = records, + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + private fun record( + contentId: String, + progressKey: String, + videoId: String = "shared-video", + lastWatched: Long, + position: Long = 100L, + ): ProgressSyncRecord = + ProgressSyncRecord( + contentId = contentId, + contentType = "series", + videoId = videoId, + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + progressKey = progressKey, + ) + + private fun deltaEvent( + operation: String, + progressKey: String, + lastWatched: Long = 100L, + position: Long = 100L, + ): ProgressDeltaEvent = + ProgressDeltaEvent( + eventId = 1L, + operation = operation, + progressKey = progressKey, + contentId = "show", + contentType = "series", + videoId = "show:1:2", + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + ) + + private fun entry( + parentMetaId: String = "show", + videoId: String = "show:1:2", + progressKey: String? = null, + lastUpdatedEpochMs: Long = 10L, + lastPositionMs: Long = 100L, + ): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = videoId, + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = lastPositionMs, + durationMs = 1_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + progressKey = progressKey, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index eefc7b90..fc1e76c9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -21,6 +21,21 @@ class WatchProgressRulesTest { assertEquals(listOf("movie-2", "movie-1"), decoded.map { it.videoId }) } + @Test + fun `codec persists dirty progress keys and drops keys without a stored row`() { + val payload = WatchProgressCodec.encodePayload( + entries = listOf(entry(videoId = "show:1:2", progressKey = "opaque-key")), + lastSuccessfulPushEpochMs = 0L, + deltaCursorEventId = 0L, + deltaInitialized = false, + dirtyProgressKeys = setOf("opaque-key", "missing-key"), + ) + + val decoded = WatchProgressCodec.decodePayload(payload) + + assertEquals(setOf("opaque-key"), decoded.dirtyProgressKeys) + } + @Test fun `codec ignores corrupt payload`() { assertTrue(WatchProgressCodec.decodeEntries("{not json").isEmpty()) @@ -137,7 +152,7 @@ class WatchProgressRulesTest { } @Test - fun `continue watching keeps active resume even when a newer episode is completed`() { + fun `continue watching drops stale resume when a newer series episode is completed`() { val inProgress = entry( videoId = "show:1:4", parentMetaId = "show", @@ -156,7 +171,132 @@ class WatchProgressRulesTest { val result = listOf(inProgress, completed).continueWatchingEntries() - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) + } + + @Test + fun `continue watching keeps legitimate resume when it is newer than completed series progress`() { + val completed = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + isCompleted = true, + ) + val inProgress = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + ) + + val result = listOf(completed, inProgress).continueWatchingEntries() + + assertEquals(listOf("show:1:5"), result.map { it.videoId }) + } + + @Test + fun `resume entry for series returns null when newer overall progress is completed`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry coalesces series type variants independent of input order`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + parentMetaType = "SeRiEs", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + parentMetaType = "TV", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + assertNull(listOf(completed, staleResume).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry ignores a movie with the same content id`() { + val movie = entry( + videoId = "shared-movie", + parentMetaId = "shared", + parentMetaType = "movie", + lastUpdatedEpochMs = 30L, + ) + val seriesResume = entry( + videoId = "shared:1:1", + parentMetaId = "shared", + parentMetaType = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 20L, + ) + + val forward = listOf(movie, seriesResume).resumeEntryForSeries("shared") + val reversed = listOf(seriesResume, movie).resumeEntryForSeries("shared") + + assertEquals(seriesResume.resolvedProgressKey(), forward?.resolvedProgressKey()) + assertEquals(seriesResume.resolvedProgressKey(), reversed?.resolvedProgressKey()) + } + + @Test + fun `continue watching does not cross map aliases that share a video id`() { + val activeShow = entry( + videoId = "shared-video", + parentMetaId = "active-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 30L, + progressKey = "active-show_s1e1", + ) + val staleAlias = entry( + videoId = "shared-video", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 10L, + progressKey = "completed-show_s1e1", + ) + val completedAlias = entry( + videoId = "completed-show:1:2", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + isCompleted = true, + progressKey = "completed-show_s1e2", + ) + + val result = listOf(staleAlias, activeShow, completedAlias).continueWatchingEntries() + + assertEquals(listOf("active-show_s1e1"), result.map { it.resolvedProgressKey() }) } @Test @@ -191,6 +331,76 @@ class WatchProgressRulesTest { assertFalse(history.shouldTreatAsInProgressForContinueWatching()) } + @Test + fun `Trakt playback does not replace watched history when watched timestamp is newer`() { + val watched = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 3_000L, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + val playback = watched.copy( + lastUpdatedEpochMs = 1_000L, + isCompleted = false, + progressPercent = 25f, + source = WatchProgressSourceTraktPlayback, + ) + + assertFalse(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = playback)) + assertTrue(shouldReplaceProgressSnapshotEntry(existing = playback, candidate = watched)) + } + + @Test + fun `Trakt playback replaces watched history when playback timestamp is newer`() { + val watched = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 1_000L, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + val playback = watched.copy( + lastUpdatedEpochMs = 3_000L, + isCompleted = false, + progressPercent = 25f, + source = WatchProgressSourceTraktPlayback, + ) + + assertTrue(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = playback)) + assertFalse(shouldReplaceProgressSnapshotEntry(existing = playback, candidate = watched)) + } + + @Test + fun `Trakt playback uses TV timestamp tolerance against watched history`() { + val watched = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + val insideTolerance = watched.copy( + lastUpdatedEpochMs = 1_001L, + isCompleted = false, + progressPercent = 25f, + source = WatchProgressSourceTraktPlayback, + ) + val outsideTolerance = insideTolerance.copy(lastUpdatedEpochMs = 999L) + + assertTrue(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = insideTolerance)) + assertFalse(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = outsideTolerance)) + } + @Test fun `completed progress does not cascade to watched history while Trakt progress is active`() { val completed = entry( @@ -271,7 +481,7 @@ class WatchProgressRulesTest { assertEquals(1779634800000L, t1) val t2 = parseReleaseDateToEpochMs("2026-05-24") - assertEquals(1779580800000L, t2) // 2026-05-24T00:00:00Z is 1779580800 seconds + assertEquals(CurrentDateProvider.localStartOfDayEpochMs("2026-05-24"), t2) assertNull(parseReleaseDateToEpochMs(null)) assertNull(parseReleaseDateToEpochMs(" ")) @@ -283,17 +493,19 @@ class WatchProgressRulesTest { parentMetaId: String = videoId.substringBefore(':'), seasonNumber: Int? = null, episodeNumber: Int? = null, + parentMetaType: String = if (seasonNumber != null && episodeNumber != null) "series" else "movie", lastUpdatedEpochMs: Long = 1L, lastPositionMs: Long = 120_000L, durationMs: Long = 1_000_000L, isCompleted: Boolean = false, progressPercent: Float? = null, source: String = WatchProgressSourceLocal, + progressKey: String? = null, ): WatchProgressEntry = WatchProgressEntry( - contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + contentType = parentMetaType, parentMetaId = parentMetaId, - parentMetaType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + parentMetaType = parentMetaType, videoId = videoId, title = "Title", seasonNumber = seasonNumber, @@ -304,5 +516,6 @@ class WatchProgressRulesTest { isCompleted = isCompleted, progressPercent = progressPercent, source = source, + progressKey = progressKey, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt new file mode 100644 index 00000000..91f6add1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt @@ -0,0 +1,347 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.trakt.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +class WatchProgressSourceCoordinatorTest { + @Test + fun `serialized transition uses latest source for the same profile`() { + val queued = context(source = WatchProgressSource.NUVIO_SYNC) + val latest = context(source = WatchProgressSource.TRAKT) + + assertEquals( + latest, + resolveSerializedWatchProgressContext( + queuedContext = queued, + currentContext = latest, + ), + ) + assertEquals( + null, + resolveSerializedWatchProgressContext( + queuedContext = queued, + currentContext = latest.copy(profileId = 2), + ), + ) + } + + @Test + fun `source transition invalidates target cache and refreshes both read models`() = runBlocking { + val invalidations = mutableListOf>() + val activatedProgress = mutableListOf() + val activatedWatched = mutableListOf() + val progressRefreshes = mutableListOf() + val watchedRefreshes = mutableListOf() + val runner = WatchProgressSourceTransitionRunner( + invalidateCache = { profileId, source -> invalidations += profileId to source }, + activateProgressSource = activatedProgress::add, + activateWatchedSource = activatedWatched::add, + refreshProgress = { profileId, source, sourceChanged, force -> + progressRefreshes += RefreshCall(profileId, source, sourceChanged, force) + true + }, + refreshWatched = { profileId, source, force -> + watchedRefreshes += RefreshCall(profileId, source, sourceChanged = false, force = force) + true + }, + ) + + runner.transition( + context = context(source = WatchProgressSource.NUVIO_SYNC), + refreshIfUnchanged = false, + forceSnapshot = false, + ) + invalidations.clear() + activatedProgress.clear() + activatedWatched.clear() + progressRefreshes.clear() + watchedRefreshes.clear() + + val result = runner.transition( + context = context(source = WatchProgressSource.TRAKT), + refreshIfUnchanged = true, + forceSnapshot = true, + ) + + assertTrue(result.succeeded) + assertEquals(listOf(1 to WatchProgressSource.TRAKT), invalidations) + assertEquals(listOf(WatchProgressSource.TRAKT), activatedProgress) + assertEquals(listOf(WatchProgressSource.TRAKT), activatedWatched) + assertEquals( + listOf(RefreshCall(1, WatchProgressSource.TRAKT, sourceChanged = true, force = true)), + progressRefreshes, + ) + assertEquals( + listOf(RefreshCall(1, WatchProgressSource.TRAKT, sourceChanged = false, force = true)), + watchedRefreshes, + ) + } + + @Test + fun `first coordinated transition detects a different already applied source`() = runBlocking { + val invalidations = mutableListOf>() + var sourceChangedAtRefresh = false + val runner = WatchProgressSourceTransitionRunner( + currentAppliedSource = { WatchProgressSource.NUVIO_SYNC }, + invalidateCache = { profileId, source -> invalidations += profileId to source }, + activateProgressSource = {}, + activateWatchedSource = {}, + refreshProgress = { _, _, sourceChanged, _ -> + sourceChangedAtRefresh = sourceChanged + true + }, + refreshWatched = { _, _, _ -> true }, + ) + + runner.transition( + context = context(source = WatchProgressSource.TRAKT), + refreshIfUnchanged = false, + forceSnapshot = true, + ) + + assertTrue(sourceChangedAtRefresh) + assertEquals(listOf(1 to WatchProgressSource.TRAKT), invalidations) + } + + @Test + fun `same observed context does not refetch without force`() = runBlocking { + var refreshCount = 0 + var invalidationCount = 0 + val runner = runner( + onInvalidate = { invalidationCount += 1 }, + onRefresh = { refreshCount += 1 }, + ) + val context = context(source = WatchProgressSource.NUVIO_SYNC) + + runner.transition(context = context, refreshIfUnchanged = false, forceSnapshot = false) + runner.transition(context = context, refreshIfUnchanged = false, forceSnapshot = false) + + assertEquals(2, refreshCount, "the initial transition refreshes progress and watched once") + assertEquals(0, invalidationCount) + } + + @Test + fun `authentication change refreshes same effective source without invalidating it`() = runBlocking { + var refreshCount = 0 + var invalidationCount = 0 + val runner = runner( + onInvalidate = { invalidationCount += 1 }, + onRefresh = { refreshCount += 1 }, + ) + + runner.transition( + context = context( + source = WatchProgressSource.NUVIO_SYNC, + isNuvioAuthenticated = false, + ), + refreshIfUnchanged = false, + forceSnapshot = false, + ) + runner.transition( + context = context( + source = WatchProgressSource.NUVIO_SYNC, + isNuvioAuthenticated = true, + ), + refreshIfUnchanged = false, + forceSnapshot = false, + ) + + assertEquals(4, refreshCount) + assertEquals(0, invalidationCount) + } + + @Test + fun `partial backend failure is surfaced`() = runBlocking { + val runner = WatchProgressSourceTransitionRunner( + invalidateCache = { _, _ -> }, + activateProgressSource = {}, + activateWatchedSource = {}, + refreshProgress = { _, _, _, _ -> true }, + refreshWatched = { _, _, _ -> false }, + ) + + val result = runner.transition( + context = context(source = WatchProgressSource.TRAKT), + refreshIfUnchanged = true, + forceSnapshot = true, + ) + + assertTrue(result.progressRefreshed) + assertFalse(result.watchedHistoryRefreshed) + assertFalse(result.succeeded) + } + + @Test + fun `failed transition retries the same observed context`() = runBlocking { + var watchedAttempts = 0 + val runner = WatchProgressSourceTransitionRunner( + invalidateCache = { _, _ -> }, + activateProgressSource = {}, + activateWatchedSource = {}, + refreshProgress = { _, _, _, _ -> true }, + refreshWatched = { _, _, _ -> + watchedAttempts += 1 + watchedAttempts > 1 + }, + ) + val context = context(source = WatchProgressSource.TRAKT) + + val first = runner.transition( + context = context, + refreshIfUnchanged = false, + forceSnapshot = true, + ) + val retry = runner.transition( + context = context, + refreshIfUnchanged = false, + forceSnapshot = true, + ) + + assertFalse(first.succeeded) + assertTrue(retry.succeeded) + assertEquals(2, watchedAttempts) + } + + @Test + fun `reverting after a failed source switch reactivates and refreshes the last successful source`() = runBlocking { + var appliedSource = WatchProgressSource.NUVIO_SYNC + val invalidations = mutableListOf() + val refreshedSources = mutableListOf() + var failTraktWatchedRefresh = true + val runner = WatchProgressSourceTransitionRunner( + currentAppliedSource = { appliedSource }, + invalidateCache = { _, source -> invalidations += source }, + activateProgressSource = { source -> appliedSource = source }, + activateWatchedSource = {}, + refreshProgress = { _, source, _, _ -> + refreshedSources += source + true + }, + refreshWatched = { _, source, _ -> + refreshedSources += source + source != WatchProgressSource.TRAKT || !failTraktWatchedRefresh + }, + ) + + assertTrue( + runner.transition( + context = context(source = WatchProgressSource.NUVIO_SYNC), + refreshIfUnchanged = true, + forceSnapshot = true, + ).succeeded, + ) + assertFalse( + runner.transition( + context = context(source = WatchProgressSource.TRAKT), + refreshIfUnchanged = false, + forceSnapshot = true, + ).succeeded, + ) + + failTraktWatchedRefresh = false + val reverted = runner.transition( + context = context(source = WatchProgressSource.NUVIO_SYNC), + refreshIfUnchanged = false, + forceSnapshot = true, + ) + + assertTrue(reverted.succeeded) + assertEquals(WatchProgressSource.NUVIO_SYNC, appliedSource) + assertEquals( + listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC), + invalidations, + ) + assertEquals( + listOf( + WatchProgressSource.NUVIO_SYNC, + WatchProgressSource.NUVIO_SYNC, + WatchProgressSource.TRAKT, + WatchProgressSource.TRAKT, + WatchProgressSource.NUVIO_SYNC, + WatchProgressSource.NUVIO_SYNC, + ), + refreshedSources, + ) + } + + @Test + fun `account reset rejects an in flight transition and forgets its context`() = runBlocking { + val refreshStarted = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + var refreshCount = 0 + val runner = WatchProgressSourceTransitionRunner( + invalidateCache = { _, _ -> }, + activateProgressSource = {}, + activateWatchedSource = {}, + refreshProgress = { _, _, _, _ -> + refreshCount += 1 + refreshStarted.complete(Unit) + releaseRefresh.await() + true + }, + refreshWatched = { _, _, _ -> true }, + ) + val context = context(source = WatchProgressSource.TRAKT) + val staleTransition = async { + runner.transition( + context = context, + refreshIfUnchanged = false, + forceSnapshot = true, + ) + } + + refreshStarted.await() + runner.reset() + releaseRefresh.complete(Unit) + assertFailsWith { staleTransition.await() } + + runner.transition( + context = context, + refreshIfUnchanged = false, + forceSnapshot = true, + ) + assertEquals(2, refreshCount) + } + + private fun runner( + onInvalidate: () -> Unit, + onRefresh: () -> Unit, + ): WatchProgressSourceTransitionRunner = WatchProgressSourceTransitionRunner( + invalidateCache = { _, _ -> onInvalidate() }, + activateProgressSource = {}, + activateWatchedSource = {}, + refreshProgress = { _, _, _, _ -> + onRefresh() + true + }, + refreshWatched = { _, _, _ -> + onRefresh() + true + }, + ) + + private fun context( + source: WatchProgressSource, + isNuvioAuthenticated: Boolean = true, + ): WatchProgressSourceContext = WatchProgressSourceContext( + profileId = 1, + requestedSource = source, + effectiveSource = source, + isNuvioAuthenticated = isNuvioAuthenticated, + ) + + private data class RefreshCall( + val profileId: Int, + val source: WatchProgressSource, + val sourceChanged: Boolean, + val force: Boolean, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index 9d23979f..bc70435f 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -10,5 +10,4 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true - actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.desktop.kt new file mode 100644 index 00000000..f5231d89 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.desktop.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.ui.graphics.ImageBitmap +import coil3.request.SuccessResult + +internal actual fun loadedBackdropImageBitmap(result: SuccessResult): ImageBitmap? = null diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 20a1f552..6cc679aa 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -10,5 +10,4 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false - actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index dc3e1839..b86fa9f4 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -10,5 +10,4 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true - actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt index 18e9d416..d38655a5 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt @@ -1,12 +1,77 @@ package com.nuvio.app +import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.window.ComposeUIViewController +import com.nuvio.app.core.ui.NativeProfileSwitcherController +import com.nuvio.app.navigation.AppRoute import platform.UIKit.UIColor +import platform.UIKit.UIViewController private val nuvioBackgroundColor = UIColor(red = 0.051, green = 0.051, blue = 0.051, alpha = 1.0) -fun MainViewController() = ComposeUIViewController { +@Suppress("unused") +fun MainViewController(): UIViewController = nuvioComposeViewController { App() -}.apply { +} + +@Suppress("unused") +fun MainViewController( + initialTabName: String, + useNativeTabBar: Boolean, + useTabletFloatingTabBar: Boolean, + onNavigate: (AppRoute, Boolean) -> Unit, + onGoBack: () -> Unit, + onReplace: (AppRoute) -> Unit, + onActivate: (String) -> Unit, + onAppReady: (Boolean) -> Unit, + onTabTitles: (String, String, String, String) -> Unit, + nativeProfileSwitcherController: NativeProfileSwitcherController, +): UIViewController { + val initialTab = AppScreenTab.fromName(initialTabName) + return nuvioComposeViewController { + App( + initialTab = initialTab, + useNativeNavigation = true, + useNativeTabBar = useNativeTabBar, + useTabletFloatingTabBar = useTabletFloatingTabBar, + ownsAppRuntime = initialTab == AppScreenTab.Home, + bypassAppGate = initialTab != AppScreenTab.Home, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = { tab -> onActivate(tab.name) }, + onAppReady = onAppReady, + onTabTitles = onTabTitles, + nativeProfileSwitcherController = nativeProfileSwitcherController, + ) + } +} + +@Suppress("unused") +fun ScreenViewController( + route: AppRoute, + onNavigate: (AppRoute, Boolean) -> Unit, + onGoBack: () -> Unit, + onReplace: (AppRoute) -> Unit, + onActivate: (String) -> Unit, +): UIViewController = nuvioComposeViewController { + App( + initialRoute = route, + useNativeNavigation = true, + ownsAppRuntime = false, + bypassAppGate = true, + onNavigate = onNavigate, + onGoBack = onGoBack, + onReplace = onReplace, + onActivate = { tab -> onActivate(tab.name) }, + ) +} + +private fun nuvioComposeViewController( + content: @androidx.compose.runtime.Composable () -> Unit, +): UIViewController = ComposeUIViewController( + configure = { onFocusBehavior = OnFocusBehavior.DoNothing }, + content = content, +).apply { view.backgroundColor = nuvioBackgroundColor } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt deleted file mode 100644 index f9fde445..00000000 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.nuvio.app.core.network - -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.isSuccess -import platform.Foundation.NSUserDefaults - -internal actual object SyncBackendStorage { - private const val KEY_SELECTION_PAYLOAD = "nuvio_sync_backend_selection_payload_v1" - - actual fun loadSelectionPayload(): String? = - NSUserDefaults.standardUserDefaults.stringForKey(KEY_SELECTION_PAYLOAD) - - actual fun saveSelectionPayload(payload: String) { - NSUserDefaults.standardUserDefaults.setObject(payload, forKey = KEY_SELECTION_PAYLOAD) - } -} - -private val syncBackendHttpClient = HttpClient(Darwin) { - install(HttpTimeout) { - requestTimeoutMillis = 10_000 - connectTimeoutMillis = 10_000 - socketTimeoutMillis = 10_000 - } - expectSuccess = false -} - -internal actual suspend fun fetchSyncBackendManifestText(url: String): String { - val response = syncBackendHttpClient.get(url) { - accept(ContentType.Application.Json) - } - val payload = response.bodyAsText() - if (!response.status.isSuccess()) { - error("Sync backend manifest request failed with HTTP ${response.status.value}") - } - return payload.takeIf { it.isNotBlank() } - ?: error("Sync backend manifest response was empty") -} 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 6c7c0792..c9a43966 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 @@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val plainKeys = listOf( "profile_payload", "avatar_catalog_payload", + "anonymous_user_id", ) private val profilePinCachePrefixes = listOf("profile_pin_cache_") private val profileIndexedPrefixes = listOf( @@ -53,6 +54,7 @@ internal actual object PlatformLocalAccountDataCleaner { "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "pending_watch_progress_source", "collection_mobile_settings_payload", "collections_payload", ) @@ -76,7 +78,10 @@ internal actual object PlatformLocalAccountDataCleaner { for (key in defaults.dictionaryRepresentation().keys) { val keyString = key as? String ?: continue - if (keyString.startsWith("stream_link_")) { + if ( + keyString.startsWith("stream_link_") || + keyString.startsWith("cw_enrichment_cache_") + ) { defaults.removeObjectForKey(keyString) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.ios.kt new file mode 100644 index 00000000..f3c2f444 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.ios.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.core.sync + +import platform.Foundation.NSUserDefaults + +actual object SyncClientIdentityStorage { + private const val clientIdKey = "client_instance_id" + + actual fun loadClientId(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(clientIdKey) + + actual fun saveClientId(clientId: String) { + NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = clientIdKey) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.ios.kt new file mode 100644 index 00000000..fcb54b53 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AshSwarmRenderer.ios.kt @@ -0,0 +1,61 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas +import org.jetbrains.skia.BlendMode +import org.jetbrains.skia.Paint +import org.jetbrains.skia.VertexMode + +internal actual val ashSwarmMaxGrainBudget: Int = 26000 + +internal actual class AshSwarmRenderer actual constructor(maxGrains: Int) { + private val positions = FloatArray(maxGrains * 12) + private val vertexColors = IntArray(maxGrains * 6) + private val paint = Paint() + private var lastVertexCount = 0 + + actual fun draw( + scope: DrawScope, + count: Int, + centersX: FloatArray, + centersY: FloatArray, + halfWidths: FloatArray, + halfHeights: FloatArray, + colors: IntArray, + ) { + var pi = 0 + var ci = 0 + for (i in 0 until count) { + val x0 = centersX[i] - halfWidths[i] + val x1 = centersX[i] + halfWidths[i] + val y0 = centersY[i] - halfHeights[i] + val y1 = centersY[i] + halfHeights[i] + positions[pi++] = x0; positions[pi++] = y0 + positions[pi++] = x1; positions[pi++] = y0 + positions[pi++] = x0; positions[pi++] = y1 + positions[pi++] = x1; positions[pi++] = y0 + positions[pi++] = x1; positions[pi++] = y1 + positions[pi++] = x0; positions[pi++] = y1 + val c = colors[i] + vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c + vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c + } + val vertexCount = count * 6 + if (vertexCount < lastVertexCount) { + positions.fill(0f, fromIndex = vertexCount * 2, toIndex = lastVertexCount * 2) + vertexColors.fill(0, fromIndex = vertexCount, toIndex = lastVertexCount) + } + lastVertexCount = vertexCount + if (vertexCount == 0) return + + scope.drawContext.canvas.nativeCanvas.drawVertices( + vertexMode = VertexMode.TRIANGLES, + positions = positions, + colors = vertexColors, + texCoords = null, + indices = null, + blendMode = BlendMode.DST, + paint = paint, + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt new file mode 100644 index 00000000..3a52ff9c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.core.ui + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object CardDepthStyleStorage { + private const val payloadKey = "card_depth_style_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/core/ui/NuvioPlatformInsets.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt similarity index 54% rename from composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.ios.kt rename to composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt index 5042b4a4..590e17cd 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.ios.kt @@ -2,11 +2,16 @@ package com.nuvio.app.core.ui import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable +import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp @@ -15,3 +20,14 @@ internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp @Composable internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom) + +@OptIn(ExperimentalForeignApi::class) +@Composable +internal actual fun platformPhysicalTopInset(): Dp { + val physicalTop = LocalUIViewController.current.view.window + ?.safeAreaInsets + ?.useContents { top.toFloat() } + + return physicalTop?.dp + ?: WindowInsets.statusBars.asPaddingValues().calculateTopPadding() +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.ios.kt new file mode 100644 index 00000000..f5231d89 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/components/BackdropPaletteImage.ios.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.ui.graphics.ImageBitmap +import coil3.request.SuccessResult + +internal actual fun loadedBackdropImageBitmap(result: SuccessResult): ImageBitmap? = null diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index a0bfc724..dd37f8e6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -37,6 +37,7 @@ import platform.Foundation.NSURLSessionDataTask import platform.Foundation.NSURLSessionTask import platform.Foundation.setHTTPMethod import platform.Foundation.setValue +import platform.UIKit.UIApplication import platform.Foundation.timeIntervalSince1970 import platform.darwin.NSObject import platform.posix.FILE @@ -176,6 +177,16 @@ internal actual object DownloadsPlatformDownloader { null } } + + actual fun openDownloadsDirectory(): Boolean { + val url = NSURL.fileURLWithPath(downloadsDirectoryPath()) + UIApplication.sharedApplication.openURL( + url = url, + options = emptyMap(), + completionHandler = null, + ) + return true + } } private class IosDownloadsTaskHandle( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt similarity index 93% rename from composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt rename to composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt index 7250d81b..a8f0271c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt @@ -20,6 +20,12 @@ interface NuvioPlayerBridge { fun seekTo(positionMs: Long) fun seekBy(offsetMs: Long) fun retry() + fun updateNowPlayingMetadata( + title: String, + subtitle: String?, + artworkUrl: String?, + ) + fun clearNowPlayingInfo() fun configureVideoOutput( hardwareDecoder: String, targetColorspaceHint: Boolean, @@ -41,6 +47,7 @@ interface NuvioPlayerBridge { fun setVolume(volume: Float) fun setMuted(muted: Boolean) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom + fun syncVideoSurfaceLayout(width: Double, height: Double) fun getAudioTrackCount(): Int fun getAudioTrackIndex(at: Int): Int fun getAudioTrackId(at: Int): String diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index d1e74d2f..f61ea564 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -18,19 +18,24 @@ import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.dp import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController +import androidx.compose.ui.platform.LocalDensity import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_play import nuvio.composeapp.generated.resources.player_error_mpv_unavailable import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private const val TAG = "NuvioiOSPlayer" @@ -56,6 +61,7 @@ actual fun PlatformPlayerSurface( val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + val density = LocalDensity.current PlayerSettingsRepository.ensureLoaded() val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() val latestPlayerSettings = rememberUpdatedState(playerSettings) @@ -93,6 +99,26 @@ actual fun PlatformPlayerSurface( bridge.retry() } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + runCatching { + bridge.updateNowPlayingMetadata( + title = info.title, + subtitle = info.subtitle, + artworkUrl = info.artworkUrl, + ) + }.onFailure { error -> + Logger.w(TAG, error) { "Failed to update iOS Now Playing metadata" } + } + } + + override fun clearNowPlayingInfo() { + runCatching { + bridge.clearNowPlayingInfo() + }.onFailure { error -> + Logger.w(TAG, error) { "Failed to clear iOS Now Playing metadata" } + } + } + override fun configureIosVideoOutput(settings: PlayerSettingsUiState) { bridge.applyIosVideoOutputSettings(settings) } @@ -336,7 +362,25 @@ actual fun PlatformPlayerSurface( Box(modifier = modifier) { UIKitViewController( factory = { bridge.createPlayerViewController() }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onSizeChanged { size -> + if (size.width > 1 && size.height > 1) { + bridge.syncVideoSurfaceLayout( + width = with(density) { size.width.toDp().value.toDouble() }, + height = with(density) { size.height.toDp().value.toDouble() }, + ) + } + }, + onResize = { viewController, rect -> + viewController.view.setFrame(rect) + rect.useContents { + bridge.syncVideoSurfaceLayout( + width = size.width, + height = size.height, + ) + } + }, interactive = false, ) @@ -361,7 +405,7 @@ actual fun PlatformPlayerSurface( if (!isPlayingLocal) { Icon( imageVector = Icons.Rounded.PlayArrow, - contentDescription = "Play", + contentDescription = stringResource(Res.string.action_play), tint = Color.White, modifier = Modifier.size(64.dp) ) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt index 90575e4d..212d5f1d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -48,9 +48,12 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { @Composable actual fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) = Unit +@Composable +actual fun rememberIsInPictureInPicture(): Boolean = false + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val controller = remember { IOSPlayerGestureController() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 4e60c4d2..4eeb657a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -18,12 +18,14 @@ import platform.Foundation.NSUserDefaults actual object PlayerSettingsStorage { private const val showLoadingOverlayKey = "show_loading_overlay" + private const val showParentalGuideKey = "show_parental_guide" private const val resizeModeKey = "resize_mode" private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedValueKey = "hold_to_speed_value" private const val touchGesturesEnabledKey = "touch_gestures_enabled" private const val externalPlayerEnabledKey = "external_player_enabled" private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" + private const val externalPlayerSendSkipSegmentsKey = "external_player_send_skip_segments" private const val externalPlayerIdKey = "external_player_id" private const val preferredAudioLanguageKey = "preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" @@ -85,12 +87,14 @@ actual object PlayerSettingsStorage { private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, + showParentalGuideKey, resizeModeKey, holdToSpeedEnabledKey, holdToSpeedValueKey, touchGesturesEnabledKey, externalPlayerEnabledKey, externalPlayerForwardSubtitlesKey, + externalPlayerSendSkipSegmentsKey, externalPlayerIdKey, preferredAudioLanguageKey, secondaryPreferredAudioLanguageKey, @@ -184,6 +188,20 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(showLoadingOverlayKey)) } + actual fun loadShowParentalGuide(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(showParentalGuideKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveShowParentalGuide(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(showParentalGuideKey)) + } + actual fun loadResizeMode(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(resizeModeKey) @@ -256,6 +274,20 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerForwardSubtitlesKey)) } + actual fun loadExternalPlayerSendSkipSegments(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveExternalPlayerSendSkipSegments(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey)) + } + actual fun loadExternalPlayerId(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(externalPlayerIdKey) @@ -863,6 +895,7 @@ actual object PlayerSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } + loadShowParentalGuide()?.let { put(showParentalGuideKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } @@ -936,6 +969,7 @@ actual object PlayerSettingsStorage { } payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncBoolean(showParentalGuideKey)?.let(::saveShowParentalGuide) payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.ios.kt new file mode 100644 index 00000000..f80fa158 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.ios.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.settings + +import platform.Foundation.NSUserDefaults + +internal actual object SentrySettingsPlatform { + actual val crashReportsSupported: Boolean = false +} + +internal actual object SentrySettingsStorage { + private const val enabledKey = "sentry_enabled" + + actual fun loadEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + return if (defaults.objectForKey(enabledKey) != null) { + defaults.boolForKey(enabledKey) + } else { + null + } + } + + actual fun saveEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = enabledKey) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt index 06c60535..c9d805bf 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt @@ -5,6 +5,7 @@ import platform.Foundation.NSUserDefaults internal actual object TraktSettingsStorage { private const val payloadKey = "trakt_settings_payload" + private const val pendingWatchProgressSourceKey = "pending_watch_progress_source" actual fun loadPayload(): String? = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) @@ -12,4 +13,22 @@ internal actual object TraktSettingsStorage { actual fun savePayload(payload: String) { NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) } + + actual fun loadPendingWatchProgressSourcePayload(profileId: Int): String? = + NSUserDefaults.standardUserDefaults.stringForKey( + ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), + ) + + actual fun savePendingWatchProgressSourcePayload(profileId: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), + ) + } + + actual fun clearPendingWatchProgressSourcePayload(profileId: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey( + ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), + ) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt index fa753e59..f437ac57 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter +import platform.Foundation.timeIntervalSince1970 actual object CurrentDateProvider { actual fun todayIsoDate(): String { @@ -9,5 +10,10 @@ actual object CurrentDateProvider { formatter.dateFormat = "yyyy-MM-dd" return formatter.stringFromDate(NSDate()) } -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? { + val formatter = NSDateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.dateFromString(isoDate)?.timeIntervalSince1970?.times(1_000.0)?.toLong() + } +} diff --git a/enginefs b/enginefs new file mode 160000 index 00000000..9f11922a --- /dev/null +++ b/enginefs @@ -0,0 +1 @@ +Subproject commit 9f11922ae092e4a7120a7e1bdabb0698df1eecb8 diff --git a/gradle.properties b/gradle.properties index 01e9d962..a9fb7358 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,11 +1,11 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx6144M -kotlin.native.jvmArgs=-Xmx12288M +kotlin.daemon.jvmargs=-Xmx8192M +kotlin.native.jvmArgs=-Xmx16384M kotlin.mpp.enableCInteropCommonization=true #Gradle -org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m +org.gradle.jvmargs=-Xmx12288M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=2048m org.gradle.configuration-cache=true org.gradle.caching=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea27f7d4..2c931ad8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ android-compileSdkMinor = "0" android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.12.2" -androidx-navigation = "2.9.2" +androidx-navigation3 = "1.1.1" androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-core-splashscreen = "1.0.1" @@ -15,11 +15,13 @@ androidx-work = "2.10.3" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" coil = "3.5.0-beta01" +compottie = "2.1.0" kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" atomicfu = "0.32.1" +kmpalette = "4.0.0-beta02" ktor = "3.4.1" material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" @@ -29,6 +31,8 @@ quickjsKt = "1.0.5" ksoup = "0.2.6" reorderable = "3.0.0" desugarJdkLibs = "2.1.5" +sentry = "8.47.0" +sentryGradle = "6.14.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -40,7 +44,7 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } -androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } @@ -51,13 +55,15 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +compottie = { module = "io.github.alexzhirkevich:compottie", version.ref = "compottie" } 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" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } -ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +kmpalette-core = { module = "com.materialkolor.palette:core", version.ref = "kmpalette" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } @@ -77,10 +83,12 @@ mpv-android-lib = { module = "io.github.abdallahmehiz:mpv-android-lib", version. supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" } supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref = "supabase" } supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", version.ref = "supabase" } +supabase-realtime = { module = "io.github.jan-tennert.supabase:realtime-kt", version.ref = "supabase" } quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } +sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -90,3 +98,4 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +sentry-android-gradle = { id = "io.sentry.android.gradle", version.ref = "sentryGradle" } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index f9897f92..98599f13 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=85 -MARKETING_VERSION=0.2.13 +CURRENT_PROJECT_VERSION=96 +MARKETING_VERSION=0.2.24 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index de67064d..2a2c1e59 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -246,7 +246,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nif [ -z \"$JAVA_HOME\" ] && [ -x \"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home/bin/java\" ]; then\n export JAVA_HOME=\"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home\"\nfi\nif [ -n \"$JAVA_HOME\" ]; then\n export PATH=\"$JAVA_HOME/bin:/opt/homebrew/bin:$PATH\"\nelse\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nif [ -z \"$JAVA_HOME\" ] && [ -x \"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home/bin/java\" ]; then\n export JAVA_HOME=\"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home\"\nfi\nif [ -n \"$JAVA_HOME\" ]; then\n export PATH=\"$JAVA_HOME/bin:/opt/homebrew/bin:$PATH\"\nelse\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\nif [ -z \"$GRADLE_OPTS\" ]; then\n export GRADLE_OPTS=\"-Xmx12288M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=2048m\"\nfi\nif [ -z \"$KOTLIN_DAEMON_JVMARGS\" ]; then\n export KOTLIN_DAEMON_JVMARGS=\"-Xmx8192M\"\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -397,7 +397,7 @@ INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = NO; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -427,7 +427,7 @@ INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = NO; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/Contents.json new file mode 100644 index 00000000..e0c34a2d --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-home.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/nuvio-tab-home.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/nuvio-tab-home.svg new file mode 100644 index 00000000..567f6bf4 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabHome.imageset/nuvio-tab-home.svg @@ -0,0 +1,3 @@ + + + diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/Contents.json new file mode 100644 index 00000000..b0dc699f --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-library.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/nuvio-tab-library.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/nuvio-tab-library.svg new file mode 100644 index 00000000..bc6aeb09 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabLibrary.imageset/nuvio-tab-library.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/Contents.json new file mode 100644 index 00000000..e00731b5 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-profile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/nuvio-tab-profile.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/nuvio-tab-profile.svg new file mode 100644 index 00000000..ff59b1f2 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabProfile.imageset/nuvio-tab-profile.svg @@ -0,0 +1,3 @@ + + + diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/Contents.json new file mode 100644 index 00000000..4bdce622 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "nuvio-tab-search.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/nuvio-tab-search.svg b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/nuvio-tab-search.svg new file mode 100644 index 00000000..b43ab612 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/NuvioTabSearch.imageset/nuvio-tab-search.svg @@ -0,0 +1,3 @@ + + + diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index c8fb0338..07d45432 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,336 +1,51 @@ -import UIKit +import Combine import SwiftUI +import UIKit import ComposeApp -private enum NuvioNativeTabIcon { - static let home = vectorIcon( - viewport: CGSize(width: 24, height: 24), - paths: [ - "M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z", - ] - ) +private let nuvioBackgroundColor = UIColor( + red: 0.051, + green: 0.051, + blue: 0.051, + alpha: 1.0 +) - static let search = drawnIcon { context, rect in - drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) { - context.setStrokeColor(UIColor.black.cgColor) - context.setLineWidth(2) - context.setLineCap(.round) - context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12)) - context.move(to: CGPoint(x: 13.6, y: 13.6)) - context.addLine(to: CGPoint(x: 17, y: 17)) - context.strokePath() - } - } +private enum NuvioComposeHost { + static let registerPlayerBridge: Void = { + NuvioPlayerRegistration.register() + }() - static let library = vectorIcon( - viewport: CGSize(width: 24, height: 24), - paths: [ - "M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z", - "M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z", - "M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z", - ] - ) - - static let profileFallback = vectorIcon( - viewport: CGSize(width: 24, height: 24), - paths: [ - "M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z", - ] - ) - - static func profileAvatar( - name: String?, - avatarColor: UIColor?, - backgroundColor: UIColor?, - avatarImage: UIImage?, - selected: Bool, - accent: UIColor - ) -> UIImage { - guard name != nil || avatarColor != nil || avatarImage != nil else { - return profileFallback - } - - let size = CGSize(width: 28, height: 28) - let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1) - let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15) - let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5) - let initial = name? - .trimmingCharacters(in: .whitespacesAndNewlines) - .prefix(1) - .uppercased() ?? "" - - return UIGraphicsImageRenderer(size: size).image { _ in - let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) - fillColor.setFill() - UIBezierPath(ovalIn: rect).fill() - - if let avatarImage { - UIBezierPath(ovalIn: rect).addClip() - drawAspectFill(image: avatarImage, in: rect) - } else if !initial.isEmpty { - let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold) - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: baseColor, - ] - let textSize = initial.size(withAttributes: attributes) - initial.draw( - at: CGPoint( - x: rect.midX - textSize.width / 2, - y: rect.midY - textSize.height / 2 - ), - withAttributes: attributes - ) - } else { - profileFallback - .withTintColor(baseColor, renderingMode: .alwaysOriginal) - .draw(in: rect.insetBy(dx: 5.5, dy: 5.5)) - } - - borderColor.setStroke() - let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) - borderPath.lineWidth = 1.5 - borderPath.stroke() - }.withRenderingMode(.alwaysOriginal) - } - - private static func drawInViewport( - context: CGContext, - rect: CGRect, - viewport: CGSize, - draw: () -> Void - ) { - let scale = min(rect.width / viewport.width, rect.height / viewport.height) - let x = rect.midX - viewport.width * scale / 2 - let y = rect.midY - viewport.height * scale / 2 - context.saveGState() - context.translateBy(x: x, y: y) - context.scaleBy(x: scale, y: scale) - draw() - context.restoreGState() - } - - private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage { - drawnIcon(size: size) { context, rect in - drawInViewport(context: context, rect: rect, viewport: viewport) { - context.setFillColor(UIColor.black.cgColor) - paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in - context.addPath(path) - context.fillPath(using: .evenOdd) - } - } - } - } - - private static func drawnIcon( - size: CGSize = CGSize(width: 25, height: 25), - draw: @escaping (CGContext, CGRect) -> Void - ) -> UIImage { - UIGraphicsImageRenderer(size: size).image { rendererContext in - draw(rendererContext.cgContext, CGRect(origin: .zero, size: size)) - }.withRenderingMode(.alwaysTemplate) - } - - private static func drawAspectFill(image: UIImage, in rect: CGRect) { - guard image.size.width > 0, image.size.height > 0 else { return } - let scale = max(rect.width / image.size.width, rect.height / image.size.height) - let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) - let drawRect = CGRect( - x: rect.midX - drawSize.width / 2, - y: rect.midY - drawSize.height / 2, - width: drawSize.width, - height: drawSize.height + static func wrap( + _ contentController: UIViewController, + disablesInteractiveContentPopGesture: Bool = false, + onTabBarAvailable: ((UITabBar) -> Void)? = nil + ) -> RootComposeViewController { + _ = registerPlayerBridge + contentController.view.backgroundColor = nuvioBackgroundColor + return RootComposeViewController( + contentController: contentController, + disablesInteractiveContentPopGesture: disablesInteractiveContentPopGesture, + onTabBarAvailable: onTabBarAvailable ) - image.draw(in: drawRect) - } - - private struct SVGPath { - private enum Token { - case command(Character) - case number(CGFloat) - } - - let data: String - - var cgPath: CGPath? { - let tokens = Self.tokens(from: data) - var index = 0 - var command: Character? - var current = CGPoint.zero - var subpathStart = CGPoint.zero - let path = CGMutablePath() - - func hasNumber() -> Bool { - guard index < tokens.count else { return false } - if case .number = tokens[index] { return true } - return false - } - - func readNumber() -> CGFloat? { - guard index < tokens.count else { return nil } - guard case let .number(value) = tokens[index] else { return nil } - index += 1 - return value - } - - func readPoint(relative: Bool) -> CGPoint? { - guard let x = readNumber(), let y = readNumber() else { return nil } - let point = CGPoint(x: x, y: y) - return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point - } - - while index < tokens.count { - if case let .command(value) = tokens[index] { - command = value - index += 1 - } - - guard let activeCommand = command else { return nil } - let relative = activeCommand.isLowercase - - switch activeCommand.uppercased() { - case "M": - guard let point = readPoint(relative: relative) else { return nil } - path.move(to: point) - current = point - subpathStart = point - command = relative ? "l" : "L" - case "L": - while hasNumber() { - guard let point = readPoint(relative: relative) else { return nil } - path.addLine(to: point) - current = point - } - case "H": - while hasNumber() { - guard let x = readNumber() else { return nil } - let point = CGPoint(x: relative ? current.x + x : x, y: current.y) - path.addLine(to: point) - current = point - } - case "V": - while hasNumber() { - guard let y = readNumber() else { return nil } - let point = CGPoint(x: current.x, y: relative ? current.y + y : y) - path.addLine(to: point) - current = point - } - case "C": - while hasNumber() { - guard - let c1 = readPoint(relative: relative), - let c2 = readPoint(relative: relative), - let end = readPoint(relative: relative) - else { return nil } - path.addCurve(to: end, control1: c1, control2: c2) - current = end - } - case "Z": - path.closeSubpath() - current = subpathStart - default: - return nil - } - } - - return path - } - - private static func tokens(from data: String) -> [Token] { - let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } - let range = NSRange(data.startIndex.. String { - defaults.string(forKey: titleKey)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? fallbackTitle - } - - var iconImage: UIImage { - switch self { - case .home: return NuvioNativeTabIcon.home - case .search: return NuvioNativeTabIcon.search - case .library: return NuvioNativeTabIcon.library - case .settings: return NuvioNativeTabIcon.profileFallback - } - } - - init?(tag: Int) { - guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil } - self = tab - } - } - - private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled" - private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" - private static let nativeSelectedTabKey = "NuvioNativeSelectedTab" - private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor" - private static let nativeProfileNameKey = "NuvioNativeProfileName" - private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" - private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" - private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" - private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") - +/// A navigation-neutral container for Compose. The MPV player is nested below the +/// Compose controller, so UIKit's immersive-system-UI queries need to be forwarded +/// to the deepest child that requests them. +final class RootComposeViewController: UIViewController { private let contentController: UIViewController - private let tabBar = UITabBar() - private let profileTabTouchOverlay = UIControl() - private var contentBottomToViewBottom: NSLayoutConstraint? - private var tabBarHeightConstraint: NSLayoutConstraint? - private var userDefaultsObserver: NSObjectProtocol? - private var tabChromeObserver: NSObjectProtocol? - private var profileTouchRestoreTab: NativeTab? - private var profileLongPressHandled = false - private var profileAvatarImageURL: String? - private var profileAvatarImageTask: URLSessionDataTask? - private var profileAvatarImage: UIImage? + private let disablesInteractiveContentPopGesture: Bool + private let onTabBarAvailable: ((UITabBar) -> Void)? - init(contentController: UIViewController) { + init( + contentController: UIViewController, + disablesInteractiveContentPopGesture: Bool, + onTabBarAvailable: ((UITabBar) -> Void)? + ) { self.contentController = contentController + self.disablesInteractiveContentPopGesture = disablesInteractiveContentPopGesture + self.onTabBarAvailable = onTabBarAvailable super.init(nibName: nil, bundle: nil) } @@ -342,51 +57,19 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .black - contentController.view.backgroundColor = .black - UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey) + view.backgroundColor = nuvioBackgroundColor + contentController.view.backgroundColor = nuvioBackgroundColor addChild(contentController) view.addSubview(contentController.view) contentController.view.translatesAutoresizingMaskIntoConstraints = false - let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) - self.contentBottomToViewBottom = bottomToViewBottom NSLayoutConstraint.activate([ contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentController.view.topAnchor.constraint(equalTo: view.topAnchor), - bottomToViewBottom, + contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) contentController.didMove(toParent: self) - - configureNativeTabBar() - installNativeTabObservers() - syncNativeTabChrome(animated: false) - } - - deinit { - if let userDefaultsObserver { - NotificationCenter.default.removeObserver(userDefaultsObserver) - } - if let tabChromeObserver { - NotificationCenter.default.removeObserver(tabChromeObserver) - } - profileAvatarImageTask?.cancel() - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - updateTabBarHeight() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - updateProfileTabTouchOverlayFrame() - } - - func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - guard let tab = NativeTab(tag: item.tag) else { return } - selectNativeTab(tab) } override var childForHomeIndicatorAutoHidden: UIViewController? { @@ -417,12 +100,37 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { .fade } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setInteractiveContentPopGestureEnabled(false) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + setInteractiveContentPopGestureEnabled(false) + if let tabBar = tabBarController?.tabBar { + onTabBarAvailable?(tabBar) + } + } + + override func viewWillDisappear(_ animated: Bool) { + setInteractiveContentPopGestureEnabled(true) + super.viewWillDisappear(animated) + } + func refreshImmersiveSystemUI() { setNeedsUpdateOfHomeIndicatorAutoHidden() setNeedsUpdateOfScreenEdgesDeferringSystemGestures() setNeedsStatusBarAppearanceUpdate() } + private func setInteractiveContentPopGestureEnabled(_ enabled: Bool) { + guard disablesInteractiveContentPopGesture else { return } + if #available(iOS 26.0, *) { + navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = enabled + } + } + private func immersiveController(in controller: UIViewController?) -> UIViewController? { guard let controller else { return nil } @@ -444,318 +152,1123 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { return nil } +} - private var nativeTabsSupported: Bool { - UIDevice.current.userInterfaceIdiom == .phone && - ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 +// MARK: - UIKit fallback + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + NuvioComposeHost.wrap(MainViewControllerKt.MainViewController()) } - private var shouldShowNativeTabBar: Bool { - nativeTabsSupported && - UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) && - UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey) + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +// MARK: - Native iOS navigation + +@available(iOS 16.0, *) +struct RouteWrapper: Hashable, Identifiable { + let id = UUID() + let route: AppRoute + + static func == (lhs: RouteWrapper, rhs: RouteWrapper) -> Bool { + lhs.id == rhs.id } - private func configureNativeTabBar() { - tabBar.delegate = self - tabBar.translatesAutoresizingMaskIntoConstraints = false - tabBar.items = NativeTab.allCases.map { tab in - let item = UITabBarItem( - title: tab.localizedTitle(), - image: tab.iconImage, - selectedImage: tab.iconImage - ) - item.tag = tab.tag - return item + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +@available(iOS 16.0, *) +@MainActor +final class TabNavigationCoordinator: ObservableObject { + @Published var path: [RouteWrapper] = [] + + func push(_ route: AppRoute, launchSingleTop: Bool) { + if launchSingleTop, + path.last?.route.navigationIdentity == route.navigationIdentity { + AppKt.disposeRoute(route: route) + return } - tabBar.selectedItem = tabBar.items?.first - applyNativeTabBarAppearance() - tabBar.alpha = 0 - tabBar.isHidden = true - - view.addSubview(tabBar) - configureProfileTabTouchOverlay() - let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) - tabBarHeightConstraint = heightConstraint - NSLayoutConstraint.activate([ - tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), - heightConstraint, - ]) + path.append(RouteWrapper(route: route)) } - private func installNativeTabObservers() { - userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, + func pop() { + guard !path.isEmpty else { return } + var updatedPath = path + updatedPath.removeLast() + setPath(updatedPath) + } + + func replace(_ route: AppRoute) { + var updatedPath = path + if updatedPath.isEmpty { + updatedPath.append(RouteWrapper(route: route)) + } else { + updatedPath[updatedPath.index(before: updatedPath.endIndex)] = RouteWrapper(route: route) + } + setPath(updatedPath) + } + + func popToRoot() { + setPath([]) + } + + /// Used by NavigationStack's path binding so interactive swipe-back and + /// programmatic mutations share the same Kotlin route-disposal behavior. + func setPath(_ newPath: [RouteWrapper]) { + let retainedIDs = Set(newPath.map(\.id)) + let removedRoutes = path + .filter { !retainedIDs.contains($0.id) } + .map(\.route) + + path = newPath + removedRoutes.forEach { AppKt.disposeRoute(route: $0) } + } +} + +@available(iOS 16.0, *) +enum NuvioAppTab: String, CaseIterable, Hashable { + case home = "Home" + case search = "Search" + case library = "Library" + case settings = "Settings" + + var fallbackTitle: String { + String(localized: String.LocalizationValue(rawValue)) + } + + static func from(kotlinName: String?) -> NuvioAppTab? { + switch kotlinName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "home": return .home + case "search": return .search + case "library": return .library + case "settings", "profile": return .settings + default: return nil + } + } + + var iconAssetName: String { + switch self { + case .home: return "NuvioTabHome" + case .search: return "NuvioTabSearch" + case .library: return "NuvioTabLibrary" + case .settings: return "NuvioTabProfile" + } + } + + var fallbackSystemImage: String { + switch self { + case .home: return "house.fill" + case .search: return "magnifyingglass" + case .library: return "rectangle.stack.fill" + case .settings: return "person.crop.circle.fill" + } + } +} + +private enum NuvioNativeTabIcon { + private static let legacyStaticIconSize = CGSize(width: 25, height: 25) + + static func image(for tab: NuvioAppTab) -> UIImage { + if let asset = UIImage(named: tab.iconAssetName) { + return UIGraphicsImageRenderer(size: legacyStaticIconSize).image { _ in + asset + .withRenderingMode(.alwaysOriginal) + .draw(in: CGRect(origin: .zero, size: legacyStaticIconSize)) + }.withRenderingMode(.alwaysTemplate) + } + + return (UIImage(systemName: tab.fallbackSystemImage) ?? UIImage()) + .withRenderingMode(.alwaysTemplate) + } + + static func profileAvatar( + name: String?, + avatarColor: UIColor?, + backgroundColor: UIColor?, + avatarImage: UIImage?, + selected: Bool, + accent: UIColor + ) -> UIImage { + guard name != nil || avatarColor != nil || avatarImage != nil else { + return image(for: .settings) + } + + let size = CGSize(width: 28, height: 28) + let baseColor = avatarColor + ?? UIColor(red: 30 / 255, green: 136 / 255, blue: 229 / 255, alpha: 1) + let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15) + let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5) + let initial = name? + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix(1) + .uppercased() ?? "" + + return UIGraphicsImageRenderer(size: size).image { _ in + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) + fillColor.setFill() + UIBezierPath(ovalIn: rect).fill() + + if let avatarImage { + UIBezierPath(ovalIn: rect).addClip() + drawAspectFill(image: avatarImage, in: rect) + } else if !initial.isEmpty { + let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: baseColor, + ] + let textSize = initial.size(withAttributes: attributes) + initial.draw( + at: CGPoint( + x: rect.midX - textSize.width / 2, + y: rect.midY - textSize.height / 2 + ), + withAttributes: attributes + ) + } else { + image(for: .settings) + .withTintColor(baseColor, renderingMode: .alwaysOriginal) + .draw(in: rect.insetBy(dx: 5.5, dy: 5.5)) + } + + borderColor.setStroke() + let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) + borderPath.lineWidth = 1.5 + borderPath.stroke() + }.withRenderingMode(.alwaysOriginal) + } + + private static func drawAspectFill(image: UIImage, in rect: CGRect) { + guard image.size.width > 0, image.size.height > 0 else { return } + let scale = max(rect.width / image.size.width, rect.height / image.size.height) + let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + image.draw( + in: CGRect( + x: rect.midX - drawSize.width / 2, + y: rect.midY - drawSize.height / 2, + width: drawSize.width, + height: drawSize.height + ) + ) + } +} + +@available(iOS 16.0, *) +final class NativeTabIconStore: ObservableObject { + private static let chromeDidChange = Notification.Name("NuvioNativeTabChromeDidChange") + private static let accentKey = "NuvioNativeTabAccentColor" + private static let profileNameKey = "NuvioNativeProfileName" + private static let profileColorKey = "NuvioNativeProfileAvatarColor" + private static let profileURLKey = "NuvioNativeProfileAvatarURL" + private static let profileBackgroundKey = "NuvioNativeProfileAvatarBackgroundColor" + + @Published private(set) var revision = 0 + @Published private(set) var accentColor = UIColor( + red: 0.96, + green: 0.96, + blue: 0.96, + alpha: 1 + ) + + private var observer: NSObjectProtocol? + private var profileAvatarURL: String? + private var profileAvatarImage: UIImage? + private var profileAvatarTask: URLSessionDataTask? + + init() { + UITabBar.appearance().unselectedItemTintColor = UIColor( + red: 150 / 255, + green: 156 / 255, + blue: 163 / 255, + alpha: 1 + ) + observer = NotificationCenter.default.addObserver( + forName: Self.chromeDidChange, object: nil, queue: .main ) { [weak self] _ in - self?.syncNativeTabChrome(animated: true) + self?.reload() } + reload() + } - tabChromeObserver = NotificationCenter.default.addObserver( - forName: Self.nativeTabChromeDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.syncNativeTabChrome(animated: true) + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) } + profileAvatarTask?.cancel() } - private var tabBarHeight: CGFloat { - 49 + view.safeAreaInsets.bottom - } - - private func updateTabBarHeight() { - tabBarHeightConstraint?.constant = tabBarHeight - updateProfileTabTouchOverlayFrame() - } - - private func syncNativeTabChrome(animated: Bool) { - updateTabBarHeight() - applyNativeTabBarAppearance() - syncSelectedNativeTab() - - let visible = shouldShowNativeTabBar - contentBottomToViewBottom?.isActive = true - if visible { - tabBar.isHidden = false - profileTabTouchOverlay.isHidden = false - } - - let changes = { - self.tabBar.alpha = visible ? 1 : 0 - self.profileTabTouchOverlay.alpha = visible ? 1 : 0 - self.view.layoutIfNeeded() - } - - let completion: (Bool) -> Void = { _ in - self.tabBar.isHidden = !visible - self.profileTabTouchOverlay.isHidden = !visible - } - - if animated && view.window != nil { - UIView.animate( - withDuration: 0.22, - delay: 0, - options: [.beginFromCurrentState, .curveEaseInOut], - animations: changes, - completion: completion - ) - } else { - changes() - completion(true) - } - } - - private func syncSelectedNativeTab() { - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == currentNativeSelectedTab.tag }) - } - - @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { - guard recognizer.state == .began else { return } - - profileLongPressHandled = true - DispatchQueue.main.async { - NativeTabBridgeKt.nativeProfileTabLongPress() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in - self?.restoreProfileTabTouchIfNeeded() - } - } - - @objc private func handleNativeProfileTabTouchDown() { - profileTouchRestoreTab = currentNativeSelectedTab - profileLongPressHandled = false - } - - @objc private func handleNativeProfileTabTap() { - if profileLongPressHandled { - profileLongPressHandled = false - restoreProfileTabTouchIfNeeded() - return - } - profileTouchRestoreTab = nil - selectNativeTab(.settings) - } - - @objc private func handleNativeProfileTabTouchCancel() { - profileLongPressHandled = false - restoreProfileTabTouchIfNeeded() - } - - private var currentNativeSelectedTab: NativeTab { - let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue - return NativeTab(rawValue: rawValue) ?? .home - } - - private func selectNativeTab(_ tab: NativeTab) { - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) - UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) - NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) - } - - private func configureProfileTabTouchOverlay() { - profileTabTouchOverlay.backgroundColor = .clear - profileTabTouchOverlay.isOpaque = false - profileTabTouchOverlay.isExclusiveTouch = true - profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() - profileTabTouchOverlay.accessibilityTraits = .button - profileTabTouchOverlay.addTarget( - self, - action: #selector(handleNativeProfileTabTouchDown), - for: .touchDown - ) - profileTabTouchOverlay.addTarget( - self, - action: #selector(handleNativeProfileTabTap), - for: .touchUpInside - ) - profileTabTouchOverlay.addTarget( - self, - action: #selector(handleNativeProfileTabTouchCancel), - for: [.touchCancel, .touchUpOutside] - ) - - let longPressRecognizer = UILongPressGestureRecognizer( - target: self, - action: #selector(handleNativeProfileTabLongPress(_:)) - ) - longPressRecognizer.minimumPressDuration = 0.45 - longPressRecognizer.cancelsTouchesInView = true - profileTabTouchOverlay.addGestureRecognizer(longPressRecognizer) - - profileTabTouchOverlay.alpha = 0 - profileTabTouchOverlay.isHidden = true - view.addSubview(profileTabTouchOverlay) - updateProfileTabTouchOverlayFrame() - } - - private func restoreProfileTabTouchIfNeeded() { - let tab = profileTouchRestoreTab ?? currentNativeSelectedTab - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) - profileTouchRestoreTab = nil - } - - private func updateProfileTabTouchOverlayFrame() { - let tabCount = CGFloat(NativeTab.allCases.count) - guard tabCount > 0, tabBar.bounds.width > 0 else { - profileTabTouchOverlay.frame = .zero - return - } - - let itemWidth = tabBar.bounds.width / tabCount - let settingsIndex = CGFloat(NativeTab.settings.tag) - let visualIndex: CGFloat - if tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft { - visualIndex = tabCount - 1 - settingsIndex - } else { - visualIndex = settingsIndex - } - let overlayFrameInTabBar = CGRect( - x: itemWidth * visualIndex, - y: 0, - width: itemWidth, - height: tabBar.bounds.height - ) - profileTabTouchOverlay.frame = tabBar.convert(overlayFrameInTabBar, to: view) - profileTabTouchOverlay.alpha = tabBar.alpha - view.bringSubviewToFront(profileTabTouchOverlay) - } - - private func applyNativeTabBarAppearance() { - let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ?? - UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) - let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1) - - updateNativeTabTitles() - refreshProfileAvatarImageIfNeeded() - updateNativeTabImages(accent: accent) - - tabBar.tintColor = accent - tabBar.unselectedItemTintColor = unselected - - let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance - appearance.stackedLayoutAppearance.normal.iconColor = unselected - appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] - appearance.stackedLayoutAppearance.selected.iconColor = accent - appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] - appearance.inlineLayoutAppearance.normal.iconColor = unselected - appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] - appearance.inlineLayoutAppearance.selected.iconColor = accent - appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] - appearance.compactInlineLayoutAppearance.normal.iconColor = unselected - appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] - appearance.compactInlineLayoutAppearance.selected.iconColor = accent - appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] - tabBar.standardAppearance = appearance - tabBar.scrollEdgeAppearance = appearance - } - - private func updateNativeTabImages(accent: UIColor) { - tabBar.items?.forEach { item in - guard let tab = NativeTab(tag: item.tag) else { return } - item.image = nativeTabImage(for: tab, selected: false, accent: accent) - item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent) - } - } - - private func updateNativeTabTitles() { - tabBar.items?.forEach { item in - guard let tab = NativeTab(tag: item.tag) else { return } - item.title = tab.localizedTitle() - } - profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() - } - - private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { + func image(for tab: NuvioAppTab, selected: Bool) -> UIImage { guard tab == .settings else { - return tab.iconImage + return NuvioNativeTabIcon.image(for: tab) } let defaults = UserDefaults.standard return NuvioNativeTabIcon.profileAvatar( - name: defaults.string(forKey: Self.nativeProfileNameKey), - avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)), - backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)), + name: defaults.string(forKey: Self.profileNameKey), + avatarColor: UIColor(hexString: defaults.string(forKey: Self.profileColorKey)), + backgroundColor: UIColor(hexString: defaults.string(forKey: Self.profileBackgroundKey)), avatarImage: profileAvatarImage, selected: selected, - accent: accent + accent: accentColor ) } - private func refreshProfileAvatarImageIfNeeded() { - let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey) - guard urlString != profileAvatarImageURL else { return } + private func reload() { + let defaults = UserDefaults.standard + accentColor = UIColor(hexString: defaults.string(forKey: Self.accentKey)) + ?? UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) - profileAvatarImageTask?.cancel() - profileAvatarImageTask = nil - profileAvatarImageURL = urlString + let nextURL = defaults.string(forKey: Self.profileURLKey) + guard nextURL != profileAvatarURL else { + revision &+= 1 + return + } + + profileAvatarTask?.cancel() + profileAvatarTask = nil + profileAvatarURL = nextURL profileAvatarImage = nil + revision &+= 1 - guard let urlString, let url = URL(string: urlString) else { return } - - profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in - guard - let self, - let data, - let image = UIImage(data: data) - else { return } - + guard let nextURL, let url = URL(string: nextURL) else { return } + profileAvatarTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let data, let image = UIImage(data: data) else { return } DispatchQueue.main.async { - guard self.profileAvatarImageURL == urlString else { return } + guard let self, self.profileAvatarURL == nextURL else { return } self.profileAvatarImage = image - self.applyNativeTabBarAppearance() + self.revision &+= 1 } } - profileAvatarImageTask?.resume() + profileAvatarTask?.resume() + } +} + +@available(iOS 16.0, *) +@MainActor +final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognizerDelegate { + var onLongPress: (() -> Void)? + private(set) var isHandlingLongPress = false + private(set) var suppressesProfileSelection = false + private weak var tabBar: UITabBar? + private var resetWorkItem: DispatchWorkItem? + private lazy var recognizer: UILongPressGestureRecognizer = { + let recognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleLongPress(_:)) + ) + recognizer.minimumPressDuration = 0.45 + recognizer.cancelsTouchesInView = true + recognizer.delegate = self + return recognizer + }() + + func attach(to tabBar: UITabBar) { + guard self.tabBar !== tabBar else { return } + self.tabBar?.removeGestureRecognizer(recognizer) + tabBar.addGestureRecognizer(recognizer) + self.tabBar = tabBar + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard gestureRecognizer === recognizer, + let tabBar, + let profileItem = tabBar.items?.last else { + return false + } + guard #available(iOS 17.0, *), + let profileFrame = profileItem.frame(in: tabBar) else { return false } + return profileFrame.contains(touch.location(in: tabBar)) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === recognizer || otherGestureRecognizer === recognizer + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + switch recognizer.state { + case .began: + resetWorkItem?.cancel() + isHandlingLongPress = true + suppressesProfileSelection = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + onLongPress?() + case .ended, .cancelled, .failed: + let workItem = DispatchWorkItem { [weak self] in + self?.isHandlingLongPress = false + self?.suppressesProfileSelection = false + } + resetWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: workItem) + default: + break + } + } +} + +@available(iOS 16.0, *) +@MainActor +final class AppNavigationCoordinator: ObservableObject { + @Published var selectedTab: NuvioAppTab = .home + @Published private(set) var isAppReady = false + @Published private var localizedTabTitles: [NuvioAppTab: String] = [:] + @Published var isProfileSwitcherPresented = false + + let homeCoordinator = TabNavigationCoordinator() + let searchCoordinator = TabNavigationCoordinator() + let libraryCoordinator = TabNavigationCoordinator() + let settingsCoordinator = TabNavigationCoordinator() + let profileSwitcherController = NativeProfileSwitcherController() + let profileTabInteraction = NativeProfileTabInteractionCoordinator() + + init() { + profileTabInteraction.onLongPress = { [weak self] in + guard let self, self.isAppReady else { return } + self.isProfileSwitcherPresented = true + } + } + + private var allCoordinators: [TabNavigationCoordinator] { + [homeCoordinator, searchCoordinator, libraryCoordinator, settingsCoordinator] + } + + func coordinator(for tab: NuvioAppTab) -> TabNavigationCoordinator { + switch tab { + case .home: return homeCoordinator + case .search: return searchCoordinator + case .library: return libraryCoordinator + case .settings: return settingsCoordinator + } + } + + func activateTab(named tabName: String) { + guard let tab = NuvioAppTab.from(kotlinName: tabName) else { return } + if tab == .home || isAppReady { + selectedTab = tab + } + } + + func title(for tab: NuvioAppTab) -> String { + localizedTabTitles[tab] ?? tab.fallbackTitle + } + + func updateTabTitles(home: String, search: String, library: String, profile: String) { + localizedTabTitles = [ + .home: home, + .search: search, + .library: library, + .settings: profile, + ] + } + + func updateAppReady(_ ready: Bool) { + isAppReady = ready + if !ready { + isProfileSwitcherPresented = false + selectedTab = .home + allCoordinators.forEach { $0.popToRoot() } + } + } + + func openProfileManagement() { + isProfileSwitcherPresented = false + updateAppReady(false) + profileSwitcherController.requestManageProfiles() + } + + func tab(for target: TabNavigationCoordinator) -> NuvioAppTab? { + NuvioAppTab.allCases.first { coordinator(for: $0) === target } + } + + func push( + _ route: AppRoute, + from origin: TabNavigationCoordinator, + launchSingleTop: Bool + ) { + guard isAppReady else { + AppKt.disposeRoute(route: route) + return + } + let targetTab = NuvioAppTab.from(kotlinName: route.preferredTabName) + ?? tab(for: origin) + ?? selectedTab + let target = coordinator(for: targetTab) + selectedTab = targetTab + target.push(route, launchSingleTop: launchSingleTop) + } + + func replace(_ route: AppRoute, in target: TabNavigationCoordinator) { + guard isAppReady else { + AppKt.disposeRoute(route: route) + return + } + if let targetTab = tab(for: target) { + selectedTab = targetTab + } + target.replace(route) + } +} + +@available(iOS 16.0, *) +struct NativeNavComposeView: UIViewControllerRepresentable { + let tab: NuvioAppTab + let usesNativeTabBar: Bool + let usesTabletFloatingTabBar: Bool + let coordinator: TabNavigationCoordinator + let appCoordinator: AppNavigationCoordinator + + func makeUIViewController(context: Context) -> UIViewController { + let controller = MainViewControllerKt.MainViewController( + initialTabName: tab.rawValue, + useNativeTabBar: usesNativeTabBar, + useTabletFloatingTabBar: usesTabletFloatingTabBar, + onNavigate: { route, launchSingleTop in + appCoordinator.push( + route, + from: coordinator, + launchSingleTop: launchSingleTop.boolValue + ) + }, + onGoBack: { + coordinator.pop() + }, + onReplace: { route in + appCoordinator.replace(route, in: coordinator) + }, + onActivate: { tabName in + appCoordinator.activateTab(named: tabName) + }, + onAppReady: { ready in + appCoordinator.updateAppReady(ready.boolValue) + }, + onTabTitles: { home, search, library, profile in + appCoordinator.updateTabTitles( + home: home, + search: search, + library: library, + profile: profile + ) + }, + nativeProfileSwitcherController: appCoordinator.profileSwitcherController + ) + return NuvioComposeHost.wrap( + controller, + onTabBarAvailable: { tabBar in + appCoordinator.profileTabInteraction.attach(to: tabBar) + } + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +@available(iOS 16.0, *) +struct DetailComposeView: UIViewControllerRepresentable { + let route: AppRoute + let coordinator: TabNavigationCoordinator + let appCoordinator: AppNavigationCoordinator + + func makeUIViewController(context: Context) -> UIViewController { + let controller = MainViewControllerKt.ScreenViewController( + route: route, + onNavigate: { newRoute, launchSingleTop in + appCoordinator.push( + newRoute, + from: coordinator, + launchSingleTop: launchSingleTop.boolValue + ) + }, + onGoBack: { + coordinator.pop() + }, + onReplace: { newRoute in + appCoordinator.replace(newRoute, in: coordinator) + }, + onActivate: { tabName in + appCoordinator.activateTab(named: tabName) + } + ) + return NuvioComposeHost.wrap( + controller, + disablesInteractiveContentPopGesture: true + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +@available(iOS 16.0, *) +struct TabContentView: View { + let tab: NuvioAppTab + let usesNativeTabBar: Bool + let usesTabletFloatingTabBar: Bool + @ObservedObject var coordinator: TabNavigationCoordinator + @ObservedObject var appCoordinator: AppNavigationCoordinator + + var body: some View { + NavigationStack( + path: Binding( + get: { coordinator.path }, + set: { coordinator.setPath($0) } + ) + ) { + NativeNavComposeView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all) + .navigationTitle(appCoordinator.title(for: tab)) + .navigationBarHidden(true) + .navigationDestination(for: RouteWrapper.self) { wrapper in + if appCoordinator.selectedTab == tab { + DetailDestinationView( + wrapper: wrapper, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + // A native replace keeps the same NavigationStack depth. + // Keying by the wrapper forces SwiftUI to replace the + // embedded Compose controller instead of reusing the old + // screen with the new route's toolbar preferences. + .id(wrapper.id) + } else { + Color.clear + } + } + } + // Tab-bar visibility is a preference emitted by the active navigation + // stack. Applying it here keeps the authentication/profile gate truly + // full-screen on iOS 26, where a modifier on TabView itself is ignored. + .toolbar( + usesNativeTabBar && appCoordinator.isAppReady && coordinator.path.isEmpty + ? Visibility.visible + : Visibility.hidden, + for: .tabBar + ) + } +} + +@available(iOS 16.0, *) +private struct NativeToolbarReadabilityFade: View { + var body: some View { + Rectangle() + .fill( + LinearGradient( + stops: [ + .init(color: Color(uiColor: nuvioBackgroundColor), location: 0), + .init(color: Color(uiColor: nuvioBackgroundColor).opacity(0.78), location: 0.55), + .init(color: Color(uiColor: nuvioBackgroundColor).opacity(0), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: 120) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +@available(iOS 16.0, *) +private struct DetailDestinationView: View { + let wrapper: RouteWrapper + @ObservedObject var coordinator: TabNavigationCoordinator + @ObservedObject var appCoordinator: AppNavigationCoordinator + + private var usesComposeNavigationHeader: Bool { + wrapper.route is DetailRoute || wrapper.route is StreamRoute + } + + private var showsReadabilityFade: Bool { + !wrapper.route.hidesNavigationBar && !usesComposeNavigationHeader + } + + private var content: some View { + ZStack(alignment: .top) { + DetailComposeView( + route: wrapper.route, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all) + + if showsReadabilityFade { + NativeToolbarReadabilityFade() + } + } + .navigationTitle(wrapper.route.title ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbarRole(usesComposeNavigationHeader ? .editor : .automatic) + .toolbar { + if usesComposeNavigationHeader { + ToolbarItem(placement: .principal) { + Color.clear.frame(width: 1, height: 1) + } + } + } + .toolbar(.hidden, for: .tabBar) + .toolbar( + wrapper.route.hidesNavigationBar ? Visibility.hidden : Visibility.visible, + for: .navigationBar + ) + } + + @ViewBuilder + var body: some View { + if #available(iOS 26.0, *), !usesComposeNavigationHeader { + content.navigationSubtitle(wrapper.route.subtitle ?? "") + } else { + content + } + } +} + +@available(iOS 26.0, *) +private struct NativeProfileItem: Identifiable, Equatable { + let id: Int32 + let name: String + let avatarColor: UIColor + let avatarBackgroundColor: UIColor + let avatarURL: URL? + let pinEnabled: Bool + let active: Bool + + init(_ option: NativeProfileOption) { + id = option.profileIndex + name = option.name + avatarColor = UIColor(hexString: option.avatarColorHex) + ?? UIColor(red: 30 / 255, green: 136 / 255, blue: 229 / 255, alpha: 1) + avatarBackgroundColor = UIColor(hexString: option.avatarBackgroundColorHex) + ?? avatarColor.withAlphaComponent(0.16) + avatarURL = option.avatarImageUrl.flatMap(URL.init(string:)) + pinEnabled = option.pinEnabled + active = option.active + } +} + +@available(iOS 26.0, *) +@MainActor +private final class NativeProfileSwitcherViewModel: ObservableObject { + @Published private(set) var profiles: [NativeProfileItem] = [] + @Published private(set) var isLoaded = false + @Published private(set) var canAddProfile = false + @Published var lockedProfile: NativeProfileItem? + @Published var pin = "" + @Published var errorMessage: String? + @Published private(set) var isSubmitting = false + + private let controller: NativeProfileSwitcherController + + init(controller: NativeProfileSwitcherController) { + self.controller = controller + apply(controller.currentState()) + } + + func startObserving() { + controller.observeState { [weak self] state in + self?.apply(state) + } + } + + func stopObserving() { + controller.stopObserving() + } + + func choose(_ profile: NativeProfileItem, onComplete: @escaping () -> Void) { + if profile.pinEnabled { + lockedProfile = profile + pin = "" + errorMessage = nil + } else { + submit(profile, pin: nil, onComplete: onComplete) + } + } + + func updatePin(_ value: String) { + pin = String(value.filter(\.isNumber).prefix(4)) + errorMessage = nil + } + + func unlock(onComplete: @escaping () -> Void) { + guard let lockedProfile, pin.count == 4 else { return } + submit(lockedProfile, pin: pin, onComplete: onComplete) + } + + func cancelUnlock() { + lockedProfile = nil + pin = "" + errorMessage = nil + } + + private func apply(_ state: NativeProfileSwitcherState) { + profiles = state.profiles.map(NativeProfileItem.init) + isLoaded = state.isLoaded + canAddProfile = state.canAddProfile + } + + private func submit( + _ profile: NativeProfileItem, + pin: String?, + onComplete: @escaping () -> Void + ) { + guard !isSubmitting else { return } + isSubmitting = true + errorMessage = nil + controller.chooseProfile(profileIndex: profile.id, pin: pin) { [weak self] result in + Task { @MainActor [weak self] in + guard let self else { return } + self.isSubmitting = false + if result.unlocked { + onComplete() + } else if let message = result.message, !message.isEmpty { + self.errorMessage = message + } else if result.retryAfterSeconds > 0 { + self.errorMessage = "Try again in \(result.retryAfterSeconds) seconds." + } else { + self.errorMessage = "Incorrect PIN." + } + } + } + } +} + +@available(iOS 26.0, *) +private struct NativeProfileAvatarView: View { + let profile: NativeProfileItem + + var body: some View { + ZStack { + Circle().fill(Color(uiColor: profile.avatarBackgroundColor)) + if let avatarURL = profile.avatarURL { + AsyncImage(url: avatarURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + initial + } + } + } else { + initial + } + } + .clipShape(Circle()) + .overlay { + Circle().stroke( + Color(uiColor: profile.avatarColor).opacity(profile.active ? 1 : 0.45), + lineWidth: profile.active ? 2.5 : 1.5 + ) + } + } + + private var initial: some View { + Text(profile.name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(1).uppercased()) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundStyle(Color(uiColor: profile.avatarColor)) + } +} + +@available(iOS 26.0, *) +private struct NativeProfileSwitcherView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var model: NativeProfileSwitcherViewModel + let onManageProfiles: () -> Void + + init( + controller: NativeProfileSwitcherController, + onManageProfiles: @escaping () -> Void + ) { + _model = StateObject( + wrappedValue: NativeProfileSwitcherViewModel(controller: controller) + ) + self.onManageProfiles = onManageProfiles + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Switch Profile") + .font(.headline) + + if model.isLoaded { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(model.profiles) { profile in + Button { + model.choose(profile, onComplete: dismiss.callAsFunction) + } label: { + VStack(spacing: 6) { + NativeProfileAvatarView(profile: profile) + .frame(width: 52, height: 52) + .overlay(alignment: .bottomTrailing) { + if profile.pinEnabled { + Image(systemName: "lock.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(.black.opacity(0.72), in: Circle()) + } + } + + Text(profile.name) + .font(.caption) + .lineLimit(1) + .frame(width: 64) + } + } + .buttonStyle(.plain) + .disabled(model.isSubmitting) + } + + if model.canAddProfile { + Button { + onManageProfiles() + } label: { + VStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 19, weight: .semibold)) + .frame(width: 52, height: 52) + .background(.secondary.opacity(0.12), in: Circle()) + Text("Add") + .font(.caption) + .frame(width: 64) + } + } + .buttonStyle(.plain) + } + } + } + } else { + ProgressView() + .frame(maxWidth: .infinity) + } + + if let lockedProfile = model.lockedProfile { + Divider() + Text("Enter PIN for \(lockedProfile.name)") + .font(.subheadline.weight(.semibold)) + + SecureField("4-digit PIN", text: Binding( + get: { model.pin }, + set: model.updatePin + )) + .keyboardType(.numberPad) + .textContentType(.password) + .multilineTextAlignment(.center) + .font(.title3.monospacedDigit()) + .padding(.horizontal, 12) + .frame(height: 42) + .background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + + if let errorMessage = model.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Cancel", action: model.cancelUnlock) + Spacer() + Button("Unlock") { + model.unlock(onComplete: dismiss.callAsFunction) + } + .disabled(model.pin.count != 4 || model.isSubmitting) + } + } + } + .padding(18) + .frame(minWidth: 250, idealWidth: 330, maxWidth: 360) + .presentationCompactAdaptation(.popover) + .presentationSizing(.fitted) + .onAppear(perform: model.startObserving) + .onDisappear(perform: model.stopObserving) + } +} + +@available(iOS 16.0, *) +struct NativeNavContentView: View { + @StateObject private var appCoordinator = AppNavigationCoordinator() + @StateObject private var iconStore = NativeTabIconStore() + + private var usesNativeTabBar: Bool { + guard UIDevice.current.userInterfaceIdiom == .phone else { + return false + } + if #available(iOS 26.0, *) { + return true + } + return false + } + + private var usesTabletFloatingTabBar: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + + private var tabSelection: Binding { + Binding( + get: { appCoordinator.selectedTab }, + set: { newTab in + if newTab == .settings && + ( + appCoordinator.profileTabInteraction.suppressesProfileSelection || + appCoordinator.isProfileSwitcherPresented + ) { + return + } + if newTab == appCoordinator.selectedTab { + NativeTabBridgeKt.nativeTabSelect(tabName: newTab.rawValue) + return + } + if appCoordinator.isAppReady || newTab == .home { + appCoordinator.selectedTab = newTab + } + } + ) + } + + private var legacyTabs: some View { + TabView(selection: tabSelection) { + ForEach(NuvioAppTab.allCases, id: \.self) { tab in + TabContentView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: appCoordinator.coordinator(for: tab), + appCoordinator: appCoordinator + ) + .tabItem { + Label { + Text(appCoordinator.title(for: tab)) + } icon: { + Image( + uiImage: iconStore.image( + for: tab, + selected: appCoordinator.selectedTab == tab + ) + ) + .id( + "\(tab.rawValue)-\(iconStore.revision)-" + + "\(appCoordinator.selectedTab == tab)" + ) + } + } + .tag(tab) + } + } + .tint(Color(uiColor: iconStore.accentColor)) + } + + @available(iOS 26.0, *) + private var nativeTabs: some View { + TabView(selection: tabSelection) { + ForEach(NuvioAppTab.allCases, id: \.self) { tab in + if tab == .settings { + Tab(value: tab) { + TabContentView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: appCoordinator.coordinator(for: tab), + appCoordinator: appCoordinator + ) + } label: { + Label { + Text(appCoordinator.title(for: tab)) + } icon: { + Image( + uiImage: iconStore.image( + for: tab, + selected: appCoordinator.selectedTab == tab + ) + ) + .id( + "\(tab.rawValue)-\(iconStore.revision)-" + + "\(appCoordinator.selectedTab == tab)" + ) + } + } + .popover( + isPresented: $appCoordinator.isProfileSwitcherPresented, + attachmentAnchor: .rect(.bounds), + arrowEdge: .bottom + ) { + NativeProfileSwitcherView( + controller: appCoordinator.profileSwitcherController, + onManageProfiles: appCoordinator.openProfileManagement + ) + } + } else { + Tab(value: tab) { + TabContentView( + tab: tab, + usesNativeTabBar: usesNativeTabBar, + usesTabletFloatingTabBar: usesTabletFloatingTabBar, + coordinator: appCoordinator.coordinator(for: tab), + appCoordinator: appCoordinator + ) + } label: { + Label { + Text(appCoordinator.title(for: tab)) + } icon: { + Image( + uiImage: iconStore.image( + for: tab, + selected: appCoordinator.selectedTab == tab + ) + ) + .id( + "\(tab.rawValue)-\(iconStore.revision)-" + + "\(appCoordinator.selectedTab == tab)" + ) + } + } + } + } + } + .tint(Color(uiColor: iconStore.accentColor)) + .tabBarMinimizeBehavior(.automatic) + } + + @ViewBuilder + var body: some View { + if #available(iOS 26.0, *), usesNativeTabBar { + nativeTabs + } else { + legacyTabs + } + } +} + +struct ContentView: View { + var body: some View { + if #available(iOS 16.0, *) { + NativeNavContentView() + } else { + ComposeView() + .ignoresSafeArea(.all) + } } } private extension UIColor { convenience init?(hexString: String?) { - guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } if value.hasPrefix("#") { @@ -772,29 +1285,3 @@ private extension UIColor { ) } } - -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } -} - -struct ComposeView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIViewController { - // Register MPV player bridge before Compose initializes - NuvioPlayerRegistration.register() - - let controller = MainViewControllerKt.MainViewController() - controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0) - return RootComposeViewController(contentController: controller) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -struct ContentView: View { - var body: some View { - ComposeView() - .ignoresSafeArea() - } -} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 3701d36a..738fce79 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -14,6 +14,7 @@ CFBundleURLSchemes nuvio + stremio @@ -29,9 +30,13 @@ outplayer open-vidhub + UIBackgroundModes + + audio + NSSupportsLiveActivities UIApplicationSupportsIndirectInputEvents - + diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 77db6808..5bfc334c 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import AVFoundation import Libmpv import ComposeApp @@ -10,14 +11,19 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { private var playerVC: MPVPlayerViewController? func createPlayerViewController() -> UIViewController { + return ensurePlayerViewController() + } + + private func ensurePlayerViewController() -> MPVPlayerViewController { + if let playerVC { return playerVC } let vc = MPVPlayerViewController() self.playerVC = vc return vc } - func loadFile(url: String) { playerVC?.loadFile(url) } + func loadFile(url: String) { ensurePlayerViewController().loadFile(url) } func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) { - playerVC?.loadFile( + ensurePlayerViewController().loadFile( videoUrl, audioUrl: audioUrl, requestHeaders: parseRequestHeaders(headersJson), @@ -49,6 +55,18 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func retry() { playerVC?.retryPlayback() } + func updateNowPlayingMetadata( + title: String, + subtitle: String?, + artworkUrl: String? + ) { + ensurePlayerViewController().updateNowPlayingMetadata( + title: title, + subtitle: subtitle, + artworkUrl: artworkUrl + ) + } + func clearNowPlayingInfo() { playerVC?.clearNowPlayingInfo() } func configureVideoOutput( hardwareDecoder: String, targetColorspaceHint: Bool, @@ -88,6 +106,9 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func setVolume(volume: Float) { playerVC?.setVolume(volume) } func setMuted(muted: Bool) { playerVC?.setMuted(muted) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } + func syncVideoSurfaceLayout(width: Double, height: Double) { + playerVC?.syncVideoSurfaceLayout(size: CGSize(width: width, height: height)) + } // Audio tracks func getAudioTrackCount() -> Int32 { Int32(playerVC?.audioTracks.count ?? 0) } @@ -228,12 +249,22 @@ final class MPVPlayerViewController: UIViewController { private static let defaultAudioOutput = "audiounit" + private struct CachedNowPlayingMetadata { + let title: String + let subtitle: String? + let artworkUrl: String? + } + private let errorStateLock = NSLock() private var metalLayer = MetalLayer() private var lastAppliedDrawableSize: CGSize = .zero + private var externallyManagedViewSize: CGSize? + private var pendingSurfaceLayoutWorkItems: [DispatchWorkItem] = [] private var pendingLoadRequest: PendingLoadRequest? private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? + private var cachedNowPlayingMetadata: CachedNowPlayingMetadata? + private lazy var nowPlayingController = PlayerNowPlayingController(owner: self) private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private var recentPlaybackLogs: [String] = [] private var activeRequestHeaders: [String: String] = [:] @@ -257,6 +288,10 @@ final class MPVPlayerViewController: UIViewController { } private var _currentErrorMessage: String? + override var canBecomeFirstResponder: Bool { + true + } + override var prefersHomeIndicatorAutoHidden: Bool { true } @@ -285,10 +320,13 @@ final class MPVPlayerViewController: UIViewController { metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor metalLayer.wantsExtendedDynamicRangeContent = true + metalLayer.anchorPoint = CGPoint(x: 0, y: 0) + metalLayer.position = .zero view.layer.addSublayer(metalLayer) layoutMetalLayer() setupMpv() + activateAudioSessionForPlayback() setupNotifications() refreshImmersiveSystemUI() } @@ -307,18 +345,95 @@ final class MPVPlayerViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshImmersiveSystemUI() + becomeFirstResponder() + UIApplication.shared.beginReceivingRemoteControlEvents() + publishCachedNowPlayingInfoIfNeeded() + syncVideoSurfaceLayout() attemptStartPendingLoad() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - layoutMetalLayer() + syncVideoSurfaceLayout() refreshImmersiveSystemUI() attemptStartPendingLoad() } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + syncVideoSurfaceLayoutNow(scheduleDeferredPasses: false) + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.syncVideoSurfaceLayoutNow(scheduleDeferredPasses: false) + }, completion: { [weak self] _ in + self?.syncVideoSurfaceLayout() + self?.attemptStartPendingLoad() + }) + } + + func syncVideoSurfaceLayout(size: CGSize) { + if Thread.isMainThread { + syncVideoSurfaceLayoutNow(size: size, scheduleDeferredPasses: true) + } else { + DispatchQueue.main.async { [weak self] in + self?.syncVideoSurfaceLayoutNow(size: size, scheduleDeferredPasses: true) + } + } + } + + private func syncVideoSurfaceLayout() { + if Thread.isMainThread { + syncVideoSurfaceLayoutNow(size: nil, scheduleDeferredPasses: true) + } else { + DispatchQueue.main.async { [weak self] in + self?.syncVideoSurfaceLayoutNow(size: nil, scheduleDeferredPasses: true) + } + } + } + + private func syncVideoSurfaceLayoutNow(size: CGSize? = nil, scheduleDeferredPasses: Bool) { + guard isViewLoaded else { return } + if let size, size.width > 1, size.height > 1 { + externallyManagedViewSize = size + applyExternallyManagedViewSize(size) + } + view.setNeedsLayout() + view.layoutIfNeeded() + layoutMetalLayer() + + if scheduleDeferredPasses { + scheduleDeferredSurfaceLayoutPasses() + } + } + + private func scheduleDeferredSurfaceLayoutPasses() { + pendingSurfaceLayoutWorkItems.forEach { $0.cancel() } + pendingSurfaceLayoutWorkItems.removeAll(keepingCapacity: true) + + [0.0, 0.05, 0.15, 0.35].forEach { delay in + let workItem = DispatchWorkItem { [weak self] in + self?.syncVideoSurfaceLayoutNow(scheduleDeferredPasses: false) + } + pendingSurfaceLayoutWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + + private func applyExternallyManagedViewSize(_ size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) + if view.bounds != targetBounds { + view.bounds = targetBounds + } + + var targetFrame = view.frame + if targetFrame.size != size { + targetFrame.size = size + view.frame = targetFrame + } + } + private func layoutMetalLayer() { - let bounds = view.bounds + let bounds = CGRect(origin: .zero, size: externallyManagedViewSize ?? view.bounds.size) guard bounds.width > 1, bounds.height > 1 else { return } let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale @@ -330,8 +445,10 @@ final class MPVPlayerViewController: UIViewController { CATransaction.begin() CATransaction.setDisableActions(true) metalLayer.contentsScale = scale - metalLayer.frame = CGRect(origin: .zero, size: bounds.size) + metalLayer.position = .zero + metalLayer.bounds = CGRect(origin: .zero, size: bounds.size) if drawableSize != lastAppliedDrawableSize { + // mpv's moltenvk context polls drawableSize and resizes its swapchain. metalLayer.drawableSize = drawableSize lastAppliedDrawableSize = drawableSize } @@ -349,7 +466,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_request_log_messages(mpv, "warn")) - checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &metalLayer)) + var layerPointer = Int64(Int(bitPattern: Unmanaged.passUnretained(metalLayer).toOpaque())) + checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layerPointer)) checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) @@ -495,12 +613,17 @@ final class MPVPlayerViewController: UIViewController { func playPlayback() { guard mpv != nil else { return } + publishNowPlayingForPlaybackSession() setFlag("pause", false) + isPlayerPlaying = true + syncNowPlayingPlaybackState(isPlaying: true) } func pausePlayback() { guard mpv != nil else { return } setFlag("pause", true) + isPlayerPlaying = false + syncNowPlayingPlaybackState(isPlaying: false) } func seekToMs(_ ms: Int64) { @@ -509,10 +632,11 @@ final class MPVPlayerViewController: UIViewController { command("seek", args: [String(format: "%.3f", seconds), "absolute"]) } - func seekByMs(_ ms: Int64) { + func seekByMs(_ ms: Int64, exact: Bool = false) { guard mpv != nil else { return } let seconds = Double(ms) / 1000.0 - command("seek", args: [String(format: "%.3f", seconds), "relative"]) + let seekMode = exact ? "relative+exact" : "relative" + command("seek", args: [String(format: "%.3f", seconds), seekMode]) } func retryPlayback() { @@ -736,15 +860,39 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + UIApplication.shared.endReceivingRemoteControlEvents() + resignFirstResponder() pendingLoadRetryWorkItem?.cancel() pendingLoadRetryWorkItem = nil + pendingSurfaceLayoutWorkItems.forEach { $0.cancel() } + pendingSurfaceLayoutWorkItems.removeAll(keepingCapacity: false) pendingLoadRequest = nil + nowPlayingController.invalidate() clearPlaybackError() + deactivateAudioSession() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading mpv_terminate_destroy(ctx) } + private func activateAudioSessionForPlayback() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback) + try session.setActive(true) + } catch { + print("[NowPlaying] Failed to activate audio session: \(error)") + } + } + + private func deactivateAudioSession() { + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("[NowPlaying] Failed to deactivate audio session: \(error)") + } + } + // MARK: - State Update /// Lightweight state refresh — called by Kotlin polling (every 250ms). @@ -768,6 +916,20 @@ final class MPVPlayerViewController: UIViewController { positionMs = Int64(max(position, 0) * 1000) bufferedMs = Int64(max(position + cached, 0) * 1000) currentSpeed = Float(speed > 0 ? speed : 1.0) + + let shouldPublishNowPlayingState = !isPlayerLoading || isPlayerPlaying || durationMs > 0 || positionMs > 0 + if shouldPublishNowPlayingState { + syncNowPlayingPlaybackState(isPlaying: isPlayerPlaying) + } + } + + private func syncNowPlayingPlaybackState(isPlaying: Bool) { + nowPlayingController.syncPlayback( + positionMs: positionMs, + durationMs: durationMs, + isPlaying: isPlaying, + playbackSpeed: currentSpeed + ) } /// Full state + track refresh — called from MPV event loop on property changes. @@ -817,6 +979,48 @@ final class MPVPlayerViewController: UIViewController { subtitleTracks = subs } + func updateNowPlayingMetadata( + title: String, + subtitle: String?, + artworkUrl: String? + ) { + cachedNowPlayingMetadata = CachedNowPlayingMetadata( + title: title, + subtitle: subtitle, + artworkUrl: artworkUrl + ) + nowPlayingController.updateMetadata( + title: title, + subtitle: subtitle, + artworkUrl: artworkUrl + ) + publishNowPlayingForPlaybackSession() + } + + func clearNowPlayingInfo() { + cachedNowPlayingMetadata = nil + nowPlayingController.clear() + } + + private func publishCachedNowPlayingInfoIfNeeded() { + guard let metadata = cachedNowPlayingMetadata else { return } + nowPlayingController.updateMetadata( + title: metadata.title, + subtitle: metadata.subtitle, + artworkUrl: metadata.artworkUrl + ) + } + + private func publishNowPlayingForPlaybackSession() { + activateAudioSessionForPlayback() + if isViewLoaded, view.window != nil { + becomeFirstResponder() + } + UIApplication.shared.beginReceivingRemoteControlEvents() + publishCachedNowPlayingInfoIfNeeded() + syncNowPlayingPlaybackState(isPlaying: isPlayerPlaying) + } + private func getTrackString(_ index: Int, _ field: String) -> String { (getString("track-list/\(index)/\(field)") ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -960,8 +1164,14 @@ final class MPVPlayerViewController: UIViewController { self.clearPlaybackError() self.isPlayerLoading = false self.updateState() + self.publishNowPlayingForPlaybackSession() self.logCurrentAudioOutput() } + case MPV_EVENT_PLAYBACK_RESTART: + DispatchQueue.main.async { + self.updateState() + self.publishNowPlayingForPlaybackSession() + } case MPV_EVENT_END_FILE: if let data = eventPtr.pointee.data { let endFile = UnsafePointer(OpaquePointer(data)).pointee diff --git a/iosApp/iosApp/Player/MetalLayer.swift b/iosApp/iosApp/Player/MetalLayer.swift index 27c325ab..49fd3772 100644 --- a/iosApp/iosApp/Player/MetalLayer.swift +++ b/iosApp/iosApp/Player/MetalLayer.swift @@ -17,7 +17,9 @@ class MetalLayer: CAMetalLayer { if Thread.isMainThread { super.wantsExtendedDynamicRangeContent = newValue } else { - DispatchQueue.main.sync { + // mpv's vo thread sets this during video-output init while it holds the + // core lock; a sync hop here deadlocks against main-thread property reads. + DispatchQueue.main.async { super.wantsExtendedDynamicRangeContent = newValue } } diff --git a/iosApp/iosApp/Player/NowPlayingController.swift b/iosApp/iosApp/Player/NowPlayingController.swift new file mode 100644 index 00000000..2c9faa6e --- /dev/null +++ b/iosApp/iosApp/Player/NowPlayingController.swift @@ -0,0 +1,274 @@ +import Foundation +import UIKit +import MediaPlayer + +final class PlayerNowPlayingController { + private struct Metadata { + var title: String? + var subtitle: String? + var artworkUrl: String? + } + + private struct PlaybackState { + var isPlaying: Bool = false + var positionMs: Int64 = 0 + var durationMs: Int64 = 0 + var playbackSpeed: Float = 1.0 + } + + private struct RemoteCommandTarget { + let command: MPRemoteCommand + let token: Any + } + + private weak var owner: MPVPlayerViewController? + private var metadata = Metadata() + private var playbackState = PlaybackState() + private var currentArtworkImage: UIImage? + private var currentArtworkURL: String? + private var artworkTask: URLSessionDataTask? + private var remoteTargets: [RemoteCommandTarget] = [] + private let artworkCache = NSCache() + + init(owner: MPVPlayerViewController) { + self.owner = owner + configureRemoteCommands() + } + + deinit { + invalidate() + } + + func updateMetadata( + title: String, + subtitle: String?, + artworkUrl: String? + ) { + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSubtitle = subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedArtworkUrl = artworkUrl?.trimmingCharacters(in: .whitespacesAndNewlines) + + if metadata.title == normalizedTitle, + metadata.subtitle == normalizedSubtitle, + currentArtworkURL == normalizedArtworkUrl { + applyNowPlayingInfo() + return + } + + metadata.title = normalizedTitle + metadata.subtitle = normalizedSubtitle + + if normalizedArtworkUrl != currentArtworkURL { + currentArtworkURL = normalizedArtworkUrl + currentArtworkImage = nil + artworkTask?.cancel() + artworkTask = nil + + guard let urlString = normalizedArtworkUrl, !urlString.isEmpty else { + applyNowPlayingInfo() + return + } + + if let cached = artworkCache.object(forKey: urlString as NSString) { + currentArtworkImage = cached + applyNowPlayingInfo() + return + } + + guard let url = URL(string: urlString) else { + applyNowPlayingInfo() + return + } + + let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self else { return } + guard let data, let image = UIImage(data: data) else { return } + self.artworkCache.setObject(image, forKey: urlString as NSString) + DispatchQueue.main.async { [weak self] in + guard let self, self.currentArtworkURL == urlString else { return } + self.currentArtworkImage = image + self.applyNowPlayingInfo() + } + } + artworkTask = task + task.resume() + } + + applyNowPlayingInfo() + } + + func syncPlayback( + positionMs: Int64, + durationMs: Int64, + isPlaying: Bool, + playbackSpeed: Float + ) { + let nextState = PlaybackState( + isPlaying: isPlaying, + positionMs: max(0, positionMs), + durationMs: max(0, durationMs), + playbackSpeed: playbackSpeed > 0 ? playbackSpeed : 1.0 + ) + let positionChanged = abs(nextState.positionMs - playbackState.positionMs) >= 1_000 + guard playbackState.isPlaying != nextState.isPlaying || + playbackState.durationMs != nextState.durationMs || + playbackState.playbackSpeed != nextState.playbackSpeed || + positionChanged else { + return + } + + playbackState = nextState + applyNowPlayingInfo() + } + + func clear() { + artworkTask?.cancel() + artworkTask = nil + currentArtworkImage = nil + currentArtworkURL = nil + metadata = Metadata() + playbackState = PlaybackState() + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + } + + func invalidate() { + clear() + removeRemoteCommandTargets() + } + + private func applyNowPlayingInfo() { + guard let title = metadata.title, !title.isEmpty else { return } + + let buildInfo = { + var info: [String: Any] = [:] + info[MPMediaItemPropertyTitle] = title + info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue + if let subtitle = self.metadata.subtitle, !subtitle.isEmpty { + info[MPMediaItemPropertyArtist] = subtitle + } + if self.playbackState.durationMs > 0 { + info[MPMediaItemPropertyPlaybackDuration] = Double(self.playbackState.durationMs) / 1000.0 + } + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(self.playbackState.positionMs) / 1000.0 + info[MPNowPlayingInfoPropertyPlaybackRate] = Double( + self.playbackState.isPlaying ? self.playbackState.playbackSpeed : 0.0 + ) + info[MPNowPlayingInfoPropertyIsLiveStream] = self.playbackState.durationMs <= 0 + if let artwork = self.currentArtworkImage { + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork } + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + if Thread.isMainThread { + buildInfo() + } else { + DispatchQueue.main.async(execute: buildInfo) + } + } + + private func configureRemoteCommands() { + guard remoteTargets.isEmpty else { return } + + let center = MPRemoteCommandCenter.shared() + center.playCommand.isEnabled = true + center.pauseCommand.isEnabled = true + center.togglePlayPauseCommand.isEnabled = true + center.skipForwardCommand.isEnabled = true + center.skipBackwardCommand.isEnabled = true + center.changePlaybackPositionCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [10] + center.skipBackwardCommand.preferredIntervals = [10] + + remoteTargets.append( + RemoteCommandTarget( + command: center.playCommand, + token: center.playCommand.addTarget { [weak self] _ in + DispatchQueue.main.async { self?.owner?.playPlayback() } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.pauseCommand, + token: center.pauseCommand.addTarget { [weak self] _ in + DispatchQueue.main.async { self?.owner?.pausePlayback() } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.togglePlayPauseCommand, + token: center.togglePlayPauseCommand.addTarget { [weak self] _ in + DispatchQueue.main.async { + guard let owner = self?.owner else { return } + if owner.isPlayerPlaying { + owner.pausePlayback() + } else { + owner.playPlayback() + } + } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.skipForwardCommand, + token: center.skipForwardCommand.addTarget { [weak self] event in + guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } + DispatchQueue.main.async { + self?.owner?.seekByMs(Int64(event.interval * 1000.0), exact: true) + } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.skipBackwardCommand, + token: center.skipBackwardCommand.addTarget { [weak self] event in + guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } + DispatchQueue.main.async { + self?.owner?.seekByMs(-Int64(event.interval * 1000.0), exact: true) + } + return .success + } + ) + ) + remoteTargets.append( + RemoteCommandTarget( + command: center.changePlaybackPositionCommand, + token: center.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + DispatchQueue.main.async { + self?.owner?.seekToMs(Int64(event.positionTime * 1000.0)) + } + return .success + } + ) + ) + } + + private func removeRemoteCommandTargets() { + guard !remoteTargets.isEmpty else { return } + + remoteTargets.forEach { target in + target.command.removeTarget(target.token) + } + remoteTargets.removeAll(keepingCapacity: false) + + let center = MPRemoteCommandCenter.shared() + center.playCommand.isEnabled = false + center.pauseCommand.isEnabled = false + center.togglePlayPauseCommand.isEnabled = false + center.skipForwardCommand.isEnabled = false + center.skipBackwardCommand.isEnabled = false + center.changePlaybackPositionCommand.isEnabled = false + } +} diff --git a/scripts/nuvio_debug_logs.sh b/scripts/debug_logs.sh similarity index 91% rename from scripts/nuvio_debug_logs.sh rename to scripts/debug_logs.sh index 020e7bec..7f2014be 100755 --- a/scripts/nuvio_debug_logs.sh +++ b/scripts/debug_logs.sh @@ -6,17 +6,18 @@ # level. Press C to clear all logs on screen. Press Q to quit. # # Usage: -# ./scripts/nuvio_debug_logs.sh [options] +# ./scripts/debug_logs.sh [options] # # Options: -# -s, --serial ADB device serial (optional, for multi-device) -# -t, --tag Additional logcat tag filter regex (default: all app) -# -c, --clear Clear logcat buffer before streaming -# -h, --help Show help +# -s, --serial ADB device serial (optional, for multi-device) +# -p, --package Android package/applicationId (default: debug build) +# -t, --tag Additional logcat tag filter regex (default: all app) +# -c, --clear Clear logcat buffer before streaming +# -h, --help Show help # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail -PACKAGE="com.nuvio.app" +PACKAGE="${NUVIO_LOG_PACKAGE:-com.nuviodebug.com}" SERIAL="" CLEAR_BUFFER=false TAG_FILTER="" @@ -53,25 +54,27 @@ CLR_HOTKEY='\033[38;5;220m' # ── Helpers ────────────────────────────────────────────────────────────────── usage() { cat <<'EOF' -Usage: ./scripts/nuvio_debug_logs.sh [options] +Usage: ./scripts/debug_logs.sh [options] Stream live colour-coded ADB logs for Nuvio debug builds. Options: - -s, --serial ADB device serial (optional) - -t, --tag Additional grep regex to filter log tags - -c, --clear Clear logcat buffer before streaming - -h, --help Show this help + -s, --serial ADB device serial (optional) + -p, --package Android package/applicationId (default: com.nuviodebug.com) + -t, --tag Additional grep regex to filter log tags + -c, --clear Clear logcat buffer before streaming + -h, --help Show this help Hotkeys (while running): C Clear all logs currently shown in the terminal Q Quit the log viewer Examples: - ./scripts/nuvio_debug_logs.sh - ./scripts/nuvio_debug_logs.sh --serial emulator-5554 - ./scripts/nuvio_debug_logs.sh --tag 'MetaDetailsRepo|SeriesContent' - ./scripts/nuvio_debug_logs.sh --clear --tag 'Sync|Auth' + ./scripts/debug_logs.sh + ./scripts/debug_logs.sh --serial emulator-5554 + ./scripts/debug_logs.sh --package com.nuvio.app + ./scripts/debug_logs.sh --tag 'MetaDetailsRepo|SeriesContent' + ./scripts/debug_logs.sh --clear --tag 'Sync|Auth' EOF } @@ -79,6 +82,7 @@ EOF while [[ $# -gt 0 ]]; do case "$1" in -s|--serial) SERIAL="${2:-}"; shift 2 ;; + -p|--package) PACKAGE="${2:-}"; shift 2 ;; -t|--tag) TAG_FILTER="${2:-}"; shift 2 ;; -c|--clear) CLEAR_BUFFER=true; shift ;; -h|--help) usage; exit 0 ;; diff --git a/scripts/run-mobile.sh b/scripts/run-mobile.sh index b6b0e147..bb9c1091 100755 --- a/scripts/run-mobile.sh +++ b/scripts/run-mobile.sh @@ -75,7 +75,7 @@ first_booted_ios_simulator() { xcrun simctl list devices booted | awk -F '[()]' '/Booted/ { print $2; exit }' } -preferred_ios_device() { +selected_ios_device() { xcrun xcdevice list --timeout 5 2>/dev/null | python3 -c ' import json import sys @@ -91,11 +91,21 @@ physical = [ if device.get("platform") == "com.apple.platform.iphoneos" and not device.get("simulator", False) and device.get("available") is True - and device.get("modelName") == os.environ["IOS_PREFERRED_DEVICE_MODEL"] ] -if physical: - print(physical[0].get("identifier", "")) +preferred_model = os.environ["IOS_PREFERRED_DEVICE_MODEL"] +preferred = [ + device for device in physical + if device.get("modelName") == preferred_model +] + +selected = (preferred or physical or [None])[0] +if selected: + print("\t".join([ + selected.get("identifier", ""), + selected.get("name", ""), + selected.get("modelName", ""), + ])) ' } @@ -336,16 +346,28 @@ run_ios_physical() { require_command xcodebuild require_command xcrun - local physical_device_id - physical_device_id="$(IOS_PREFERRED_DEVICE_MODEL="$IOS_PREFERRED_DEVICE_MODEL" preferred_ios_device)" + local physical_device_info + physical_device_info="$(IOS_PREFERRED_DEVICE_MODEL="$IOS_PREFERRED_DEVICE_MODEL" selected_ios_device)" + + if [[ -n "$physical_device_info" ]]; then + local physical_device_id="" + local physical_device_name="" + local physical_device_model="" + IFS=$'\t' read -r physical_device_id physical_device_name physical_device_model <<<"$physical_device_info" - if [[ -n "$physical_device_id" ]]; then local derived_data_path derived_data_path="$(ios_derived_data_path device "$distribution")" local device_app_path device_app_path="$derived_data_path/Build/Products/Debug-iphoneos/$IOS_APP_NAME" + if [[ "$physical_device_model" == "$IOS_PREFERRED_DEVICE_MODEL" ]]; then + echo "Using preferred iOS physical device: ${physical_device_name:-$physical_device_id} ($physical_device_model)" + else + echo "Preferred iOS device not available: $IOS_PREFERRED_DEVICE_MODEL" + echo "Using connected iOS physical device: ${physical_device_name:-$physical_device_id}${physical_device_model:+ ($physical_device_model)}" + fi + echo "Building iOS $distribution debug app for physical device $physical_device_id..." env NUVIO_IOS_DISTRIBUTION="$distribution" xcodebuild \ -project "$IOS_PROJECT" \ @@ -368,8 +390,8 @@ run_ios_physical() { return fi - echo "Preferred iOS device not available: $IOS_PREFERRED_DEVICE_MODEL" >&2 - echo "Connect and unlock that device, then rerun: ./scripts/run-mobile.sh ios p" >&2 + echo "No available iOS physical devices found." >&2 + echo "Connect and unlock a device, then rerun: ./scripts/run-mobile.sh ios p" >&2 exit 1 } diff --git a/stremiotorrernt/public/enginefs b/stremiotorrernt/public/enginefs new file mode 160000 index 00000000..9f11922a --- /dev/null +++ b/stremiotorrernt/public/enginefs @@ -0,0 +1 @@ +Subproject commit 9f11922ae092e4a7120a7e1bdabb0698df1eecb8