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))
+ }
+}