Port improved external players support

This commit is contained in:
skoruppa 2026-05-29 11:38:11 +02:00
parent 3b28b2d78a
commit 464cfb3059
23 changed files with 1117 additions and 163 deletions

View file

@ -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<Intent, ExternalPlayerResult?>() {
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"
}
}

View file

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

View file

@ -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<String, String>.toAndroidHeadersBundle(): Bundle =
Bundle().apply {
forEach { (key, value) ->
putString(key, value)
}
}
private fun String.videoMimeType(): String {
val normalized = substringBefore('?').substringBefore('#').lowercase()
return when {

View file

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

View file

@ -25,6 +25,7 @@
<string name="addons_badge_active">Aktywny</string>
<string name="addons_badge_catalogs">%1$d katalogów</string>
<string name="addons_badge_configurable">Konfigurowalny</string>
<string name="addons_badge_disabled">Wyłączony</string>
<string name="addons_badge_refreshing">Odświeżanie</string>
<string name="addons_badge_resources">%1$d zasobów</string>
<string name="addons_badge_unavailable">Niedostępny</string>
@ -311,8 +312,11 @@
<string name="compose_nav_search">Szukaj</string>
<string name="compose_player_audio_tracks">Ścieżki audio</string>
<string name="compose_player_audio">Audio</string>
<string name="compose_player_auto_sync">Automatyczna synchronizacja</string>
<string name="compose_player_bold">Pogrubienie</string>
<string name="compose_player_built_in">Wbudowane</string>
<string name="compose_player_bottom_offset">Przesunięcie dolne</string>
<string name="compose_player_capture_line">Przechwyć</string>
<string name="compose_player_close">Zamknij odtwarzacz</string>
<string name="compose_player_color">Kolor</string>
<string name="compose_player_currently_playing">Aktualnie odtwarzane</string>
@ -323,11 +327,13 @@
<string name="compose_player_font_size">Rozmiar czcionki</string>
<string name="compose_player_font_size_value">%1$dsp</string>
<string name="compose_player_lock_controls">Zablokuj kontrolki odtwarzacza</string>
<string name="compose_player_loading_lines">Ładowanie linii napisów...</string>
<string name="compose_player_no_audio_tracks_available">Brak dostępnych ścieżek audio</string>
<string name="compose_player_no_episodes_available">Brak dostępnych odcinków</string>
<string name="compose_player_no_streams_found">Nie znaleziono strumieni</string>
<string name="compose_player_none">Brak</string>
<string name="compose_player_outline">Obrys</string>
<string name="compose_player_outline_color">Kolor obrysu</string>
<string name="compose_player_panel_episodes">Odcinki</string>
<string name="compose_player_panel_sources">Źródła</string>
<string name="compose_player_panel_streams">Strumienie</string>
@ -335,6 +341,8 @@
<string name="compose_player_playing">Odtwarzanie</string>
<string name="compose_player_fetch_subtitles">Dotknij, aby pobrać napisy</string>
<string name="compose_player_go_back">Wróć</string>
<string name="compose_player_reload">Odśwież</string>
<string name="compose_player_reset">Resetuj</string>
<string name="compose_player_reset_defaults">Przywróć domyślne</string>
<string name="compose_player_resize_fill">Wypełnij</string>
<string name="compose_player_resize_fit">Dopasuj</string>
@ -347,8 +355,11 @@
<string name="compose_player_seek_forward_10">Przewiń do przodu 10 sekund</string>
<string name="compose_player_sources">Źródła</string>
<string name="compose_player_style">Styl</string>
<string name="compose_player_select_addon_subtitle_first">Najpierw wybierz napisy z dodatku</string>
<string name="compose_player_subs">Napisy</string>
<string name="compose_player_subtitle_delay">Opóźnienie napisów</string>
<string name="compose_player_subtitles">Napisy</string>
<string name="compose_player_text_opacity">Przezroczystość tekstu</string>
<string name="compose_player_brightness_level">Jasność %1$s</string>
<string name="compose_player_volume_level">Głośność %1$s</string>
<string name="compose_player_muted">Wyciszony</string>
@ -424,6 +435,10 @@
<string name="settings_licenses_attributions_imdb_body">Nuvio korzysta z niekomercyjnych zbiorów danych IMDb, w tym title.ratings.tsv.gz, do ocen i liczby głosów IMDb. Informacje dzięki uprzejmości IMDb (https://www.imdb.com). Wykorzystywane za zgodą. Dane IMDb służą do użytku osobistego i niekomercyjnego zgodnie z warunkami IMDb.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">Nuvio łączy się z Trakt w celu uwierzytelniania konta, historii oglądania, synchronizacji postępu, danych biblioteki, ocen, list i komentarzy. Nuvio nie jest powiązane z Trakt ani przez nie wspierane.</string>
<string name="settings_licenses_attributions_premiumize_title">Premiumize</string>
<string name="settings_licenses_attributions_premiumize_body">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.</string>
<string name="settings_licenses_attributions_torbox_title">TorBox</string>
<string name="settings_licenses_attributions_torbox_body">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.</string>
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
<string name="settings_licenses_attributions_mdblist_body">Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane.</string>
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
@ -592,15 +607,34 @@
<string name="settings_integrations_debrid_description">Eksperymentalne źródła z kont chmurowych</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_experimental_notice">Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.</string>
<string name="settings_debrid_cloud_library">Biblioteka w chmurze</string>
<string name="settings_debrid_cloud_library_description">Przeglądaj i odtwarzaj pliki z połączonych kont.</string>
<string name="settings_debrid_enable">Włącz źródła</string>
<string name="settings_debrid_enable_description">Pokaż odtwarzalne wyniki z połączonych kont.</string>
<string name="settings_debrid_resolve_with">Rozwiązuj przez</string>
<string name="settings_debrid_resolve_with_description">Wybierz, które połączone konto obsługuje odtwarzalne linki.</string>
<string name="settings_debrid_add_key_first">Najpierw dodaj klucz API.</string>
<string name="settings_debrid_section_providers">Konto</string>
<string name="settings_debrid_provider_description">Połącz swoje konto %1$s.</string>
<string name="settings_debrid_provider_device_description">Połącz konto %1$s w przeglądarce.</string>
<string name="settings_debrid_provider_torbox_description">Połącz swoje konto Torbox.</string>
<string name="settings_debrid_dialog_title">Klucz API Torbox</string>
<string name="settings_debrid_dialog_subtitle">Wprowadź swój klucz API Torbox.</string>
<string name="settings_debrid_dialog_placeholder">Wprowadź klucz API Torbox</string>
<string name="settings_debrid_not_set">Nie ustawiono</string>
<string name="settings_debrid_connected">Połączono</string>
<string name="settings_debrid_connect_provider">Połącz %1$s</string>
<string name="settings_debrid_disconnect_provider">Rozłącz %1$s</string>
<string name="settings_debrid_disconnect">Rozłącz</string>
<string name="settings_debrid_device_auth_connected">%1$s jest połączony na tym urządzeniu.</string>
<string name="settings_debrid_device_auth_starting">Rozpoczynanie bezpiecznego logowania...</string>
<string name="settings_debrid_device_auth_instructions">Otwórz link i wprowadź ten kod, aby zatwierdzić Nuvio.</string>
<string name="settings_debrid_device_auth_code_copied">Kod skopiowany.</string>
<string name="settings_debrid_device_auth_open">Otwórz link</string>
<string name="settings_debrid_device_auth_waiting">Oczekiwanie na zatwierdzenie...</string>
<string name="settings_debrid_device_auth_failed">Nie udało się rozpocząć logowania.</string>
<string name="settings_debrid_device_auth_missing_configuration">Ta metoda logowania nie jest skonfigurowana w tej wersji.</string>
<string name="settings_debrid_device_auth_expired">Ten kod wygasł. Spróbuj ponownie.</string>
<string name="settings_debrid_section_instant_playback">Natychmiastowe odtwarzanie</string>
<string name="settings_debrid_prepare_instant_playback">Przygotuj linki</string>
<string name="settings_debrid_prepare_instant_playback_description">Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.</string>
@ -738,6 +772,8 @@
<string name="settings_playback_external_player_description_android">Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym.</string>
<string name="settings_playback_external_player_description_ios">Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu.</string>
<string name="settings_playback_external_player_none_available">Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy</string>
<string name="settings_playback_external_player_forward_subtitles">Przekazuj napisy do zewnętrznego odtwarzacza</string>
<string name="settings_playback_external_player_forward_subtitles_description">Pobierz napisy z dodatków w preferowanym języku i przekaż je do zewnętrznego odtwarzacza.</string>
<string name="settings_playback_hold_speed">Prędkość przy przytrzymaniu</string>
<string name="settings_playback_hold_to_speed">Przytrzymaj, aby przyspieszyć</string>
<string name="settings_playback_hold_to_speed_description">Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania.</string>
@ -754,6 +790,17 @@
<string name="settings_playback_option_device_language">Język urządzenia</string>
<string name="settings_playback_option_forced">Wymuszone</string>
<string name="settings_playback_option_none">Brak</string>
<string name="settings_playback_addon_subtitle_startup_all">Wszystkie napisy</string>
<string name="settings_playback_addon_subtitle_startup_all_description">Pobierz i pokaż wszystkie napisy z dodatków dla tego wideo.</string>
<string name="settings_playback_addon_subtitle_startup_fast">Szybki start</string>
<string name="settings_playback_addon_subtitle_startup_fast_description">Pomiń automatyczne pobieranie napisów z dodatków do momentu żądania w odtwarzaczu.</string>
<string name="settings_playback_addon_subtitle_startup_mode">Tryb uruchamiania napisów z dodatków</string>
<string name="settings_playback_addon_subtitle_startup_preferred">Tylko preferowane</string>
<string name="settings_playback_addon_subtitle_startup_preferred_description">Pobierz napisy z dodatków, ale pokaż tylko te w preferowanym języku.</string>
<string name="settings_playback_player_preference">Odtwarzacz</string>
<string name="settings_playback_player_preference_internal">Wewnętrzny</string>
<string name="settings_playback_player_preference_external">Zewnętrzny</string>
<string name="settings_playback_player_preference_description">Wybierz, który odtwarzacz obsługuje nowe odtwarzanie.</string>
<string name="settings_playback_prefer_binge_group">Preferuj grupę binge</string>
<string name="settings_playback_prefer_binge_group_description">Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący.</string>
<string name="settings_playback_reuse_binge_group">Ponownie użyj grupy binge</string>
@ -798,6 +845,20 @@
<string name="settings_playback_selected_count">%1$d wybranych</string>
<string name="settings_playback_show_loading_overlay">Pokaż nakładkę ładowania</string>
<string name="settings_playback_show_loading_overlay_description">Pokaż nakładkę ładowania podczas uruchamiania strumienia.</string>
<string name="settings_playback_subtitle_background_color">Kolor tła</string>
<string name="settings_playback_subtitle_bold">Pogrubienie</string>
<string name="settings_playback_subtitle_bold_description">Użyj grubszej czcionki napisów.</string>
<string name="settings_playback_subtitle_color_transparent">Przezroczysty</string>
<string name="settings_playback_subtitle_outline">Obrys</string>
<string name="settings_playback_subtitle_outline_color">Kolor obrysu</string>
<string name="settings_playback_subtitle_outline_description">Rysuj obramowanie wokół tekstu napisów.</string>
<string name="settings_playback_subtitle_show_preferred_only">Pokaż tylko preferowane języki</string>
<string name="settings_playback_subtitle_show_preferred_only_description">Pokaż tylko napisy pasujące do preferowanych języków.</string>
<string name="settings_playback_subtitle_size">Rozmiar napisów</string>
<string name="settings_playback_subtitle_text_color">Kolor tekstu</string>
<string name="settings_playback_subtitle_use_forced">Używaj wymuszonych napisów</string>
<string name="settings_playback_subtitle_use_forced_description">Preferuj wymuszone napisy pasujące do ustawień języka napisów.</string>
<string name="settings_playback_subtitle_vertical_offset">Przesunięcie pionowe</string>
<string name="settings_playback_skip_intro_outro_recap">Pomiń intro/outro/podsumowanie</string>
<string name="settings_playback_skip_intro_outro_recap_description">Pokaż przycisk pomijania podczas wykrytych segmentów intro, outro i podsumowania.</string>
<string name="settings_playback_source_scope">Zakres źródeł</string>
@ -1046,6 +1107,7 @@
<string name="discover_type">Typ</string>
<string name="episode_mark_previous_unwatched">Oznacz poprzednie jako nieobejrzane</string>
<string name="episode_mark_previous_watched">Oznacz poprzednie jako obejrzane</string>
<string name="episode_mark_previous_seasons_watched">Oznacz poprzednie sezony jako obejrzane</string>
<string name="episode_mark_season_unwatched">Oznacz %1$s jako nieobejrzane</string>
<string name="episode_mark_season_watched">Oznacz %1$s jako obejrzane</string>
<string name="episode_mark_unwatched">Oznacz jako nieobejrzane</string>
@ -1145,6 +1207,7 @@
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
<string name="streams_fetching">Pobieranie…</string>
<string name="streams_finding_source">Szukanie źródła…</string>
<string name="streams_loading_subtitles">Pobieranie napisów z dodatków…</string>
<string name="streams_finding_streams">Szukanie strumieni…</string>
<string name="streams_link_copied">Link strumienia skopiowany</string>
<string name="streams_no_direct_link">Brak bezpośredniego linku strumienia</string>
@ -1155,6 +1218,7 @@
<string name="streams_size">ROZMIAR %1$s</string>
<string name="streams_torrent_not_supported">Ten typ strumienia nie jest obsługiwany</string>
<string name="debrid_missing_api_key">Dodaj klucz API Debrid w Ustawieniach.</string>
<string name="debrid_not_cached">Nie zapisano w pamięci podręcznej Torbox.</string>
<string name="debrid_stream_stale">Ten wynik Debrid wygasł. Odświeżanie strumieni.</string>
<string name="debrid_resolve_failed">Nie udało się rozwiązać tego strumienia Debrid.</string>
<string name="external_player_failed">Nie udało się otworzyć zewnętrznego odtwarzacza</string>
@ -1208,6 +1272,8 @@
<string name="library_remove_from_list_message">Usunąć %1$s z %2$s?</string>
<string name="library_remove_message">Usunąć %1$s z biblioteki?</string>
<string name="library_remove_title">Usunąć z biblioteki?</string>
<string name="library_source_cloud">Chmura</string>
<string name="library_source_saved">Zapisane</string>
<string name="media_movie">Film</string>
<string name="notifications_channel_episode_releases_description">Alerty o premierach nowych odcinków zapisanych seriali.</string>
<string name="notifications_test_preview_body">Podgląd alertu o premierze odcinka.</string>
@ -1361,4 +1427,33 @@
</plurals>
<string name="cw_new_episode">Nowy odcinek</string>
<string name="cw_new_season">Nowy sezon</string>
<string name="cloud_library_connect_action">Połącz konto</string>
<string name="cloud_library_connect_message">Połącz konto w ustawieniach Połączonych usług, aby przeglądać odtwarzalne pliki z biblioteki w chmurze.</string>
<string name="cloud_library_connect_title">Brak połączonego konta chmurowego</string>
<string name="cloud_library_disabled_action">Otwórz Połączone usługi</string>
<string name="cloud_library_disabled_message">Włącz Bibliotekę w chmurze w ustawieniach Połączonych usług, aby przeglądać pliki z połączonych kont.</string>
<string name="cloud_library_disabled_title">Biblioteka w chmurze jest wyłączona</string>
<string name="cloud_library_empty_message">Żadne odtwarzalne pliki z chmury nie pasują do bieżących filtrów.</string>
<string name="cloud_library_empty_title">Jeszcze nic tu nie ma</string>
<string name="cloud_library_file_picker_title">Wybierz plik do odtworzenia</string>
<string name="cloud_library_load_failed">Nie udało się załadować biblioteki %1$s</string>
<string name="cloud_library_no_files_message">Ten element nie udostępnia odtwarzalnego pliku wideo.</string>
<string name="cloud_library_no_files_title">Brak odtwarzalnych plików</string>
<string name="cloud_library_no_playable_files">Brak odtwarzalnych plików</string>
<string name="cloud_library_play_disabled">Biblioteka w chmurze jest wyłączona.</string>
<string name="cloud_library_play_failed">Nie udało się odtworzyć tego pliku z chmury.</string>
<string name="cloud_library_play_file">Odtwórz plik</string>
<string name="cloud_library_play_not_connected">Usługa chmurowa nie jest połączona.</string>
<string name="cloud_library_play_provider_not_connected">%1$s nie jest połączony.</string>
<string name="cloud_library_playable_file_count">%1$d odtwarzalnych plików</string>
<string name="cloud_library_provider_all">Wszystkie</string>
<string name="cloud_library_refresh">Odśwież bibliotekę w chmurze</string>
<string name="cloud_library_select_provider">Wybierz dostawcę</string>
<string name="cloud_library_select_type">Wybierz typ</string>
<string name="cloud_library_status_ready">Gotowe do odtwarzania</string>
<string name="cloud_library_type_all">Wszystkie</string>
<string name="cloud_library_type_files">Pliki</string>
<string name="cloud_library_type_torrents">Torrenty</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
</resources>

View file

@ -770,7 +770,13 @@
<string name="settings_playback_external_player_app">External Player App</string>
<string name="settings_playback_external_player_description_android">Open new playback with Android&apos;s default video app or system chooser.</string>
<string name="settings_playback_external_player_description_ios">Open new playback with the selected installed player.</string>
<string name="settings_playback_external_player_forward_subtitles">Forward Subtitles to External Player</string>
<string name="settings_playback_external_player_forward_subtitles_description">Fetch addon subtitles in your preferred language and pass them to the external player.</string>
<string name="settings_playback_external_player_none_available">No supported external players installed</string>
<string name="settings_playback_player_preference">Player</string>
<string name="settings_playback_player_preference_internal">Internal</string>
<string name="settings_playback_player_preference_external">External</string>
<string name="settings_playback_player_preference_description">Choose which player handles new playback.</string>
<string name="settings_playback_hold_speed">Hold Speed</string>
<string name="settings_playback_hold_to_speed">Hold To Speed</string>
<string name="settings_playback_hold_to_speed_description">Long-press anywhere on the player surface to temporarily boost playback speed.</string>
@ -1200,6 +1206,7 @@
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
<string name="streams_fetching">Fetching…</string>
<string name="streams_finding_source">Finding source…</string>
<string name="streams_loading_subtitles">Loading subtitles from addons…</string>
<string name="streams_finding_streams">Finding streams…</string>
<string name="streams_link_copied">Stream link copied</string>
<string name="streams_no_direct_link">No direct stream link available</string>

View file

@ -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<ContinueWatchingItem?>(null) }
var lastExternalPlayerLaunch by remember { mutableStateOf<PlayerLaunch?>(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<StreamRoute> { 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)

View file

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

View file

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

View file

@ -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<String, String> = emptyMap(),
val resumePositionMs: Long = 0L,
val subtitles: List<SubtitleInput>? = 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<ExternalPlayerApp>
@ -26,4 +40,8 @@ internal expect object ExternalPlayerPlatform {
request: ExternalPlayerPlaybackRequest,
playerId: String?,
): ExternalPlayerOpenResult
fun buildIntent(
request: ExternalPlayerPlaybackRequest,
playerId: String?,
): ExternalPlayerIntentResult
}

View file

@ -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<ParentalWarning> = 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,
)
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<SubtitleInput>? {
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
}
}
}

View file

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

View file

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

View file

@ -189,6 +189,7 @@ data class StreamsUiState(
val autoPlayCandidates: List<StreamItem> = emptyList(),
val isDirectAutoPlayFlow: Boolean = false,
val showDirectAutoPlayOverlay: Boolean = false,
val overlayMessage: String? = null,
) {
val filteredGroups: List<AddonStreamGroup>
get() = if (selectedFilter == null) groups

View file

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

View file

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

View file

@ -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<Any?, Any>(),
completionHandler = null,
)
// iOS doesn't return playback results from external players
true
} else {
false
}
}
}
}

View file

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

View file

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