From 5fe7364d5d59e99f74122fe05ff8064bd2eca7d9 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 20 May 2026 13:05:14 +0200 Subject: [PATCH] Port Reuse Binge Group from the TV version --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../player/PlayerSettingsStorage.android.kt | 21 ++ .../streams/BingeGroupCacheStorage.android.kt | 32 +++ .../composeResources/values-no/strings.xml | 1 - .../composeResources/values-pl/strings.xml | 183 ++++++++++++++ .../composeResources/values/strings.xml | 2 + .../commonMain/kotlin/com/nuvio/app/App.kt | 2 + .../nuvio/app/features/player/PlayerScreen.kt | 233 ++++++++++++++---- .../player/PlayerSettingsRepository.kt | 44 ++++ .../features/player/PlayerSettingsStorage.kt | 2 + .../features/settings/PlaybackSettingsPage.kt | 27 +- .../streams/BingeGroupCacheRepository.kt | 21 ++ .../streams/BingeGroupCacheStorage.kt | 7 + .../features/streams/StreamAutoPlayPolicy.kt | 1 + .../streams/StreamAutoPlaySelector.kt | 9 +- .../app/features/streams/StreamsRepository.kt | 142 +++++++++-- .../app/features/streams/StreamsScreen.kt | 2 + .../player/PlayerSettingsStorage.ios.kt | 18 ++ .../streams/BingeGroupCacheStorage.ios.kt | 17 ++ 19 files changed, 694 insertions(+), 72 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt 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-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 1fa4e846..4a1efbc3 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -1199,7 +1199,6 @@ Fjern %1$s fra biblioteket ditt? Fjern fra bibliotek? Film - Serie Varsler når en ny episode for en lagret serie er ute. Forhåndsvisning av episodeutgivelsesvarsel. Kunne ikke sende testvarsel. 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 ddbb9b65..601a432c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -756,6 +756,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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..e486476d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1527,6 +1527,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, @@ -1636,6 +1637,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/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 476b0a77..a18b195d 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 @@ -56,8 +56,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 @@ -73,6 +75,7 @@ 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 @@ -86,6 +89,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 @@ -323,6 +328,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, @@ -1101,6 +1115,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 @@ -1108,7 +1128,7 @@ fun PlayerScreen( settings.streamAutoPlayMode } val effectiveSource = if (shouldAutoSelectInManualMode) { - com.nuvio.app.features.streams.StreamAutoPlaySource.ALL_SOURCES + StreamAutoPlaySource.ALL_SOURCES } else { settings.streamAutoPlaySource } @@ -1128,6 +1148,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,59 +1167,171 @@ fun PlayerScreen( .map { it.displayTitle } .toSet() - val timeoutMs = settings.streamAutoPlayTimeoutSeconds * 1000L - val startTime = WatchProgressClock.nowEpochMs() + val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds + val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE + var autoSelectTriggered = false + var timeoutElapsed = false + var selectedStream: StreamItem? = null - // Collect streams as they arrive - PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> - if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest + // 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, + ) + } - val allStreams = state.groups.flatMap { it.streams } - val elapsed = WatchProgressClock.nowEpochMs() - startTime + // 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, + ) + } - 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, - ) - } else null + val innerJob = launch { + // Collect streams as they arrive + PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> + if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest - 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) + 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) { + autoSelectTriggered = true + selectedStream = candidate + } + } + } else { + // Before timeout: eagerly check binge group only + if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + autoSelectTriggered = true + selectedStream = earlyMatch + } } - 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 + + // 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) { + autoSelectTriggered = true + selectedStream = candidate + } + } + if (!autoSelectTriggered) { + autoSelectTriggered = true + } + 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) { + autoSelectTriggered = true + selectedStream = 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() + autoSelectTriggered = true + } else { + // No addon responded yet — wait with hard ceiling + val completed = withTimeoutOrNull(timeoutMs) { innerJob.join() } + if (completed == null) { + innerJob.cancel() + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + autoSelectTriggered = true + } + } + } + } else { + // Instant (0) or unlimited: timeoutElapsed immediately so each + // addon response triggers a full select attempt in the collect. + timeoutElapsed = true + val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS + val completed = withTimeoutOrNull(hardTimeout) { innerJob.join() } + if (completed == null) { + innerJob.cancel() + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + autoSelectTriggered = true + } + } + } + + // 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 + } } } @@ -1890,7 +2029,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 5917325a..a5e97de5 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, ): StreamItem? { if (streams.isEmpty()) return null @@ -57,7 +58,7 @@ object StreamAutoPlaySelector { } } if (candidateStreams.isEmpty()) return null - if (mode == StreamAutoPlayMode.MANUAL) return null + if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) return null val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { @@ -65,6 +66,12 @@ object StreamAutoPlaySelector { stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() } if (bingeGroupMatch != null) return bingeGroupMatch + // When bingeGroupOnly = true, do NOT fall through to mode-based selection + if (bingeGroupOnly) return null + } else if (bingeGroupOnly) { + // bingeGroupOnly requested but no preferredBingeGroup or preferBingeGroupInSelection is false + // Fall through to mode-based selection (bingeGroupOnly has no effect without a binge group to match) + if (mode == StreamAutoPlayMode.MANUAL) return null } return when (mode) { 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 2fc87a24..5441ce47 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 @@ -48,10 +48,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, @@ -59,10 +60,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, @@ -70,7 +72,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 @@ -105,7 +107,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( @@ -237,16 +253,59 @@ object StreamsRepository { } } - 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, + ) + _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()) { - autoSelectTriggered = true val selected = StreamAutoPlaySelector.selectAutoPlayStream( streams = allStreams, mode = autoPlayMode, @@ -255,9 +314,14 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, ) - _uiState.update { it.copy(autoPlayStream = selected) } - if (selected == null) { + if (selected != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = selected) } + } else { _uiState.update { it.copy( isDirectAutoPlayFlow = false, @@ -268,11 +332,6 @@ object StreamsRepository { } } } - } else if (timeoutMs <= 0L) { - timeoutElapsed = true - null - } else { - null } } else { null @@ -479,9 +538,54 @@ 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, + ) + 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, + ) + 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 selected = StreamAutoPlaySelector.selectAutoPlayStream( @@ -492,6 +596,9 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, ) _uiState.update { it.copy(autoPlayStream = selected) } } @@ -512,6 +619,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 e7078fd9..ee5b52e0 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 @@ -176,6 +176,7 @@ fun StreamsScreen( StreamsRepository.load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, @@ -277,6 +278,7 @@ fun StreamsScreen( StreamsRepository.reload( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, 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)) + } +}