From 8039c32a707015586011007cf3a1c63003336670 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 18 Jun 2026 13:54:59 +0200 Subject: [PATCH 1/2] fix: resolve debrid instant streams before download/copy link Long-press menu was blocked for debrid instant streams because playableDirectUrl is null until resolved. Now the menu opens for any stream that has a direct URL or is a debrid candidate. Download and Copy Link actions now resolve the debrid stream URL via DirectDebridPlaybackResolver before enqueuing/copying, matching the same flow used by the player. --- .../app/features/streams/StreamsScreen.kt | 99 +++++++++++++++---- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 8fdf7e116..d8853d5c9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -84,6 +84,9 @@ import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch @@ -144,6 +147,7 @@ fun StreamsScreen( val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } + val downloadScope = rememberCoroutineScope() var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } var autoPlayOverlayLogoLoadError by remember(logo) { mutableStateOf(false) } val autoPlayOverlayLogoUrl = logo?.takeIf { it.isNotBlank() } @@ -355,27 +359,88 @@ fun StreamsScreen( if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) NuvioToastController.show(streamLinkCopiedText) + } else if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) { + downloadScope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = seasonNumber, + episode = episodeNumber, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> { + val resolvedUrl = resolved.stream.playableDirectUrl + if (!resolvedUrl.isNullOrBlank()) { + clipboardManager.setText(AnnotatedString(resolvedUrl)) + NuvioToastController.show(streamLinkCopiedText) + } else { + NuvioToastController.show(noDirectStreamLinkText) + } + } + else -> { + val message = resolved.toastMessage() + if (message != null) { + NuvioToastController.show(message) + } + } + } + } } else { NuvioToastController.show(noDirectStreamLinkText) } }, onDownload = { stream -> - val result = DownloadsRepository.enqueueFromStream( - contentType = type, - videoId = videoId, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - stream = stream, - ) - NuvioToastController.show(result.toastMessage()) + if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) { + downloadScope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = seasonNumber, + episode = episodeNumber, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> { + val result = DownloadsRepository.enqueueFromStream( + contentType = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + stream = resolved.stream, + ) + NuvioToastController.show(result.toastMessage()) + } + else -> { + val message = resolved.toastMessage() + if (message != null) { + NuvioToastController.show(message) + } + } + } + } + } else { + val result = DownloadsRepository.enqueueFromStream( + contentType = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + stream = stream, + ) + NuvioToastController.show(result.toastMessage()) + } }, onOpen = { stream, openExternally -> onStreamActionOpen( @@ -911,7 +976,7 @@ private fun LazyListScope.streamSection( } }, onLongClick = { - if (stream.playableDirectUrl != null) { + if (stream.playableDirectUrl != null || stream.isAddonDebridCandidate) { onStreamLongPress(stream) } }, From 311da8759254e3a1ee1feacc1722a8788a3959df Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 18 Jun 2026 14:46:31 +0200 Subject: [PATCH 2/2] feat: add Original Audio language option with TMDB enrichment - Add AudioLanguageOption.ORIGINAL to preferred audio language picker (primary and secondary) with hint 'Requires TMDB enrichment' - Resolve content original language from meta.language with fallback to country-based inference (countryToLanguageCode mapping) - Pass contentLanguage through PlayerLaunch, PlayerScreen, and StreamLinkCache for reuse on subsequent playback sessions - Add Device Language as default app language option (resets to system locale instead of forcing English) - Add all missing Polish translations (417 strings) - Add LanguageSelectionOption.description support in settings dialogs --- .../settings/ThemeSettingsStorage.android.kt | 14 +- .../composeResources/values-pl/strings.xml | 411 ++++++++++++++++++ .../composeResources/values/strings.xml | 3 + .../commonMain/kotlin/com/nuvio/app/App.kt | 2 + .../player/PlayerLanguagePreferences.kt | 71 +++ .../nuvio/app/features/player/PlayerModels.kt | 1 + .../nuvio/app/features/player/PlayerScreen.kt | 2 + .../app/features/player/PlayerScreenArgs.kt | 1 + .../PlayerScreenRuntimeSourceActions.kt | 4 + .../player/PlayerScreenRuntimeTrackActions.kt | 4 + .../app/features/settings/AppLanguage.kt | 4 +- .../features/settings/PlaybackSettingsPage.kt | 26 +- .../settings/ThemeSettingsRepository.kt | 4 +- .../streams/StreamLinkCacheRepository.kt | 3 + .../settings/ThemeSettingsStorage.ios.kt | 7 +- 15 files changed, 542 insertions(+), 15 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index 6a2e1584d..07954478b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -29,7 +29,7 @@ actual object ThemeSettingsStorage { fun initialize(context: Context) { preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) - applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } actual fun loadSelectedTheme(): String? = @@ -84,9 +84,13 @@ actual object ThemeSettingsStorage { } actual fun applySelectedAppLanguage(languageCode: String) { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(languageCode), - ) + if (languageCode.equals("device", ignoreCase = true)) { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } else { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(languageCode), + ) + } } actual fun exportToSyncPayload(): JsonObject = buildJsonObject { @@ -103,6 +107,6 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } } diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index f179fcf56..e8091e03d 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1459,4 +1459,415 @@ Logo dodatku Pokazuj logo i nazwę dodatku obok źródeł. Wyświetlanie + Język oryginalny + Wymaga włączonego wzbogacania TMDB + Język urządzenia + W manifeście brakuje \"%1$s\" + Biblioteka w chmurze jest wyłączona. + Biblioteka w chmurze nie jest dostępna dla %1$s. + Filmy + Seriale + Rozpoznana lista Trakt + Kolekcja TMDB %1$s + Australia + Kanada + Niemcy + US, KR, JP, IN + 2020-01-01 + 2024-12-31 + Reżyser TMDB %1$s + TMDB Odkrywaj + TMDB Odkrywaj + 28,12 + 18,35 + Wprowadź prawidłowy ID lub URL TMDB. + Wprowadź prawidłowy ID lub URL TMDB. + en, ko, ja, hi + Lista TMDB %1$s + Nie udało się załadować źródła TMDB + Sieć TMDB %1$s + Osoba TMDB %1$s + Produkcja TMDB %1$s + 10 + 7.0 + Nie udało się załadować źródła TMDB + 100 + 2024 + Wprowadź ID lub URL listy Trakt + Wprowadź nazwę, URL lub ID listy Trakt + Lista Trakt %1$d + Wprowadź ID lub URL listy Trakt + Wprowadź nazwę, URL lub ID listy Trakt + ID %1$s + Lista Trakt %1$s + Nie udało się załadować listy Trakt + Nie udało się załadować listy Trakt + Nie znaleziono list Trakt + Rozpoznana lista Trakt + US, KR, JP, IN + Lista filmów Trakt + Lista seriali Trakt + Dodaj klucz API TMDB w Ustawieniach, aby korzystać ze źródeł TMDB. + Nie znaleziono kolekcji TMDB + Nie znaleziono firmy TMDB + TMDB Odkrywaj nie zwrócił danych + Nie znaleziono listy TMDB + Brak ID kolekcji TMDB + Brak ID listy TMDB + Brak ID osoby TMDB + Nie znaleziono sieci TMDB + Nie znaleziono filmografii osoby TMDB + Nie znaleziono osoby TMDB + Brak danych uwierzytelniających Trakt. + %1$s (%2$d) + Wprowadź prawidłowy ID lub URL listy Trakt + %1$d elementów + %1$d polubień + Lista Trakt nie znaleziona lub niepubliczna + Brak ID listy Trakt + Lista Trakt nie zawiera numerycznego ID + Publiczna lista Trakt + Osiągnięto limit zapytań Trakt + Zapytanie Trakt nie powiodło się + Nie znaleziono linii napisów + Nie udało się załadować linii napisów + Zaawansowane + Źródła + Uruchamianie i zachowanie profilu. + ZAAWANSOWANE + Wyświetlanie wyników źródeł i reguły badge URL. + Napędzane przez TMDB + Napędzane przez Trakt + Więcej akcji + Nie udało się załadować komentarzy Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Nie udało się sfinalizować pobieranego pliku + Nie udało się sfinalizować pobieranego pliku + Nie udało się otworzyć częściowego pliku + Nie udało się otworzyć częściowego pliku + Częściowy plik nie jest otwarty + Częściowy plik nie jest otwarty + Nie udało się zapisać częściowego pliku + Nie udało się zapisać częściowego pliku + Systemowy odtwarzacz Android + Pozostało %1$dh %2$dm + Pozostało %1$dm + Biblioteka Nuvio + Problem z połączeniem + Pusta treść odpowiedzi + Pusta treść odpowiedzi + Zapytanie nie powiodło się z kodem HTTP %1$d + Sprawdź połączenie i spróbuj ponownie. + Zapytanie nie powiodło się z kodem HTTP %1$d + Ten strumień wykorzystuje technologię peer-to-peer (P2P). Włączając P2P, przyjmujesz do wiadomości i zgadzasz się, że:\n\n• Twój adres IP będzie widoczny dla innych użytkowników w sieci\n• Ponosisz wyłączną odpowiedzialność za treści, do których uzyskujesz dostęp\n• Potwierdzasz, że masz prawo do strumieniowania tych treści w swojej jurysdykcji\n• Nuvio nie hostuje, nie dystrybuuje ani nie kontroluje żadnych treści P2P\n• Nuvio nie ponosi odpowiedzialności za jakiekolwiek konsekwencje prawne wynikające z korzystania ze strumieniowania P2P\n\nKorzystasz z tej funkcji wyłącznie na własne ryzyko. P2P można wyłączyć w dowolnym momencie w Ustawieniach. + Anuluj + Włącz P2P + Strumieniowanie P2P + Nieznany błąd torrenta + Zgłoś intro + Ustawienia wideo + %1$s (%2$s) + Silnik odtwarzacza MPV niedostępny. Przebuduj aplikację. + Nie udało się uruchomić torrenta: %1$s + Silnik odtwarzacza MPV niedostępny. Przebuduj aplikację. + Błąd torrenta: %1$s + Nie można odtworzyć tego strumienia. + Wyłączony + Najbliżej starszego zachowania MPV na iOS. + Kompatybilność + Użyj własnych zaawansowanych wartości poniżej. + Własne + Najlepsze dla iPhone\'ów i iPadów z obsługą HDR. + Natywne EDR + Bardziej przewidywalne biele i czernie na wyjściu SDR. + Mapowanie tonalne SDR + %1$s buforowane · %2$s · %3$s + Łączenie z peerami… + %1$d seedów · %2$d peerów + Uruchamianie silnika P2P… + %1$d peerów · %2$d seedów · %3$d%% + %1$s · %2$s + Jasność + Kontrast + Deband + Zmniejsz pasmowanie kolorów kosztem wydajności. + Gamma + Wykrywanie szczytów HDR + Szacuj szczytową jasność HDR gdy metadane są błędne lub brakujące. + Interpolacja klatek + Płynny ruch gdy mpv może czysto zsynchronizować z wyświetlaczem. + Preset wyjściowy + Resetuj strojenie + Nasycenie + Wideo + Mapowanie tonalne + Wtyczki wyłączone + Wtyczki włączone + %1$d dostawców + Odświeżanie + %1$d repozytoriów + Brak klucza API TMDB + Klucz API TMDB ustawiony + Instalowanie… + Zainstaluj repozytorium wtyczek + Testowanie… + Testuj dostawcę + Usuń repozytorium wtyczek + Odśwież repozytorium wtyczek + Brak dostępnych dostawców. + Dodaj URL repozytorium, aby zainstalować wtyczki do odkrywania źródeł. + Nie zainstalowano jeszcze repozytoriów wtyczek. + Używaj dostawców wtyczek podczas wyszukiwania źródeł. + Włącz dostawców wtyczek globalnie + To repozytorium wtyczek jest już zainstalowane. + Wprowadź URL repozytorium wtyczek. + Wprowadź prawidłowy URL wtyczki. + Nie udało się zainstalować repozytorium wtyczek + Nie znaleziono dostawcy + Nie udało się odświeżyć repozytorium + Wtyczki nie są dostępne w tej wersji. + W Źródłach pokazuj jednego dostawcę na repozytorium zamiast na źródło. + Grupuj dostawców wtyczek wg repozytorium + URL manifestu wtyczki + Brak nazwy w manifeście. + Manifest nie ma dostawców. + Brak wersji w manifeście. + Brak nazwy w manifeście. + Manifest nie ma dostawców. + Brak wersji w manifeście. + Zainstalowano %1$s. + Wyłączony przez repozytorium + Brak opisu + v%1$s + Repozytorium wtyczek + To repozytorium wtyczek jest już zainstalowane. + Nie udało się zainstalować repozytorium wtyczek + Nie udało się odświeżyć repozytorium + Wersja %1$s + DODAJ REPOZYTORIUM + ZAINSTALOWANE REPOZYTORIA + PRZEGLĄD + DOSTAWCY + Błąd + Test dostawcy nie powiódł się + Wyniki testu (%1$d) + Dostawcy wtyczek wymagają klucza API TMDB. Ustaw go na ekranie TMDB, w przeciwnym razie dostawcy nie będą działać poprawnie. + Brak wyników wyszukiwania dla %1$s. + Backend synchronizacji + Wyczyść pamięć Kontynuuj oglądanie + Pamięć wyczyszczona + Usuń dane Kontynuuj oglądanie i odśwież postęp + Zapamiętaj ostatni profil + Zapamiętaj ostatnio wybrany profil przy uruchomieniu + PAMIĘĆ PODRĘCZNA + URUCHAMIANIE + Karta + Karta pozioma w stylu TV + Dowiedz się więcej + Maks. wyników + Ogranicz liczbę wyświetlanych wyników. + Limit na jakość + Ogranicz powtarzające się wyniki BluRay, WEB-DL, REMUX po sortowaniu. + Limit na rozdzielczość + Ogranicz powtarzające się wyniki 2160p, 1080p, 720p po sortowaniu. + Wprowadź jedną grupę na linię. + Wszystkie wyniki + %1$d wyników + Wykluczone tagi audio + Ukryj wybrane tagi audio. + Wykluczone kanały + Ukryj wybrane układy kanałów. + Wykluczone kodowania + Ukryj wybrane kodowania. + Wykluczone języki + Ukryj wyniki gdzie każdy język jest wykluczony. + Wykluczone jakości + Ukryj wybrane jakości. + Wykluczone grupy release + Ukryj wybrane grupy release. + Wykluczone rozdzielczości + Ukryj wybrane rozdzielczości. + Wykluczone tagi wizualne + Ukryj DV, HDR, 10bit, 3D i podobne tagi. + Preferowane tagi audio + Sortuj Atmos, TrueHD, DTS, AAC i podobne tagi. + Preferowane kanały + Sortuj preferowane układy kanałów jako pierwsze. + Preferowane kodowania + Sortuj AV1, HEVC, AVC i podobne kodowania. + Preferowane języki + Sortuj preferowane języki audio jako pierwsze. + Preferowane jakości + Sortuj wybrane jakości jako pierwsze, w domyślnej kolejności. + Preferowane rozdzielczości + Sortuj wybrane rozdzielczości jako pierwsze, w domyślnej kolejności. + Preferowane tagi wizualne + Sortuj DV, HDR, 10bit, IMAX i podobne tagi. + Wymagane tagi audio + Wymagaj Atmos, TrueHD, DTS, AAC i podobnych tagów. + Wymagane kanały + Pokazuj tylko wybrane układy kanałów. + Wymagane kodowania + Wymagaj AV1, HEVC, AVC i podobnych kodowań. + Wymagane języki + Pokazuj tylko wyniki z wybranymi językami. + Wymagane jakości + Pokazuj tylko wybrane jakości. + Wymagane grupy release + Pokazuj tylko wybrane grupy release. + Wymagane rozdzielczości + Pokazuj tylko wybrane rozdzielczości. + Wymagane tagi wizualne + Wymagaj DV, HDR, 10bit, IMAX, SDR i podobnych tagów. + Zarządzanie wynikami + Dowolne + %1$d wybranych + %1$dGB+ + Zakres rozmiaru + Filtruj wyniki wg rozmiaru pliku. + %1$d-%2$dGB + Do %1$dGB + Najlepszy dźwięk najpierw + Najlepsza jakość najpierw + Język najpierw + Największe najpierw + Oryginalna kolejność + Sortuj wyniki + Wybierz kolejność wyników. + Najmniejsze najpierw + Format domyślny + Format oryginalny + Grupa %1$d + Inne badge Fusion + Podgląd + %1$d badge Fusion z tego URL + Brak badge Fusion w tym URL. + Podgląd badge Fusion + Brak zaimportowanych URL badge Fusion. + %1$d/%2$d URL, %3$d aktywnych badge Fusion + Aktywny + Nieaktywny + URL JSON badge Fusion + %1$d/%2$d URL Fusion zaimportowanych + %1$s, %2$d włączonych badge, %3$d grup + Odtwarzanie zwiastuna w tle + Odtwarzaj podgląd zwiastuna w tle szczegółów gdy dostępny. + Ukryj bufor, seedy, peery i prędkość pobierania podczas ładowania i odtwarzania + Ukryj statystyki torrenta + Zezwalaj na strumienie peer-to-peer (torrent) + Strumieniowanie P2P + Nieprawidłowy klucz API lub błąd połączenia + Nieprawidłowy klucz API lub błąd połączenia + Wyjście audio + Użyj starszego wyjścia AudioUnit. + Najpierw AVFoundation, potem AudioUnit jako zapasowe. + Eksperymentalna obsługa Spatial Audio i wielokanałowego wyjścia. + Wyjście audio + Wyjście audio iOS + Wskazówka koloru wyświetlacza + Pozwól mpv celować w aktywną przestrzeń kolorów wyświetlacza. + Rozszerzony zakres dynamiczny + Domyślny tryb wyjścia Metal dla nowych sesji odtwarzania. + Dekoder sprzętowy + Dekoder sprzętowy + Docelowe kolory podstawowe + Docelowe kolory podstawowe + Docelowy transfer + Docelowy transfer + Wyjście wideo iOS + Gesty dotykowe + Pozwól na przesunięcia i podwójne dotknięcia na powierzchni odtwarzacza do przewijania, regulacji jasności lub głośności. + Wprowadź URL JSON badge. + Import badge nie powiódł się. + Możesz zaimportować do %1$d URL badge. + Na dole + Wybierz czy badge Fusion i rozmiaru pojawiają się nad czy pod kartami źródeł. + Wybierz gdzie badge źródeł pojawiają się na kartach. + Pozycja badge + Pozycja badge + Na górze + Styl Fusion + URL badge musi zaczynać się od http:// lub https://. + Importuj do %1$d URL JSON badge Fusion. Każdy URL można aktualizować lub usuwać osobno. + Zarządzaj zaimportowanymi URL JSON badge Fusion. + URL badge Fusion + Pokazuj badge rozmiaru pliku w wynikach źródeł i panelu źródeł odtwarzacza. + Badge rozmiaru + Repozytorium wtyczek + Zgłoś intro + Wyślij + Przechwyć + Przechwyć + CZAS KOŃCA (MM:SS) + CZAS KOŃCA (MM:SS) + Intro + Outro + Podsumowanie + TYP SEGMENTU + TYP SEGMENTU + CZAS STARTU (MM:SS) + CZAS STARTU (MM:SS) + Wyślij + Zgłoś znaczniki czasu + Dodaj klucz API TMDB w Ustawieniach, aby korzystać ze źródeł TMDB. + Kolekcja TMDB %1$d + Nie znaleziono kolekcji TMDB + Produkcja TMDB %1$d + Nie znaleziono firmy TMDB + Reżyser TMDB %1$d + TMDB Odkrywaj nie zwrócił danych + TMDB Odkrywaj + Wprowadź prawidłowy ID lub URL TMDB. + Lista TMDB %1$d + Nie znaleziono listy TMDB + Nie udało się załadować źródła TMDB + Brak ID kolekcji TMDB + Brak ID listy TMDB + Brak ID osoby TMDB + Sieć TMDB %1$d + Nie znaleziono sieci TMDB + Nie znaleziono filmografii osoby TMDB + Osoba TMDB %1$d + Nie znaleziono osoby TMDB + Połączono z Trakt + Połączono z Trakt + Rozłączono z Trakt + Rozłączono z Trakt + Nie udało się dodać do listy Trakt + Nie udało się dodać do listy obserwowanych Trakt + Autoryzacja Trakt wygasła + Pusta treść odpowiedzi + Osiągnięto limit list Trakt + Nie znaleziono listy Trakt + Brak kompatybilnych ID Trakt + Osiągnięto limit zapytań Trakt + Zapytanie Trakt nie powiodło się + Wybierz źródło rekomendacji na stronach szczegółów. + Źródło Podobnych + Wybierz skąd pochodzą rekomendacje na stronach szczegółów + Źródło Podobnych + TMDB + Trakt + Publiczna lista Trakt + Wprowadź prawidłowy ID lub URL listy Trakt + %1$d elementów + %1$d polubień + Brak danych uwierzytelniających Trakt w local.properties (TRAKT_CLIENT_ID). + Brak ID listy Trakt + Lista Trakt nie zawiera numerycznego ID + Lista Trakt nie znaleziona lub niepubliczna + Osiągnięto limit zapytań Trakt + Zapytanie Trakt nie powiodło się + TB + Brak pliku APK w wydaniu + Pobrany plik aktualizacji nie istnieje. + Pusta treść pobierania + Pobieranie nie powiodło się z kodem HTTP %1$d + Pobrany plik aktualizacji nie istnieje. + Pusta treść pobierania + Błąd API GitHub releases: %1$d + Nie opublikowano jeszcze żadnej aktualizacji. + Wydanie nie ma tagu ani nazwy diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index be123bdf7..3138238c4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -536,6 +536,7 @@ AMOLED Black Use pure black backgrounds for OLED screens. App Language + Device language Choose Language Settings for the Continue Watching section. Liquid Glass @@ -860,6 +861,8 @@ Not set Default (media file) Device language + Original language + Requires TMDB enrichment to be enabled Forced None All subtitles diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 0dadb2ddf..a75d21d09 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1995,6 +1995,7 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, + contentLanguage = cached.contentLanguage, ) if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) @@ -2418,6 +2419,7 @@ private fun MainAppContent( torrentTrackers = launch.torrentTrackers, initialPositionMs = launch.initialPositionMs, initialProgressFraction = launch.initialProgressFraction, + contentLanguage = launch.contentLanguage, onBack = { ResumePromptRepository.markPlayerExitedNormally() PlayerLaunchStore.remove(route.launchId) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt index 6f5c2348d..8f9e28bfc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt @@ -84,6 +84,7 @@ import nuvio.composeapp.generated.resources.settings_playback_option_default import nuvio.composeapp.generated.resources.settings_playback_option_device_language import nuvio.composeapp.generated.resources.settings_playback_option_forced import nuvio.composeapp.generated.resources.settings_playback_option_none +import nuvio.composeapp.generated.resources.settings_playback_option_original import nuvio.composeapp.generated.resources.subtitle_language_unknown import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -97,6 +98,7 @@ data class LanguagePreferenceOption( object AudioLanguageOption { const val DEFAULT = "default" const val DEVICE = "device" + const val ORIGINAL = "original" } object SubtitleLanguageOption { @@ -463,6 +465,8 @@ fun languageLabelForCode(code: String?): String = when { code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) || code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) -> stringResource(Res.string.settings_playback_option_device_language) + code.equals(AudioLanguageOption.ORIGINAL, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_original) else -> languageLabelResForCode(code)?.let { stringResource(it) } ?: stringResource(Res.string.subtitle_language_unknown) } @@ -477,6 +481,8 @@ suspend fun getLanguageLabelForCode(code: String?): String = when { code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) || code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) -> getString(Res.string.settings_playback_option_device_language) + code.equals(AudioLanguageOption.ORIGINAL, ignoreCase = true) -> + getString(Res.string.settings_playback_option_original) else -> languageLabelResForCode(code)?.let { getString(it) } ?: getString(Res.string.subtitle_language_unknown) } @@ -485,6 +491,7 @@ fun resolvePreferredAudioLanguageTargets( preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, deviceLanguages: List, + contentOriginalLanguage: String? = null, ): List { fun normalize(language: String?): String? { val normalized = normalizeLanguageCode(language) @@ -495,6 +502,7 @@ fun resolvePreferredAudioLanguageTargets( SubtitleLanguageOption.NONE, SubtitleLanguageOption.FORCED, -> null + AudioLanguageOption.ORIGINAL -> contentOriginalLanguage?.trim()?.lowercase()?.takeIf { it.isNotBlank() } else -> normalized } } @@ -511,6 +519,21 @@ fun resolvePreferredAudioLanguageTargets( + listOfNotNull(normalize(secondaryPreferredAudioLanguage)) ).distinct() + AudioLanguageOption.ORIGINAL -> { + val originalLang = contentOriginalLanguage?.trim()?.lowercase()?.takeIf { it.isNotBlank() } + if (originalLang != null) { + listOfNotNull( + originalLang, + normalize(secondaryPreferredAudioLanguage), + ).distinct() + } else { + // Fallback to device languages when original language is unknown + (deviceLanguages.mapNotNull(::normalize) + + listOfNotNull(normalize(secondaryPreferredAudioLanguage)) + ).distinct() + } + } + else -> listOfNotNull( normalize(preferredAudioLanguage), normalize(secondaryPreferredAudioLanguage), @@ -575,3 +598,51 @@ fun inferForcedSubtitleTrack( if ("forced" in text) return true return text.contains("songs") && text.contains("sign") } + +/** + * Best-effort mapping from country name/code to ISO 639-1 primary language. + * Used as a fallback when [resolveContentLanguage] has no explicit language field. + */ +fun countryToLanguageCode(country: String?): String? { + val normalized = country?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + return COUNTRY_TO_LANGUAGE_MAP[normalized] +} + +/** + * Resolves the original content language as an ISO 639-1 code. + * Falls back to country-based inference when the explicit language field is absent. + */ +fun resolveContentLanguage(language: String?, country: String?): String? { + normalizeLanguageCode(language)?.let { return it } + countryToLanguageCode(country)?.let { return it } + return null +} + +private val COUNTRY_TO_LANGUAGE_MAP = mapOf( + // ISO 3166-1 alpha-2 + "jp" to "ja", "kr" to "ko", "cn" to "zh", "tw" to "zh", + "fr" to "fr", "de" to "de", "it" to "it", "es" to "es", + "pt" to "pt", "br" to "pt", "ru" to "ru", "in" to "hi", + "tr" to "tr", "pl" to "pl", "nl" to "nl", "se" to "sv", + "no" to "no", "dk" to "da", "fi" to "fi", "th" to "th", + "il" to "he", "cz" to "cs", "ro" to "ro", "hu" to "hu", + "ua" to "uk", "gr" to "el", + // ISO 3166-1 alpha-3 + "jpn" to "ja", "kor" to "ko", "chn" to "zh", "twn" to "zh", + "fra" to "fr", "deu" to "de", "ita" to "it", "esp" to "es", + "prt" to "pt", "bra" to "pt", "rus" to "ru", "ind" to "hi", + "tur" to "tr", "pol" to "pl", "nld" to "nl", "swe" to "sv", + "nor" to "no", "dnk" to "da", "fin" to "fi", "tha" to "th", + "isr" to "he", "cze" to "cs", "rou" to "ro", "hun" to "hu", + "ukr" to "uk", "grc" to "el", + // Common full names + "japan" to "ja", "south korea" to "ko", "korea" to "ko", + "china" to "zh", "taiwan" to "zh", "france" to "fr", + "germany" to "de", "italy" to "it", "spain" to "es", + "portugal" to "pt", "brazil" to "pt", "russia" to "ru", + "india" to "hi", "turkey" to "tr", "poland" to "pl", + "netherlands" to "nl", "sweden" to "sv", "norway" to "no", + "denmark" to "da", "finland" to "fi", "thailand" to "th", + "israel" to "he", "romania" to "ro", "hungary" to "hu", + "ukraine" to "uk", "greece" to "el", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 1989a25f1..2639c33a6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -49,6 +49,7 @@ data class PlayerLaunch( val torrentTrackers: List = emptyList(), val initialPositionMs: Long = 0L, val initialProgressFraction: Float? = null, + val contentLanguage: String? = null, ) object PlayerLaunchStore { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 431e49783..097c229d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -37,6 +37,7 @@ fun PlayerScreen( torrentTrackers: List = emptyList(), initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, + contentLanguage: String? = null, ) { PlayerScreenContent( PlayerScreenArgs( @@ -72,6 +73,7 @@ fun PlayerScreen( torrentTrackers = torrentTrackers, initialPositionMs = initialPositionMs, initialProgressFraction = initialProgressFraction, + contentLanguage = contentLanguage, ) ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt index 2aa779d2b..662d5ed79 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -35,4 +35,5 @@ internal data class PlayerScreenArgs( val torrentTrackers: List, val initialPositionMs: Long, val initialProgressFraction: Float?, + val contentLanguage: String? = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index ade131f57..43095cdc8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -482,5 +482,9 @@ private fun PlayerScreenRuntime.saveDirectStreamForReuse( videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, streamType = stream.streamType, + contentLanguage = resolveContentLanguage( + language = metaUiState.meta?.language, + country = metaUiState.meta?.country, + ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt index 118baba9c..098c258f5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -152,6 +152,10 @@ internal fun PlayerScreenRuntime.refreshTracks() { preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + contentOriginalLanguage = resolveContentLanguage( + language = metaUiState.meta?.language, + country = metaUiState.meta?.country, + ) ?: args.contentLanguage, ) if (preferredAudioTargets.isEmpty()) { preferredAudioSelectionApplied = true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index 4cdb8924a..85bcf497a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -13,12 +13,14 @@ import nuvio.composeapp.generated.resources.lang_portuguese_portugal import nuvio.composeapp.generated.resources.lang_spanish import nuvio.composeapp.generated.resources.lang_turkish import nuvio.composeapp.generated.resources.lang_norwegian +import nuvio.composeapp.generated.resources.settings_appearance_app_language_device import org.jetbrains.compose.resources.StringResource enum class AppLanguage( val code: String, val labelRes: StringResource, ) { + DEVICE("device", Res.string.settings_appearance_app_language_device), CZECH("cs", Res.string.lang_czech), ENGLISH("en", Res.string.lang_english), FRENCH("fr", Res.string.lang_french), @@ -35,6 +37,6 @@ enum class AppLanguage( companion object { fun fromCode(code: String?): AppLanguage = - entries.firstOrNull { it.code.equals(code, ignoreCase = true) } ?: ENGLISH + entries.firstOrNull { it.code.equals(code, ignoreCase = true) } ?: DEVICE } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 54718c44f..6dc6f3093 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -404,6 +404,7 @@ private fun PlaybackSettingsSection( description = when (preferredAudioLanguage) { AudioLanguageOption.DEFAULT -> stringResource(Res.string.settings_playback_option_default) AudioLanguageOption.DEVICE -> stringResource(Res.string.settings_playback_option_device_language) + AudioLanguageOption.ORIGINAL -> stringResource(Res.string.settings_playback_option_original) else -> languageLabelForCode(preferredAudioLanguage) }, enabled = audioLanguageEnabled, @@ -1090,11 +1091,13 @@ private fun PlaybackSettingsSection( } if (showPreferredAudioDialog) { + val originalHint = stringResource(Res.string.settings_playback_option_original_hint) LanguageSelectionDialog( title = stringResource(Res.string.settings_playback_preferred_audio_language), options = listOf( LanguageSelectionOption(AudioLanguageOption.DEFAULT, stringResource(Res.string.settings_playback_option_default)), LanguageSelectionOption(AudioLanguageOption.DEVICE, stringResource(Res.string.settings_playback_option_device_language)), + LanguageSelectionOption(AudioLanguageOption.ORIGINAL, stringResource(Res.string.settings_playback_option_original), description = originalHint), ) + AvailableLanguageOptions.map { option -> LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, @@ -1108,10 +1111,12 @@ private fun PlaybackSettingsSection( } if (showSecondaryAudioDialog) { + val originalHint = stringResource(Res.string.settings_playback_option_original_hint) LanguageSelectionDialog( title = stringResource(Res.string.settings_playback_secondary_audio_language), options = listOf( LanguageSelectionOption(null, stringResource(Res.string.settings_playback_option_none)), + LanguageSelectionOption(AudioLanguageOption.ORIGINAL, stringResource(Res.string.settings_playback_option_original), description = originalHint), ) + AvailableLanguageOptions.map { option -> LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, @@ -1441,6 +1446,7 @@ private fun formatReuseCacheDuration(hours: Int): String = when { private data class LanguageSelectionOption( val value: String?, val label: String, + val description: String? = null, ) @Composable @@ -1720,12 +1726,20 @@ private fun LanguageSelectionDialog( .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = option.label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = option.label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!option.description.isNullOrBlank()) { + Text( + text = option.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Box( modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 431b9d20e..217faa940 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -17,7 +17,7 @@ object ThemeSettingsRepository { private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false) val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow() - private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) + private val _selectedAppLanguage = MutableStateFlow(AppLanguage.DEVICE) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() private var hasLoaded = false @@ -38,7 +38,7 @@ object ThemeSettingsRepository { _liquidGlassNativeTabBarEnabled.value = false NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) - _selectedAppLanguage.value = AppLanguage.ENGLISH + _selectedAppLanguage.value = AppLanguage.DEVICE } private fun loadFromDisk() { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index e3a7e8efc..d1b830227 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt @@ -19,6 +19,7 @@ data class CachedStreamLink( val sources: List = emptyList(), val bingeGroup: String? = null, val streamType: String? = null, + val contentLanguage: String? = null, ) internal expect fun epochMs(): Long @@ -56,6 +57,7 @@ object StreamLinkCacheRepository { sources: List = emptyList(), bingeGroup: String? = null, streamType: String? = null, + contentLanguage: String? = null, ) { if (url.isNotBlank() && url.hasLikelyExpiringPlaybackCredentials()) { remove(contentKey) @@ -77,6 +79,7 @@ object StreamLinkCacheRepository { sources = sources, bingeGroup = bingeGroup, streamType = streamType, + contentLanguage = contentLanguage, ) val payload = json.encodeToString(CachedStreamLink.serializer(), entry) StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index bf14a4528..f36f0a921 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -72,6 +72,11 @@ actual object ThemeSettingsStorage { } actual fun applySelectedAppLanguage(languageCode: String) { + if (languageCode.equals("device", ignoreCase = true)) { + NSUserDefaults.standardUserDefaults.removeObjectForKey("AppleLanguages") + NSUserDefaults.standardUserDefaults.synchronize() + return + } val normalizedCode = languageCode .trim() .takeIf { it.isNotBlank() } @@ -97,6 +102,6 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } }