Merge branch 'cwtest' into stripdebrid

This commit is contained in:
tapframe 2026-05-22 16:42:04 +05:30
commit 2e35652e81
32 changed files with 1458 additions and 334 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

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

@ -774,6 +774,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>
@ -1064,6 +1066,7 @@
<string name="episode_mark_previous_watched">Mark previous as watched</string>
<string name="episode_mark_season_unwatched">Mark %1$s as unwatched</string>
<string name="episode_mark_season_watched">Mark %1$s as watched</string>
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
<string name="episode_mark_unwatched">Mark as unwatched</string>
<string name="episode_mark_watched">Mark as watched</string>
<string name="home_continue_watching_up_next">Up next</string>

View file

@ -1657,6 +1657,7 @@ private fun MainAppContent(
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
@ -1773,6 +1774,7 @@ private fun MainAppContent(
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,

View file

@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.details.components.DetailTrailersSection
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
@ -93,6 +94,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.releasedEpisodesForSeason
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
@ -152,6 +154,7 @@ fun MetaDetailsScreen(
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
var selectedSeasonForActions by remember(type, id) { mutableStateOf<Int?>(null) }
val commentsEnabled by remember {
TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled
@ -338,7 +341,10 @@ fun MetaDetailsScreen(
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
val movieProgress = progressByVideoId[meta.id]
?.takeUnless { it.isCompleted }
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
@ -739,11 +745,12 @@ fun MetaDetailsScreen(
},
onCommentClick = { review -> selectedComment = review },
onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId,
progressByVideoId = progressByVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onSeasonLongPress = { season -> selectedSeasonForActions = season },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
@ -800,12 +807,12 @@ fun MetaDetailsScreen(
)
selectedEpisodeForActions?.let { selectedEpisode ->
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
WatchingState.isEpisodeWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
isEpisodeWatchedForActions(
meta = meta,
episode = selectedEpisode,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
@ -820,20 +827,20 @@ fun MetaDetailsScreen(
todayIsoDate = todayIsoDate,
)
}
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = previousEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = seasonEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
EpisodeWatchedActionSheet(
@ -874,6 +881,62 @@ fun MetaDetailsScreen(
)
}
selectedSeasonForActions?.let { selectedSeason ->
val seasonLabel = selectedSeasonLabel(selectedSeason)
val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
meta.releasedEpisodesForSeason(
seasonNumber = selectedSeason,
todayIsoDate = todayIsoDate,
)
}
val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0)
meta.releasedPlayableEpisodes(todayIsoDate)
.filter { episode ->
val season = episode.season?.coerceAtLeast(0) ?: 0
season > 0 && season < normalizedSelectedSeason
}
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = seasonEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
previousSeasonEpisodes.any { episode ->
!isEpisodeWatchedForActions(
meta = meta,
episode = episode,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
}
SeasonWatchedActionSheet(
seasonLabel = seasonLabel,
isSeasonWatched = isSeasonWatched,
canMarkPreviousSeasons = canMarkPreviousSeasons,
onDismiss = { selectedSeasonForActions = null },
onToggleSeasonWatched = {
WatchingActions.toggleSeasonWatched(
meta = meta,
episodes = seasonEpisodes,
areCurrentlyWatched = isSeasonWatched,
)
},
onMarkPreviousSeasonsWatched = {
WatchingActions.togglePreviousEpisodesWatched(
meta = meta,
episodes = previousSeasonEpisodes,
areCurrentlyWatched = false,
)
},
)
}
if (inAppTrailerPlaybackEnabled) {
TrailerPlayerPopup(
visible = selectedTrailer != null,
@ -994,6 +1057,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
}
@Composable
private fun selectedSeasonLabel(season: Int): String =
if (season == 0) {
stringResource(Res.string.episodes_specials)
} else {
stringResource(Res.string.episodes_season, season)
}
private fun isEpisodeWatchedForActions(
meta: MetaDetails,
episode: MetaVideo,
watchedKeys: Set<String>,
progressByVideoId: Map<String, WatchProgressEntry>,
): Boolean {
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
)
}
private fun areEpisodesWatchedForActions(
meta: MetaDetails,
episodes: Collection<MetaVideo>,
watchedKeys: Set<String>,
progressByVideoId: Map<String, WatchProgressEntry>,
): Boolean = episodes.isNotEmpty() && episodes.all { episode ->
isEpisodeWatchedForActions(
meta = meta,
episode = episode,
watchedKeys = watchedKeys,
progressByVideoId = progressByVideoId,
)
}
private fun extractImdbId(value: String?): String? =
value
?.trim()
@ -1050,6 +1156,7 @@ private fun ConfiguredMetaSections(
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
onSeasonLongPress: (Int) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson, String?) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
@ -1144,6 +1251,7 @@ private fun ConfiguredMetaSections(
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
onSeasonLongPress = onSeasonLongPress,
)
}
}

View file

@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction(
watchedItems: List<WatchedItem>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): SeriesPrimaryAction? =
seriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
entries = entries,
watchedItems = watchedItems,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
)
internal fun MetaDetails.seriesPrimaryAction(
content: WatchingContentRef,
entries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): SeriesPrimaryAction? =
decideSeriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
content = content,
episodes = videos.map(MetaVideo::toDomainReleasedEpisode),
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
)?.toLegacySeriesPrimaryAction()
internal fun MetaVideo.playLabel(): String =

View file

@ -100,6 +100,7 @@ fun DetailSeriesContent(
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
onSeasonLongPress: ((Int) -> Unit)? = null,
) {
val hasVideos = meta.videos.isNotEmpty()
if (meta.type != "series" && !hasVideos) return
@ -230,12 +231,14 @@ fun DetailSeriesContent(
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
onLongPress = onSeasonLongPress,
)
SeasonViewMode.Text -> SeasonTextChipScrollRow(
seasons = seasons,
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
onLongPress = onSeasonLongPress,
)
}
}
@ -245,6 +248,7 @@ fun DetailSeriesContent(
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
onLongPress = onSeasonLongPress,
)
}
}
@ -372,12 +376,14 @@ private fun SeasonViewModeToggle(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SeasonTextChipScrollRow(
seasons: List<Int>,
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
onLongPress: ((Int) -> Unit)?,
) {
val seasonListState = rememberLazyListState()
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow(
Color.Transparent
},
)
.clickable { onSelect(season) }
.combinedClickable(
onClick = { onSelect(season) },
onLongClick = onLongPress?.let { handler -> { handler(season) } },
)
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow(
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
onLongPress: ((Int) -> Unit)?,
) {
val seasonListState = rememberLazyListState()
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@ -475,11 +485,13 @@ private fun SeasonPosterScrollRow(
isSelected = season == currentSeason,
sizing = sizing,
onClick = { onSelect(season) },
onLongClick = onLongPress?.let { handler -> { handler(season) } },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SeasonPosterButton(
label: String,
@ -487,11 +499,15 @@ private fun SeasonPosterButton(
isSelected: Boolean,
sizing: SeriesContentSizing,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier
.width(sizing.seasonPosterWidth)
.clickable(onClick = onClick),
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(

View file

@ -1,14 +1,9 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DoneAll
@ -135,6 +130,73 @@ fun EpisodeWatchedActionSheet(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SeasonWatchedActionSheet(
seasonLabel: String,
isSeasonWatched: Boolean,
canMarkPreviousSeasons: Boolean,
onDismiss: () -> Unit,
onToggleSeasonWatched: () -> Unit,
onMarkPreviousSeasonsWatched: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
onDismissRequest = {
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
) {
Text(
text = seasonLabel,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.PlaylistAddCheckCircle,
title = if (isSeasonWatched) {
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
} else {
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
},
onClick = {
onToggleSeasonWatched()
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
)
if (canMarkPreviousSeasons) {
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.DoneAll,
title = stringResource(Res.string.episode_mark_previous_seasons_watched),
onClick = {
onMarkPreviousSeasonsWatched()
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
)
}
}
}
}
@Composable
private fun EpisodeActionSheetHeader(
episode: MetaVideo,

View file

@ -25,8 +25,11 @@ import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.CloudLibraryUiState
import com.nuvio.app.features.cloud.findPlaybackTargetForProgress
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.details.SeriesPrimaryAction
import com.nuvio.app.features.details.seriesPrimaryAction
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -48,6 +51,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
@ -55,14 +59,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.continueWatchingEntries
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.collection.CollectionRepository
@ -175,47 +177,41 @@ fun HomeScreen(
)
}
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
if (isTraktProgressActive) emptyList() else watchedUiState.items
}
val allNextUpSeedEntries = remember(
val allNextUpSeedCandidates = remember(
watchProgressUiState.entries,
effectiveWatchedItems,
watchedUiState.items,
isTraktProgressActive,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
buildTvParityNextUpSeedEntries(
buildHomeNextUpSeedCandidates(
progressEntries = watchProgressUiState.entries,
watchedItems = effectiveWatchedItems,
watchedItems = watchedUiState.items,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val recentNextUpSeedEntries = remember(
allNextUpSeedEntries,
val recentNextUpSeedCandidates = remember(
allNextUpSeedCandidates,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterEntriesForTraktContinueWatchingWindow(
entries = allNextUpSeedEntries,
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) {
allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId }
val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) {
allNextUpSeedEntries.mapNotNull { entry ->
val season = entry.seasonNumber ?: return@mapNotNull null
val episode = entry.episodeNumber ?: return@mapNotNull null
entry.parentMetaId to (season to episode)
val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.associate { candidate ->
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
}.toMap()
}
@ -229,10 +225,10 @@ fun HomeScreen(
}
}
val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
allNextUpSeedEntries
.groupBy { entry -> entry.parentMetaId }
.mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE }
val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates
.groupBy { candidate -> candidate.content.id }
.mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE }
}
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
@ -250,17 +246,9 @@ fun HomeScreen(
.toSet()
}
val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) {
recentNextUpSeedEntries.mapNotNull { seed ->
val season = seed.seasonNumber ?: return@mapNotNull null
val episode = seed.episodeNumber ?: return@mapNotNull null
if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null
CompletedSeriesCandidate(
content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId),
seasonNumber = season,
episodeNumber = episode,
markedAtEpochMs = seed.lastUpdatedEpochMs,
)
val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) {
recentNextUpSeedCandidates.filter { candidate ->
candidate.content.id !in nextUpSuppressedSeriesIds
}
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
@ -270,6 +258,17 @@ fun HomeScreen(
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
val shouldValidateMissingNextUpSeeds = remember(
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.isLoaded,
) {
if (isTraktProgressActive) {
watchProgressUiState.hasLoadedRemoteProgress
} else {
watchedUiState.isLoaded
}
}
val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
@ -277,6 +276,7 @@ fun HomeScreen(
currentNextUpSeedByContentId,
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
shouldValidateMissingNextUpSeeds,
processedNextUpContentIds,
nextUpItemsBySeries,
continueWatchingPreferences.showUnairedNextUp,
@ -284,25 +284,13 @@ fun HomeScreen(
) {
cachedSnapshots.first.mapNotNull { cached ->
if (
!isTraktProgressActive &&
watchedUiState.isLoaded &&
cached.contentId !in activeNextUpSeedContentIds
) {
return@mapNotNull null
}
if (
isTraktProgressActive &&
watchProgressUiState.hasLoadedRemoteProgress &&
shouldValidateMissingNextUpSeeds &&
cached.contentId !in activeNextUpSeedContentIds
) {
return@mapNotNull null
}
val currentSeed = currentNextUpSeedByContentId[cached.contentId]
if (
currentSeed != null &&
cached.seedSeason != null &&
cached.seedEpisode != null
) {
if (currentSeed != null) {
val (currentSeason, currentEpisode) = currentSeed
val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode
if (seedChanged) return@mapNotNull null
@ -335,8 +323,16 @@ fun HomeScreen(
nextUpItemsBySeries,
cachedNextUpItems,
continueWatchingPreferences.dismissedNextUpKeys,
activeNextUpSeedContentIds,
currentNextUpSeedByContentId,
shouldValidateMissingNextUpSeeds,
) {
val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) ->
val liveNextUpItems = filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries = nextUpItemsBySeries,
activeSeedContentIds = activeNextUpSeedContentIds,
currentSeedByContentId = currentNextUpSeedByContentId,
shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds,
).filterValues { (_, item) ->
nextUpDismissKey(
item.parentMetaId,
item.nextUpSeedSeasonNumber,
@ -412,6 +408,9 @@ fun HomeScreen(
visibleContinueWatchingEntries,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
continueWatchingPreferences.upNextFromFurthestEpisode,
watchProgressUiState.entries,
watchedUiState.items,
) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
@ -462,12 +461,18 @@ fun HomeScreen(
if (meta == null) {
return@withPermit null
}
val nextEpisode = meta.nextReleasedEpisodeAfter(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
val action = meta.seriesPrimaryAction(
content = completedEntry.content,
entries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
)
if (action?.resumePositionMs != null) {
return@withPermit null
}
val nextEpisode = action?.let { meta.videoForSeriesAction(it) }
if (nextEpisode == null) {
return@withPermit null
}
@ -736,40 +741,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow(
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
private fun buildTvParityNextUpSeedEntries(
internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates: List<CompletedSeriesCandidate>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<CompletedSeriesCandidate> {
if (!isTraktProgressActive) return candidates
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs }
}
internal fun buildHomeNextUpSeedCandidates(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
isTraktProgressActive: Boolean,
preferFurthestEpisode: Boolean,
nowEpochMs: Long,
): List<WatchProgressEntry> {
val rawSeeds = if (isTraktProgressActive) {
progressEntries.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
.filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) }
.toList()
} else {
watchedItems.asSequence()
.filter { item -> item.type.isSeriesTypeForContinueWatching() }
.filter { item -> item.season != null && item.episode != null && item.season != 0 }
.filter { item -> !isMalformedNextUpSeedContentId(item.id) }
.map { item -> item.toNextUpSeedEntry() }
.toList()
): List<CompletedSeriesCandidate> {
val progressSeeds = progressEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
.filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) }
.filter { entry ->
if (isTraktProgressActive) {
shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs)
} else {
entry.shouldUseAsCompletedSeedForContinueWatching()
}
}
.toList()
val watchedSeeds = watchedItems.filter { item ->
item.type.isSeriesTypeForContinueWatching() &&
item.season != null &&
item.episode != null &&
item.season != 0 &&
!isMalformedNextUpSeedContentId(item.id)
}
return if (isTraktProgressActive) {
mergeTvTraktNextUpSeeds(rawSeeds)
} else {
rawSeeds
.groupBy { entry -> nextUpSeedKey(entry) }
.mapNotNull { (_, entries) ->
choosePreferredNextUpSeed(
entries = entries,
preferFurthestEpisode = preferFurthestEpisode,
)
}
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
return WatchingState.latestCompletedBySeries(
progressEntries = progressSeeds,
watchedItems = watchedSeeds,
preferFurthestEpisode = preferFurthestEpisode,
).mapNotNull { (content, completed) ->
if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null
if (completed.seasonNumber == 0) return@mapNotNull null
if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null
CompletedSeriesCandidate(
content = content,
seasonNumber = completed.seasonNumber,
episodeNumber = completed.episodeNumber,
markedAtEpochMs = completed.markedAtEpochMs,
)
}.sortedWith(
compareByDescending<CompletedSeriesCandidate> { candidate -> candidate.markedAtEpochMs }
.thenByDescending { candidate -> candidate.seasonNumber }
.thenByDescending { candidate -> candidate.episodeNumber },
)
}
internal fun filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
activeSeedContentIds: Set<String>,
currentSeedByContentId: Map<String, Pair<Int, Int>>,
shouldDropItemsWithoutActiveSeed: Boolean,
): Map<String, Pair<Long, ContinueWatchingItem>> =
nextUpItemsBySeries.filter { (contentId, pair) ->
if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) {
return@filter false
}
val item = pair.second
val currentSeed = currentSeedByContentId[contentId] ?: return@filter true
item.nextUpSeedSeasonNumber == currentSeed.first &&
item.nextUpSeedEpisodeNumber == currentSeed.second
}
private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? {
if (action.seasonNumber != null && action.episodeNumber != null) {
videos.firstOrNull { video ->
video.season == action.seasonNumber &&
video.episode == action.episodeNumber
}?.let { return it }
}
return videos.firstOrNull { video ->
com.nuvio.app.features.watchprogress.buildPlaybackVideoId(
parentMetaId = id,
seasonNumber = video.season,
episodeNumber = video.episode,
fallbackVideoId = video.id,
) == action.videoId || video.id == action.videoId
}
}
@ -784,103 +848,6 @@ private fun shouldUseAsTraktNextUpSeed(
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
}
private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry =
WatchProgressEntry(
contentType = type,
parentMetaId = id,
parentMetaType = type,
videoId = id,
title = name,
poster = poster,
seasonNumber = season,
episodeNumber = episode,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = markedAtEpochMs,
isCompleted = true,
progressPercent = 100f,
source = WatchProgressSourceLocal,
)
private fun nextUpSeedKey(entry: WatchProgressEntry): String =
entry.parentMetaId.trim()
private fun mergeTvTraktNextUpSeeds(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val merged = linkedMapOf<String, WatchProgressEntry>()
entries
.filter { entry -> entry.source == WatchProgressSourceTraktShowProgress }
.forEach { seed ->
merged[nextUpSeedKey(seed)] = seed
}
entries
.filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback }
.forEach { seed ->
val key = nextUpSeedKey(seed)
val existing = merged[key]
if (existing == null || shouldReplaceNextUpSeed(existing, seed)) {
merged[key] = seed
}
}
return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
}
private fun shouldReplaceNextUpSeed(
existing: WatchProgressEntry,
candidate: WatchProgressEntry,
): Boolean {
val candidateSeason = candidate.seasonNumber ?: -1
val candidateEpisode = candidate.episodeNumber ?: -1
val existingSeason = existing.seasonNumber ?: -1
val existingEpisode = existing.episodeNumber ?: -1
return candidateSeason > existingSeason ||
(
candidateSeason == existingSeason &&
(
candidateEpisode > existingEpisode ||
(
candidateEpisode == existingEpisode &&
candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs
)
)
)
}
private fun choosePreferredNextUpSeed(
entries: List<WatchProgressEntry>,
preferFurthestEpisode: Boolean,
): WatchProgressEntry? {
if (entries.isEmpty()) return null
val bestRank = entries.minOf(::nextUpSeedSourceRank)
return entries
.asSequence()
.filter { entry -> nextUpSeedSourceRank(entry) == bestRank }
.maxWithOrNull(
if (preferFurthestEpisode) {
compareBy<WatchProgressEntry>(
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
{ it.lastUpdatedEpochMs },
)
} else {
compareBy<WatchProgressEntry>(
{ it.lastUpdatedEpochMs },
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
)
},
)
}
private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int =
when (entry.source) {
WatchProgressSourceTraktPlayback,
WatchProgressSourceTraktShowProgress,
-> 0
WatchProgressSourceTraktHistory -> 1
WatchProgressSourceLocal -> 2
else -> 4
}
private fun shouldTreatAsActiveInProgressForNextUpSuppression(
progress: WatchProgressEntry,
latestCompletedAt: Long?,
@ -890,15 +857,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression(
return progress.lastUpdatedEpochMs >= latestCompletedAt
}
private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
val trimmed = contentId?.trim().orEmpty()
if (trimmed.isEmpty()) return true
return when (trimmed.lowercase()) {
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
else -> false
}
}
private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float,
continueWatchingVisible: Boolean,
@ -1016,7 +974,7 @@ private fun applyStreamingStyleSort(
return sortedReleased + sortedUnreleased
}
private data class CompletedSeriesCandidate(
internal data class CompletedSeriesCandidate(
val content: WatchingContentRef,
val seasonNumber: Int,
val episodeNumber: Int,

View file

@ -57,8 +57,10 @@ import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
import com.nuvio.app.features.player.skip.SkipIntroButton
import com.nuvio.app.features.player.skip.SkipIntroRepository
import com.nuvio.app.features.player.skip.SkipInterval
import com.nuvio.app.features.streams.BingeGroupCacheRepository
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamAutoPlaySource
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState
@ -70,10 +72,12 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.isIos
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs
@ -87,6 +91,8 @@ private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f
/** Hard ceiling for next-episode stream search to prevent hanging forever. */
private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
private val PlayerSliderOverlayGap = 12.dp
private val PlayerTimeRowHeight = 36.dp
private val PlayerActionRowHeight = 50.dp
@ -324,6 +330,15 @@ fun PlayerScreen(
}
}
// Persist binge group per content so subsequent episode plays
// (from CW, Details, or next-episode) can reuse the same source group.
LaunchedEffect(currentStreamBingeGroup, parentMetaId) {
val bg = currentStreamBingeGroup
if (bg != null && parentMetaId.isNotBlank()) {
BingeGroupCacheRepository.save(parentMetaId, bg)
}
}
ManagePlayerPictureInPicture(
isPlaying = playbackSnapshot.isPlaying,
playerSize = layoutSize,
@ -1102,6 +1117,12 @@ fun PlayerScreen(
settings.streamAutoPlayPreferBingeGroup
)
// bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle)
val bingeGroupOnlyManualMode =
shouldAutoSelectInManualMode &&
!settings.streamAutoPlayNextEpisodeEnabled &&
settings.streamAutoPlayPreferBingeGroup
// Determine auto-play mode for next episode
val effectiveMode = if (shouldAutoSelectInManualMode) {
StreamAutoPlayMode.FIRST_STREAM
@ -1109,7 +1130,7 @@ fun PlayerScreen(
settings.streamAutoPlayMode
}
val effectiveSource = if (shouldAutoSelectInManualMode) {
com.nuvio.app.features.streams.StreamAutoPlaySource.ALL_SOURCES
StreamAutoPlaySource.ALL_SOURCES
} else {
settings.streamAutoPlaySource
}
@ -1129,6 +1150,13 @@ fun PlayerScreen(
settings.streamAutoPlayRegex
}
// Determine preferred binge group from current stream (not cache)
val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) {
currentStreamBingeGroup
} else {
null
}
nextEpisodeAutoPlayJob = scope.launch {
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
@ -1140,61 +1168,196 @@ fun PlayerScreen(
val installedAddonNames = AddonRepository.uiState.value.addons
.map { it.displayTitle }
.toSet()
val debridSettings = DebridSettingsRepository.snapshot()
val timeoutMs = settings.streamAutoPlayTimeoutSeconds * 1000L
val startTime = WatchProgressClock.nowEpochMs()
val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds
var autoSelectTriggered = false
var timeoutElapsed = false
var selectedStream: StreamItem? = null
val autoSelectSettled = CompletableDeferred<Unit>()
// Collect streams as they arrive
PlayerStreamsRepository.episodeStreamsState.collectLatest { state ->
if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest
val allStreams = state.groups.flatMap { it.streams }
val elapsed = WatchProgressClock.nowEpochMs() - startTime
val selected = if (allStreams.isNotEmpty()) {
StreamAutoPlaySelector.selectAutoPlayStream(
streams = allStreams,
mode = effectiveMode,
regexPattern = effectiveRegex,
source = effectiveSource,
installedAddonNames = installedAddonNames,
selectedAddons = effectiveSelectedAddons,
selectedPlugins = effectiveSelectedPlugins,
preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) {
currentStreamBingeGroup
} else {
null
},
preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
debridEnabled = DebridSettingsRepository.snapshot().canResolvePlayableLinks,
)
} else null
if (selected != null || !state.isAnyLoading || elapsed >= timeoutMs) {
nextEpisodeAutoPlaySearching = false
if (selected != null) {
nextEpisodeAutoPlaySourceName = selected.addonName
// Countdown before playing
for (i in 3 downTo 1) {
nextEpisodeAutoPlayCountdown = i
delay(1000)
}
switchToEpisodeStream(selected, nextVideo)
showNextEpisodeCard = false
nextEpisodeAutoPlayCountdown = null
nextEpisodeAutoPlaySourceName = null
} else if (!state.isAnyLoading || elapsed >= timeoutMs) {
// No stream found — open the episode streams panel for manual selection
episodeStreamsPanelState = EpisodeStreamsPanelState(
showStreams = true,
selectedEpisode = nextVideo,
)
showEpisodesPanel = true
showNextEpisodeCard = false
}
return@collectLatest
fun settleAutoSelect() {
if (!autoSelectSettled.isCompleted) {
autoSelectSettled.complete(Unit)
}
}
fun selectStream(stream: StreamItem) {
autoSelectTriggered = true
selectedStream = stream
settleAutoSelect()
}
fun finishWithoutSelection() {
autoSelectTriggered = true
settleAutoSelect()
}
// Full select: tries binge group first, then falls back to mode-based selection
fun trySelectStream(streams: List<StreamItem>): StreamItem? {
return StreamAutoPlaySelector.selectAutoPlayStream(
streams = streams,
mode = effectiveMode,
regexPattern = effectiveRegex,
source = effectiveSource,
installedAddonNames = installedAddonNames,
selectedAddons = effectiveSelectedAddons,
selectedPlugins = effectiveSelectedPlugins,
preferredBingeGroup = preferredBingeGroup,
preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
bingeGroupOnly = bingeGroupOnlyManualMode,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
}
// Binge group only early match: returns null if no binge group match
fun tryBingeGroupOnly(streams: List<StreamItem>): StreamItem? {
if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null
return StreamAutoPlaySelector.selectAutoPlayStream(
streams = streams,
mode = effectiveMode,
regexPattern = effectiveRegex,
source = effectiveSource,
installedAddonNames = installedAddonNames,
selectedAddons = effectiveSelectedAddons,
selectedPlugins = effectiveSelectedPlugins,
preferredBingeGroup = preferredBingeGroup,
preferBingeGroupInSelection = true,
bingeGroupOnly = true,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
}
val innerJob = launch {
// Collect streams as they arrive
PlayerStreamsRepository.episodeStreamsState.collectLatest { state ->
if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest
val allStreams = state.groups.flatMap { it.streams }
if (autoSelectTriggered) {
// Already resolved
} else if (timeoutElapsed) {
// Timeout elapsed: full select (binge group + fallback to mode)
if (allStreams.isNotEmpty()) {
val candidate = trySelectStream(allStreams)
if (candidate != null) {
selectStream(candidate)
}
}
} else {
// Before timeout: eagerly check binge group only
if (allStreams.isNotEmpty()) {
val earlyMatch = tryBingeGroupOnly(allStreams)
if (earlyMatch != null) {
selectStream(earlyMatch)
}
}
}
// If all addons finished loading and no match yet, do a final full select
if (!autoSelectTriggered && !state.isAnyLoading) {
if (allStreams.isNotEmpty()) {
val candidate = trySelectStream(allStreams)
if (candidate != null) {
selectStream(candidate)
}
}
if (!autoSelectTriggered) {
finishWithoutSelection()
}
return@collectLatest
}
if (autoSelectTriggered) return@collectLatest
}
}
// Timeout logic
val timeoutMs = timeoutSeconds * 1_000L
val isBoundedTimeout = timeoutSeconds in 1..30
if (isBoundedTimeout) {
// Bounded timeout (1-30s): wait, then trigger full select
delay(timeoutMs)
timeoutElapsed = true
if (!autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
val candidate = trySelectStream(allStreams)
if (candidate != null) {
selectStream(candidate)
}
}
}
if (selectedStream != null) {
innerJob.cancel()
} else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) {
// Streams arrived but no match after full select — don't wait further
innerJob.cancel()
finishWithoutSelection()
} else {
// No addon responded yet — wait with hard ceiling
val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() }
innerJob.cancel()
if (completed == null) {
if (!autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
selectedStream = trySelectStream(allStreams)
}
finishWithoutSelection()
}
}
}
} else {
// Instant (0) or unlimited: timeoutElapsed immediately so each
// addon response triggers a full select attempt in the collect.
timeoutElapsed = true
if (!autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
trySelectStream(allStreams)?.let(::selectStream)
}
}
val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS
val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() }
innerJob.cancel()
if (completed == null) {
if (!autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
selectedStream = trySelectStream(allStreams)
}
finishWithoutSelection()
}
}
}
// Handle result
nextEpisodeAutoPlaySearching = false
if (selectedStream != null) {
nextEpisodeAutoPlaySourceName = selectedStream!!.addonName
// Countdown before playing
for (i in 3 downTo 1) {
nextEpisodeAutoPlayCountdown = i
delay(1000)
}
switchToEpisodeStream(selectedStream!!, nextVideo)
showNextEpisodeCard = false
nextEpisodeAutoPlayCountdown = null
nextEpisodeAutoPlaySourceName = null
} else {
// No stream found — open the episode streams panel for manual selection
episodeStreamsPanelState = EpisodeStreamsPanelState(
showStreams = true,
selectedEpisode = nextVideo,
)
showEpisodesPanel = true
showNextEpisodeCard = false
}
}
}
@ -1892,7 +2055,7 @@ fun PlayerScreen(
// Skip intro/recap/outro button
if (!playerControlsLocked) {
SkipIntroButton(
interval = activeSkipInterval,
interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval,
dismissed = skipIntervalDismissed,
controlsVisible = controlsVisible,
onSkip = {

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,
debridEnabled: Boolean = true,
activeResolverProviderId: String? = null,
): StreamItem? =
@ -53,6 +54,7 @@ object StreamAutoPlaySelector {
selectedPlugins = selectedPlugins,
preferredBingeGroup = preferredBingeGroup,
preferBingeGroupInSelection = preferBingeGroupInSelection,
bingeGroupOnly = bingeGroupOnly,
debridEnabled = debridEnabled,
activeResolverProviderId = activeResolverProviderId,
).stream
@ -67,6 +69,7 @@ object StreamAutoPlaySelector {
selectedPlugins: Set<String>,
preferredBingeGroup: String? = null,
preferBingeGroupInSelection: Boolean = false,
bingeGroupOnly: Boolean = false,
debridEnabled: Boolean = true,
activeResolverProviderId: String? = null,
): StreamAutoPlayEvaluation {
@ -86,10 +89,34 @@ object StreamAutoPlaySelector {
}
}
if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation()
if (mode == StreamAutoPlayMode.MANUAL) return StreamAutoPlayEvaluation()
if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) {
return StreamAutoPlayEvaluation()
}
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
val preferredReadyStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
val bingeGroupCandidates = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
candidateStreams.filter { stream -> stream.behaviorHints.bingeGroup == targetBingeGroup }
} else {
emptyList()
}
val preferredReadyStream = bingeGroupCandidates.firstOrNull { stream ->
stream.isAutoPlayable(debridEnabled, activeResolverProviderId)
}
if (bingeGroupOnly) {
val readyStreams = preferredReadyStream?.let(::listOf).orEmpty()
return StreamAutoPlayEvaluation(
stream = preferredReadyStream,
readyStreams = readyStreams,
hasPendingDebridCandidate = preferredReadyStream == null &&
bingeGroupCandidates.any {
it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId)
},
)
}
if (mode == StreamAutoPlayMode.MANUAL) {
return StreamAutoPlayEvaluation()
}
val preferredStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
candidateStreams.firstOrNull { stream ->
stream.behaviorHints.bingeGroup == targetBingeGroup &&
stream.isAutoPlayable(debridEnabled, activeResolverProviderId)
@ -139,13 +166,13 @@ object StreamAutoPlaySelector {
}
}
}
if (matchingStreams.isEmpty() && preferredReadyStream == null) return StreamAutoPlayEvaluation()
if (matchingStreams.isEmpty() && preferredStream == null) return StreamAutoPlayEvaluation()
val readyStreams = buildList {
preferredReadyStream?.let(::add)
preferredStream?.let(::add)
matchingStreams
.filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) }
.filterNot { it == preferredReadyStream }
.filterNot { it == preferredStream }
.forEach(::add)
}
val selected = readyStreams.firstOrNull()

View file

@ -50,10 +50,11 @@ object StreamsRepository {
): String =
"$type::$videoId::$season::$episode::$manualSelection"
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
fun load(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
season = season,
episode = episode,
manualSelection = manualSelection,
@ -61,10 +62,11 @@ object StreamsRepository {
)
}
fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
fun reload(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
season = season,
episode = episode,
manualSelection = manualSelection,
@ -72,7 +74,7 @@ object StreamsRepository {
)
}
private fun load(type: String, videoId: String, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) {
private fun load(type: String, videoId: String, parentMetaId: String?, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) {
val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.uiState.value
@ -108,7 +110,21 @@ object StreamsRepository {
val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL &&
!(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH &&
!StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex))
val isDirectAutoPlayFlow = isAutoPlayEnabled
// Look up persisted binge group when both settings are enabled
val persistedBingeGroup = if (
playerSettings.streamAutoPlayPreferBingeGroup &&
playerSettings.streamAutoPlayReuseBingeGroup
) {
parentMetaId?.let { BingeGroupCacheRepository.get(it) }
} else null
// Enable direct auto-play flow if normal auto-play is enabled,
// OR if we have a persisted binge group in MANUAL mode
val bingeGroupDirectFlow = !manualSelection &&
persistedBingeGroup != null &&
autoPlayMode == StreamAutoPlayMode.MANUAL
val isDirectAutoPlayFlow = isAutoPlayEnabled || bingeGroupDirectFlow
if (isDirectAutoPlayFlow) {
_uiState.value = StreamsUiState(
@ -232,6 +248,7 @@ object StreamsRepository {
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
val debridAvailabilityJobs = mutableListOf<Job>()
var autoSelectTriggered = false
var timeoutElapsed = false
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late stream load completion after channel close" }
@ -280,11 +297,58 @@ object StreamsRepository {
debridAvailabilityJobs += availabilityJob
}
val timeoutJob = if (isAutoPlayEnabled) {
val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L
if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) {
val timeoutJob = if (isDirectAutoPlayFlow) {
val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds
val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE
// Timeout semantics:
// - 0 (instant): timeoutElapsed immediately, full select on each response
// - 1-30 (bounded): wait the configured delay, then full select
// - unlimited (Int.MAX_VALUE): timeoutElapsed immediately, full select on each response,
// with 60s hard fallback to stream picker
if (timeoutSeconds <= 0 || isUnlimitedTimeout) {
timeoutElapsed = true
// For unlimited: launch a hard 60s fallback to dismiss overlay
if (isUnlimitedTimeout) {
launch {
delay(60_000L)
if (!autoSelectTriggered) {
autoSelectTriggered = true
val allStreams = _uiState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
streams = allStreams,
mode = autoPlayMode,
regexPattern = playerSettings.streamAutoPlayRegex,
source = playerSettings.streamAutoPlaySource,
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
preferredBingeGroup = persistedBingeGroup,
preferBingeGroupInSelection = persistedBingeGroup != null,
bingeGroupOnly = false,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
_uiState.update { it.copy(autoPlayStream = selected) }
}
if (_uiState.value.autoPlayStream == null) {
_uiState.update {
it.copy(
isDirectAutoPlayFlow = false,
showDirectAutoPlayOverlay = false,
)
}
}
}
}
} else {
null
}
} else {
// Bounded timeout (1-30s)
launch {
delay(timeoutMs)
delay(timeoutSeconds * 1_000L)
timeoutElapsed = true
if (!autoSelectTriggered) {
val allStreams = _uiState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
@ -296,6 +360,9 @@ object StreamsRepository {
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
preferredBingeGroup = persistedBingeGroup,
preferBingeGroupInSelection = persistedBingeGroup != null,
bingeGroupOnly = false,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
@ -319,8 +386,6 @@ object StreamsRepository {
}
}
}
} else {
null
}
} else {
null
@ -488,9 +553,58 @@ object StreamsRepository {
)
}
}
// Early match / timeout-elapsed auto-select on each addon response
if (isDirectAutoPlayFlow && !autoSelectTriggered) {
val allStreams = _uiState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
if (timeoutElapsed) {
// After timeout: full fallback (bingeGroupOnly = false)
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
streams = allStreams,
mode = autoPlayMode,
regexPattern = playerSettings.streamAutoPlayRegex,
source = playerSettings.streamAutoPlaySource,
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
preferredBingeGroup = persistedBingeGroup,
preferBingeGroupInSelection = persistedBingeGroup != null,
bingeGroupOnly = false,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
if (selected != null) {
autoSelectTriggered = true
_uiState.update { it.copy(autoPlayStream = selected) }
}
} else if (persistedBingeGroup != null) {
// Before timeout: try binge-group-only early match
val earlyMatch = StreamAutoPlaySelector.selectAutoPlayStream(
streams = allStreams,
mode = autoPlayMode,
regexPattern = playerSettings.streamAutoPlayRegex,
source = playerSettings.streamAutoPlaySource,
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
preferredBingeGroup = persistedBingeGroup,
preferBingeGroupInSelection = true,
bingeGroupOnly = true,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
if (earlyMatch != null) {
autoSelectTriggered = true
_uiState.update { it.copy(autoPlayStream = earlyMatch) }
}
}
}
}
}
if (isAutoPlayEnabled && !autoSelectTriggered) {
// All addons finished — run final auto-select if not yet triggered
if (isDirectAutoPlayFlow && !autoSelectTriggered) {
autoSelectTriggered = true
val allStreams = _uiState.value.groups.flatMap { it.streams }
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
@ -501,6 +615,9 @@ object StreamsRepository {
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
preferredBingeGroup = persistedBingeGroup,
preferBingeGroupInSelection = persistedBingeGroup != null,
bingeGroupOnly = false,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
@ -528,6 +645,7 @@ object StreamsRepository {
}
fun consumeAutoPlay() {
activeRequestKey = null
_uiState.update {
it.copy(
autoPlayStream = null,

View file

@ -184,6 +184,7 @@ fun StreamsScreen(
StreamsRepository.load(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
season = seasonNumber,
episode = episodeNumber,
manualSelection = manualSelection,
@ -281,6 +282,7 @@ fun StreamsScreen(
StreamsRepository.reload(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
season = seasonNumber,
episode = episodeNumber,
manualSelection = manualSelection,

View file

@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
@Serializable
private data class StoredWatchedPayload(
val items: List<WatchedItem> = emptyList(),
val lastSuccessfulPushEpochMs: Long = 0L,
)
object WatchedRepository {
@ -43,6 +44,7 @@ object WatchedRepository {
private var hasLoaded = false
private var currentProfileId: Int = 1
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
private var lastSuccessfulPushEpochMs: Long = 0L
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
private fun activePullSyncAdapter(): WatchedSyncAdapter =
@ -62,6 +64,7 @@ object WatchedRepository {
hasLoaded = false
currentProfileId = 1
itemsByKey.clear()
lastSuccessfulPushEpochMs = 0L
_uiState.value = WatchedUiState()
}
@ -72,13 +75,16 @@ object WatchedRepository {
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
if (payload.isNotEmpty()) {
val items = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload).items
}.getOrDefault(emptyList())
itemsByKey = items
val storedPayload = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload)
}.getOrDefault(StoredWatchedPayload())
lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs
itemsByKey = storedPayload.items
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
} else {
lastSuccessfulPushEpochMs = 0L
}
publish()
@ -88,16 +94,23 @@ object WatchedRepository {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
currentProfileId = profileId
val pullStartedEpochMs = WatchedClock.nowEpochMs()
val localBeforePull = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.toList()
val lastPushEpochMs = lastSuccessfulPushEpochMs
runCatching {
val serverItems = activePullSyncAdapter().pull(
profileId = profileId,
pageSize = watchedItemsPageSize,
)
itemsByKey = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
itemsByKey = mergeWatchedItemsPreservingUnsynced(
serverItems = serverItems,
localItems = localBeforePull,
lastSuccessfulPushEpochMs = lastPushEpochMs,
pullStartedEpochMs = pullStartedEpochMs,
).toMutableMap()
hasLoaded = true
publish()
persist()
@ -213,6 +226,7 @@ object WatchedRepository {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
pushToActiveTargets(profileId = profileId, items = items)
recordSuccessfulPush(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@ -252,11 +266,24 @@ object WatchedRepository {
items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
),
),
)
}
private fun recordSuccessfulPush(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != currentProfileId) return
val latestPushed = items
.asSequence()
.map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) }
.maxOrNull()
?: return
if (latestPushed <= lastSuccessfulPushEpochMs) return
lastSuccessfulPushEpochMs = latestPushed
persist()
}
private fun shouldUseTraktWatchedSync(): Boolean =
shouldUseTraktWatchedSync(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
@ -294,6 +321,33 @@ object WatchedRepository {
}
}
internal fun mergeWatchedItemsPreservingUnsynced(
serverItems: Collection<WatchedItem>,
localItems: Collection<WatchedItem>,
lastSuccessfulPushEpochMs: Long,
pullStartedEpochMs: Long,
): Map<String, WatchedItem> {
val merged = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
localItems
.map(WatchedItem::normalizedMarkedAt)
.forEach { localItem ->
val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode)
if (key in merged) return@forEach
val markedAt = localItem.markedAtEpochMs
val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs
val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs
if (wasMarkedAfterLastPush || wasMarkedDuringPull) {
merged[key] = localItem
}
}
return merged
}
internal fun shouldUseTraktWatchedSync(
isAuthenticated: Boolean,
source: WatchProgressSource,

View file

@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter(
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
showUnairedNextUp: Boolean = false,
): WatchingReleasedEpisode? {
val sortedEpisodes = episodes.sortedWith(
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter(
candidateSeasonNumber = episode.seasonNumber,
todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate,
showUnairedNextUp = false,
showUnairedNextUp = showUnairedNextUp,
)
}
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction(
watchedRecords: List<WatchingWatchedRecord>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): WatchingSeriesPrimaryAction? {
val resumeRecord = resumeProgressForSeries(
content = content,
@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction(
seasonNumber = latestCompletedEpisode.seasonNumber,
episodeNumber = latestCompletedEpisode.episodeNumber,
todayIsoDate = todayIsoDate,
showUnairedNextUp = showUnairedNextUp,
)
} else {
val sorted = episodes

View file

@ -14,7 +14,11 @@ data class ProgressSyncRecord(
)
interface ProgressSyncAdapter {
suspend fun pull(profileId: Int): List<ProgressSyncRecord>
suspend fun pull(
profileId: Int,
sinceLastWatched: Long? = null,
limit: Int? = null,
): List<ProgressSyncRecord>
suspend fun push(
profileId: Int,

View file

@ -17,8 +17,20 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
encodeDefaults = true
}
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) }
override suspend fun pull(
profileId: Int,
sinceLastWatched: Long?,
limit: Int?,
): List<ProgressSyncRecord> {
val params = buildJsonObject {
put("p_profile_id", profileId)
if (sinceLastWatched != null) {
put("p_since_last_watched", sinceLastWatched)
}
if (limit != null) {
put("p_limit", limit)
}
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
return serverEntries.map { entry ->

View file

@ -12,6 +12,7 @@ import com.nuvio.app.features.trakt.TraktProgressRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressSyncRecord
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
import kotlinx.coroutines.CancellationException
@ -180,38 +181,23 @@ object WatchProgressRepository {
}
runCatching {
val serverEntries = syncAdapter.pull(profileId = profileId)
val sinceLastWatched = entriesByVideoId.values
.maxOfOrNull { entry -> entry.lastUpdatedEpochMs }
?.takeIf { hasCompletedInitialNuvioSyncPull }
val serverEntries = syncAdapter.pull(
profileId = profileId,
sinceLastWatched = sinceLastWatched,
)
val isIncrementalPull = sinceLastWatched != null
val oldLocal = entriesByVideoId.toMap()
val newMap = mutableMapOf<String, WatchProgressEntry>()
val newMap = if (isIncrementalPull) {
entriesByVideoId.toMutableMap()
} else {
mutableMapOf()
}
serverEntries.forEach { entry ->
val videoId = entry.videoId
val cached = oldLocal[videoId]
newMap[videoId] = WatchProgressEntry(
contentType = entry.contentType,
parentMetaId = entry.contentId,
parentMetaType = cached?.parentMetaType ?: entry.contentType,
videoId = videoId,
title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId,
logo = cached?.logo,
poster = cached?.poster,
background = cached?.background,
seasonNumber = entry.season,
episodeNumber = entry.episode,
episodeTitle = cached?.episodeTitle,
episodeThumbnail = cached?.episodeThumbnail,
lastPositionMs = entry.position,
durationMs = entry.duration,
lastUpdatedEpochMs = entry.lastWatched,
providerName = cached?.providerName,
providerAddonId = cached?.providerAddonId,
lastStreamTitle = cached?.lastStreamTitle,
lastStreamSubtitle = cached?.lastStreamSubtitle,
pauseDescription = cached?.pauseDescription,
lastSourceUrl = cached?.lastSourceUrl,
isCompleted = isWatchProgressComplete(entry.position, entry.duration, false),
)
newMap[entry.videoId] = entry.toWatchProgressEntry(cached = oldLocal[entry.videoId])
}
entriesByVideoId = newMap
@ -232,6 +218,32 @@ object WatchProgressRepository {
}
}
private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry =
WatchProgressEntry(
contentType = contentType,
parentMetaId = contentId,
parentMetaType = cached?.parentMetaType ?: contentType,
videoId = videoId,
title = cached?.title?.takeIf { it.isNotBlank() } ?: contentId,
logo = cached?.logo,
poster = cached?.poster,
background = cached?.background,
seasonNumber = season,
episodeNumber = episode,
episodeTitle = cached?.episodeTitle,
episodeThumbnail = cached?.episodeThumbnail,
lastPositionMs = position,
durationMs = duration,
lastUpdatedEpochMs = lastWatched,
providerName = cached?.providerName,
providerAddonId = cached?.providerAddonId,
lastStreamTitle = cached?.lastStreamTitle,
lastStreamSubtitle = cached?.lastStreamSubtitle,
pauseDescription = cached?.pauseDescription,
lastSourceUrl = cached?.lastSourceUrl,
isCompleted = isWatchProgressComplete(position, duration, false),
)
private fun resolveRemoteMetadata() {
val needsResolution = entriesByVideoId.values
.filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() }

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest {
assertEquals("show:1:3", action.videoId)
}
@Test
fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() {
val meta = MetaDetails(
id = "tt1234567",
type = "series",
name = "Show",
videos = listOf(
MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"),
MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"),
),
)
val action = meta.seriesPrimaryAction(
content = WatchingContentRef(type = "series", id = "tmdb:98765"),
entries = listOf(
WatchProgressEntry(
contentType = "series",
parentMetaId = "tmdb:98765",
parentMetaType = "series",
videoId = "tmdb:98765:4:14",
title = "Show",
seasonNumber = 4,
episodeNumber = 14,
lastPositionMs = 10_000L,
durationMs = 10_000L,
lastUpdatedEpochMs = 100L,
isCompleted = true,
),
),
watchedItems = emptyList(),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Up Next • S4E15", action.label)
assertEquals("tmdb:98765:4:15", action.videoId)
assertEquals(4, action.seasonNumber)
assertEquals(15, action.episodeNumber)
}
@Test
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
val meta = MetaDetails(

View file

@ -9,9 +9,11 @@ import com.nuvio.app.features.cloud.playbackVideoId
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HomeScreenTest {
@ -192,6 +194,85 @@ class HomeScreenTest {
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
}
@Test
fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() {
val completedProgress = progressEntry(
videoId = "show:4:14",
title = "Show",
seasonNumber = 4,
episodeNumber = 14,
lastUpdatedEpochMs = 2_000L,
isCompleted = true,
)
val olderWatchedItem = watchedItem(
id = "show",
season = 4,
episode = 10,
markedAtEpochMs = 1_000L,
)
val result = buildHomeNextUpSeedCandidates(
progressEntries = listOf(completedProgress),
watchedItems = listOf(olderWatchedItem),
isTraktProgressActive = false,
preferFurthestEpisode = true,
nowEpochMs = 3_000L,
)
assertEquals(1, result.size)
assertEquals("show", result.single().content.id)
assertEquals(4, result.single().seasonNumber)
assertEquals(14, result.single().episodeNumber)
}
@Test
fun `home next up seed uses furthest watched item when progress is older`() {
val olderCompletedProgress = progressEntry(
videoId = "show:4:10",
title = "Show",
seasonNumber = 4,
episodeNumber = 10,
lastUpdatedEpochMs = 2_000L,
isCompleted = true,
)
val newerWatchedItem = watchedItem(
id = "show",
season = 4,
episode = 14,
markedAtEpochMs = 1_000L,
)
val result = buildHomeNextUpSeedCandidates(
progressEntries = listOf(olderCompletedProgress),
watchedItems = listOf(newerWatchedItem),
isTraktProgressActive = false,
preferFurthestEpisode = true,
nowEpochMs = 3_000L,
)
assertEquals(4, result.single().seasonNumber)
assertEquals(14, result.single().episodeNumber)
}
@Test
fun `stale live next up item is dropped when current seed advances`() {
val staleNextUp = continueWatchingItem(
videoId = "show:4:11",
subtitle = "Up Next • S4E11",
seedSeasonNumber = 4,
seedEpisodeNumber = 10,
)
val result = filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)),
activeSeedContentIds = setOf("show"),
currentSeedByContentId = mapOf("show" to (4 to 14)),
shouldDropItemsWithoutActiveSeed = true,
)
assertTrue(result.isEmpty())
}
private fun progressEntry(
videoId: String,
title: String,
@ -199,6 +280,7 @@ class HomeScreenTest {
seasonNumber: Int? = 1,
episodeNumber: Int? = 4,
episodeTitle: String? = "Episode",
isCompleted: Boolean = false,
): WatchProgressEntry =
WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
@ -212,11 +294,16 @@ class HomeScreenTest {
lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L,
durationMs = 1_000_000L,
lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted,
)
private fun continueWatchingItem(
videoId: String,
subtitle: String,
seasonNumber: Int? = 1,
episodeNumber: Int? = 4,
seedSeasonNumber: Int? = seasonNumber,
seedEpisodeNumber: Int? = episodeNumber,
): ContinueWatchingItem =
ContinueWatchingItem(
parentMetaId = videoId.substringBefore(':'),
@ -225,14 +312,32 @@ class HomeScreenTest {
title = "Show",
subtitle = subtitle,
imageUrl = null,
seasonNumber = 1,
episodeNumber = 4,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = subtitle.substringAfterLast("", "Episode"),
isNextUp = true,
nextUpSeedSeasonNumber = seedSeasonNumber,
nextUpSeedEpisodeNumber = seedEpisodeNumber,
resumePositionMs = 0L,
durationMs = 0L,
progressFraction = 0f,
)
private fun watchedItem(
id: String,
season: Int,
episode: Int,
markedAtEpochMs: Long,
): WatchedItem =
WatchedItem(
id = id,
type = "series",
name = "Show",
season = season,
episode = episode,
markedAtEpochMs = markedAtEpochMs,
)
private companion object {
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
}

View file

@ -44,5 +44,57 @@ class WatchedRepositoryTest {
assertTrue(result)
}
}
@Test
fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() {
val serverItem = WatchedItem(
id = "show",
type = "series",
name = "Episode 1",
season = 1,
episode = 1,
markedAtEpochMs = 1_000L,
)
val unsyncedLocalItem = WatchedItem(
id = "show",
type = "series",
name = "Episode 2",
season = 1,
episode = 2,
markedAtEpochMs = 3_000L,
)
val merged = mergeWatchedItemsPreservingUnsynced(
serverItems = listOf(serverItem),
localItems = listOf(serverItem, unsyncedLocalItem),
lastSuccessfulPushEpochMs = 2_000L,
pullStartedEpochMs = 4_000L,
)
assertEquals(
setOf("series:show:1:1", "series:show:1:2"),
merged.keys,
)
}
@Test
fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() {
val oldLocalItem = WatchedItem(
id = "show",
type = "series",
name = "Episode 1",
season = 1,
episode = 1,
markedAtEpochMs = 1_000L,
)
val merged = mergeWatchedItemsPreservingUnsynced(
serverItems = emptyList(),
localItems = listOf(oldLocalItem),
lastSuccessfulPushEpochMs = 2_000L,
pullStartedEpochMs = 4_000L,
)
assertTrue(merged.isEmpty())
}
}

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