diff --git a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar index e3705fb6..565df23b 100644 Binary files a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar and b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar differ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index e4e5c4d6..94036653 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -44,6 +44,7 @@ import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage +import com.nuvio.app.features.streams.BingeGroupCacheStorage import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ResumePromptStorage @@ -87,6 +88,7 @@ class MainActivity : AppCompatActivity() { EpisodeReleaseNotificationsStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) + BingeGroupCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) CollectionMobileSettingsStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext) 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 d6e11982..743c27f8 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 @@ -51,6 +51,7 @@ actual object PlayerSettingsStorage { private const val introSubmitEnabledKey = "intro_submit_enabled" private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" @@ -101,6 +102,7 @@ actual object PlayerSettingsStorage { animeSkipClientIdKey, streamAutoPlayNextEpisodeEnabledKey, streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, nextEpisodeThresholdModeKey, nextEpisodeThresholdPercentKey, nextEpisodeThresholdMinutesBeforeEndKey, @@ -609,6 +611,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey), enabled) + ?.apply() + } + actual fun loadNextEpisodeThresholdMode(): String? = preferences?.getString(ProfileScopedKey.of(nextEpisodeThresholdModeKey), null) @@ -825,6 +844,7 @@ actual object PlayerSettingsStorage { loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } @@ -883,6 +903,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt new file mode 100644 index 00000000..253c3594 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.streams + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object BingeGroupCacheStorage { + private const val preferencesName = "nuvio_binge_group_cache" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun load(hashedKey: String): String? = + preferences?.getString(ProfileScopedKey.of(hashedKey), null) + + actual fun save(hashedKey: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(hashedKey), value) + ?.apply() + } + + actual fun remove(hashedKey: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(hashedKey)) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 00af8afd..330c2cd6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1,4 +1,5 @@ + Źródła danych, podziękowania i licencje platformy Osoby wspierające i współtworzące projekt Wstecz Anuluj @@ -17,6 +18,8 @@ Wznów Ponów Zapisz + Zapisywanie… + Sprawdź Instalowanie Dodatki Aktywny @@ -110,29 +113,38 @@ Produkcja Stacja Kolekcja + Osoba + Reżyser Niestandardowy Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu. Wklej publiczny URL listy TMDB lub sam numer z URL. Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio. Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach. Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB. + Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii aktora. + Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii reżysera. Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra. Publiczna lista TMDB ID stacji ID kolekcji + ID osoby Nazwa firmy produkcyjnej, ID lub URL ID lub URL TMDB https://www.themoviedb.org/list/8504994 lub 8504994 213 dla Netflix, 49 dla HBO, 2739 dla Disney+ 10 dla kolekcji Star Wars Marvel Studios, 420 lub URL firmy + 31 dla Toma Hanksa lub URL osoby Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420. Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji. Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739. Przykład: https://www.themoviedb.org/list/8504994 lub 8504994. + Przykład: https://www.themoviedb.org/person/31-tom-hanks lub 31. Wyświetlany tytuł Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła. Filmy Marvela, Oryginały Netflix, Pixar + Filmy Toma Hanksa, Ulubieni aktorzy + Filmy Christophera Nolana, Ulubieni reżyserzy Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024 Wyniki wyszukiwania Kolekcja TMDB @@ -179,6 +191,27 @@ Szablony Szukaj Dodaj źródło + Dodaj listę Trakt + Edytuj listę Trakt + Listy Trakt + Lista Trakt + Szukaj tytułu, URL Trakt lub ID listy + Użyj publicznego URL listy Trakt, numerycznego ID listy lub wyszukaj po nazwie. + Weekendowe filmy, Laureaci nagród + Wyniki wyszukiwania + Popularne teraz + Popularne listy + Kierunek + Rosnąco + Malejąco + Kolejność listy + Ostatnio dodane + Tytuł + Data premiery + Czas trwania + Popularne + Procent + Głosy Akcja Przygodowy Animacja @@ -212,13 +245,29 @@ Disney+ Prime Video Hulu + Oryginalna Popularne Najwyżej oceniane Ostatnie + Najczęściej głosowane + Region dostępności + Kod kraju ISO 3166-1, w którym tytuł jest dostępny. Przykład: US, GB. + Popularne regiony dostępności + ID platform streamingowych + Użyj ID platform TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR. + 8|337|350 + Popularne platformy streamingowe + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu Lista TMDB Kolekcja filmów TMDB Produkcja Stacja + Osoba + Reżyser TMDB Discover Utwórz jedną, aby uporządkować katalogi. Brak kolekcji @@ -331,8 +380,10 @@ Wygląd Treści i odkrywanie Kontynuuj oglądanie + Debrid Ekran główny Integracje + Licencje i atrybucje Oceny MDBList Ekran metadanych Powiadomienia @@ -358,6 +409,31 @@ Przełącz na inny profil. Przełącz profil Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt. + Nie znaleziono ustawień. + Szukaj ustawień... + WYNIKI + LICENCJA APLIKACJI + DANE I USŁUGI + LICENCJA ODTWARZANIA + Nuvio Mobile + Kod źródłowy i warunki licencji są dostępne w repozytorium projektu. + Licencjonowany na podstawie GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio korzysta z API TMDB do metadanych filmów i seriali, grafik, zwiastunów, obsady, szczegółów produkcji, kolekcji i rekomendacji. Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB. + Niekomercyjne zbiory danych IMDb + Nuvio korzysta z niekomercyjnych zbiorów danych IMDb, w tym title.ratings.tsv.gz, do ocen i liczby głosów IMDb. Informacje dzięki uprzejmości IMDb (https://www.imdb.com). Wykorzystywane za zgodą. Dane IMDb służą do użytku osobistego i niekomercyjnego zgodnie z warunkami IMDb. + Trakt + Nuvio łączy się z Trakt w celu uwierzytelniania konta, historii oglądania, synchronizacji postępu, danych biblioteki, ocen, list i komentarzy. Nuvio nie jest powiązane z Trakt ani przez nie wspierane. + MDBList + Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane. + IntroDB + Nuvio korzysta z API IntroDB do dostarczanych przez społeczność znaczników intro, podsumowań, napisów końcowych i podglądów używanych przez kontrolki pomijania. Nuvio nie jest powiązane z IntroDB ani przez nie wspierane. + MPVKit + Używany do odtwarzania w wersjach na iOS. + Kod źródłowy MPVKit jest licencjonowany na podstawie LGPL v3.0. Pakiety MPVKit, w tym biblioteki libmpv i FFmpeg, są również licencjonowane na podstawie LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Używany do odtwarzania w wersjach na Androida. + Licencjonowany na podstawie Apache License, wersja 2.0. Ładowanie list Trakt… Wybierz, gdzie zapisać ten tytuł w Trakt Wesprzyj @@ -416,6 +492,8 @@ Język aplikacji Wybierz język Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. + Liquid Glass + Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona. Dostosuj szerokość i zaokrąglenie rogów kart plakatów. WYŚWIETLANIE EKRAN GŁÓWNY @@ -442,9 +520,14 @@ %1$d z %2$d wybranych Pokaż Hero Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej. + Ukryj niewydane treści + Ukryj filmy i seriale, które nie zostały jeszcze wydane. + Ukryj podkreślenie katalogu + Usuń linię akcentu pod tytułami katalogów i kolekcji w całej aplikacji. %1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność. Widoczne + Ukryj wartość Odtwarzacz, napisy i automatyczne odtwarzanie Zaokrąglenie karty STYL KARTY PLAKATU @@ -469,8 +552,19 @@ Gęsty Duży Standardowy + Pokaż wartość Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza. Monit o wznowienie przy uruchomieniu + Rozmyj miniatury następnych odcinków w Kontynuuj oglądanie, aby uniknąć spoilerów. + Rozmyj nieobejrzane w Kontynuuj oglądanie + Uwzględnij nadchodzące odcinki w Kontynuuj oglądanie przed ich emisją. + Pokaż niewyemitowane następne odcinki + KOLEJNOŚĆ SORTOWANIA + Kolejność sortowania + Domyślna + Sortuj wszystkie elementy według czasu + Styl streamingowy + Wydane najpierw, nadchodzące na końcu STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO @@ -483,6 +577,8 @@ Pozioma karta z informacjami Gdy włączone, Następny zawsze kontynuuje od najdalej obejrzanego odcinka. Gdy wyłączone, kontynuuje od ostatnio obejrzanego. Przydatne przy ponownym oglądaniu wcześniejszych odcinków. Następny od najdalszego odcinka + Preferuj miniatury odcinków, gdy są dostępne. + Preferuj miniatury odcinków w Kontynuuj oglądanie EKRAN GŁÓWNY ŹRÓDŁA Instaluj, usuwaj, odświeżaj i sortuj źródła treści. @@ -493,6 +589,34 @@ INTEGRACJE Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. + Eksperymentalne źródła z kont chmurowych + Debrid + Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. + Włącz źródła + Pokaż odtwarzalne wyniki z połączonych kont. + Najpierw dodaj klucz API. + Konto + Połącz swoje konto Torbox. + Klucz API Torbox + Wprowadź swój klucz API Torbox. + Wprowadź klucz API Torbox + Nie ustawiono + Natychmiastowe odtwarzanie + Przygotuj linki + Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. + Źródła do przygotowania + Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem. + 1 źródło + %1$d źródeł + Formatowanie + Szablon nazwy + Kontroluje sposób wyświetlania nazw źródeł. + Szablon opisu + Kontroluje metadane wyświetlane pod każdym źródłem. + Resetuj formatowanie + Przywróć domyślne formatowanie źródeł. + Klucz API zweryfikowany. + Nie udało się zweryfikować tego klucza API. Dodaj klucz API MDBList poniżej przed włączeniem ocen. Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. Klucz API @@ -522,6 +646,8 @@ Karty ze szczegółami na pierwszym planie Odcinki Sezony i lista odcinków dla seriali. + Rozmyj nieobejrzane odcinki + Rozmyj miniatury odcinków do momentu obejrzenia, aby uniknąć spoilerów. Grupa %1$d Podobne Wiersz rekomendacji. @@ -588,6 +714,10 @@ Anime Skip ID klienta AnimeSkip Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com. + Włącz przesyłanie intro + Pokaż przycisk do przesyłania znaczników intro/outro do bazy społeczności. + Klucz API IntroDB + Wprowadź klucz API IntroDB, aby przesyłać znaczniki czasowe. Wymagany do przesyłania. Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta). Automatyczne odtwarzanie następnego odcinka Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu. @@ -603,6 +733,11 @@ %1$d godzin Włącz libass Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera. + Zewnętrzny odtwarzacz + Aplikacja zewnętrznego odtwarzacza + Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym. + Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu. + Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy Prędkość przy przytrzymaniu Przytrzymaj, aby przyspieszyć Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania. @@ -621,6 +756,8 @@ Brak Preferuj grupę binge Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący. + Ponownie użyj grupy binge + Zapamiętaj i ponownie użyj ostatniej grupy binge między sesjami (Kontynuuj oglądanie, Szczegóły itp.). Preferowany język audio Preferowany język napisów Szablony @@ -744,6 +881,28 @@ Otwórz logowanie Trakt Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt. Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt. + Źródło biblioteki + Wybierz, której biblioteki używać do zapisywania i przeglądania kolekcji + Źródło biblioteki + Wybierz, gdzie zapisywać i zarządzać elementami biblioteki + Trakt + Biblioteka Nuvio + Wybrano bibliotekę Trakt + Wybrano bibliotekę Nuvio + Postęp oglądania + Wybierz, które źródło postępu obsługuje wznawianie i Kontynuuj oglądanie + Postęp oglądania + Wybierz, czy wznawianie i Kontynuuj oglądanie powinno korzystać z Trakt czy Nuvio Sync, podczas gdy scrobblowanie Trakt pozostaje aktywne. + Trakt + Nuvio Sync + Źródło postępu ustawione na Trakt + Źródło postępu ustawione na Nuvio Sync + Okno Kontynuuj oglądanie + Historia Trakt uwzględniana w Kontynuuj oglądanie + Okno Kontynuuj oglądanie + Wybierz, ile aktywności Trakt ma się pojawiać w Kontynuuj oglądanie. + Cała historia + %1$d dni Ocena widzów IMDb Letterboxd @@ -934,9 +1093,14 @@ Zablokowane. Spróbuj ponownie za %1$ds Opcje awatarów pojawią się tutaj po załadowaniu katalogu. Awatar: %1$s + Wprowadź prawidłowy URL obrazu http:// lub https://. Wybierz awatar Wybierz awatar poniżej. Utwórz profil + Wybrano niestandardowy URL awatara. + Niestandardowy URL awatara + Wklej link do obrazu lub zostaw puste, aby użyć wbudowanego katalogu awatarów. + https://example.com/avatar.png Wszystkie dane profilu „%1$s" zostaną trwale usunięte. Usuń profil Dodaj profil @@ -968,6 +1132,8 @@ Sprawdzanie kolejnych dodatków… Kopiuj link strumienia Pobierz plik + Otwórz w zewnętrznym odtwarzaczu + Otwórz w wewnętrznym odtwarzaczu Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi. Nie można załadować strumieni Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu. @@ -987,6 +1153,13 @@ Wznów od %1$d% Wznów od %1$s ROZMIAR %1$s + Ten typ strumienia nie jest obsługiwany + Dodaj klucz API Debrid w Ustawieniach. + Ten wynik Debrid wygasł. Odświeżanie strumieni. + Nie udało się rozwiązać tego strumienia Debrid. + Nie udało się otworzyć zewnętrznego odtwarzacza + Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach + Brak dostępnego zewnętrznego odtwarzacza Zamknij zwiastun Nie można odtworzyć zwiastuna Nie udało się załadować list Trakt @@ -1032,6 +1205,7 @@ Pobieranie nie powiodło się Wstrzymano %1$s Usuń + Usunąć %1$s z %2$s? Usunąć %1$s z biblioteki? Usunąć z biblioteki? Film @@ -1075,6 +1249,7 @@ Folder %1$d w „%2$s" ma puste id. Folder „%1$s" w „%2$s" ma pusty tytuł. Źródło %1$d w folderze „%2$s" ma puste pola. + Źródło %1$d w folderze '%2$s' nie ma ID listy Trakt. Nieprawidłowy JSON: %1$s Nie znaleziono dodatku: %1$s Styczeń @@ -1148,6 +1323,14 @@ Nowy odcinek jest już dostępny %1$s jest już dostępny Premiery odcinków + Alkohol/Narkotyki + Przerażające + Nagość + Wulgaryzmy + Łagodne + Umiarkowane + Intensywne + Przemoc Twórca Reżyser Scenarzysta diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 43397cbe..4d057fd2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -774,6 +774,8 @@ None Prefer Binge Group (Next Episode) Try the same source profile first (same addon/quality group) before normal auto-play rules. + Reuse Binge Group + Remember and reuse the last binge group across sessions (Continue Watching, Details, etc.). Preferred Audio Language Preferred Language Presets @@ -1064,6 +1066,7 @@ Mark previous as watched Mark %1$s as unwatched Mark %1$s as watched + Mark previous seasons as watched Mark as unwatched Mark as watched Up next diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 266ac99a..2f33f3dc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1657,6 +1657,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, @@ -1773,6 +1774,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, 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 bad8527f..a9ea7373 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 @@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection import com.nuvio.app.features.details.components.DetailSeriesContent import com.nuvio.app.features.details.components.DetailTrailersSection import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet +import com.nuvio.app.features.details.components.SeasonWatchedActionSheet import com.nuvio.app.features.details.components.TrailerPlayerPopup import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository @@ -93,6 +94,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.previousReleasedEpisodesBefore +import com.nuvio.app.features.watched.releasedPlayableEpisodes import com.nuvio.app.features.watched.releasedEpisodesForSeason import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -152,6 +154,7 @@ 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 selectedSeasonForActions by remember(type, id) { mutableStateOf(null) } val commentsEnabled by remember { TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.enabled @@ -338,7 +341,10 @@ fun MetaDetailsScreen( LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L)) } } - val movieProgress = watchProgressUiState.byVideoId[meta.id] + val progressByVideoId = remember(watchProgressUiState.entries) { + watchProgressUiState.byVideoId + } + val movieProgress = progressByVideoId[meta.id] ?.takeUnless { it.isCompleted } val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) { @@ -739,11 +745,12 @@ fun MetaDetailsScreen( }, onCommentClick = { review -> selectedComment = review }, onTrailerClick = resolveTrailer, - progressByVideoId = watchProgressUiState.byVideoId, + progressByVideoId = progressByVideoId, watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onSeasonLongPress = { season -> selectedSeasonForActions = season }, onOpenMeta = onOpenMeta, onCastClick = onCastClick, onCompanyClick = onCompanyClick, @@ -800,12 +807,12 @@ fun MetaDetailsScreen( ) selectedEpisodeForActions?.let { selectedEpisode -> - val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) { - WatchingState.isEpisodeWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = meta.type, - metaId = meta.id, + val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) { + isEpisodeWatchedForActions( + meta = meta, episode = selectedEpisode, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, ) } val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) { @@ -820,20 +827,20 @@ fun MetaDetailsScreen( todayIsoDate = todayIsoDate, ) } - val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) { - WatchingState.areEpisodesWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = meta.type, - metaId = meta.id, + val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, episodes = previousEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, ) } - val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) { - WatchingState.areEpisodesWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = meta.type, - metaId = meta.id, + val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, episodes = seasonEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, ) } EpisodeWatchedActionSheet( @@ -874,6 +881,62 @@ fun MetaDetailsScreen( ) } + selectedSeasonForActions?.let { selectedSeason -> + val seasonLabel = selectedSeasonLabel(selectedSeason) + val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) { + meta.releasedEpisodesForSeason( + seasonNumber = selectedSeason, + todayIsoDate = todayIsoDate, + ) + } + val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) { + val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0) + meta.releasedPlayableEpisodes(todayIsoDate) + .filter { episode -> + val season = episode.season?.coerceAtLeast(0) ?: 0 + season > 0 && season < normalizedSelectedSeason + } + } + val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, + episodes = seasonEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + previousSeasonEpisodes.any { episode -> + !isEpisodeWatchedForActions( + meta = meta, + episode = episode, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + } + SeasonWatchedActionSheet( + seasonLabel = seasonLabel, + isSeasonWatched = isSeasonWatched, + canMarkPreviousSeasons = canMarkPreviousSeasons, + onDismiss = { selectedSeasonForActions = null }, + onToggleSeasonWatched = { + WatchingActions.toggleSeasonWatched( + meta = meta, + episodes = seasonEpisodes, + areCurrentlyWatched = isSeasonWatched, + ) + }, + onMarkPreviousSeasonsWatched = { + WatchingActions.togglePreviousEpisodesWatched( + meta = meta, + episodes = previousSeasonEpisodes, + areCurrentlyWatched = false, + ) + }, + ) + } + if (inAppTrailerPlaybackEnabled) { TrailerPlayerPopup( visible = selectedTrailer != null, @@ -994,6 +1057,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") } +@Composable +private fun selectedSeasonLabel(season: Int): String = + if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + } + +private fun isEpisodeWatchedForActions( + meta: MetaDetails, + episode: MetaVideo, + watchedKeys: Set, + progressByVideoId: Map, +): Boolean { + val episodeVideoId = buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || + WatchingState.isEpisodeWatched( + watchedKeys = watchedKeys, + metaType = meta.type, + metaId = meta.id, + episode = episode, + ) +} + +private fun areEpisodesWatchedForActions( + meta: MetaDetails, + episodes: Collection, + watchedKeys: Set, + progressByVideoId: Map, +): Boolean = episodes.isNotEmpty() && episodes.all { episode -> + isEpisodeWatchedForActions( + meta = meta, + episode = episode, + watchedKeys = watchedKeys, + progressByVideoId = progressByVideoId, + ) +} + private fun extractImdbId(value: String?): String? = value ?.trim() @@ -1050,6 +1156,7 @@ private fun ConfiguredMetaSections( blurUnwatchedEpisodes: Boolean, onEpisodeClick: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit, + onSeasonLongPress: (Int) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, onCastClick: ((MetaPerson, String?) -> Unit)?, onCompanyClick: ((MetaCompany, String) -> Unit)?, @@ -1144,6 +1251,7 @@ private fun ConfiguredMetaSections( blurUnwatchedEpisodes = blurUnwatchedEpisodes, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, + onSeasonLongPress = onSeasonLongPress, ) } } 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 d2210058..9e655f77 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 @@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction( watchedItems: List, todayIsoDate: String, preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, +): SeriesPrimaryAction? = + seriesPrimaryAction( + content = WatchingContentRef(type = type, id = id), + entries = entries, + watchedItems = watchedItems, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, + ) + +internal fun MetaDetails.seriesPrimaryAction( + content: WatchingContentRef, + entries: List, + watchedItems: List, + todayIsoDate: String, + preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, ): SeriesPrimaryAction? = decideSeriesPrimaryAction( - content = WatchingContentRef(type = type, id = id), + content = content, episodes = videos.map(MetaVideo::toDomainReleasedEpisode), progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord), watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord), todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, )?.toLegacySeriesPrimaryAction() internal fun MetaVideo.playLabel(): String = 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 e5140b74..1c598f66 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 @@ -100,6 +100,7 @@ fun DetailSeriesContent( blurUnwatchedEpisodes: Boolean = false, onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, + onSeasonLongPress: ((Int) -> Unit)? = null, ) { val hasVideos = meta.videos.isNotEmpty() if (meta.type != "series" && !hasVideos) return @@ -230,12 +231,14 @@ fun DetailSeriesContent( currentSeason = currentSeason, sizing = sizing, onSelect = { selectedSeasonOverride = it }, + onLongPress = onSeasonLongPress, ) SeasonViewMode.Text -> SeasonTextChipScrollRow( seasons = seasons, currentSeason = currentSeason, sizing = sizing, onSelect = { selectedSeasonOverride = it }, + onLongPress = onSeasonLongPress, ) } } @@ -245,6 +248,7 @@ fun DetailSeriesContent( currentSeason = currentSeason, sizing = sizing, onSelect = { selectedSeasonOverride = it }, + onLongPress = onSeasonLongPress, ) } } @@ -372,12 +376,14 @@ private fun SeasonViewModeToggle( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SeasonTextChipScrollRow( seasons: List, currentSeason: Int, sizing: SeriesContentSizing, onSelect: (Int) -> Unit, + onLongPress: ((Int) -> Unit)?, ) { val seasonListState = rememberLazyListState() var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) } @@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow( Color.Transparent }, ) - .clickable { onSelect(season) } + .combinedClickable( + onClick = { onSelect(season) }, + onLongClick = onLongPress?.let { handler -> { handler(season) } }, + ) .padding( horizontal = sizing.seasonChipHorizontalPadding, vertical = sizing.seasonChipVerticalPadding, @@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow( currentSeason: Int, sizing: SeriesContentSizing, onSelect: (Int) -> Unit, + onLongPress: ((Int) -> Unit)?, ) { val seasonListState = rememberLazyListState() var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) } @@ -475,11 +485,13 @@ private fun SeasonPosterScrollRow( isSelected = season == currentSeason, sizing = sizing, onClick = { onSelect(season) }, + onLongClick = onLongPress?.let { handler -> { handler(season) } }, ) } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SeasonPosterButton( label: String, @@ -487,11 +499,15 @@ private fun SeasonPosterButton( isSelected: Boolean, sizing: SeriesContentSizing, onClick: () -> Unit, + onLongClick: (() -> Unit)?, ) { Column( modifier = Modifier .width(sizing.seasonPosterWidth) - .clickable(onClick = onClick), + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 44c02bba..00dd40f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -1,14 +1,9 @@ package com.nuvio.app.features.details.components -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.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.CheckCircle import androidx.compose.material.icons.filled.DoneAll @@ -135,6 +130,73 @@ fun EpisodeWatchedActionSheet( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SeasonWatchedActionSheet( + seasonLabel: String, + isSeasonWatched: Boolean, + canMarkPreviousSeasons: Boolean, + onDismiss: () -> Unit, + onToggleSeasonWatched: () -> Unit, + onMarkPreviousSeasonsWatched: () -> Unit, +) { + 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(16.dp)), + ) { + Text( + text = seasonLabel, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.PlaylistAddCheckCircle, + title = if (isSeasonWatched) { + stringResource(Res.string.episode_mark_season_unwatched, seasonLabel) + } else { + stringResource(Res.string.episode_mark_season_watched, seasonLabel) + }, + onClick = { + onToggleSeasonWatched() + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) + if (canMarkPreviousSeasons) { + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.DoneAll, + title = stringResource(Res.string.episode_mark_previous_seasons_watched), + onClick = { + onMarkPreviousSeasonsWatched() + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) + } + } + } +} + @Composable private fun EpisodeActionSheetHeader( episode: MetaVideo, 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 030a0643..6cabeec3 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 @@ -25,8 +25,11 @@ import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.cloud.findPlaybackTargetForProgress +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.nextReleasedEpisodeAfter +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.details.SeriesPrimaryAction +import com.nuvio.app.features.details.seriesPrimaryAction import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -48,6 +51,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode +import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching @@ -55,14 +59,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW 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.WatchProgressSourceLocal -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.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem +import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository @@ -175,47 +177,41 @@ fun HomeScreen( ) } - val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { - if (isTraktProgressActive) emptyList() else watchedUiState.items - } - - val allNextUpSeedEntries = remember( + val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - effectiveWatchedItems, + watchedUiState.items, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { - buildTvParityNextUpSeedEntries( + buildHomeNextUpSeedCandidates( progressEntries = watchProgressUiState.entries, - watchedItems = effectiveWatchedItems, + watchedItems = watchedUiState.items, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val recentNextUpSeedEntries = remember( - allNextUpSeedEntries, + val recentNextUpSeedCandidates = remember( + allNextUpSeedCandidates, isTraktProgressActive, traktSettingsUiState.continueWatchingDaysCap, ) { - filterEntriesForTraktContinueWatchingWindow( - entries = allNextUpSeedEntries, + filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + candidates = allNextUpSeedCandidates, isTraktProgressActive = isTraktProgressActive, daysCap = traktSettingsUiState.continueWatchingDaysCap, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) { - allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId } + val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } } - val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) { - allNextUpSeedEntries.mapNotNull { entry -> - val season = entry.seasonNumber ?: return@mapNotNull null - val episode = entry.episodeNumber ?: return@mapNotNull null - entry.parentMetaId to (season to episode) + val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates.associate { candidate -> + candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber) }.toMap() } @@ -229,10 +225,10 @@ fun HomeScreen( } } - val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { - allNextUpSeedEntries - .groupBy { entry -> entry.parentMetaId } - .mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE } + val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates + .groupBy { candidate -> candidate.content.id } + .mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE } } val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) { @@ -250,17 +246,9 @@ fun HomeScreen( .toSet() } - val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) { - recentNextUpSeedEntries.mapNotNull { seed -> - val season = seed.seasonNumber ?: return@mapNotNull null - val episode = seed.episodeNumber ?: return@mapNotNull null - if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null - CompletedSeriesCandidate( - content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId), - seasonNumber = season, - episodeNumber = episode, - markedAtEpochMs = seed.lastUpdatedEpochMs, - ) + val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) { + recentNextUpSeedCandidates.filter { candidate -> + candidate.content.id !in nextUpSuppressedSeriesIds } } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() @@ -270,6 +258,17 @@ fun HomeScreen( var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } + val shouldValidateMissingNextUpSeeds = remember( + isTraktProgressActive, + watchProgressUiState.hasLoadedRemoteProgress, + watchedUiState.isLoaded, + ) { + if (isTraktProgressActive) { + watchProgressUiState.hasLoadedRemoteProgress + } else { + watchedUiState.isLoaded + } + } val cachedNextUpItems = remember( cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys, @@ -277,6 +276,7 @@ fun HomeScreen( currentNextUpSeedByContentId, isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, + shouldValidateMissingNextUpSeeds, processedNextUpContentIds, nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, @@ -284,25 +284,13 @@ fun HomeScreen( ) { cachedSnapshots.first.mapNotNull { cached -> if ( - !isTraktProgressActive && - watchedUiState.isLoaded && - cached.contentId !in activeNextUpSeedContentIds - ) { - return@mapNotNull null - } - if ( - isTraktProgressActive && - watchProgressUiState.hasLoadedRemoteProgress && + shouldValidateMissingNextUpSeeds && cached.contentId !in activeNextUpSeedContentIds ) { return@mapNotNull null } val currentSeed = currentNextUpSeedByContentId[cached.contentId] - if ( - currentSeed != null && - cached.seedSeason != null && - cached.seedEpisode != null - ) { + if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode if (seedChanged) return@mapNotNull null @@ -335,8 +323,16 @@ fun HomeScreen( nextUpItemsBySeries, cachedNextUpItems, continueWatchingPreferences.dismissedNextUpKeys, + activeNextUpSeedContentIds, + currentNextUpSeedByContentId, + shouldValidateMissingNextUpSeeds, ) { - val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) -> + val liveNextUpItems = filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries = nextUpItemsBySeries, + activeSeedContentIds = activeNextUpSeedContentIds, + currentSeedByContentId = currentNextUpSeedByContentId, + shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds, + ).filterValues { (_, item) -> nextUpDismissKey( item.parentMetaId, item.nextUpSeedSeasonNumber, @@ -412,6 +408,9 @@ fun HomeScreen( visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + watchProgressUiState.entries, + watchedUiState.items, ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() @@ -462,12 +461,18 @@ fun HomeScreen( if (meta == null) { return@withPermit null } - val nextEpisode = meta.nextReleasedEpisodeAfter( - seasonNumber = completedEntry.seasonNumber, - episodeNumber = completedEntry.episodeNumber, + val action = meta.seriesPrimaryAction( + content = completedEntry.content, + entries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) + if (action?.resumePositionMs != null) { + return@withPermit null + } + val nextEpisode = action?.let { meta.videoForSeriesAction(it) } if (nextEpisode == null) { return@withPermit null } @@ -736,40 +741,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow( return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } } -private fun buildTvParityNextUpSeedEntries( +internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + candidates: List, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + if (!isTraktProgressActive) return candidates + val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) + if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates + + val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) + return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs } +} + +internal fun buildHomeNextUpSeedCandidates( progressEntries: List, watchedItems: List, isTraktProgressActive: Boolean, preferFurthestEpisode: Boolean, nowEpochMs: Long, -): List { - val rawSeeds = if (isTraktProgressActive) { - progressEntries.asSequence() - .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } - .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } - .filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) } - .toList() - } else { - watchedItems.asSequence() - .filter { item -> item.type.isSeriesTypeForContinueWatching() } - .filter { item -> item.season != null && item.episode != null && item.season != 0 } - .filter { item -> !isMalformedNextUpSeedContentId(item.id) } - .map { item -> item.toNextUpSeedEntry() } - .toList() +): List { + val progressSeeds = progressEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } + .filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) } + .filter { entry -> + if (isTraktProgressActive) { + shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs) + } else { + entry.shouldUseAsCompletedSeedForContinueWatching() + } + } + .toList() + val watchedSeeds = watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) } - return if (isTraktProgressActive) { - mergeTvTraktNextUpSeeds(rawSeeds) - } else { - rawSeeds - .groupBy { entry -> nextUpSeedKey(entry) } - .mapNotNull { (_, entries) -> - choosePreferredNextUpSeed( - entries = entries, - preferFurthestEpisode = preferFurthestEpisode, - ) - } - .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + return WatchingState.latestCompletedBySeries( + progressEntries = progressSeeds, + watchedItems = watchedSeeds, + preferFurthestEpisode = preferFurthestEpisode, + ).mapNotNull { (content, completed) -> + if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null + if (completed.seasonNumber == 0) return@mapNotNull null + if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null + CompletedSeriesCandidate( + content = content, + seasonNumber = completed.seasonNumber, + episodeNumber = completed.episodeNumber, + markedAtEpochMs = completed.markedAtEpochMs, + ) + }.sortedWith( + compareByDescending { candidate -> candidate.markedAtEpochMs } + .thenByDescending { candidate -> candidate.seasonNumber } + .thenByDescending { candidate -> candidate.episodeNumber }, + ) +} + +internal fun filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries: Map>, + activeSeedContentIds: Set, + currentSeedByContentId: Map>, + shouldDropItemsWithoutActiveSeed: Boolean, +): Map> = + nextUpItemsBySeries.filter { (contentId, pair) -> + if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) { + return@filter false + } + val item = pair.second + val currentSeed = currentSeedByContentId[contentId] ?: return@filter true + item.nextUpSeedSeasonNumber == currentSeed.first && + item.nextUpSeedEpisodeNumber == currentSeed.second + } + +private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { + if (action.seasonNumber != null && action.episodeNumber != null) { + videos.firstOrNull { video -> + video.season == action.seasonNumber && + video.episode == action.episodeNumber + }?.let { return it } + } + return videos.firstOrNull { video -> + com.nuvio.app.features.watchprogress.buildPlaybackVideoId( + parentMetaId = id, + seasonNumber = video.season, + episodeNumber = video.episode, + fallbackVideoId = video.id, + ) == action.videoId || video.id == action.videoId } } @@ -784,103 +848,6 @@ private fun shouldUseAsTraktNextUpSeed( return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS } -private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry = - WatchProgressEntry( - contentType = type, - parentMetaId = id, - parentMetaType = type, - videoId = id, - title = name, - poster = poster, - seasonNumber = season, - episodeNumber = episode, - lastPositionMs = 1L, - durationMs = 1L, - lastUpdatedEpochMs = markedAtEpochMs, - isCompleted = true, - progressPercent = 100f, - source = WatchProgressSourceLocal, - ) - -private fun nextUpSeedKey(entry: WatchProgressEntry): String = - entry.parentMetaId.trim() - -private fun mergeTvTraktNextUpSeeds(entries: List): List { - val merged = linkedMapOf() - entries - .filter { entry -> entry.source == WatchProgressSourceTraktShowProgress } - .forEach { seed -> - merged[nextUpSeedKey(seed)] = seed - } - entries - .filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback } - .forEach { seed -> - val key = nextUpSeedKey(seed) - val existing = merged[key] - if (existing == null || shouldReplaceNextUpSeed(existing, seed)) { - merged[key] = seed - } - } - return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs } -} - -private fun shouldReplaceNextUpSeed( - existing: WatchProgressEntry, - candidate: WatchProgressEntry, -): Boolean { - val candidateSeason = candidate.seasonNumber ?: -1 - val candidateEpisode = candidate.episodeNumber ?: -1 - val existingSeason = existing.seasonNumber ?: -1 - val existingEpisode = existing.episodeNumber ?: -1 - return candidateSeason > existingSeason || - ( - candidateSeason == existingSeason && - ( - candidateEpisode > existingEpisode || - ( - candidateEpisode == existingEpisode && - candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs - ) - ) - ) -} - -private fun choosePreferredNextUpSeed( - entries: List, - preferFurthestEpisode: Boolean, -): WatchProgressEntry? { - if (entries.isEmpty()) return null - val bestRank = entries.minOf(::nextUpSeedSourceRank) - return entries - .asSequence() - .filter { entry -> nextUpSeedSourceRank(entry) == bestRank } - .maxWithOrNull( - if (preferFurthestEpisode) { - compareBy( - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - { it.lastUpdatedEpochMs }, - ) - } else { - compareBy( - { it.lastUpdatedEpochMs }, - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - ) - }, - ) -} - -private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int = - when (entry.source) { - WatchProgressSourceTraktPlayback, - WatchProgressSourceTraktShowProgress, - -> 0 - WatchProgressSourceTraktHistory -> 1 - WatchProgressSourceLocal -> 2 - else -> 4 - } - private fun shouldTreatAsActiveInProgressForNextUpSuppression( progress: WatchProgressEntry, latestCompletedAt: Long?, @@ -890,15 +857,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( return progress.lastUpdatedEpochMs >= latestCompletedAt } -private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { - val trimmed = contentId?.trim().orEmpty() - if (trimmed.isEmpty()) return true - return when (trimmed.lowercase()) { - "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true - else -> false - } -} - private fun heroMobileBelowSectionHeightHint( maxWidthDp: Float, continueWatchingVisible: Boolean, @@ -1016,7 +974,7 @@ private fun applyStreamingStyleSort( return sortedReleased + sortedUnreleased } -private data class CompletedSeriesCandidate( +internal data class CompletedSeriesCandidate( val content: WatchingContentRef, val seasonNumber: Int, val episodeNumber: Int, 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 ce00fcde..27c0036d 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 @@ -57,8 +57,10 @@ import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules import com.nuvio.app.features.player.skip.SkipIntroButton import com.nuvio.app.features.player.skip.SkipIntroRepository import com.nuvio.app.features.player.skip.SkipInterval +import com.nuvio.app.features.streams.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamAutoPlayMode import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState @@ -70,10 +72,12 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.isIos +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import kotlin.math.abs @@ -87,6 +91,8 @@ private const val PlayerLockedOverlayDurationMs = 2_000L private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerRightGestureBoundary = 0.6f private const val PlayerVerticalGestureSensitivity = 1f +/** Hard ceiling for next-episode stream search to prevent hanging forever. */ +private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L private val PlayerSliderOverlayGap = 12.dp private val PlayerTimeRowHeight = 36.dp private val PlayerActionRowHeight = 50.dp @@ -324,6 +330,15 @@ fun PlayerScreen( } } + // Persist binge group per content so subsequent episode plays + // (from CW, Details, or next-episode) can reuse the same source group. + LaunchedEffect(currentStreamBingeGroup, parentMetaId) { + val bg = currentStreamBingeGroup + if (bg != null && parentMetaId.isNotBlank()) { + BingeGroupCacheRepository.save(parentMetaId, bg) + } + } + ManagePlayerPictureInPicture( isPlaying = playbackSnapshot.isPlaying, playerSize = layoutSize, @@ -1102,6 +1117,12 @@ fun PlayerScreen( settings.streamAutoPlayPreferBingeGroup ) + // bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle) + val bingeGroupOnlyManualMode = + shouldAutoSelectInManualMode && + !settings.streamAutoPlayNextEpisodeEnabled && + settings.streamAutoPlayPreferBingeGroup + // Determine auto-play mode for next episode val effectiveMode = if (shouldAutoSelectInManualMode) { StreamAutoPlayMode.FIRST_STREAM @@ -1109,7 +1130,7 @@ fun PlayerScreen( settings.streamAutoPlayMode } val effectiveSource = if (shouldAutoSelectInManualMode) { - com.nuvio.app.features.streams.StreamAutoPlaySource.ALL_SOURCES + StreamAutoPlaySource.ALL_SOURCES } else { settings.streamAutoPlaySource } @@ -1129,6 +1150,13 @@ fun PlayerScreen( settings.streamAutoPlayRegex } + // Determine preferred binge group from current stream (not cache) + val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { + currentStreamBingeGroup + } else { + null + } + nextEpisodeAutoPlayJob = scope.launch { PlayerStreamsRepository.loadEpisodeStreams( type = type, @@ -1140,61 +1168,196 @@ fun PlayerScreen( val installedAddonNames = AddonRepository.uiState.value.addons .map { it.displayTitle } .toSet() + val debridSettings = DebridSettingsRepository.snapshot() - val timeoutMs = settings.streamAutoPlayTimeoutSeconds * 1000L - val startTime = WatchProgressClock.nowEpochMs() + val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds + var autoSelectTriggered = false + var timeoutElapsed = false + var selectedStream: StreamItem? = null + val autoSelectSettled = CompletableDeferred() - // Collect streams as they arrive - PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> - if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest - - val allStreams = state.groups.flatMap { it.streams } - val elapsed = WatchProgressClock.nowEpochMs() - startTime - - val selected = if (allStreams.isNotEmpty()) { - StreamAutoPlaySelector.selectAutoPlayStream( - streams = allStreams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { - currentStreamBingeGroup - } else { - null - }, - preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - debridEnabled = DebridSettingsRepository.snapshot().canResolvePlayableLinks, - ) - } else null - - if (selected != null || !state.isAnyLoading || elapsed >= timeoutMs) { - nextEpisodeAutoPlaySearching = false - if (selected != null) { - nextEpisodeAutoPlaySourceName = selected.addonName - // Countdown before playing - for (i in 3 downTo 1) { - nextEpisodeAutoPlayCountdown = i - delay(1000) - } - switchToEpisodeStream(selected, nextVideo) - showNextEpisodeCard = false - nextEpisodeAutoPlayCountdown = null - nextEpisodeAutoPlaySourceName = null - } else if (!state.isAnyLoading || elapsed >= timeoutMs) { - // No stream found — open the episode streams panel for manual selection - episodeStreamsPanelState = EpisodeStreamsPanelState( - showStreams = true, - selectedEpisode = nextVideo, - ) - showEpisodesPanel = true - showNextEpisodeCard = false - } - return@collectLatest + fun settleAutoSelect() { + if (!autoSelectSettled.isCompleted) { + autoSelectSettled.complete(Unit) } } + + fun selectStream(stream: StreamItem) { + autoSelectTriggered = true + selectedStream = stream + settleAutoSelect() + } + + fun finishWithoutSelection() { + autoSelectTriggered = true + settleAutoSelect() + } + + // Full select: tries binge group first, then falls back to mode-based selection + fun trySelectStream(streams: List): StreamItem? { + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + bingeGroupOnly = bingeGroupOnlyManualMode, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + } + + // Binge group only early match: returns null if no binge group match + fun tryBingeGroupOnly(streams: List): StreamItem? { + if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + } + + val innerJob = launch { + // Collect streams as they arrive + PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> + if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest + + val allStreams = state.groups.flatMap { it.streams } + + if (autoSelectTriggered) { + // Already resolved + } else if (timeoutElapsed) { + // Timeout elapsed: full select (binge group + fallback to mode) + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } else { + // Before timeout: eagerly check binge group only + if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + selectStream(earlyMatch) + } + } + } + + // If all addons finished loading and no match yet, do a final full select + if (!autoSelectTriggered && !state.isAnyLoading) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + if (!autoSelectTriggered) { + finishWithoutSelection() + } + return@collectLatest + } + + if (autoSelectTriggered) return@collectLatest + } + } + + // Timeout logic + val timeoutMs = timeoutSeconds * 1_000L + val isBoundedTimeout = timeoutSeconds in 1..30 + + if (isBoundedTimeout) { + // Bounded timeout (1-30s): wait, then trigger full select + delay(timeoutMs) + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } + if (selectedStream != null) { + innerJob.cancel() + } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { + // Streams arrived but no match after full select — don't wait further + innerJob.cancel() + finishWithoutSelection() + } else { + // No addon responded yet — wait with hard ceiling + val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null) { + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + } + } else { + // Instant (0) or unlimited: timeoutElapsed immediately so each + // addon response triggers a full select attempt in the collect. + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + trySelectStream(allStreams)?.let(::selectStream) + } + } + val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS + val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null) { + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + } + + // Handle result + nextEpisodeAutoPlaySearching = false + if (selectedStream != null) { + nextEpisodeAutoPlaySourceName = selectedStream!!.addonName + // Countdown before playing + for (i in 3 downTo 1) { + nextEpisodeAutoPlayCountdown = i + delay(1000) + } + switchToEpisodeStream(selectedStream!!, nextVideo) + showNextEpisodeCard = false + nextEpisodeAutoPlayCountdown = null + nextEpisodeAutoPlaySourceName = null + } else { + // No stream found — open the episode streams panel for manual selection + episodeStreamsPanelState = EpisodeStreamsPanelState( + showStreams = true, + selectedEpisode = nextVideo, + ) + showEpisodesPanel = true + showNextEpisodeCard = false + } } } @@ -1892,7 +2055,7 @@ fun PlayerScreen( // Skip intro/recap/outro button if (!playerControlsLocked) { SkipIntroButton( - interval = activeSkipInterval, + interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, dismissed = skipIntervalDismissed, controlsVisible = controlsVisible, onSkip = { 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 1c5cd8c7..3ccbfea8 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 @@ -7,6 +7,29 @@ import com.nuvio.app.features.streams.StreamAutoPlaySource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.abs + +val STREAM_AUTO_PLAY_TIMEOUT_VALUES: List = listOf( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, Int.MAX_VALUE +) + +/** + * Snaps [value] to the nearest allowed timeout value in [STREAM_AUTO_PLAY_TIMEOUT_VALUES]. + * Ties break to the lower value. Negative values snap to 0. + */ +fun snapToAllowedTimeout(value: Int): Int { + if (value <= 0) return 0 + var bestValue = STREAM_AUTO_PLAY_TIMEOUT_VALUES[0] + var bestDistance = Long.MAX_VALUE + for (allowed in STREAM_AUTO_PLAY_TIMEOUT_VALUES) { + val distance = abs(value.toLong() - allowed.toLong()) + if (distance < bestDistance || (distance == bestDistance && allowed < bestValue)) { + bestDistance = distance + bestValue = allowed + } + } + return bestValue +} data class PlayerSettingsUiState( val showLoadingOverlay: Boolean = true, @@ -38,6 +61,7 @@ data class PlayerSettingsUiState( val introSubmitEnabled: Boolean = false, val streamAutoPlayNextEpisodeEnabled: Boolean = false, val streamAutoPlayPreferBingeGroup: Boolean = true, + val streamAutoPlayReuseBingeGroup: Boolean = true, val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE, val nextEpisodeThresholdPercent: Float = 99f, val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, @@ -93,6 +117,7 @@ object PlayerSettingsRepository { private var introSubmitEnabled = false private var streamAutoPlayNextEpisodeEnabled = false private var streamAutoPlayPreferBingeGroup = true + private var streamAutoPlayReuseBingeGroup = true private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE private var nextEpisodeThresholdPercent = 99f private var nextEpisodeThresholdMinutesBeforeEnd = 2f @@ -153,6 +178,7 @@ object PlayerSettingsRepository { introSubmitEnabled = false streamAutoPlayNextEpisodeEnabled = false streamAutoPlayPreferBingeGroup = true + streamAutoPlayReuseBingeGroup = true nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE nextEpisodeThresholdPercent = 99f nextEpisodeThresholdMinutesBeforeEnd = 2f @@ -232,6 +258,14 @@ object PlayerSettingsRepository { } streamAutoPlayRegex = PlayerSettingsStorage.loadStreamAutoPlayRegex() ?: "" streamAutoPlayTimeoutSeconds = PlayerSettingsStorage.loadStreamAutoPlayTimeoutSeconds() ?: 3 + // Legacy migration: 11 was the old sentinel for "unlimited" + if (streamAutoPlayTimeoutSeconds == 11) { + streamAutoPlayTimeoutSeconds = Int.MAX_VALUE + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(streamAutoPlayTimeoutSeconds) + } else if (streamAutoPlayTimeoutSeconds !in STREAM_AUTO_PLAY_TIMEOUT_VALUES) { + streamAutoPlayTimeoutSeconds = snapToAllowedTimeout(streamAutoPlayTimeoutSeconds) + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(streamAutoPlayTimeoutSeconds) + } skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: "" @@ -239,6 +273,7 @@ object PlayerSettingsRepository { introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true + streamAutoPlayReuseBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayReuseBingeGroup() ?: true nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode() ?.let { runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrNull() } ?: NextEpisodeThresholdMode.PERCENTAGE @@ -524,6 +559,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveStreamAutoPlayPreferBingeGroup(enabled) } + fun setStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + ensureLoaded() + if (streamAutoPlayReuseBingeGroup == enabled) return + streamAutoPlayReuseBingeGroup = enabled + publish() + PlayerSettingsStorage.saveStreamAutoPlayReuseBingeGroup(enabled) + } + fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) { ensureLoaded() if (nextEpisodeThresholdMode == mode) return @@ -753,6 +796,7 @@ object PlayerSettingsRepository { introSubmitEnabled = introSubmitEnabled, streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled, streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup, + streamAutoPlayReuseBingeGroup = streamAutoPlayReuseBingeGroup, nextEpisodeThresholdMode = nextEpisodeThresholdMode, nextEpisodeThresholdPercent = nextEpisodeThresholdPercent, nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd, 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 d36cd301..2b07020e 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 @@ -68,6 +68,8 @@ internal expect object PlayerSettingsStorage { fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) fun loadStreamAutoPlayPreferBingeGroup(): Boolean? fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) + fun loadStreamAutoPlayReuseBingeGroup(): Boolean? + fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) fun loadNextEpisodeThresholdMode(): String? fun saveNextEpisodeThresholdMode(mode: String) fun loadNextEpisodeThresholdPercent(): Float? 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 83daa6df..98b1a83b 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 @@ -59,6 +59,7 @@ import com.nuvio.app.features.player.IosHardwareDecoderMode import com.nuvio.app.features.player.IosTargetPrimaries import com.nuvio.app.features.player.IosTargetTransfer import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.player.STREAM_AUTO_PLAY_TIMEOUT_VALUES import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.languageLabelForCode @@ -365,7 +366,7 @@ private fun PlaybackSettingsSection( val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds val timeoutLabel = when (timeoutSec) { 0 -> stringResource(Res.string.settings_playback_timeout_instant) - 11 -> stringResource(Res.string.settings_playback_timeout_unlimited) + Int.MAX_VALUE -> stringResource(Res.string.settings_playback_timeout_unlimited) else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) } Column( @@ -391,8 +392,11 @@ private fun PlaybackSettingsSection( } ValueBox(text = timeoutLabel, modifier = Modifier.wrapContentWidth()) } - var sliderValue by remember(timeoutSec) { mutableFloatStateOf(timeoutSec.toFloat()) } - var lastHapticStep by remember(timeoutSec) { mutableStateOf(timeoutSec.toFloat()) } + val timeoutIndex = STREAM_AUTO_PLAY_TIMEOUT_VALUES.indexOf(timeoutSec) + .coerceAtLeast(0) + val maxIndex = (STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1).toFloat() + var sliderValue by remember(timeoutIndex) { mutableFloatStateOf(timeoutIndex.toFloat()) } + var lastHapticStep by remember(timeoutIndex) { mutableStateOf(timeoutIndex.toFloat()) } Slider( value = sliderValue, onValueChange = { @@ -405,10 +409,11 @@ private fun PlaybackSettingsSection( } }, onValueChangeFinished = { - PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(sliderValue.toInt()) + val index = sliderValue.toInt().coerceIn(0, STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1) + PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(STREAM_AUTO_PLAY_TIMEOUT_VALUES[index]) }, - valueRange = 0f..11f, - steps = calculateSteps(0f, 11f, 1f), + valueRange = 0f..maxIndex, + steps = calculateSteps(0f, maxIndex, 1f), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, @@ -658,6 +663,16 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayPreferBingeGroup, ) + if (autoPlayPlayerSettings.streamAutoPlayPreferBingeGroup) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_reuse_binge_group), + description = stringResource(Res.string.settings_playback_reuse_binge_group_description), + checked = autoPlayPlayerSettings.streamAutoPlayReuseBingeGroup, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayReuseBingeGroup, + ) + } SettingsGroupDivider(isTablet = isTablet) var showThresholdModeDialog by remember { mutableStateOf(false) } SettingsNavigationRow( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt new file mode 100644 index 00000000..5ca8968c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.streams + +object BingeGroupCacheRepository { + + fun save(contentId: String, bingeGroup: String) { + BingeGroupCacheStorage.save(hashedKey(contentId), bingeGroup) + } + + fun get(contentId: String): String? { + return BingeGroupCacheStorage.load(hashedKey(contentId)) + } + + fun remove(contentId: String) { + BingeGroupCacheStorage.remove(hashedKey(contentId)) + } + + private fun hashedKey(contentId: String): String { + val hash = contentId.fold(0L) { acc, c -> acc * 31 + c.code }.toULong() + return "binge_group_$hash" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt new file mode 100644 index 00000000..eda9a4ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.streams + +internal expect object BingeGroupCacheStorage { + fun load(hashedKey: String): String? + fun save(hashedKey: String, value: String) + fun remove(hashedKey: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt index 445af267..0ad4aa10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.player.PlayerSettingsUiState object StreamAutoPlayPolicy { fun isEffectivelyEnabled(settings: PlayerSettingsUiState): Boolean { if (settings.streamReuseLastLinkEnabled) return true + if (settings.streamAutoPlayReuseBingeGroup && settings.streamAutoPlayPreferBingeGroup) return true return when (settings.streamAutoPlayMode) { StreamAutoPlayMode.MANUAL -> false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index db1552c8..00d9856f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -40,6 +40,7 @@ object StreamAutoPlaySelector { selectedPlugins: Set, preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, + bingeGroupOnly: Boolean = false, debridEnabled: Boolean = true, activeResolverProviderId: String? = null, ): StreamItem? = @@ -53,6 +54,7 @@ object StreamAutoPlaySelector { selectedPlugins = selectedPlugins, preferredBingeGroup = preferredBingeGroup, preferBingeGroupInSelection = preferBingeGroupInSelection, + bingeGroupOnly = bingeGroupOnly, debridEnabled = debridEnabled, activeResolverProviderId = activeResolverProviderId, ).stream @@ -67,6 +69,7 @@ object StreamAutoPlaySelector { selectedPlugins: Set, preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, + bingeGroupOnly: Boolean = false, debridEnabled: Boolean = true, activeResolverProviderId: String? = null, ): StreamAutoPlayEvaluation { @@ -86,10 +89,34 @@ object StreamAutoPlaySelector { } } if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation() - if (mode == StreamAutoPlayMode.MANUAL) return StreamAutoPlayEvaluation() + if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) { + return StreamAutoPlayEvaluation() + } val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() - val preferredReadyStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { + val bingeGroupCandidates = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { + candidateStreams.filter { stream -> stream.behaviorHints.bingeGroup == targetBingeGroup } + } else { + emptyList() + } + val preferredReadyStream = bingeGroupCandidates.firstOrNull { stream -> + stream.isAutoPlayable(debridEnabled, activeResolverProviderId) + } + if (bingeGroupOnly) { + val readyStreams = preferredReadyStream?.let(::listOf).orEmpty() + return StreamAutoPlayEvaluation( + stream = preferredReadyStream, + readyStreams = readyStreams, + hasPendingDebridCandidate = preferredReadyStream == null && + bingeGroupCandidates.any { + it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + }, + ) + } + if (mode == StreamAutoPlayMode.MANUAL) { + return StreamAutoPlayEvaluation() + } + val preferredStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { candidateStreams.firstOrNull { stream -> stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable(debridEnabled, activeResolverProviderId) @@ -139,13 +166,13 @@ object StreamAutoPlaySelector { } } } - if (matchingStreams.isEmpty() && preferredReadyStream == null) return StreamAutoPlayEvaluation() + if (matchingStreams.isEmpty() && preferredStream == null) return StreamAutoPlayEvaluation() val readyStreams = buildList { - preferredReadyStream?.let(::add) + preferredStream?.let(::add) matchingStreams .filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) } - .filterNot { it == preferredReadyStream } + .filterNot { it == preferredStream } .forEach(::add) } val selected = readyStreams.firstOrNull() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index fffbb8be..bb17f2f5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -50,10 +50,11 @@ object StreamsRepository { ): String = "$type::$videoId::$season::$episode::$manualSelection" - fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun load(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = season, episode = episode, manualSelection = manualSelection, @@ -61,10 +62,11 @@ object StreamsRepository { ) } - fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun reload(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = season, episode = episode, manualSelection = manualSelection, @@ -72,7 +74,7 @@ object StreamsRepository { ) } - private fun load(type: String, videoId: String, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { + private fun load(type: String, videoId: String, parentMetaId: String?, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.uiState.value @@ -108,7 +110,21 @@ object StreamsRepository { val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && !StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)) - val isDirectAutoPlayFlow = isAutoPlayEnabled + + // Look up persisted binge group when both settings are enabled + val persistedBingeGroup = if ( + playerSettings.streamAutoPlayPreferBingeGroup && + playerSettings.streamAutoPlayReuseBingeGroup + ) { + parentMetaId?.let { BingeGroupCacheRepository.get(it) } + } else null + + // Enable direct auto-play flow if normal auto-play is enabled, + // OR if we have a persisted binge group in MANUAL mode + val bingeGroupDirectFlow = !manualSelection && + persistedBingeGroup != null && + autoPlayMode == StreamAutoPlayMode.MANUAL + val isDirectAutoPlayFlow = isAutoPlayEnabled || bingeGroupDirectFlow if (isDirectAutoPlayFlow) { _uiState.value = StreamsUiState( @@ -232,6 +248,7 @@ object StreamsRepository { val installedAddonIds = streamAddons.map { it.addonId }.toSet() val debridAvailabilityJobs = mutableListOf() var autoSelectTriggered = false + var timeoutElapsed = false fun publishCompletion(completion: StreamLoadCompletion) { if (completions.trySend(completion).isFailure) { log.d { "Ignoring late stream load completion after channel close" } @@ -280,11 +297,58 @@ object StreamsRepository { debridAvailabilityJobs += availabilityJob } - val timeoutJob = if (isAutoPlayEnabled) { - val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L - if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) { + val timeoutJob = if (isDirectAutoPlayFlow) { + val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds + val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE + // Timeout semantics: + // - 0 (instant): timeoutElapsed immediately, full select on each response + // - 1-30 (bounded): wait the configured delay, then full select + // - unlimited (Int.MAX_VALUE): timeoutElapsed immediately, full select on each response, + // with 60s hard fallback to stream picker + if (timeoutSeconds <= 0 || isUnlimitedTimeout) { + timeoutElapsed = true + // For unlimited: launch a hard 60s fallback to dismiss overlay + if (isUnlimitedTimeout) { + launch { + delay(60_000L) + if (!autoSelectTriggered) { + autoSelectTriggered = true + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + _uiState.update { it.copy(autoPlayStream = selected) } + } + if (_uiState.value.autoPlayStream == null) { + _uiState.update { + it.copy( + isDirectAutoPlayFlow = false, + showDirectAutoPlayOverlay = false, + ) + } + } + } + } + } else { + null + } + } else { + // Bounded timeout (1-30s) launch { - delay(timeoutMs) + delay(timeoutSeconds * 1_000L) + timeoutElapsed = true if (!autoSelectTriggered) { val allStreams = _uiState.value.groups.flatMap { it.streams } if (allStreams.isNotEmpty()) { @@ -296,6 +360,9 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, debridEnabled = debridSettings.canResolvePlayableLinks, activeResolverProviderId = debridSettings.activeResolverProviderId, ) @@ -319,8 +386,6 @@ object StreamsRepository { } } } - } else { - null } } else { null @@ -488,9 +553,58 @@ object StreamsRepository { ) } } + + // Early match / timeout-elapsed auto-select on each addon response + if (isDirectAutoPlayFlow && !autoSelectTriggered) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + if (timeoutElapsed) { + // After timeout: full fallback (bingeGroupOnly = false) + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + if (selected != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = selected) } + } + } else if (persistedBingeGroup != null) { + // Before timeout: try binge-group-only early match + val earlyMatch = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + if (earlyMatch != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = earlyMatch) } + } + } + } + } } - if (isAutoPlayEnabled && !autoSelectTriggered) { + // All addons finished — run final auto-select if not yet triggered + if (isDirectAutoPlayFlow && !autoSelectTriggered) { autoSelectTriggered = true val allStreams = _uiState.value.groups.flatMap { it.streams } val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( @@ -501,6 +615,9 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, debridEnabled = debridSettings.canResolvePlayableLinks, activeResolverProviderId = debridSettings.activeResolverProviderId, ) @@ -528,6 +645,7 @@ object StreamsRepository { } fun consumeAutoPlay() { + activeRequestKey = null _uiState.update { it.copy( autoPlayStream = null, 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 04853296..b7440d06 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 @@ -184,6 +184,7 @@ fun StreamsScreen( StreamsRepository.load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, @@ -281,6 +282,7 @@ fun StreamsScreen( StreamsRepository.reload( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, 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 c2ae8997..a208fe94 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 @@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json @Serializable private data class StoredWatchedPayload( val items: List = emptyList(), + val lastSuccessfulPushEpochMs: Long = 0L, ) object WatchedRepository { @@ -43,6 +44,7 @@ object WatchedRepository { private var hasLoaded = false private var currentProfileId: Int = 1 private var itemsByKey: MutableMap = mutableMapOf() + private var lastSuccessfulPushEpochMs: Long = 0L internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter private fun activePullSyncAdapter(): WatchedSyncAdapter = @@ -62,6 +64,7 @@ object WatchedRepository { hasLoaded = false currentProfileId = 1 itemsByKey.clear() + lastSuccessfulPushEpochMs = 0L _uiState.value = WatchedUiState() } @@ -72,13 +75,16 @@ object WatchedRepository { val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { - val items = runCatching { - json.decodeFromString(payload).items - }.getOrDefault(emptyList()) - itemsByKey = items + val storedPayload = runCatching { + json.decodeFromString(payload) + }.getOrDefault(StoredWatchedPayload()) + lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs + itemsByKey = storedPayload.items .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + } else { + lastSuccessfulPushEpochMs = 0L } publish() @@ -88,16 +94,23 @@ object WatchedRepository { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() currentProfileId = profileId + val pullStartedEpochMs = WatchedClock.nowEpochMs() + val localBeforePull = itemsByKey.values + .map(WatchedItem::normalizedMarkedAt) + .toList() + val lastPushEpochMs = lastSuccessfulPushEpochMs runCatching { val serverItems = activePullSyncAdapter().pull( profileId = profileId, pageSize = watchedItemsPageSize, ) - itemsByKey = serverItems - .map(WatchedItem::normalizedMarkedAt) - .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } - .toMutableMap() + itemsByKey = mergeWatchedItemsPreservingUnsynced( + serverItems = serverItems, + localItems = localBeforePull, + lastSuccessfulPushEpochMs = lastPushEpochMs, + pullStartedEpochMs = pullStartedEpochMs, + ).toMutableMap() hasLoaded = true publish() persist() @@ -213,6 +226,7 @@ object WatchedRepository { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId pushToActiveTargets(profileId = profileId, items = items) + recordSuccessfulPush(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched items" } } @@ -252,11 +266,24 @@ object WatchedRepository { items = itemsByKey.values .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs }, + lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, ), ), ) } + private fun recordSuccessfulPush(profileId: Int, items: Collection) { + if (profileId != currentProfileId) return + val latestPushed = items + .asSequence() + .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) } + .maxOrNull() + ?: return + if (latestPushed <= lastSuccessfulPushEpochMs) return + lastSuccessfulPushEpochMs = latestPushed + persist() + } + private fun shouldUseTraktWatchedSync(): Boolean = shouldUseTraktWatchedSync( isAuthenticated = TraktAuthRepository.isAuthenticated.value, @@ -294,6 +321,33 @@ object WatchedRepository { } } +internal fun mergeWatchedItemsPreservingUnsynced( + serverItems: Collection, + localItems: Collection, + lastSuccessfulPushEpochMs: Long, + pullStartedEpochMs: Long, +): Map { + val merged = serverItems + .map(WatchedItem::normalizedMarkedAt) + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + .toMutableMap() + + localItems + .map(WatchedItem::normalizedMarkedAt) + .forEach { localItem -> + val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode) + if (key in merged) return@forEach + val markedAt = localItem.markedAtEpochMs + val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs + val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs + if (wasMarkedAfterLastPush || wasMarkedDuringPull) { + merged[key] = localItem + } + } + + return merged +} + internal fun shouldUseTraktWatchedSync( isAuthenticated: Boolean, source: WatchProgressSource, 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 10263a55..d3c2bc37 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 @@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter( seasonNumber: Int?, episodeNumber: Int?, todayIsoDate: String, + showUnairedNextUp: Boolean = false, ): WatchingReleasedEpisode? { val sortedEpisodes = episodes.sortedWith( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), @@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter( candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, - showUnairedNextUp = false, + showUnairedNextUp = showUnairedNextUp, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } @@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction( watchedRecords: List, todayIsoDate: String, preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, ): WatchingSeriesPrimaryAction? { val resumeRecord = resumeProgressForSeries( content = content, @@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction( seasonNumber = latestCompletedEpisode.seasonNumber, episodeNumber = latestCompletedEpisode.episodeNumber, todayIsoDate = todayIsoDate, + showUnairedNextUp = showUnairedNextUp, ) } else { val sorted = episodes 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 d8449cce..3109c4fc 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 @@ -14,7 +14,11 @@ data class ProgressSyncRecord( ) interface ProgressSyncAdapter { - suspend fun pull(profileId: Int): List + suspend fun pull( + profileId: Int, + sinceLastWatched: Long? = null, + limit: Int? = null, + ): List suspend fun push( profileId: Int, 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 083a3b93..6743de34 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 @@ -17,8 +17,20 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { encodeDefaults = true } - override suspend fun pull(profileId: Int): List { - val params = buildJsonObject { put("p_profile_id", profileId) } + override suspend fun pull( + profileId: Int, + sinceLastWatched: Long?, + limit: Int?, + ): List { + val params = buildJsonObject { + put("p_profile_id", profileId) + if (sinceLastWatched != null) { + put("p_since_last_watched", sinceLastWatched) + } + if (limit != null) { + put("p_limit", limit) + } + } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val serverEntries = result.decodeList() return serverEntries.map { entry -> 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 d452d3a8..8f4569b3 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 @@ -12,6 +12,7 @@ import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions +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 @@ -180,38 +181,23 @@ object WatchProgressRepository { } runCatching { - val serverEntries = syncAdapter.pull(profileId = profileId) - + val sinceLastWatched = entriesByVideoId.values + .maxOfOrNull { entry -> entry.lastUpdatedEpochMs } + ?.takeIf { hasCompletedInitialNuvioSyncPull } + val serverEntries = syncAdapter.pull( + profileId = profileId, + sinceLastWatched = sinceLastWatched, + ) + val isIncrementalPull = sinceLastWatched != null val oldLocal = entriesByVideoId.toMap() - val newMap = mutableMapOf() + val newMap = if (isIncrementalPull) { + entriesByVideoId.toMutableMap() + } else { + mutableMapOf() + } serverEntries.forEach { entry -> - val videoId = entry.videoId - val cached = oldLocal[videoId] - newMap[videoId] = WatchProgressEntry( - contentType = entry.contentType, - parentMetaId = entry.contentId, - parentMetaType = cached?.parentMetaType ?: entry.contentType, - videoId = videoId, - title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId, - logo = cached?.logo, - poster = cached?.poster, - background = cached?.background, - seasonNumber = entry.season, - episodeNumber = entry.episode, - episodeTitle = cached?.episodeTitle, - episodeThumbnail = cached?.episodeThumbnail, - lastPositionMs = entry.position, - durationMs = entry.duration, - lastUpdatedEpochMs = entry.lastWatched, - providerName = cached?.providerName, - providerAddonId = cached?.providerAddonId, - lastStreamTitle = cached?.lastStreamTitle, - lastStreamSubtitle = cached?.lastStreamSubtitle, - pauseDescription = cached?.pauseDescription, - lastSourceUrl = cached?.lastSourceUrl, - isCompleted = isWatchProgressComplete(entry.position, entry.duration, false), - ) + newMap[entry.videoId] = entry.toWatchProgressEntry(cached = oldLocal[entry.videoId]) } entriesByVideoId = newMap @@ -232,6 +218,32 @@ object WatchProgressRepository { } } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = + WatchProgressEntry( + contentType = contentType, + parentMetaId = contentId, + parentMetaType = cached?.parentMetaType ?: contentType, + videoId = videoId, + title = cached?.title?.takeIf { it.isNotBlank() } ?: contentId, + logo = cached?.logo, + poster = cached?.poster, + background = cached?.background, + seasonNumber = season, + episodeNumber = episode, + episodeTitle = cached?.episodeTitle, + episodeThumbnail = cached?.episodeThumbnail, + lastPositionMs = position, + durationMs = duration, + lastUpdatedEpochMs = lastWatched, + providerName = cached?.providerName, + providerAddonId = cached?.providerAddonId, + lastStreamTitle = cached?.lastStreamTitle, + lastStreamSubtitle = cached?.lastStreamSubtitle, + pauseDescription = cached?.pauseDescription, + lastSourceUrl = cached?.lastSourceUrl, + isCompleted = isWatchProgressComplete(position, duration, false), + ) + private fun resolveRemoteMetadata() { val needsResolution = entriesByVideoId.values .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } 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 e5428e16..a70a2627 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest { assertEquals("show:1:3", action.videoId) } + @Test + fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() { + val meta = MetaDetails( + id = "tt1234567", + type = "series", + name = "Show", + videos = listOf( + MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"), + MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"), + ), + ) + + val action = meta.seriesPrimaryAction( + content = WatchingContentRef(type = "series", id = "tmdb:98765"), + entries = listOf( + WatchProgressEntry( + contentType = "series", + parentMetaId = "tmdb:98765", + parentMetaType = "series", + videoId = "tmdb:98765:4:14", + title = "Show", + seasonNumber = 4, + episodeNumber = 14, + lastPositionMs = 10_000L, + durationMs = 10_000L, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + ), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Up Next • S4E15", action.label) + assertEquals("tmdb:98765:4:15", action.videoId) + assertEquals(4, action.seasonNumber) + assertEquals(15, action.episodeNumber) + } + @Test fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { val meta = MetaDetails( 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 7c96c5af..b7226664 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 @@ -9,9 +9,11 @@ import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class HomeScreenTest { @@ -192,6 +194,85 @@ class HomeScreenTest { assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) } + @Test + fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() { + val completedProgress = progressEntry( + videoId = "show:4:14", + title = "Show", + seasonNumber = 4, + episodeNumber = 14, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val olderWatchedItem = watchedItem( + id = "show", + season = 4, + episode = 10, + markedAtEpochMs = 1_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(completedProgress), + watchedItems = listOf(olderWatchedItem), + isTraktProgressActive = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + ) + + assertEquals(1, result.size) + assertEquals("show", result.single().content.id) + assertEquals(4, result.single().seasonNumber) + assertEquals(14, result.single().episodeNumber) + } + + @Test + fun `home next up seed uses furthest watched item when progress is older`() { + val olderCompletedProgress = progressEntry( + videoId = "show:4:10", + title = "Show", + seasonNumber = 4, + episodeNumber = 10, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val newerWatchedItem = watchedItem( + id = "show", + season = 4, + episode = 14, + markedAtEpochMs = 1_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(olderCompletedProgress), + watchedItems = listOf(newerWatchedItem), + isTraktProgressActive = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + ) + + assertEquals(4, result.single().seasonNumber) + assertEquals(14, result.single().episodeNumber) + } + + @Test + fun `stale live next up item is dropped when current seed advances`() { + val staleNextUp = continueWatchingItem( + videoId = "show:4:11", + subtitle = "Up Next • S4E11", + seedSeasonNumber = 4, + seedEpisodeNumber = 10, + ) + + val result = filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)), + activeSeedContentIds = setOf("show"), + currentSeedByContentId = mapOf("show" to (4 to 14)), + shouldDropItemsWithoutActiveSeed = true, + ) + + assertTrue(result.isEmpty()) + } + private fun progressEntry( videoId: String, title: String, @@ -199,6 +280,7 @@ class HomeScreenTest { seasonNumber: Int? = 1, episodeNumber: Int? = 4, episodeTitle: String? = "Episode", + isCompleted: Boolean = false, ): WatchProgressEntry = WatchProgressEntry( contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", @@ -212,11 +294,16 @@ class HomeScreenTest { lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L, durationMs = 1_000_000L, lastUpdatedEpochMs = lastUpdatedEpochMs, + isCompleted = isCompleted, ) private fun continueWatchingItem( videoId: String, subtitle: String, + seasonNumber: Int? = 1, + episodeNumber: Int? = 4, + seedSeasonNumber: Int? = seasonNumber, + seedEpisodeNumber: Int? = episodeNumber, ): ContinueWatchingItem = ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), @@ -225,14 +312,32 @@ class HomeScreenTest { title = "Show", subtitle = subtitle, imageUrl = null, - seasonNumber = 1, - episodeNumber = 4, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, episodeTitle = subtitle.substringAfterLast(" • ", "Episode"), + isNextUp = true, + nextUpSeedSeasonNumber = seedSeasonNumber, + nextUpSeedEpisodeNumber = seedEpisodeNumber, resumePositionMs = 0L, durationMs = 0L, progressFraction = 0f, ) + private fun watchedItem( + id: String, + season: Int, + episode: Int, + markedAtEpochMs: Long, + ): WatchedItem = + WatchedItem( + id = id, + type = "series", + name = "Show", + season = season, + episode = episode, + markedAtEpochMs = markedAtEpochMs, + ) + private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L } 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 ca489fe2..43356837 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 @@ -44,5 +44,57 @@ class WatchedRepositoryTest { assertTrue(result) } -} + @Test + fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { + val serverItem = WatchedItem( + 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, + ) + + assertEquals( + setOf("series:show:1:1", "series:show:1:2"), + merged.keys, + ) + } + + @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, + ) + + val merged = mergeWatchedItemsPreservingUnsynced( + serverItems = emptyList(), + localItems = listOf(oldLocalItem), + lastSuccessfulPushEpochMs = 2_000L, + pullStartedEpochMs = 4_000L, + ) + + assertTrue(merged.isEmpty()) + } +} 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 9e539f5d..48649658 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 @@ -49,6 +49,7 @@ actual object PlayerSettingsStorage { private const val introSubmitEnabledKey = "intro_submit_enabled" private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" @@ -99,6 +100,7 @@ actual object PlayerSettingsStorage { animeSkipClientIdKey, streamAutoPlayNextEpisodeEnabledKey, streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, nextEpisodeThresholdModeKey, nextEpisodeThresholdPercentKey, nextEpisodeThresholdMinutesBeforeEndKey, @@ -554,6 +556,20 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey)) } + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey)) + } + actual fun loadNextEpisodeThresholdMode(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(nextEpisodeThresholdModeKey) @@ -725,6 +741,7 @@ actual object PlayerSettingsStorage { loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } @@ -782,6 +799,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt new file mode 100644 index 00000000..45e807a4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object BingeGroupCacheStorage { + actual fun load(hashedKey: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(hashedKey)) + + actual fun save(hashedKey: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(hashedKey)) + } + + actual fun remove(hashedKey: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(hashedKey)) + } +}