mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
Merge branch 'cwtest' into stripdebrid
This commit is contained in:
commit
2e35652e81
32 changed files with 1458 additions and 334 deletions
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue