mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-30 16:19:25 +00:00
Port improved external players support
This commit is contained in:
parent
3b28b2d78a
commit
464cfb3059
23 changed files with 1117 additions and 163 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue