From 464cfb3059cd16fa5fbf80d2d6d0a8bf7ea51667 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Fri, 29 May 2026 11:38:11 +0200 Subject: [PATCH] Port improved external players support --- .../player/ExternalPlayerActivityContract.kt | 74 +++ .../ExternalPlayerLauncherEffect.android.kt | 40 ++ .../player/ExternalPlayerPlatform.android.kt | 93 +++- .../player/PlayerSettingsStorage.android.kt | 21 + .../composeResources/values-pl/strings.xml | 95 ++++ .../composeResources/values/strings.xml | 7 + .../commonMain/kotlin/com/nuvio/app/App.kt | 186 +++++++- .../player/ExternalPlayerLaunchCoordinator.kt | 33 ++ .../player/ExternalPlayerLauncherEffect.kt | 27 ++ .../features/player/ExternalPlayerPlatform.kt | 18 + .../app/features/player/PlayerControls.kt | 11 + .../nuvio/app/features/player/PlayerScreen.kt | 29 ++ .../player/PlayerSettingsRepository.kt | 13 + .../features/player/PlayerSettingsStorage.kt | 2 + .../app/features/player/SubtitleForwarder.kt | 53 +++ .../features/settings/PlaybackSettingsPage.kt | 425 +++++++++++++----- .../features/settings/SettingsComponents.kt | 6 +- .../app/features/streams/StreamModels.kt | 1 + .../app/features/streams/StreamsRepository.kt | 81 ++++ .../app/features/streams/StreamsScreen.kt | 3 +- .../ExternalPlayerLauncherEffect.ios.kt | 28 ++ .../player/ExternalPlayerPlatform.ios.kt | 16 + .../player/PlayerSettingsStorage.ios.kt | 18 + 23 files changed, 1117 insertions(+), 163 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerActivityContract.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleForwarder.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerActivityContract.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerActivityContract.kt new file mode 100644 index 00000000..386a37aa --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerActivityContract.kt @@ -0,0 +1,74 @@ +package com.nuvio.app.features.player + +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +/** + * Result returned by an external video player after playback ends. + * Not all players return this data — MX Player, VLC, Just Player, and mpv-android are known to support it. + */ +data class ExternalPlayerResult( + val positionMs: Long, + val durationMs: Long?, + val endedByUser: Boolean = true, +) + +/** + * ActivityResultContract that launches an external video player via a pre-built Intent + * and parses the playback position returned by the player (if supported). + * + * Supported players: + * - MX Player: returns "position" (Int, ms), "duration" (Int, ms), "end_by" (String) + * - VLC: returns "extra_position" (Long, ms), "extra_duration" (Long, ms) + * - Just Player: returns "position" (Int, ms), "duration" (Int, ms) + * - mpv-android: returns "position" (Int, ms), "duration" (Int, ms) + * - Vimu Player: returns "position" (Int, ms), "duration" (Int, ms) + */ +class ExternalPlayerActivityContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: Intent): Intent = input + + override fun parseResult(resultCode: Int, intent: Intent?): ExternalPlayerResult? { + // Some players return RESULT_OK, others return RESULT_CANCELED even on normal exit. + // We try to parse position regardless of resultCode. + val data = intent ?: return null + val position = parsePosition(data) ?: return null + val duration = parseDuration(data) + val endedByUser = parseEndReason(data) + + return ExternalPlayerResult( + positionMs = position, + durationMs = duration, + endedByUser = endedByUser, + ) + } + + private fun parsePosition(data: Intent): Long? { + // VLC uses Long extras + val vlcPosition = data.getLongExtra("extra_position", -1L) + if (vlcPosition > 0) return vlcPosition + + // MX Player / Just Player / mpv / Vimu use Int extras + val mxPosition = data.getIntExtra("position", -1) + if (mxPosition > 0) return mxPosition.toLong() + + return null + } + + private fun parseDuration(data: Intent): Long? { + val vlcDuration = data.getLongExtra("extra_duration", -1L) + if (vlcDuration > 0) return vlcDuration + + val mxDuration = data.getIntExtra("duration", -1) + if (mxDuration > 0) return mxDuration.toLong() + + return null + } + + private fun parseEndReason(data: Intent): Boolean { + // MX Player returns "end_by" with values "user" or "playback_completion" + val endBy = data.getStringExtra("end_by") + return endBy != "playback_completion" + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.android.kt new file mode 100644 index 00000000..973ea717 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.android.kt @@ -0,0 +1,40 @@ +package com.nuvio.app.features.player + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberExternalPlayerLauncher( + onResult: (ExternalPlaybackResult?) -> Unit, +): (ExternalPlayerIntentResult.Success) -> Boolean { + val launcher = rememberLauncherForActivityResult( + contract = ExternalPlayerActivityContract(), + ) { playerResult -> + val commonResult = playerResult?.let { + ExternalPlaybackResult( + positionMs = it.positionMs, + durationMs = it.durationMs, + endedByUser = it.endedByUser, + ) + } + onResult(commonResult) + } + + return remember(launcher) { + { intentResult: ExternalPlayerIntentResult.Success -> + val intent = intentResult.intent + if (intent is Intent) { + try { + launcher.launch(intent) + true + } catch (_: Throwable) { + false + } + } else { + false + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt index 3f101118..9466bb41 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt @@ -4,7 +4,6 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Bundle import androidx.core.content.FileProvider import java.io.File import java.net.URI @@ -30,18 +29,9 @@ internal actual object ExternalPlayerPlatform { val context = appContext ?: return ExternalPlayerOpenResult.Failed val uri = request.sourceUrl.toExternalPlaybackUri(context) ?: return ExternalPlayerOpenResult.Failed - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, request.sourceUrl.videoMimeType()) - addCategory(Intent.CATEGORY_DEFAULT) + val intent = buildExternalPlayerIntent(context, request, uri).apply { + // Required when launching from application context (fire-and-forget path) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - if (uri.scheme.equals("content", ignoreCase = true)) { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - putExtra(Intent.EXTRA_TITLE, request.streamTitle ?: request.title) - putExtra("title", request.streamTitle ?: request.title) - if (request.sourceHeaders.isNotEmpty()) { - putExtra("headers", request.sourceHeaders.toAndroidHeadersBundle()) - } } return try { @@ -54,6 +44,78 @@ internal actual object ExternalPlayerPlatform { } } + actual fun buildIntent( + request: ExternalPlayerPlaybackRequest, + playerId: String?, + ): ExternalPlayerIntentResult { + val context = appContext ?: return ExternalPlayerIntentResult.Failed + val uri = request.sourceUrl.toExternalPlaybackUri(context) + ?: return ExternalPlayerIntentResult.Failed + val intent = buildExternalPlayerIntent(context, request, uri) + return ExternalPlayerIntentResult.Success(intent) + } + + private fun buildExternalPlayerIntent( + context: Context, + request: ExternalPlayerPlaybackRequest, + uri: Uri, + ): Intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, request.sourceUrl.videoMimeType()) + addCategory(Intent.CATEGORY_DEFAULT) + if (uri.scheme.equals("content", ignoreCase = true)) { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + // Title extras + val displayTitle = request.streamTitle ?: request.title + putExtra(Intent.EXTRA_TITLE, displayTitle) + putExtra("title", displayTitle) + putExtra("forcename", displayTitle) // Vimu Player + + // Resume position extras + if (request.resumePositionMs > 0L) { + putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv + putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player + putExtra("from_start", false) // VLC: don't force start from beginning + } + + // Request that the player returns result with position/duration. + // Required by MX Player; harmless for other players. + putExtra("return_result", true) + + // Headers + if (request.sourceHeaders.isNotEmpty()) { + val headerArray = request.sourceHeaders.entries + .map { "${it.key}: ${it.value}" } + .toTypedArray() + putExtra("headers", headerArray) + } + + // Subtitle extras + val subtitles = request.subtitles + if (!subtitles.isNullOrEmpty()) { + val subtitleUris = subtitles.map { Uri.parse(it.url) }.toTypedArray() + val subtitleNames = subtitles.map { it.name }.toTypedArray() + val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray() + + // MX Player / mpv-android / Nova + putExtra("subs", subtitleUris) + putExtra("subs.name", subtitleNames) + putExtra("subs.filename", subtitleFilenames) + putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url))) + + // Just Player + putExtra("subtitle_uri", subtitleUris) + putExtra("subtitle_name", subtitleNames) + + // VLC (single subtitle — use first one) + putExtra("subtitles_location", Uri.parse(subtitles.first().url)) + + // Vimu Player + putExtra("forcedsrt", subtitles.first().url) + } + } + private fun String.toExternalPlaybackUri(context: Context): Uri? { val trimmed = trim() if (trimmed.isBlank()) return null @@ -72,13 +134,6 @@ internal actual object ExternalPlayerPlatform { } } -private fun Map.toAndroidHeadersBundle(): Bundle = - Bundle().apply { - forEach { (key, value) -> - putString(key, value) - } - } - private fun String.videoMimeType(): String { val normalized = substringBefore('?').substringBefore('#').lowercase() return when { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 3f75197d..3dc73ce1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -24,6 +24,7 @@ actual object PlayerSettingsStorage { private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedValueKey = "hold_to_speed_value" private const val externalPlayerEnabledKey = "external_player_enabled" + private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" private const val externalPlayerIdKey = "external_player_id" private const val preferredAudioLanguageKey = "preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" @@ -84,6 +85,7 @@ actual object PlayerSettingsStorage { holdToSpeedEnabledKey, holdToSpeedValueKey, externalPlayerEnabledKey, + externalPlayerForwardSubtitlesKey, externalPlayerIdKey, preferredAudioLanguageKey, secondaryPreferredAudioLanguageKey, @@ -222,6 +224,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadExternalPlayerForwardSubtitles(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(externalPlayerForwardSubtitlesKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, false) + } else { + null + } + } + + actual fun saveExternalPlayerForwardSubtitles(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(externalPlayerForwardSubtitlesKey), enabled) + ?.apply() + } + actual fun loadExternalPlayerId(): String? = preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null) @@ -931,6 +950,7 @@ actual object PlayerSettingsStorage { loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } + loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } @@ -995,6 +1015,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) + payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 6805c407..3e487c29 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -25,6 +25,7 @@ Aktywny %1$d katalogów Konfigurowalny + Wyłączony Odświeżanie %1$d zasobów Niedostępny @@ -311,8 +312,11 @@ Szukaj Ścieżki audio Audio + Automatyczna synchronizacja + Pogrubienie Wbudowane Przesunięcie dolne + Przechwyć Zamknij odtwarzacz Kolor Aktualnie odtwarzane @@ -323,11 +327,13 @@ Rozmiar czcionki %1$dsp Zablokuj kontrolki odtwarzacza + Ładowanie linii napisów... Brak dostępnych ścieżek audio Brak dostępnych odcinków Nie znaleziono strumieni Brak Obrys + Kolor obrysu Odcinki Źródła Strumienie @@ -335,6 +341,8 @@ Odtwarzanie Dotknij, aby pobrać napisy Wróć + Odśwież + Resetuj Przywróć domyślne Wypełnij Dopasuj @@ -347,8 +355,11 @@ Przewiń do przodu 10 sekund Źródła Styl + Najpierw wybierz napisy z dodatku Napisy + Opóźnienie napisów Napisy + Przezroczystość tekstu Jasność %1$s Głośność %1$s Wyciszony @@ -424,6 +435,10 @@ Nuvio korzysta z niekomercyjnych zbiorów danych IMDb, w tym title.ratings.tsv.gz, do ocen i liczby głosów IMDb. Informacje dzięki uprzejmości IMDb (https://www.imdb.com). Wykorzystywane za zgodą. Dane IMDb służą do użytku osobistego i niekomercyjnego zgodnie z warunkami IMDb. Trakt Nuvio łączy się z Trakt w celu uwierzytelniania konta, historii oglądania, synchronizacji postępu, danych biblioteki, ocen, list i komentarzy. Nuvio nie jest powiązane z Trakt ani przez nie wspierane. + Premiumize + Nuvio łączy się z Premiumize w celu uwierzytelniania konta, dostępu do biblioteki w chmurze, sprawdzania pamięci podręcznej i odtwarzania z chmury. Nuvio nie jest powiązane z Premiumize ani przez nie wspierane. + TorBox + Nuvio łączy się z TorBox w celu uwierzytelniania konta, dostępu do biblioteki w chmurze, sprawdzania pamięci podręcznej i odtwarzania z chmury. Nuvio nie jest powiązane z TorBox ani przez nie wspierane. MDBList Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane. IntroDB @@ -592,15 +607,34 @@ Eksperymentalne źródła z kont chmurowych Debrid Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. + Biblioteka w chmurze + Przeglądaj i odtwarzaj pliki z połączonych kont. Włącz źródła Pokaż odtwarzalne wyniki z połączonych kont. + Rozwiązuj przez + Wybierz, które połączone konto obsługuje odtwarzalne linki. Najpierw dodaj klucz API. Konto + Połącz swoje konto %1$s. + Połącz konto %1$s w przeglądarce. Połącz swoje konto Torbox. Klucz API Torbox Wprowadź swój klucz API Torbox. Wprowadź klucz API Torbox Nie ustawiono + Połączono + Połącz %1$s + Rozłącz %1$s + Rozłącz + %1$s jest połączony na tym urządzeniu. + Rozpoczynanie bezpiecznego logowania... + Otwórz link i wprowadź ten kod, aby zatwierdzić Nuvio. + Kod skopiowany. + Otwórz link + Oczekiwanie na zatwierdzenie... + Nie udało się rozpocząć logowania. + Ta metoda logowania nie jest skonfigurowana w tej wersji. + Ten kod wygasł. Spróbuj ponownie. Natychmiastowe odtwarzanie Przygotuj linki Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. @@ -738,6 +772,8 @@ Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym. Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu. Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy + Przekazuj napisy do zewnętrznego odtwarzacza + Pobierz napisy z dodatków w preferowanym języku i przekaż je do zewnętrznego odtwarzacza. Prędkość przy przytrzymaniu Przytrzymaj, aby przyspieszyć Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania. @@ -754,6 +790,17 @@ Język urządzenia Wymuszone Brak + Wszystkie napisy + Pobierz i pokaż wszystkie napisy z dodatków dla tego wideo. + Szybki start + Pomiń automatyczne pobieranie napisów z dodatków do momentu żądania w odtwarzaczu. + Tryb uruchamiania napisów z dodatków + Tylko preferowane + Pobierz napisy z dodatków, ale pokaż tylko te w preferowanym języku. + Odtwarzacz + Wewnętrzny + Zewnętrzny + Wybierz, który odtwarzacz obsługuje nowe odtwarzanie. Preferuj grupę binge Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący. Ponownie użyj grupy binge @@ -798,6 +845,20 @@ %1$d wybranych Pokaż nakładkę ładowania Pokaż nakładkę ładowania podczas uruchamiania strumienia. + Kolor tła + Pogrubienie + Użyj grubszej czcionki napisów. + Przezroczysty + Obrys + Kolor obrysu + Rysuj obramowanie wokół tekstu napisów. + Pokaż tylko preferowane języki + Pokaż tylko napisy pasujące do preferowanych języków. + Rozmiar napisów + Kolor tekstu + Używaj wymuszonych napisów + Preferuj wymuszone napisy pasujące do ustawień języka napisów. + Przesunięcie pionowe Pomiń intro/outro/podsumowanie Pokaż przycisk pomijania podczas wykrytych segmentów intro, outro i podsumowania. Zakres źródeł @@ -1046,6 +1107,7 @@ Typ Oznacz poprzednie jako nieobejrzane Oznacz poprzednie jako obejrzane + Oznacz poprzednie sezony jako obejrzane Oznacz %1$s jako nieobejrzane Oznacz %1$s jako obejrzane Oznacz jako nieobejrzane @@ -1145,6 +1207,7 @@ S%1$dE%2$d - %3$s Pobieranie… Szukanie źródła… + Pobieranie napisów z dodatków… Szukanie strumieni… Link strumienia skopiowany Brak bezpośredniego linku strumienia @@ -1155,6 +1218,7 @@ ROZMIAR %1$s Ten typ strumienia nie jest obsługiwany Dodaj klucz API Debrid w Ustawieniach. + Nie zapisano w pamięci podręcznej Torbox. Ten wynik Debrid wygasł. Odświeżanie strumieni. Nie udało się rozwiązać tego strumienia Debrid. Nie udało się otworzyć zewnętrznego odtwarzacza @@ -1208,6 +1272,8 @@ Usunąć %1$s z %2$s? Usunąć %1$s z biblioteki? Usunąć z biblioteki? + Chmura + Zapisane Film Alerty o premierach nowych odcinków zapisanych seriali. Podgląd alertu o premierze odcinka. @@ -1361,4 +1427,33 @@ Nowy odcinek Nowy sezon + Połącz konto + Połącz konto w ustawieniach Połączonych usług, aby przeglądać odtwarzalne pliki z biblioteki w chmurze. + Brak połączonego konta chmurowego + Otwórz Połączone usługi + Włącz Bibliotekę w chmurze w ustawieniach Połączonych usług, aby przeglądać pliki z połączonych kont. + Biblioteka w chmurze jest wyłączona + Żadne odtwarzalne pliki z chmury nie pasują do bieżących filtrów. + Jeszcze nic tu nie ma + Wybierz plik do odtworzenia + Nie udało się załadować biblioteki %1$s + Ten element nie udostępnia odtwarzalnego pliku wideo. + Brak odtwarzalnych plików + Brak odtwarzalnych plików + Biblioteka w chmurze jest wyłączona. + Nie udało się odtworzyć tego pliku z chmury. + Odtwórz plik + Usługa chmurowa nie jest połączona. + %1$s nie jest połączony. + %1$d odtwarzalnych plików + Wszystkie + Odśwież bibliotekę w chmurze + Wybierz dostawcę + Wybierz typ + Gotowe do odtwarzania + Wszystkie + Pliki + Torrenty + Usenet + Web diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 319408a0..f2784b68 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -770,7 +770,13 @@ External Player App Open new playback with Android's default video app or system chooser. Open new playback with the selected installed player. + Forward Subtitles to External Player + Fetch addon subtitles in your preferred language and pass them to the external player. No supported external players installed + Player + Internal + External + Choose which player handles new playback. Hold Speed Hold To Speed Long-press anywhere on the player surface to temporarily boost playback speed. @@ -1200,6 +1206,7 @@ S%1$dE%2$d - %3$s Fetching… Finding source… + Loading subtitles from addons… Finding streams… Stream link copied No direct stream link available diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 1869734b..2028147c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -141,9 +141,13 @@ import com.nuvio.app.features.player.PlayerLaunch import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen -import com.nuvio.app.features.player.ExternalPlayerOpenResult +import com.nuvio.app.features.player.PlayerPlaybackSnapshot +import com.nuvio.app.features.player.ExternalPlayerIntentResult import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest +import com.nuvio.app.features.player.rememberExternalPlayerLauncher +import com.nuvio.app.features.player.prepareExternalPlayerLaunch +import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.AvatarRepository @@ -172,6 +176,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy +import com.nuvio.app.features.streams.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLaunch import com.nuvio.app.features.streams.StreamLaunchStore @@ -180,13 +185,16 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ResumePromptRepository +import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem @@ -320,6 +328,7 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba title = title, streamTitle = streamTitle, sourceHeaders = sourceHeaders, + resumePositionMs = initialPositionMs, ) private enum class AppGateScreen { @@ -786,6 +795,69 @@ private fun MainAppContent( } var profileSwitchLoading by remember { mutableStateOf(false) } var resumePromptItem by remember { mutableStateOf(null) } + var lastExternalPlayerLaunch by remember { mutableStateOf(null) } + val launchExternalPlayer = rememberExternalPlayerLauncher { result -> + if (result != null && result.positionMs > 0L) { + coroutineScope.launch { + val durationMs = result.durationMs + val progressPercent = if (durationMs != null && durationMs > 0L) { + (result.positionMs.toFloat() / durationMs.toFloat() * 100f).coerceIn(0f, 100f) + } else { + null + } + if (TraktAuthRepository.isAuthenticated.value && progressPercent != null) { + val scrobbleItem = TraktScrobbleRepository.buildItem( + contentType = lastExternalPlayerLaunch?.parentMetaType ?: "", + parentMetaId = lastExternalPlayerLaunch?.parentMetaId ?: "", + videoId = lastExternalPlayerLaunch?.videoId, + title = lastExternalPlayerLaunch?.title, + seasonNumber = lastExternalPlayerLaunch?.seasonNumber, + episodeNumber = lastExternalPlayerLaunch?.episodeNumber, + episodeTitle = lastExternalPlayerLaunch?.episodeTitle, + ) + if (scrobbleItem != null) { + runCatching { + TraktScrobbleRepository.scrobbleStop( + item = scrobbleItem, + progressPercent = progressPercent, + ) + } + } + } + lastExternalPlayerLaunch?.let { playerLaunch -> + val session = WatchProgressPlaybackSession( + contentType = playerLaunch.contentType ?: playerLaunch.parentMetaType, + parentMetaId = playerLaunch.parentMetaId, + parentMetaType = playerLaunch.parentMetaType, + videoId = playerLaunch.videoId ?: playerLaunch.parentMetaId, + title = playerLaunch.title, + logo = playerLaunch.logo, + poster = playerLaunch.poster, + background = playerLaunch.background, + seasonNumber = playerLaunch.seasonNumber, + episodeNumber = playerLaunch.episodeNumber, + episodeTitle = playerLaunch.episodeTitle, + episodeThumbnail = playerLaunch.episodeThumbnail, + providerName = playerLaunch.providerName, + providerAddonId = playerLaunch.providerAddonId, + lastStreamTitle = playerLaunch.streamTitle, + lastSourceUrl = playerLaunch.sourceUrl, + ) + val snapshot = PlayerPlaybackSnapshot( + isLoading = false, + isPlaying = false, + isEnded = !result.endedByUser, + durationMs = durationMs ?: 0L, + positionMs = result.positionMs, + ) + WatchProgressRepository.upsertPlaybackProgress( + session = session, + snapshot = snapshot, + ) + } + } + } + } val continueWatchingPreferencesUiState by remember { ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.uiState @@ -828,23 +900,49 @@ private fun MainAppContent( } } - fun openExternalPlayback(launch: PlayerLaunch): Boolean { + suspend fun openExternalPlayback(launch: PlayerLaunch): Boolean { + lastExternalPlayerLaunch = launch + + // Persist binge group for subsequent episode plays (same as internal player) + val bingeGroup = launch.bingeGroup + if (bingeGroup != null && launch.parentMetaId.isNotBlank()) { + BingeGroupCacheRepository.save(launch.parentMetaId, bingeGroup) + } + + val baseRequest = launch.toExternalPlayerPlaybackRequest() + val shouldForwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles && + !playerSettingsUiState.preferredSubtitleLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true) + if (shouldForwardSubtitles) { + StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_subtitles)) + } + val enrichedRequest = prepareExternalPlayerLaunch( + request = baseRequest, + type = launch.contentType ?: launch.parentMetaType, + videoId = launch.videoId ?: launch.parentMetaId, + forwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles, + preferredLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + onOverlayMessage = { _ -> }, + ) + StreamsRepository.setOverlayVisible(false) return when ( - ExternalPlayerPlatform.open( - request = launch.toExternalPlayerPlaybackRequest(), + val intentResult = ExternalPlayerPlatform.buildIntent( + request = enrichedRequest, playerId = playerSettingsUiState.externalPlayerId, ) ) { - ExternalPlayerOpenResult.Opened -> true - ExternalPlayerOpenResult.NotConfigured -> { + is ExternalPlayerIntentResult.Success -> { + val launched = launchExternalPlayer(intentResult) + if (!launched) { + NuvioToastController.show(externalPlayerFailedText) + } + launched + } + ExternalPlayerIntentResult.NotConfigured -> { NuvioToastController.show(externalPlayerNotConfiguredText) false } - ExternalPlayerOpenResult.NoPlayerAvailable -> { - NuvioToastController.show(externalPlayerUnavailableText) - false - } - ExternalPlayerOpenResult.Failed -> { + ExternalPlayerIntentResult.Failed -> { NuvioToastController.show(externalPlayerFailedText) false } @@ -953,7 +1051,7 @@ private fun MainAppContent( initialProgressFraction = targetResumeProgressFraction, ) if (playerSettingsUiState.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) + coroutineScope.launch { openExternalPlayback(playerLaunch) } return } val launchId = PlayerLaunchStore.put(playerLaunch) @@ -1580,7 +1678,6 @@ private fun MainAppContent( val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { - StreamsRepository.clear() val playerLaunch = PlayerLaunch( title = launch.title, sourceUrl = cached.url, @@ -1608,9 +1705,11 @@ private fun MainAppContent( ) if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) + StreamsRepository.setOverlayVisible(false) reuseNavigated = true return@LaunchedEffect } + StreamsRepository.clear() reuseNavigated = true val launchId = PlayerLaunchStore.put(playerLaunch) navController.navigate(PlayerRoute(launchId = launchId)) { @@ -1723,12 +1822,14 @@ private fun MainAppContent( initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, ) - StreamsRepository.consumeAutoPlay() - StreamsRepository.cancelLoading() if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) + StreamsRepository.consumeAutoPlay() + StreamsRepository.cancelLoading() return@LaunchedEffect } + StreamsRepository.consumeAutoPlay() + StreamsRepository.cancelLoading() val launchId = PlayerLaunchStore.put(playerLaunch) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } @@ -1836,7 +1937,7 @@ private fun MainAppContent( ) if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) { - openExternalPlayback(playerLaunch) + coroutineScope.launch { openExternalPlayback(playerLaunch) } StreamsRepository.cancelLoading() return } @@ -1848,6 +1949,13 @@ private fun MainAppContent( ) } + // Hide overlay when reuse navigated to external player (prevents reload from showing it again) + LaunchedEffect(reuseNavigated) { + if (reuseNavigated) { + StreamsRepository.setOverlayVisible(false) + } + } + Box(modifier = Modifier.fillMaxSize()) { StreamsScreen( type = launch.type, @@ -1968,6 +2076,50 @@ private fun MainAppContent( PlayerLaunchStore.remove(route.launchId) navController.popBackStack() }, + onOpenInExternalPlayer = { request -> + val playerLaunch = PlayerLaunch( + title = launch.title, + sourceUrl = request.sourceUrl, + sourceHeaders = request.sourceHeaders, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + streamTitle = request.streamTitle ?: launch.streamTitle, + streamSubtitle = launch.streamSubtitle, + bingeGroup = launch.bingeGroup, + pauseDescription = launch.pauseDescription, + providerName = launch.providerName, + providerAddonId = launch.providerAddonId, + contentType = launch.contentType, + videoId = launch.videoId, + parentMetaId = launch.parentMetaId, + parentMetaType = launch.parentMetaType, + initialPositionMs = request.resumePositionMs, + ) + lastExternalPlayerLaunch = playerLaunch + val intentResult = ExternalPlayerPlatform.buildIntent( + request = request, + playerId = playerSettingsUiState.externalPlayerId, + ) + when (intentResult) { + is ExternalPlayerIntentResult.Success -> { + val launched = launchExternalPlayer(intentResult) + if (!launched) { + NuvioToastController.show(externalPlayerFailedText) + } + } + ExternalPlayerIntentResult.NotConfigured -> { + NuvioToastController.show(externalPlayerNotConfiguredText) + } + ExternalPlayerIntentResult.Failed -> { + NuvioToastController.show(externalPlayerFailedText) + } + } + }, modifier = Modifier.fillMaxSize(), ) } @@ -2068,7 +2220,7 @@ private fun MainAppContent( initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, ) if (playerSettingsUiState.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) + coroutineScope.launch { openExternalPlayback(playerLaunch) } return@DownloadsScreen } val launchId = PlayerLaunchStore.put(playerLaunch) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt new file mode 100644 index 00000000..7c79566a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt @@ -0,0 +1,33 @@ +package com.nuvio.app.features.player + +/** + * Orchestrates the full external player launch flow: + * fetches subtitles if forwarding is enabled, then returns an enriched request + * for the caller to dispatch. + */ +suspend fun prepareExternalPlayerLaunch( + request: ExternalPlayerPlaybackRequest, + type: String, + videoId: String, + forwardSubtitles: Boolean, + preferredLanguage: String, + secondaryLanguage: String?, + onOverlayMessage: (String?) -> Unit, +): ExternalPlayerPlaybackRequest { + if (forwardSubtitles && !preferredLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true)) { + onOverlayMessage("Loading subtitles from addons...") + + val subtitles = SubtitleForwarder.fetchForExternalPlayer( + type = type, + videoId = videoId, + preferredLanguage = preferredLanguage, + secondaryLanguage = secondaryLanguage, + ) + + if (subtitles != null) { + return request.copy(subtitles = subtitles) + } + } + + return request +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.kt new file mode 100644 index 00000000..dc203656 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.kt @@ -0,0 +1,27 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable + +/** + * Common playback result from an external player. + * Mirrors ExternalPlayerResultParser.PlaybackResult but lives in commonMain. + */ +data class ExternalPlaybackResult( + val positionMs: Long, + val durationMs: Long?, + val endedByUser: Boolean, +) + +/** + * A composable effect that registers a platform-specific external player launcher. + * + * On Android, this registers an ActivityResultLauncher using ExternalPlayerActivityContract. + * On iOS, this falls back to the fire-and-forget open() approach. + * + * @param onResult Called when the external player returns a result (Android only). + * @return A lambda that accepts an ExternalPlayerIntentResult.Success and launches the external player. + */ +@Composable +expect fun rememberExternalPlayerLauncher( + onResult: (ExternalPlaybackResult?) -> Unit, +): (ExternalPlayerIntentResult.Success) -> Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt index 10a03f51..b7e4d5c9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt @@ -5,11 +5,19 @@ data class ExternalPlayerApp( val name: String, ) +data class SubtitleInput( + val url: String, + val name: String, + val lang: String, +) + data class ExternalPlayerPlaybackRequest( val sourceUrl: String, val title: String, val streamTitle: String? = null, val sourceHeaders: Map = emptyMap(), + val resumePositionMs: Long = 0L, + val subtitles: List? = null, ) enum class ExternalPlayerOpenResult { @@ -19,6 +27,12 @@ enum class ExternalPlayerOpenResult { Failed, } +sealed interface ExternalPlayerIntentResult { + data class Success(val intent: Any) : ExternalPlayerIntentResult + data object NotConfigured : ExternalPlayerIntentResult + data object Failed : ExternalPlayerIntentResult +} + internal expect object ExternalPlayerPlatform { fun defaultPlayerId(): String? fun availablePlayers(): List @@ -26,4 +40,8 @@ internal expect object ExternalPlayerPlatform { request: ExternalPlayerPlaybackRequest, playerId: String?, ): ExternalPlayerOpenResult + fun buildIntent( + request: ExternalPlayerPlaybackRequest, + playerId: String?, + ): ExternalPlayerIntentResult } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 4abec385..b28939da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.Build import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Forward10 @@ -87,6 +88,7 @@ internal fun PlayerControlsShell( onVideoSettingsClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, + onOpenInExternalPlayer: (() -> Unit)? = null, onSubmitIntroClick: (() -> Unit)? = null, parentalWarnings: List = emptyList(), showParentalGuide: Boolean = false, @@ -187,6 +189,7 @@ internal fun PlayerControlsShell( onAudioClick = onAudioClick, onSourcesClick = onSourcesClick, onEpisodesClick = onEpisodesClick, + onOpenInExternalPlayer = onOpenInExternalPlayer, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() @@ -484,6 +487,7 @@ private fun ProgressControls( onAudioClick: () -> Unit, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, + onOpenInExternalPlayer: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) @@ -565,6 +569,13 @@ private fun ProgressControls( onClick = onEpisodesClick, ) } + if (onOpenInExternalPlayer != null) { + PlayerActionPillButton( + label = stringResource(Res.string.streams_open_external_player), + icon = Icons.AutoMirrored.Rounded.OpenInNew, + onClick = onOpenInExternalPlayer, + ) + } } } } 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 7c354fb4..cc902113 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 @@ -144,6 +144,7 @@ fun PlayerScreen( initialBingeGroup: String? = null, pauseDescription: String? = null, onBack: () -> Unit, + onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)? = null, modifier: Modifier = Modifier, logo: String? = null, poster: String? = null, @@ -2268,6 +2269,34 @@ fun PlayerScreen( }, onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, + onOpenInExternalPlayer = if (onOpenInExternalPlayer != null) { + { + val currentPositionMs = playbackSnapshot.positionMs + val loadedSubtitles = addonSubtitles + .takeIf { it.isNotEmpty() } + ?.map { sub -> + SubtitleInput( + url = sub.url, + name = buildString { + if (!sub.addonName.isNullOrBlank()) { + append("[${sub.addonName}] ") + } + append(sub.display) + }, + lang = sub.language, + ) + } + val request = ExternalPlayerPlaybackRequest( + sourceUrl = activeSourceUrl, + title = title, + streamTitle = activeStreamTitle, + sourceHeaders = activeSourceHeaders, + resumePositionMs = currentPositionMs, + subtitles = loadedSubtitles, + ) + onOpenInExternalPlayer(request) + } + } else null, onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, parentalWarnings = parentalWarnings, showParentalGuide = showParentalGuide, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index a2560b1a..7499364f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -37,6 +37,7 @@ data class PlayerSettingsUiState( val holdToSpeedEnabled: Boolean = true, val holdToSpeedValue: Float = 2f, val externalPlayerEnabled: Boolean = false, + val externalPlayerForwardSubtitles: Boolean = false, val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(), val preferredAudioLanguage: String = AudioLanguageOption.DEVICE, val secondaryPreferredAudioLanguage: String? = null, @@ -94,6 +95,7 @@ object PlayerSettingsRepository { private var holdToSpeedEnabled = true private var holdToSpeedValue = 2f private var externalPlayerEnabled = false + private var externalPlayerForwardSubtitles = false private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId() private var preferredAudioLanguage = AudioLanguageOption.DEVICE private var secondaryPreferredAudioLanguage: String? = null @@ -156,6 +158,7 @@ object PlayerSettingsRepository { holdToSpeedEnabled = true holdToSpeedValue = 2f externalPlayerEnabled = false + externalPlayerForwardSubtitles = false externalPlayerId = ExternalPlayerPlatform.defaultPlayerId() preferredAudioLanguage = AudioLanguageOption.DEVICE secondaryPreferredAudioLanguage = null @@ -213,6 +216,7 @@ object PlayerSettingsRepository { holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false + externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId() ?: ExternalPlayerPlatform.defaultPlayerId() preferredAudioLanguage = @@ -384,6 +388,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveExternalPlayerId(normalized) } + fun setExternalPlayerForwardSubtitles(enabled: Boolean) { + ensureLoaded() + if (externalPlayerForwardSubtitles == enabled) return + externalPlayerForwardSubtitles = enabled + publish() + PlayerSettingsStorage.saveExternalPlayerForwardSubtitles(enabled) + } + fun setPreferredAudioLanguage(language: String) { ensureLoaded() val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE @@ -804,6 +816,7 @@ object PlayerSettingsRepository { holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, externalPlayerEnabled = externalPlayerEnabled, + externalPlayerForwardSubtitles = externalPlayerForwardSubtitles, externalPlayerId = externalPlayerId, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 516f94ec..e8da2d41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -13,6 +13,8 @@ internal expect object PlayerSettingsStorage { fun saveHoldToSpeedValue(speed: Float) fun loadExternalPlayerEnabled(): Boolean? fun saveExternalPlayerEnabled(enabled: Boolean) + fun loadExternalPlayerForwardSubtitles(): Boolean? + fun saveExternalPlayerForwardSubtitles(enabled: Boolean) fun loadExternalPlayerId(): String? fun saveExternalPlayerId(playerId: String?) fun loadPreferredAudioLanguage(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleForwarder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleForwarder.kt new file mode 100644 index 00000000..009c137a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleForwarder.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.player + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +object SubtitleForwarder { + + /** + * Fetches addon subtitles for the given content and filters them by the user's + * preferred and secondary language. Returns null on failure or timeout for + * graceful degradation (external player launches without subtitles). + */ + suspend fun fetchForExternalPlayer( + type: String, + videoId: String, + preferredLanguage: String, + secondaryLanguage: String?, + timeoutMs: Long = 10_000L, + ): List? { + return try { + withTimeoutOrNull(timeoutMs) { + SubtitleRepository.fetchAddonSubtitles(type, videoId) + + // Give the internal coroutine a chance to start and set isLoading = true + kotlinx.coroutines.delay(50) + + // Wait for loading to complete (isLoading goes from true back to false) + // If it's already false (fetch completed very quickly or never started), skip waiting + if (SubtitleRepository.isLoading.value) { + SubtitleRepository.isLoading.first { !it } + } + + val allSubtitles = SubtitleRepository.addonSubtitles.value + + val filtered = allSubtitles.filter { subtitle -> + languageMatchesPreference(subtitle.language, preferredLanguage) || + (secondaryLanguage != null && + languageMatchesPreference(subtitle.language, secondaryLanguage)) + } + + filtered.map { subtitle -> + SubtitleInput( + url = subtitle.url, + name = subtitle.display, + lang = subtitle.language, + ) + } + } + } catch (_: Exception) { + null + } + } +} 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 313216d6..44cbbe63 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 @@ -42,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -178,6 +179,7 @@ private fun SettingsSliderRow( valueRange: IntRange, step: Int, isTablet: Boolean, + enabled: Boolean = true, onValueChange: (Int) -> Unit, ) { val horizontalPadding = if (isTablet) 20.dp else 16.dp @@ -186,7 +188,8 @@ private fun SettingsSliderRow( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = 10.dp), + .padding(horizontal = horizontalPadding, vertical = 10.dp) + .alpha(if (enabled) 1f else 0.55f), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( @@ -204,10 +207,11 @@ private fun SettingsSliderRow( } Slider( value = sliderValue.coerceIn(valueRange.first.toFloat(), valueRange.last.toFloat()), - onValueChange = { sliderValue = snapToStep(it, step.toFloat()) }, + onValueChange = { if (enabled) sliderValue = snapToStep(it, step.toFloat()) }, onValueChangeFinished = { - onValueChange(sliderValue.roundToInt().coerceIn(valueRange.first, valueRange.last)) + if (enabled) onValueChange(sliderValue.roundToInt().coerceIn(valueRange.first, valueRange.last)) }, + enabled = enabled, valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(), steps = calculateSteps(valueRange.first.toFloat(), valueRange.last.toFloat(), step.toFloat()), colors = SliderDefaults.colors( @@ -255,6 +259,7 @@ private fun PlaybackSettingsSection( var showSubtitleBackgroundColorDialog by remember { mutableStateOf(false) } var showSubtitleOutlineColorDialog by remember { mutableStateOf(false) } var showExternalPlayerDialog by remember { mutableStateOf(false) } + var showExternalPlayerAppDialog by remember { mutableStateOf(false) } var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } @@ -299,23 +304,16 @@ private fun PlaybackSettingsSection( onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, ) SettingsGroupDivider(isTablet = isTablet) - SettingsSwitchRow( - title = stringResource(Res.string.settings_playback_external_player), - description = stringResource( - if (isIos) { - Res.string.settings_playback_external_player_description_ios - } else { - Res.string.settings_playback_external_player_description_android - }, - ), - checked = autoPlayPlayerSettings.externalPlayerEnabled, - isTablet = isTablet, - onCheckedChange = { enabled -> - PlayerSettingsRepository.setExternalPlayerEnabled(enabled) - if (enabled && isIos) { - showExternalPlayerDialog = true - } + // Player preference picker: Internal / External + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_player_preference), + description = if (autoPlayPlayerSettings.externalPlayerEnabled) { + stringResource(Res.string.settings_playback_player_preference_external) + } else { + stringResource(Res.string.settings_playback_player_preference_internal) }, + isTablet = isTablet, + onClick = { showExternalPlayerDialog = true }, ) if (isIos && autoPlayPlayerSettings.externalPlayerEnabled) { SettingsGroupDivider(isTablet = isTablet) @@ -328,7 +326,17 @@ private fun PlaybackSettingsSection( stringResource(Res.string.settings_playback_not_set) }, isTablet = isTablet, - onClick = { showExternalPlayerDialog = true }, + onClick = { showExternalPlayerAppDialog = true }, + ) + } + if (autoPlayPlayerSettings.externalPlayerEnabled) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_external_player_forward_subtitles), + description = stringResource(Res.string.settings_playback_external_player_forward_subtitles_description), + checked = autoPlayPlayerSettings.externalPlayerForwardSubtitles, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setExternalPlayerForwardSubtitles, ) } SettingsGroupDivider(isTablet = isTablet) @@ -336,10 +344,11 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_hold_to_speed), description = stringResource(Res.string.settings_playback_hold_to_speed_description), checked = holdToSpeedEnabled, + enabled = !autoPlayPlayerSettings.externalPlayerEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setHoldToSpeedEnabled, ) - if (holdToSpeedEnabled) { + if (holdToSpeedEnabled && !autoPlayPlayerSettings.externalPlayerEnabled) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_playback_hold_speed), @@ -355,6 +364,17 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_section_subtitle_audio), isTablet = isTablet, ) { + // Subtitle/Audio settings enable/disable logic: + // Internal: everything enabled + // External + forwarding enabled: subtitle language pickers enabled, other subtitle options disabled + // External + forwarding disabled: entire subtitle section disabled + // External: audio language pickers always disabled (external player manages audio tracks) + val isExternalPlayer = autoPlayPlayerSettings.externalPlayerEnabled + val isForwardingSubtitles = autoPlayPlayerSettings.externalPlayerForwardSubtitles + val audioLanguageEnabled = !isExternalPlayer + val subtitleLanguageEnabled = !isExternalPlayer || isForwardingSubtitles + val otherSubtitleOptionsEnabled = !isExternalPlayer + SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( title = stringResource(Res.string.settings_playback_preferred_audio_language), @@ -363,6 +383,7 @@ private fun PlaybackSettingsSection( AudioLanguageOption.DEVICE -> stringResource(Res.string.settings_playback_option_device_language) else -> languageLabelForCode(preferredAudioLanguage) }, + enabled = audioLanguageEnabled, isTablet = isTablet, onClick = { showPreferredAudioDialog = true }, ) @@ -370,6 +391,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_secondary_audio_language), description = languageLabelForCode(secondaryPreferredAudioLanguage), + enabled = audioLanguageEnabled, isTablet = isTablet, onClick = { showSecondaryAudioDialog = true }, ) @@ -382,6 +404,7 @@ private fun PlaybackSettingsSection( SubtitleLanguageOption.FORCED -> stringResource(Res.string.settings_playback_option_forced) else -> languageLabelForCode(preferredSubtitleLanguage) }, + enabled = subtitleLanguageEnabled, isTablet = isTablet, onClick = { showPreferredSubtitleDialog = true }, ) @@ -389,6 +412,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_secondary_subtitle_language), description = languageLabelForCode(secondaryPreferredSubtitleLanguage), + enabled = subtitleLanguageEnabled, isTablet = isTablet, onClick = { showSecondarySubtitleDialog = true }, ) @@ -397,6 +421,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_subtitle_use_forced), description = stringResource(Res.string.settings_playback_subtitle_use_forced_description), checked = autoPlayPlayerSettings.subtitleStyle.useForcedSubtitles, + enabled = otherSubtitleOptionsEnabled, isTablet = isTablet, onCheckedChange = { enabled -> PlayerSettingsRepository.setSubtitleStyle( @@ -409,6 +434,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_subtitle_show_preferred_only), description = stringResource(Res.string.settings_playback_subtitle_show_preferred_only_description), checked = autoPlayPlayerSettings.subtitleStyle.showOnlyPreferredLanguages, + enabled = otherSubtitleOptionsEnabled, isTablet = isTablet, onCheckedChange = { enabled -> PlayerSettingsRepository.setSubtitleStyle( @@ -420,6 +446,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode), description = addonSubtitleStartupModeLabel(autoPlayPlayerSettings.addonSubtitleStartupMode), + enabled = otherSubtitleOptionsEnabled, isTablet = isTablet, onClick = { showAddonSubtitleStartupModeDialog = true }, ) @@ -430,6 +457,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_section_subtitle_rendering), isTablet = isTablet, ) { + val subtitleRenderingEnabled = !autoPlayPlayerSettings.externalPlayerEnabled SettingsGroup(isTablet = isTablet) { val subtitleStyle = autoPlayPlayerSettings.subtitleStyle SettingsSliderRow( @@ -439,6 +467,7 @@ private fun PlaybackSettingsSection( valueRange = 12..40, step = 2, isTablet = isTablet, + enabled = subtitleRenderingEnabled, onValueChange = { value -> PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(fontSizeSp = value)) }, @@ -451,6 +480,7 @@ private fun PlaybackSettingsSection( valueRange = 0..200, step = 5, isTablet = isTablet, + enabled = subtitleRenderingEnabled, onValueChange = { value -> PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bottomOffset = value)) }, @@ -460,6 +490,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_subtitle_bold), description = stringResource(Res.string.settings_playback_subtitle_bold_description), checked = subtitleStyle.bold, + enabled = subtitleRenderingEnabled, isTablet = isTablet, onCheckedChange = { enabled -> PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bold = enabled)) @@ -469,6 +500,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_subtitle_text_color), description = subtitleColorLabel(subtitleStyle.textColor), + enabled = subtitleRenderingEnabled, isTablet = isTablet, onClick = { showSubtitleTextColorDialog = true }, ) @@ -476,6 +508,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_subtitle_background_color), description = subtitleColorLabel(subtitleStyle.backgroundColor), + enabled = subtitleRenderingEnabled, isTablet = isTablet, onClick = { showSubtitleBackgroundColorDialog = true }, ) @@ -484,6 +517,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_subtitle_outline), description = stringResource(Res.string.settings_playback_subtitle_outline_description), checked = subtitleStyle.outlineEnabled, + enabled = subtitleRenderingEnabled, isTablet = isTablet, onCheckedChange = { enabled -> PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineEnabled = enabled)) @@ -494,6 +528,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_subtitle_outline_color), description = subtitleColorLabel(subtitleStyle.outlineColor), + enabled = subtitleRenderingEnabled, isTablet = isTablet, onClick = { showSubtitleOutlineColorDialog = true }, ) @@ -504,6 +539,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_enable_libass), description = stringResource(Res.string.settings_playback_enable_libass_description), checked = useLibass, + enabled = subtitleRenderingEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setUseLibass, ) @@ -512,6 +548,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_render_type), description = libassRenderTypeLabel(libassRenderType), + enabled = subtitleRenderingEnabled, isTablet = isTablet, onClick = { showLibassRenderTypeDialog = true }, ) @@ -555,122 +592,121 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onClick = { showAutoPlayModeDialog = true }, ) - if (autoPlayPlayerSettings.streamAutoPlayMode != StreamAutoPlayMode.MANUAL) { - if (autoPlayPlayerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { - SettingsGroupDivider(isTablet = isTablet) - val notSetLabel = stringResource(Res.string.settings_playback_not_set) - SettingsNavigationRow( - title = stringResource(Res.string.settings_playback_regex_pattern), - description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { notSetLabel }, - isTablet = isTablet, - onClick = { showAutoPlayRegexDialog = true }, - ) - } - SettingsGroupDivider(isTablet = isTablet) - val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds - val timeoutLabel = when (timeoutSec) { - 0 -> stringResource(Res.string.settings_playback_timeout_instant) - Int.MAX_VALUE -> stringResource(Res.string.settings_playback_timeout_unlimited) - else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = if (isTablet) 18.dp else 16.dp, vertical = 10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text( - text = stringResource(Res.string.settings_playback_stream_timeout), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.settings_playback_stream_timeout_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - ValueBox(text = timeoutLabel, modifier = Modifier.wrapContentWidth()) - } - 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 = { - val snapped = snapToStep(it, 1f) - sliderValue = snapped - - if (snapped != lastHapticStep) { - lastHapticStep = snapped - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - }, - onValueChangeFinished = { - val index = sliderValue.toInt().coerceIn(0, STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1) - PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(STREAM_AUTO_PLAY_TIMEOUT_VALUES[index]) - }, - valueRange = 0f..maxIndex, - steps = calculateSteps(0f, maxIndex, 1f), - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary, - ), - modifier = Modifier.fillMaxWidth(), - ) - } + if (autoPlayPlayerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { SettingsGroupDivider(isTablet = isTablet) + val notSetLabel = stringResource(Res.string.settings_playback_not_set) SettingsNavigationRow( - title = stringResource(Res.string.settings_playback_source_scope), - description = stringResource(autoPlayPlayerSettings.streamAutoPlaySource.labelRes(pluginsEnabled)), + title = stringResource(Res.string.settings_playback_regex_pattern), + description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { notSetLabel }, isTablet = isTablet, - onClick = { showAutoPlaySourceDialog = true }, + onClick = { showAutoPlayRegexDialog = true }, ) - if (autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.ENABLED_PLUGINS_ONLY) { - SettingsGroupDivider(isTablet = isTablet) - val addonSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedAddons.isEmpty()) { - stringResource(Res.string.settings_playback_all_addons) - } else { - stringResource( - Res.string.settings_playback_selected_count, - autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size, + } + SettingsGroupDivider(isTablet = isTablet) + val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds + val timeoutLabel = when (timeoutSec) { + 0 -> stringResource(Res.string.settings_playback_timeout_instant) + Int.MAX_VALUE -> stringResource(Res.string.settings_playback_timeout_unlimited) + else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 18.dp else 16.dp, vertical = 10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text( + text = stringResource(Res.string.settings_playback_stream_timeout), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.settings_playback_stream_timeout_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SettingsNavigationRow( - title = stringResource(Res.string.settings_playback_allowed_addons), - description = addonSubtitle, - isTablet = isTablet, - onClick = { showAutoPlayAddonSelectionDialog = true }, + ValueBox(text = timeoutLabel, modifier = Modifier.wrapContentWidth()) + } + 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 = { + val snapped = snapToStep(it, 1f) + sliderValue = snapped + + if (snapped != lastHapticStep) { + lastHapticStep = snapped + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + }, + onValueChangeFinished = { + val index = sliderValue.toInt().coerceIn(0, STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1) + PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(STREAM_AUTO_PLAY_TIMEOUT_VALUES[index]) + }, + valueRange = 0f..maxIndex, + steps = calculateSteps(0f, maxIndex, 1f), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_source_scope), + description = stringResource(autoPlayPlayerSettings.streamAutoPlaySource.labelRes(pluginsEnabled)), + isTablet = isTablet, + onClick = { showAutoPlaySourceDialog = true }, + ) + if (autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.ENABLED_PLUGINS_ONLY) { + SettingsGroupDivider(isTablet = isTablet) + val addonSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedAddons.isEmpty()) { + stringResource(Res.string.settings_playback_all_addons) + } else { + stringResource( + Res.string.settings_playback_selected_count, + autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size, ) } - if (pluginsEnabled && autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.INSTALLED_ADDONS_ONLY) { - SettingsGroupDivider(isTablet = isTablet) - val pluginSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.isEmpty()) { - stringResource(Res.string.settings_playback_all_plugins) - } else { - stringResource( - Res.string.settings_playback_selected_count, - autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size, - ) - } - SettingsNavigationRow( - title = stringResource(Res.string.settings_playback_allowed_plugins), - description = pluginSubtitle, - isTablet = isTablet, - onClick = { showAutoPlayPluginSelectionDialog = true }, + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_allowed_addons), + description = addonSubtitle, + isTablet = isTablet, + onClick = { showAutoPlayAddonSelectionDialog = true }, + ) + } + if (pluginsEnabled && autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.INSTALLED_ADDONS_ONLY) { + SettingsGroupDivider(isTablet = isTablet) + val pluginSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.isEmpty()) { + stringResource(Res.string.settings_playback_all_plugins) + } else { + stringResource( + Res.string.settings_playback_selected_count, + autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size, ) } + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_allowed_plugins), + description = pluginSubtitle, + isTablet = isTablet, + onClick = { showAutoPlayPluginSelectionDialog = true }, + ) } } } if (!isIos) { + val decoderEnabled = !autoPlayPlayerSettings.externalPlayerEnabled SettingsSection( title = stringResource(Res.string.settings_playback_section_decoder), isTablet = isTablet, @@ -679,6 +715,7 @@ private fun PlaybackSettingsSection( SettingsNavigationRow( title = stringResource(Res.string.settings_playback_decoder_priority), description = decoderPriorityLabel(decoderPriority), + enabled = decoderEnabled, isTablet = isTablet, onClick = { showDecoderPriorityDialog = true }, ) @@ -687,6 +724,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_map_dv7_to_hevc), description = stringResource(Res.string.settings_playback_map_dv7_to_hevc_description), checked = mapDV7ToHevc, + enabled = decoderEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setMapDV7ToHevc, ) @@ -695,6 +733,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_tunneled_playback), description = stringResource(Res.string.settings_playback_tunneled_playback_description), checked = tunnelingEnabled, + enabled = decoderEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setTunnelingEnabled, ) @@ -1116,14 +1155,25 @@ private fun PlaybackSettingsSection( } if (showExternalPlayerDialog) { + PlayerPreferenceDialog( + isExternal = autoPlayPlayerSettings.externalPlayerEnabled, + onPreferenceSelected = { external -> + PlayerSettingsRepository.setExternalPlayerEnabled(external) + showExternalPlayerDialog = false + }, + onDismiss = { showExternalPlayerDialog = false }, + ) + } + + if (showExternalPlayerAppDialog) { ExternalPlayerSelectionDialog( players = availableExternalPlayers, selectedPlayerId = autoPlayPlayerSettings.externalPlayerId, onPlayerSelected = { playerId -> PlayerSettingsRepository.setExternalPlayerId(playerId) - showExternalPlayerDialog = false + showExternalPlayerAppDialog = false }, - onDismiss = { showExternalPlayerDialog = false }, + onDismiss = { showExternalPlayerAppDialog = false }, ) } @@ -1294,6 +1344,133 @@ private data class LanguageSelectionOption( val label: String, ) +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun PlayerPreferenceDialog( + isExternal: Boolean, + onPreferenceSelected: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_playback_player_preference), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Text( + text = stringResource(Res.string.settings_playback_player_preference_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Internal option + val internalSelected = !isExternal + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onPreferenceSelected(false) }, + shape = RoundedCornerShape(12.dp), + color = if (internalSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.settings_playback_player_preference_internal), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (internalSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + + // External option + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onPreferenceSelected(true) }, + shape = RoundedCornerShape(12.dp), + color = if (isExternal) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.settings_playback_player_preference_external), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isExternal) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun ExternalPlayerSelectionDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index b526a058..ff6a17d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -220,6 +220,7 @@ internal fun SettingsNavigationRow( description: String, icon: ImageVector? = null, iconPainter: Painter? = null, + enabled: Boolean = true, isTablet: Boolean, onClick: () -> Unit, ) { @@ -231,8 +232,9 @@ internal fun SettingsNavigationRow( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding) + .alpha(if (enabled) 1f else 0.55f), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index b88c8251..79987dcf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -189,6 +189,7 @@ data class StreamsUiState( val autoPlayCandidates: List = emptyList(), val isDirectAutoPlayFlow: Boolean = false, val showDirectAutoPlayOverlay: Boolean = false, + val overlayMessage: String? = null, ) { val filteredGroups: List get() = if (selectedFilter == null) groups diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 5fe167d7..e3591a71 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -294,6 +294,31 @@ object StreamsRepository { eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup publishAddonGroup(presentDebridGroup(availabilityGroup)) + + // Early binge-group match right after this addon's availability is resolved + if (isDirectAutoPlayFlow && !autoSelectTriggered && persistedBingeGroup != null && !timeoutElapsed) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + 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) } + } + } + } } debridAvailabilityJobs += availabilityJob } @@ -532,7 +557,34 @@ object StreamsRepository { for (availabilityJob in debridAvailabilityJobs) { availabilityJob.join() + + // Early binge-group match after each availability job completes + if (isDirectAutoPlayFlow && !autoSelectTriggered && persistedBingeGroup != null) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + 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 = !timeoutElapsed, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + if (earlyMatch != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = earlyMatch) } + break + } + } + } } + launch { DirectDebridStreamPreparer.prepare( streams = _uiState.value.groups @@ -553,6 +605,31 @@ object StreamsRepository { ), ) } + + // Early binge-group match after each debrid-prepared stream + if (isDirectAutoPlayFlow && !autoSelectTriggered && persistedBingeGroup != null) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + 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 = !timeoutElapsed, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + if (earlyMatch != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = earlyMatch) } + } + } + } } // Early match / timeout-elapsed auto-select on each addon response @@ -706,6 +783,10 @@ object StreamsRepository { activeRequestKey = null _uiState.value = StreamsUiState() } + + fun setOverlayVisible(visible: Boolean, message: String? = null) { + _uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) } + } } private data class InstalledStreamAddonTarget( 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 b7440d06..e1c3269b 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 @@ -331,7 +331,8 @@ fun StreamsScreen( strokeWidth = 2.5.dp, ) Text( - text = stringResource(Res.string.streams_finding_source), + text = uiState.overlayMessage + ?: stringResource(Res.string.streams_finding_source), style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.8f), ) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.ios.kt new file mode 100644 index 00000000..24ee550f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.ios.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +@Composable +actual fun rememberExternalPlayerLauncher( + onResult: (ExternalPlaybackResult?) -> Unit, +): (ExternalPlayerIntentResult.Success) -> Boolean { + return remember { + { intentResult: ExternalPlayerIntentResult.Success -> + val url = intentResult.intent + if (url is NSURL) { + UIApplication.sharedApplication.openURL( + url = url, + options = emptyMap(), + completionHandler = null, + ) + // iOS doesn't return playback results from external players + true + } else { + false + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt index 738848c6..9fe3cb6d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt @@ -82,6 +82,22 @@ internal actual object ExternalPlayerPlatform { ) return ExternalPlayerOpenResult.Opened } + + actual fun buildIntent( + request: ExternalPlayerPlaybackRequest, + playerId: String?, + ): ExternalPlayerIntentResult { + // iOS doesn't use Android intents; this returns the URL as the "intent" payload + if (playerId.isNullOrBlank()) return ExternalPlayerIntentResult.NotConfigured + val spec = iosExternalPlayerSpecs.firstOrNull { it.id == playerId } + ?: return ExternalPlayerIntentResult.NotConfigured + if (!UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl())) { + return ExternalPlayerIntentResult.Failed + } + val url = NSURL.URLWithString(spec.buildUrl(request)) + ?: return ExternalPlayerIntentResult.Failed + return ExternalPlayerIntentResult.Success(url) + } } private fun IosExternalPlayerSpec.schemeProbeUrl(): NSURL = diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 3d68ffa4..d1e7a6ef 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -22,6 +22,7 @@ actual object PlayerSettingsStorage { private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedValueKey = "hold_to_speed_value" private const val externalPlayerEnabledKey = "external_player_enabled" + private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" private const val externalPlayerIdKey = "external_player_id" private const val preferredAudioLanguageKey = "preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" @@ -82,6 +83,7 @@ actual object PlayerSettingsStorage { holdToSpeedEnabledKey, holdToSpeedValueKey, externalPlayerEnabledKey, + externalPlayerForwardSubtitlesKey, externalPlayerIdKey, preferredAudioLanguageKey, secondaryPreferredAudioLanguageKey, @@ -222,6 +224,20 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerEnabledKey)) } + actual fun loadExternalPlayerForwardSubtitles(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(externalPlayerForwardSubtitlesKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveExternalPlayerForwardSubtitles(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerForwardSubtitlesKey)) + } + actual fun loadExternalPlayerId(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(externalPlayerIdKey) @@ -784,6 +800,7 @@ actual object PlayerSettingsStorage { loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } + loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } @@ -848,6 +865,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) + payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)