Port Reuse Binge Group from the TV version

This commit is contained in:
skoruppa 2026-05-20 13:05:14 +02:00
parent d29c1e363a
commit 5fe7364d5d
19 changed files with 694 additions and 72 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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()
}
}

View file

@ -1199,7 +1199,6 @@
<string name="library_remove_message">Fjern %1$s fra biblioteket ditt?</string>
<string name="library_remove_title">Fjern fra bibliotek?</string>
<string name="media_movie">Film</string>
<string name="media_series">Serie</string>
<string name="notifications_channel_episode_releases_description">Varsler når en ny episode for en lagret serie er ute.</string>
<string name="notifications_test_preview_body">Forhåndsvisning av episodeutgivelsesvarsel.</string>
<string name="notifications_test_send_failed">Kunne ikke sende testvarsel.</string>

View file

@ -1,4 +1,5 @@
<resources>
<string name="about_licenses_attributions_subtitle">Źródła danych, podziękowania i licencje platformy</string>
<string name="about_supporters_contributors_subtitle">Osoby wspierające i współtworzące projekt</string>
<string name="action_back">Wstecz</string>
<string name="action_cancel">Anuluj</string>
@ -17,6 +18,8 @@
<string name="action_resume">Wznów</string>
<string name="action_retry">Ponów</string>
<string name="action_save">Zapisz</string>
<string name="action_saving">Zapisywanie…</string>
<string name="action_validate">Sprawdź</string>
<string name="addon_installing">Instalowanie</string>
<string name="addon_title">Dodatki</string>
<string name="addons_badge_active">Aktywny</string>
@ -110,29 +113,38 @@
<string name="collections_editor_tmdb_production_mode">Produkcja</string>
<string name="collections_editor_tmdb_network_mode">Stacja</string>
<string name="collections_editor_tmdb_collection_mode">Kolekcja</string>
<string name="collections_editor_tmdb_person_mode">Osoba</string>
<string name="collections_editor_tmdb_director_mode">Reżyser</string>
<string name="collections_editor_tmdb_custom_mode">Niestandardowy</string>
<string name="collections_editor_tmdb_help_presets">Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu.</string>
<string name="collections_editor_tmdb_help_list">Wklej publiczny URL listy TMDB lub sam numer z URL.</string>
<string name="collections_editor_tmdb_help_production">Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio.</string>
<string name="collections_editor_tmdb_help_network">Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach.</string>
<string name="collections_editor_tmdb_help_collection">Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB.</string>
<string name="collections_editor_tmdb_help_person">Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii aktora.</string>
<string name="collections_editor_tmdb_help_director">Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii reżysera.</string>
<string name="collections_editor_tmdb_help_discover">Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra.</string>
<string name="collections_editor_tmdb_public_list">Publiczna lista TMDB</string>
<string name="collections_editor_tmdb_network_id">ID stacji</string>
<string name="collections_editor_tmdb_collection_id">ID kolekcji</string>
<string name="collections_editor_tmdb_person_id">ID osoby</string>
<string name="collections_editor_tmdb_company_search">Nazwa firmy produkcyjnej, ID lub URL</string>
<string name="collections_editor_tmdb_id_or_url">ID lub URL TMDB</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 lub 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">213 dla Netflix, 49 dla HBO, 2739 dla Disney+</string>
<string name="collections_editor_tmdb_collection_placeholder">10 dla kolekcji Star Wars</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 lub URL firmy</string>
<string name="collections_editor_tmdb_person_placeholder">31 dla Toma Hanksa lub URL osoby</string>
<string name="collections_editor_tmdb_search_helper">Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji.</string>
<string name="collections_editor_tmdb_network_helper">Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Przykład: https://www.themoviedb.org/list/8504994 lub 8504994.</string>
<string name="collections_editor_tmdb_person_helper">Przykład: https://www.themoviedb.org/person/31-tom-hanks lub 31.</string>
<string name="collections_editor_tmdb_display_title">Wyświetlany tytuł</string>
<string name="collections_editor_tmdb_title_helper">Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła.</string>
<string name="collections_editor_tmdb_title_placeholder">Filmy Marvela, Oryginały Netflix, Pixar</string>
<string name="collections_editor_tmdb_person_title_placeholder">Filmy Toma Hanksa, Ulubieni aktorzy</string>
<string name="collections_editor_tmdb_director_title_placeholder">Filmy Christophera Nolana, Ulubieni reżyserzy</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024</string>
<string name="collections_editor_tmdb_search_results">Wyniki wyszukiwania</string>
<string name="collections_editor_tmdb_collection">Kolekcja TMDB</string>
@ -179,6 +191,27 @@
<string name="collections_editor_tmdb_presets">Szablony</string>
<string name="collections_editor_tmdb_search">Szukaj</string>
<string name="collections_editor_add_source">Dodaj źródło</string>
<string name="collections_editor_add_trakt_source">Dodaj listę Trakt</string>
<string name="collections_editor_edit_trakt_source">Edytuj listę Trakt</string>
<string name="collections_editor_trakt_sources">Listy Trakt</string>
<string name="collections_editor_trakt_list">Lista Trakt</string>
<string name="collections_editor_trakt_input_placeholder">Szukaj tytułu, URL Trakt lub ID listy</string>
<string name="collections_editor_trakt_input_helper">Użyj publicznego URL listy Trakt, numerycznego ID listy lub wyszukaj po nazwie.</string>
<string name="collections_editor_trakt_title_placeholder">Weekendowe filmy, Laureaci nagród</string>
<string name="collections_editor_trakt_search_results">Wyniki wyszukiwania</string>
<string name="collections_editor_trakt_trending">Popularne teraz</string>
<string name="collections_editor_trakt_popular">Popularne listy</string>
<string name="collections_editor_trakt_direction">Kierunek</string>
<string name="collections_editor_trakt_ascending">Rosnąco</string>
<string name="collections_editor_trakt_descending">Malejąco</string>
<string name="collections_editor_trakt_sort_list_order">Kolejność listy</string>
<string name="collections_editor_trakt_sort_recently_added">Ostatnio dodane</string>
<string name="collections_editor_trakt_sort_title">Tytuł</string>
<string name="collections_editor_trakt_sort_released">Data premiery</string>
<string name="collections_editor_trakt_sort_runtime">Czas trwania</string>
<string name="collections_editor_trakt_sort_popular">Popularne</string>
<string name="collections_editor_trakt_sort_percentage">Procent</string>
<string name="collections_editor_trakt_sort_votes">Głosy</string>
<string name="collections_editor_tmdb_genre_action">Akcja</string>
<string name="collections_editor_tmdb_genre_adventure">Przygodowy</string>
<string name="collections_editor_tmdb_genre_animation">Animacja</string>
@ -212,13 +245,29 @@
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_original">Oryginalna</string>
<string name="collections_editor_tmdb_sort_popular">Popularne</string>
<string name="collections_editor_tmdb_sort_top_rated">Najwyżej oceniane</string>
<string name="collections_editor_tmdb_sort_recent">Ostatnie</string>
<string name="collections_editor_tmdb_sort_vote_count">Najczęściej głosowane</string>
<string name="collections_editor_tmdb_watch_region">Region dostępności</string>
<string name="collections_editor_tmdb_watch_region_helper">Kod kraju ISO 3166-1, w którym tytuł jest dostępny. Przykład: US, GB.</string>
<string name="collections_editor_tmdb_quick_watch_regions">Popularne regiony dostępności</string>
<string name="collections_editor_tmdb_watch_providers">ID platform streamingowych</string>
<string name="collections_editor_tmdb_watch_providers_helper">Użyj ID platform TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR.</string>
<string name="collections_editor_tmdb_watch_providers_placeholder">8|337|350</string>
<string name="collections_editor_tmdb_quick_watch_providers">Popularne platformy streamingowe</string>
<string name="collections_editor_tmdb_watch_provider_netflix">Netflix</string>
<string name="collections_editor_tmdb_watch_provider_prime">Prime Video</string>
<string name="collections_editor_tmdb_watch_provider_disney">Disney+</string>
<string name="collections_editor_tmdb_watch_provider_apple">Apple TV+</string>
<string name="collections_editor_tmdb_watch_provider_hulu">Hulu</string>
<string name="collections_editor_tmdb_subtitle_list">Lista TMDB</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">Kolekcja filmów TMDB</string>
<string name="collections_editor_tmdb_subtitle_production">Produkcja</string>
<string name="collections_editor_tmdb_subtitle_network">Stacja</string>
<string name="collections_editor_tmdb_subtitle_person">Osoba</string>
<string name="collections_editor_tmdb_subtitle_director">Reżyser</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="collections_empty_subtitle">Utwórz jedną, aby uporządkować katalogi.</string>
<string name="collections_empty_title">Brak kolekcji</string>
@ -331,8 +380,10 @@
<string name="compose_settings_page_appearance">Wygląd</string>
<string name="compose_settings_page_content_discovery">Treści i odkrywanie</string>
<string name="compose_settings_page_continue_watching">Kontynuuj oglądanie</string>
<string name="compose_settings_page_debrid">Debrid</string>
<string name="compose_settings_page_homescreen">Ekran główny</string>
<string name="compose_settings_page_integrations">Integracje</string>
<string name="compose_settings_page_licenses_attributions">Licencje i atrybucje</string>
<string name="compose_settings_page_mdblist_ratings">Oceny MDBList</string>
<string name="compose_settings_page_meta_screen">Ekran metadanych</string>
<string name="compose_settings_page_notifications">Powiadomienia</string>
@ -358,6 +409,31 @@
<string name="compose_settings_root_switch_profile_description">Przełącz na inny profil.</string>
<string name="compose_settings_root_switch_profile_title">Przełącz profil</string>
<string name="compose_settings_root_trakt_description">Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt.</string>
<string name="settings_search_empty">Nie znaleziono ustawień.</string>
<string name="settings_search_placeholder">Szukaj ustawień...</string>
<string name="settings_search_results_section">WYNIKI</string>
<string name="settings_licenses_attributions_section_app">LICENCJA APLIKACJI</string>
<string name="settings_licenses_attributions_section_data">DANE I USŁUGI</string>
<string name="settings_licenses_attributions_section_playback">LICENCJA ODTWARZANIA</string>
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobile</string>
<string name="settings_licenses_attributions_nuvio_body">Kod źródłowy i warunki licencji są dostępne w repozytorium projektu.</string>
<string name="settings_licenses_attributions_nuvio_license">Licencjonowany na podstawie GNU General Public License v3.0.</string>
<string name="settings_licenses_attributions_tmdb_title">The Movie Database (TMDB)</string>
<string name="settings_licenses_attributions_tmdb_body">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.</string>
<string name="settings_licenses_attributions_imdb_title">Niekomercyjne zbiory danych IMDb</string>
<string name="settings_licenses_attributions_imdb_body">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.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">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.</string>
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
<string name="settings_licenses_attributions_mdblist_body">Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane.</string>
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
<string name="settings_licenses_attributions_introdb_body">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.</string>
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
<string name="settings_licenses_attributions_mpvkit_body">Używany do odtwarzania w wersjach na iOS.</string>
<string name="settings_licenses_attributions_mpvkit_license">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.</string>
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
<string name="settings_licenses_attributions_exoplayer_body">Używany do odtwarzania w wersjach na Androida.</string>
<string name="settings_licenses_attributions_exoplayer_license">Licencjonowany na podstawie Apache License, wersja 2.0.</string>
<string name="compose_trakt_list_picker_loading">Ładowanie list Trakt…</string>
<string name="compose_trakt_list_picker_subtitle">Wybierz, gdzie zapisać ten tytuł w Trakt</string>
<string name="action_donate">Wesprzyj</string>
@ -416,6 +492,8 @@
<string name="settings_appearance_app_language">Język aplikacji</string>
<string name="settings_appearance_app_language_sheet_title">Wybierz język</string>
<string name="settings_appearance_continue_watching_description">Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_poster_customization_description">Dostosuj szerokość i zaokrąglenie rogów kart plakatów.</string>
<string name="settings_appearance_section_display">WYŚWIETLANIE</string>
<string name="settings_appearance_section_home">EKRAN GŁÓWNY</string>
@ -442,9 +520,14 @@
<string name="settings_homescreen_selected_count">%1$d z %2$d wybranych</string>
<string name="settings_homescreen_show_hero">Pokaż Hero</string>
<string name="settings_homescreen_show_hero_description">Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej.</string>
<string name="layout_hide_unreleased">Ukryj niewydane treści</string>
<string name="layout_hide_unreleased_sub">Ukryj filmy i seriale, które nie zostały jeszcze wydane.</string>
<string name="settings_homescreen_hide_catalog_underline">Ukryj podkreślenie katalogu</string>
<string name="settings_homescreen_hide_catalog_underline_description">Usuń linię akcentu pod tytułami katalogów i kolekcji w całej aplikacji.</string>
<string name="settings_homescreen_summary">%1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych</string>
<string name="settings_homescreen_summary_hint">Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność.</string>
<string name="settings_homescreen_visible">Widoczne</string>
<string name="settings_hide_secret">Ukryj wartość</string>
<string name="settings_playback_subtitle">Odtwarzacz, napisy i automatyczne odtwarzanie</string>
<string name="settings_poster_card_radius">Zaokrąglenie karty</string>
<string name="settings_poster_card_style">STYL KARTY PLAKATU</string>
@ -469,8 +552,19 @@
<string name="settings_poster_width_dense">Gęsty</string>
<string name="settings_poster_width_large">Duży</string>
<string name="settings_poster_width_standard">Standardowy</string>
<string name="settings_show_secret">Pokaż wartość</string>
<string name="settings_continue_watching_resume_prompt_description">Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza.</string>
<string name="settings_continue_watching_resume_prompt_title">Monit o wznowienie przy uruchomieniu</string>
<string name="settings_continue_watching_blur_next_up_description">Rozmyj miniatury następnych odcinków w Kontynuuj oglądanie, aby uniknąć spoilerów.</string>
<string name="settings_continue_watching_blur_next_up_title">Rozmyj nieobejrzane w Kontynuuj oglądanie</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Uwzględnij nadchodzące odcinki w Kontynuuj oglądanie przed ich emisją.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Pokaż niewyemitowane następne odcinki</string>
<string name="settings_continue_watching_section_sort_order">KOLEJNOŚĆ SORTOWANIA</string>
<string name="settings_continue_watching_sort_mode_title">Kolejność sortowania</string>
<string name="settings_continue_watching_sort_mode_default">Domyślna</string>
<string name="settings_continue_watching_sort_mode_default_desc">Sortuj wszystkie elementy według czasu</string>
<string name="settings_continue_watching_sort_mode_streaming">Styl streamingowy</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Wydane najpierw, nadchodzące na końcu</string>
<string name="settings_continue_watching_section_card_style">STYL KARTY</string>
<string name="settings_continue_watching_section_on_launch">PRZY URUCHOMIENIU</string>
<string name="settings_continue_watching_section_up_next_behavior">ZACHOWANIE NASTĘPNEGO</string>
@ -483,6 +577,8 @@
<string name="settings_continue_watching_style_wide_description">Pozioma karta z informacjami</string>
<string name="settings_continue_watching_up_next_description">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.</string>
<string name="settings_continue_watching_up_next_title">Następny od najdalszego odcinka</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Preferuj miniatury odcinków, gdy są dostępne.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Preferuj miniatury odcinków w Kontynuuj oglądanie</string>
<string name="settings_content_discovery_section_home">EKRAN GŁÓWNY</string>
<string name="settings_content_discovery_section_sources">ŹRÓDŁA</string>
<string name="settings_content_discovery_addons_description">Instaluj, usuwaj, odświeżaj i sortuj źródła treści.</string>
@ -493,6 +589,34 @@
<string name="settings_integrations_section_title">INTEGRACJE</string>
<string name="settings_integrations_tmdb_description">Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.</string>
<string name="settings_integrations_mdblist_description">Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.</string>
<string name="settings_integrations_debrid_description">Eksperymentalne źródła z kont chmurowych</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_experimental_notice">Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.</string>
<string name="settings_debrid_enable">Włącz źródła</string>
<string name="settings_debrid_enable_description">Pokaż odtwarzalne wyniki z połączonych kont.</string>
<string name="settings_debrid_add_key_first">Najpierw dodaj klucz API.</string>
<string name="settings_debrid_section_providers">Konto</string>
<string name="settings_debrid_provider_torbox_description">Połącz swoje konto Torbox.</string>
<string name="settings_debrid_dialog_title">Klucz API Torbox</string>
<string name="settings_debrid_dialog_subtitle">Wprowadź swój klucz API Torbox.</string>
<string name="settings_debrid_dialog_placeholder">Wprowadź klucz API Torbox</string>
<string name="settings_debrid_not_set">Nie ustawiono</string>
<string name="settings_debrid_section_instant_playback">Natychmiastowe odtwarzanie</string>
<string name="settings_debrid_prepare_instant_playback">Przygotuj linki</string>
<string name="settings_debrid_prepare_instant_playback_description">Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.</string>
<string name="settings_debrid_prepare_stream_count">Źródła do przygotowania</string>
<string name="settings_debrid_prepare_stream_count_warning">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.</string>
<string name="settings_debrid_prepare_count_one">1 źródło</string>
<string name="settings_debrid_prepare_count_many">%1$d źródeł</string>
<string name="settings_debrid_section_formatting">Formatowanie</string>
<string name="settings_debrid_name_template">Szablon nazwy</string>
<string name="settings_debrid_name_template_description">Kontroluje sposób wyświetlania nazw źródeł.</string>
<string name="settings_debrid_description_template">Szablon opisu</string>
<string name="settings_debrid_description_template_description">Kontroluje metadane wyświetlane pod każdym źródłem.</string>
<string name="settings_debrid_formatter_reset_title">Resetuj formatowanie</string>
<string name="settings_debrid_formatter_reset_subtitle">Przywróć domyślne formatowanie źródeł.</string>
<string name="settings_debrid_key_valid">Klucz API zweryfikowany.</string>
<string name="settings_debrid_key_invalid">Nie udało się zweryfikować tego klucza API.</string>
<string name="settings_mdb_add_api_key_first">Dodaj klucz API MDBList poniżej przed włączeniem ocen.</string>
<string name="settings_mdb_api_key_description">Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.</string>
<string name="settings_mdb_api_key_label">Klucz API</string>
@ -522,6 +646,8 @@
<string name="settings_meta_episode_style_list_description">Karty ze szczegółami na pierwszym planie</string>
<string name="settings_meta_episodes">Odcinki</string>
<string name="settings_meta_episodes_description">Sezony i lista odcinków dla seriali.</string>
<string name="settings_meta_blur_unwatched_episodes">Rozmyj nieobejrzane odcinki</string>
<string name="settings_meta_blur_unwatched_episodes_description">Rozmyj miniatury odcinków do momentu obejrzenia, aby uniknąć spoilerów.</string>
<string name="settings_meta_group_label">Grupa %1$d</string>
<string name="settings_meta_more_like_this">Podobne</string>
<string name="settings_meta_more_like_this_description">Wiersz rekomendacji.</string>
@ -588,6 +714,10 @@
<string name="settings_playback_anime_skip">Anime Skip</string>
<string name="settings_playback_anime_skip_client_id">ID klienta AnimeSkip</string>
<string name="settings_playback_anime_skip_client_id_description">Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com.</string>
<string name="settings_playback_intro_submit_enabled">Włącz przesyłanie intro</string>
<string name="settings_playback_intro_submit_enabled_description">Pokaż przycisk do przesyłania znaczników intro/outro do bazy społeczności.</string>
<string name="settings_playback_introdb_api_key">Klucz API IntroDB</string>
<string name="settings_playback_introdb_api_key_description">Wprowadź klucz API IntroDB, aby przesyłać znaczniki czasowe. Wymagany do przesyłania.</string>
<string name="settings_playback_anime_skip_description">Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta).</string>
<string name="settings_playback_auto_play_next_episode">Automatyczne odtwarzanie następnego odcinka</string>
<string name="settings_playback_auto_play_next_episode_description">Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu.</string>
@ -603,6 +733,11 @@
<string name="settings_playback_duration_hours">%1$d godzin</string>
<string name="settings_playback_enable_libass">Włącz libass</string>
<string name="settings_playback_enable_libass_description">Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera.</string>
<string name="settings_playback_external_player">Zewnętrzny odtwarzacz</string>
<string name="settings_playback_external_player_app">Aplikacja zewnętrznego odtwarzacza</string>
<string name="settings_playback_external_player_description_android">Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym.</string>
<string name="settings_playback_external_player_description_ios">Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu.</string>
<string name="settings_playback_external_player_none_available">Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy</string>
<string name="settings_playback_hold_speed">Prędkość przy przytrzymaniu</string>
<string name="settings_playback_hold_to_speed">Przytrzymaj, aby przyspieszyć</string>
<string name="settings_playback_hold_to_speed_description">Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania.</string>
@ -621,6 +756,8 @@
<string name="settings_playback_option_none">Brak</string>
<string name="settings_playback_prefer_binge_group">Preferuj grupę binge</string>
<string name="settings_playback_prefer_binge_group_description">Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący.</string>
<string name="settings_playback_reuse_binge_group">Ponownie użyj grupy binge</string>
<string name="settings_playback_reuse_binge_group_description">Zapamiętaj i ponownie użyj ostatniej grupy binge między sesjami (Kontynuuj oglądanie, Szczegóły itp.).</string>
<string name="settings_playback_preferred_audio_language">Preferowany język audio</string>
<string name="settings_playback_preferred_subtitle_language">Preferowany język napisów</string>
<string name="settings_playback_presets">Szablony</string>
@ -744,6 +881,28 @@
<string name="settings_trakt_open_login">Otwórz logowanie Trakt</string>
<string name="settings_trakt_save_actions_description">Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt.</string>
<string name="settings_trakt_sign_in_description">Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt.</string>
<string name="trakt_library_source_title">Źródło biblioteki</string>
<string name="trakt_library_source_subtitle">Wybierz, której biblioteki używać do zapisywania i przeglądania kolekcji</string>
<string name="trakt_library_source_dialog_title">Źródło biblioteki</string>
<string name="trakt_library_source_dialog_subtitle">Wybierz, gdzie zapisywać i zarządzać elementami biblioteki</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Biblioteka Nuvio</string>
<string name="trakt_library_source_trakt_selected">Wybrano bibliotekę Trakt</string>
<string name="trakt_library_source_nuvio_selected">Wybrano bibliotekę Nuvio</string>
<string name="trakt_watch_progress_title">Postęp oglądania</string>
<string name="trakt_watch_progress_subtitle">Wybierz, które źródło postępu obsługuje wznawianie i Kontynuuj oglądanie</string>
<string name="trakt_watch_progress_dialog_title">Postęp oglądania</string>
<string name="trakt_watch_progress_dialog_subtitle">Wybierz, czy wznawianie i Kontynuuj oglądanie powinno korzystać z Trakt czy Nuvio Sync, podczas gdy scrobblowanie Trakt pozostaje aktywne.</string>
<string name="trakt_watch_progress_source_trakt">Trakt</string>
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Źródło postępu ustawione na Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Źródło postępu ustawione na Nuvio Sync</string>
<string name="trakt_continue_watching_window">Okno Kontynuuj oglądanie</string>
<string name="trakt_continue_watching_subtitle">Historia Trakt uwzględniana w Kontynuuj oglądanie</string>
<string name="trakt_cw_window_title">Okno Kontynuuj oglądanie</string>
<string name="trakt_cw_window_subtitle">Wybierz, ile aktywności Trakt ma się pojawiać w Kontynuuj oglądanie.</string>
<string name="trakt_all_history">Cała historia</string>
<string name="trakt_days_format">%1$d dni</string>
<string name="source_audience_score">Ocena widzów</string>
<string name="source_imdb">IMDb</string>
<string name="source_letterboxd">Letterboxd</string>
@ -934,9 +1093,14 @@
<string name="pin_locked_try_again">Zablokowane. Spróbuj ponownie za %1$ds</string>
<string name="profile_avatar_options_pending">Opcje awatarów pojawią się tutaj po załadowaniu katalogu.</string>
<string name="profile_avatar_selected">Awatar: %1$s</string>
<string name="profile_avatar_url_invalid">Wprowadź prawidłowy URL obrazu http:// lub https://.</string>
<string name="profile_choose_avatar">Wybierz awatar</string>
<string name="profile_choose_avatar_below">Wybierz awatar poniżej.</string>
<string name="profile_create_profile">Utwórz profil</string>
<string name="profile_custom_avatar_selected">Wybrano niestandardowy URL awatara.</string>
<string name="profile_custom_avatar_url">Niestandardowy URL awatara</string>
<string name="profile_custom_avatar_url_description">Wklej link do obrazu lub zostaw puste, aby użyć wbudowanego katalogu awatarów.</string>
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="profile_delete_confirm_message">Wszystkie dane profilu „%1$s" zostaną trwale usunięte.</string>
<string name="profile_delete_title">Usuń profil</string>
<string name="profile_edit_add_title">Dodaj profil</string>
@ -968,6 +1132,8 @@
<string name="streams_checking_more_addons">Sprawdzanie kolejnych dodatków…</string>
<string name="streams_copy_link">Kopiuj link strumienia</string>
<string name="streams_download_file">Pobierz plik</string>
<string name="streams_open_external_player">Otwórz w zewnętrznym odtwarzaczu</string>
<string name="streams_open_internal_player">Otwórz w wewnętrznym odtwarzaczu</string>
<string name="streams_empty_load_failed_message">Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi.</string>
<string name="streams_empty_load_failed_title">Nie można załadować strumieni</string>
<string name="streams_empty_no_addons_message">Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu.</string>
@ -987,6 +1153,13 @@
<string name="streams_resume_from_percent">Wznów od %1$d%</string>
<string name="streams_resume_from_time">Wznów od %1$s</string>
<string name="streams_size">ROZMIAR %1$s</string>
<string name="streams_torrent_not_supported">Ten typ strumienia nie jest obsługiwany</string>
<string name="debrid_missing_api_key">Dodaj klucz API Debrid w Ustawieniach.</string>
<string name="debrid_stream_stale">Ten wynik Debrid wygasł. Odświeżanie strumieni.</string>
<string name="debrid_resolve_failed">Nie udało się rozwiązać tego strumienia Debrid.</string>
<string name="external_player_failed">Nie udało się otworzyć zewnętrznego odtwarzacza</string>
<string name="external_player_not_configured">Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach</string>
<string name="external_player_unavailable">Brak dostępnego zewnętrznego odtwarzacza</string>
<string name="trailer_close">Zamknij zwiastun</string>
<string name="trailer_unable_to_play">Nie można odtworzyć zwiastuna</string>
<string name="trakt_lists_load_failed">Nie udało się załadować list Trakt</string>
@ -1032,6 +1205,7 @@
<string name="downloads_live_failed">Pobieranie nie powiodło się</string>
<string name="downloads_live_paused">Wstrzymano %1$s</string>
<string name="library_remove_confirm">Usuń</string>
<string name="library_remove_from_list_message">Usunąć %1$s z %2$s?</string>
<string name="library_remove_message">Usunąć %1$s z biblioteki?</string>
<string name="library_remove_title">Usunąć z biblioteki?</string>
<string name="media_movie">Film</string>
@ -1075,6 +1249,7 @@
<string name="collections_import_error_folder_blank_id">Folder %1$d w „%2$s" ma puste id.</string>
<string name="collections_import_error_folder_blank_title">Folder „%1$s" w „%2$s" ma pusty tytuł.</string>
<string name="collections_import_error_source_blank_fields">Źródło %1$d w folderze „%2$s" ma puste pola.</string>
<string name="collections_import_error_trakt_list_id">Źródło %1$d w folderze '%2$s' nie ma ID listy Trakt.</string>
<string name="collections_import_error_invalid_json">Nieprawidłowy JSON: %1$s</string>
<string name="collections_folder_addon_not_found">Nie znaleziono dodatku: %1$s</string>
<string name="date_month_january">Styczeń</string>
@ -1148,6 +1323,14 @@
<string name="notifications_episode_release_body_generic">Nowy odcinek jest już dostępny</string>
<string name="notifications_episode_release_body_title">%1$s jest już dostępny</string>
<string name="notifications_channel_episode_releases_name">Premiery odcinków</string>
<string name="parental_alcohol">Alkohol/Narkotyki</string>
<string name="parental_frightening">Przerażające</string>
<string name="parental_nudity">Nagość</string>
<string name="parental_profanity">Wulgaryzmy</string>
<string name="parental_severity_mild">Łagodne</string>
<string name="parental_severity_moderate">Umiarkowane</string>
<string name="parental_severity_severe">Intensywne</string>
<string name="parental_violence">Przemoc</string>
<string name="person_role_creator">Twórca</string>
<string name="person_role_director">Reżyser</string>
<string name="person_role_writer">Scenarzysta</string>

View file

@ -756,6 +756,8 @@
<string name="settings_playback_option_none">None</string>
<string name="settings_playback_prefer_binge_group">Prefer Binge Group (Next Episode)</string>
<string name="settings_playback_prefer_binge_group_description">Try the same source profile first (same addon/quality group) before normal auto-play rules.</string>
<string name="settings_playback_reuse_binge_group">Reuse Binge Group</string>
<string name="settings_playback_reuse_binge_group_description">Remember and reuse the last binge group across sessions (Continue Watching, Details, etc.).</string>
<string name="settings_playback_preferred_audio_language">Preferred Audio Language</string>
<string name="settings_playback_preferred_subtitle_language">Preferred Language</string>
<string name="settings_playback_presets">Presets</string>

View file

@ -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,

View file

@ -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>): 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>): 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 = {

View file

@ -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<Int> = 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,

View file

@ -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?

View file

@ -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(

View file

@ -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"
}
}

View file

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

View file

@ -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

View file

@ -40,6 +40,7 @@ object StreamAutoPlaySelector {
selectedPlugins: Set<String>,
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) {

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

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