diff --git a/composeApp/src/androidFull/AndroidManifest.xml b/composeApp/src/androidFull/AndroidManifest.xml
index 16854174..47497cd7 100644
--- a/composeApp/src/androidFull/AndroidManifest.xml
+++ b/composeApp/src/androidFull/AndroidManifest.xml
@@ -3,16 +3,4 @@
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index dc8f0964..f77af143 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -40,6 +40,16 @@
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index 339340ab..1b2734cd 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -25,6 +25,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
+import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.AvatarStorage
@@ -65,6 +66,7 @@ class MainActivity : AppCompatActivity() {
MetaScreenSettingsStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
+ ExternalPlayerPlatform.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
new file mode 100644
index 00000000..3f101118
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
@@ -0,0 +1,93 @@
+package com.nuvio.app.features.player
+
+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
+
+private const val AndroidSystemPlayerId = "android_system"
+
+internal actual object ExternalPlayerPlatform {
+ private var appContext: Context? = null
+
+ fun initialize(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ actual fun defaultPlayerId(): String? = AndroidSystemPlayerId
+
+ actual fun availablePlayers(): List =
+ listOf(ExternalPlayerApp(AndroidSystemPlayerId, "Android system player"))
+
+ actual fun open(
+ request: ExternalPlayerPlaybackRequest,
+ playerId: String?,
+ ): ExternalPlayerOpenResult {
+ 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)
+ 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 {
+ context.startActivity(intent)
+ ExternalPlayerOpenResult.Opened
+ } catch (_: ActivityNotFoundException) {
+ ExternalPlayerOpenResult.NoPlayerAvailable
+ } catch (_: Throwable) {
+ ExternalPlayerOpenResult.Failed
+ }
+ }
+
+ private fun String.toExternalPlaybackUri(context: Context): Uri? {
+ val trimmed = trim()
+ if (trimmed.isBlank()) return null
+ if (!trimmed.startsWith("file:", ignoreCase = true)) {
+ return Uri.parse(trimmed)
+ }
+
+ val localFile = runCatching { File(URI(trimmed)) }.getOrNull() ?: return Uri.parse(trimmed)
+ return runCatching {
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ localFile,
+ )
+ }.getOrNull()
+ }
+}
+
+private fun Map.toAndroidHeadersBundle(): Bundle =
+ Bundle().apply {
+ forEach { (key, value) ->
+ putString(key, value)
+ }
+ }
+
+private fun String.videoMimeType(): String {
+ val normalized = substringBefore('?').substringBefore('#').lowercase()
+ return when {
+ normalized.endsWith(".m3u8") -> "application/x-mpegURL"
+ normalized.endsWith(".mpd") -> "application/dash+xml"
+ normalized.endsWith(".mkv") -> "video/x-matroska"
+ normalized.endsWith(".webm") -> "video/webm"
+ normalized.endsWith(".avi") -> "video/x-msvideo"
+ normalized.endsWith(".mov") -> "video/quicktime"
+ else -> "video/*"
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index 4a589306..5cb861a8 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -23,6 +23,8 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode"
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 externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
@@ -59,6 +61,8 @@ actual object PlayerSettingsStorage {
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
+ externalPlayerEnabledKey,
+ externalPlayerIdKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey,
@@ -157,6 +161,40 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadExternalPlayerEnabled(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(externalPlayerEnabledKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, false)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveExternalPlayerEnabled(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(externalPlayerEnabledKey), enabled)
+ ?.apply()
+ }
+
+ actual fun loadExternalPlayerId(): String? =
+ preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null)
+
+ actual fun saveExternalPlayerId(playerId: String?) {
+ preferences
+ ?.edit()
+ ?.apply {
+ val key = ProfileScopedKey.of(externalPlayerIdKey)
+ if (playerId.isNullOrBlank()) {
+ remove(key)
+ } else {
+ putString(key, playerId)
+ }
+ }
+ ?.apply()
+ }
+
actual fun loadPreferredAudioLanguage(): String? =
preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null)
@@ -619,6 +657,8 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
+ loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
+ loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
@@ -659,6 +699,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
+ payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
+ payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
diff --git a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
new file mode 100644
index 00000000..9759b279
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 782a24e0..c32e32d0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -689,6 +689,11 @@
%1$d hours
Use libass for ASS/SSA subtitles
Experimental: advanced ASS/SSA rendering (styles, positioning, animations)
+ External Player
+ External Player App
+ Open new playback with Android's default video app or system chooser.
+ Open new playback with the selected installed player.
+ No supported external players installed
Hold Speed
Hold To Speed
Long-press anywhere on the player surface to temporarily boost playback speed.
@@ -1081,6 +1086,8 @@
Checking more addons…
Copy stream link
Download file
+ Open in external player
+ Open in internal player
The installed stream addons failed to return a valid stream response.
Could not load streams
Install an addon first to load streams for this title.
@@ -1101,6 +1108,9 @@
Resume from %1$s
SIZE %1$s
Torrent streams are not supported
+ Couldn't open external player
+ Choose an external player in settings first
+ No external player is available
Close trailer
Unable to play trailer
Failed to load Trakt lists
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 2d1fbaad..160d30b4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -128,6 +128,9 @@ 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.ExternalPlayerPlatform
+import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.AvatarRepository
@@ -288,6 +291,14 @@ private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
NativeNavigationTab.Settings -> AppScreenTab.Settings
}
+private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest =
+ ExternalPlayerPlaybackRequest(
+ sourceUrl = sourceUrl,
+ title = title,
+ streamTitle = streamTitle,
+ sourceHeaders = sourceHeaders,
+ )
+
private enum class AppGateScreen {
Loading,
Auth,
@@ -562,6 +573,9 @@ private fun MainAppContent(
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
+ val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
+ val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
+ val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@@ -752,6 +766,29 @@ private fun MainAppContent(
}
}
+ fun openExternalPlayback(launch: PlayerLaunch): Boolean {
+ return when (
+ ExternalPlayerPlatform.open(
+ request = launch.toExternalPlayerPlaybackRequest(),
+ playerId = playerSettingsUiState.externalPlayerId,
+ )
+ ) {
+ ExternalPlayerOpenResult.Opened -> true
+ ExternalPlayerOpenResult.NotConfigured -> {
+ NuvioToastController.show(externalPlayerNotConfiguredText)
+ false
+ }
+ ExternalPlayerOpenResult.NoPlayerAvailable -> {
+ NuvioToastController.show(externalPlayerUnavailableText)
+ false
+ }
+ ExternalPlayerOpenResult.Failed -> {
+ NuvioToastController.show(externalPlayerFailedText)
+ false
+ }
+ }
+ }
+
fun launchPlaybackWithDownloadPreference(
type: String,
videoId: String,
@@ -783,8 +820,7 @@ private fun MainAppContent(
)
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) {
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = title,
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
@@ -807,8 +843,12 @@ private fun MainAppContent(
parentMetaType = parentMetaType,
initialPositionMs = targetResumePositionMs,
initialProgressFraction = targetResumeProgressFraction,
- ),
- )
+ )
+ if (playerSettingsUiState.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
return
}
@@ -1348,10 +1388,8 @@ private fun MainAppContent(
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
- reuseNavigated = true
StreamsRepository.clear()
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = launch.title,
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
@@ -1376,7 +1414,13 @@ private fun MainAppContent(
initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
)
- )
+ if (playerSettings.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ reuseNavigated = true
+ return@LaunchedEffect
+ }
+ reuseNavigated = true
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo { inclusive = true }
}
@@ -1428,8 +1472,7 @@ private fun MainAppContent(
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = launch.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
@@ -1454,9 +1497,13 @@ private fun MainAppContent(
initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
)
- )
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
+ if (playerSettings.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return@LaunchedEffect
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo { inclusive = true }
}
@@ -1472,6 +1519,74 @@ private fun MainAppContent(
return@composable
}
+ fun openSelectedStream(
+ stream: com.nuvio.app.features.streams.StreamItem,
+ resolvedResumePositionMs: Long?,
+ resolvedResumeProgressFraction: Float?,
+ forceExternal: Boolean,
+ forceInternal: Boolean,
+ ) {
+ val sourceUrl = stream.directPlaybackUrl ?: return
+ if (playerSettings.streamReuseLastLinkEnabled) {
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
+ StreamLinkCacheRepository.save(
+ contentKey = cacheKey,
+ url = sourceUrl,
+ streamName = stream.streamLabel,
+ addonName = stream.addonName,
+ addonId = stream.addonId,
+ requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
+ responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
+ filename = stream.behaviorHints.filename,
+ videoSize = stream.behaviorHints.videoSize,
+ bingeGroup = stream.behaviorHints.bingeGroup,
+ )
+ }
+ val playerLaunch = PlayerLaunch(
+ title = launch.title,
+ sourceUrl = sourceUrl,
+ sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
+ sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
+ logo = launch.logo,
+ poster = launch.poster,
+ background = launch.background,
+ seasonNumber = launch.seasonNumber,
+ episodeNumber = launch.episodeNumber,
+ episodeTitle = launch.episodeTitle,
+ episodeThumbnail = launch.episodeThumbnail,
+ streamTitle = stream.streamLabel,
+ streamSubtitle = stream.streamSubtitle,
+ bingeGroup = stream.behaviorHints.bingeGroup,
+ pauseDescription = pauseDescription,
+ providerName = stream.addonName,
+ providerAddonId = stream.addonId,
+ contentType = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId ?: effectiveVideoId,
+ parentMetaType = launch.parentMetaType ?: launch.type,
+ initialPositionMs = resolvedResumePositionMs ?: 0L,
+ initialProgressFraction = resolvedResumeProgressFraction,
+ )
+
+ if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) {
+ openExternalPlayback(playerLaunch)
+ StreamsRepository.cancelLoading()
+ return
+ }
+
+ val launchId = PlayerLaunchStore.put(playerLaunch)
+ StreamsRepository.cancelLoading()
+ navController.navigate(
+ PlayerRoute(launchId = launchId)
+ )
+ }
+
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
@@ -1490,62 +1605,22 @@ private fun MainAppContent(
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
- val sourceUrl = stream.directPlaybackUrl
- if (sourceUrl != null) {
- // Persist for Reuse Last Link
- if (playerSettings.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(
- type = launch.type,
- videoId = effectiveVideoId,
- parentMetaId = launch.parentMetaId,
- season = launch.seasonNumber,
- episode = launch.episodeNumber,
- )
- StreamLinkCacheRepository.save(
- contentKey = cacheKey,
- url = sourceUrl,
- streamName = stream.streamLabel,
- addonName = stream.addonName,
- addonId = stream.addonId,
- requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
- responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
- filename = stream.behaviorHints.filename,
- videoSize = stream.behaviorHints.videoSize,
- bingeGroup = stream.behaviorHints.bingeGroup,
- )
- }
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
- title = launch.title,
- sourceUrl = sourceUrl,
- sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
- sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
- logo = launch.logo,
- poster = launch.poster,
- background = launch.background,
- seasonNumber = launch.seasonNumber,
- episodeNumber = launch.episodeNumber,
- episodeTitle = launch.episodeTitle,
- episodeThumbnail = launch.episodeThumbnail,
- streamTitle = stream.streamLabel,
- streamSubtitle = stream.streamSubtitle,
- bingeGroup = stream.behaviorHints.bingeGroup,
- pauseDescription = pauseDescription,
- providerName = stream.addonName,
- providerAddonId = stream.addonId,
- contentType = launch.type,
- videoId = effectiveVideoId,
- parentMetaId = launch.parentMetaId ?: effectiveVideoId,
- parentMetaType = launch.parentMetaType ?: launch.type,
- initialPositionMs = resolvedResumePositionMs ?: 0L,
- initialProgressFraction = resolvedResumeProgressFraction,
- )
- )
- StreamsRepository.cancelLoading()
- navController.navigate(
- PlayerRoute(launchId = launchId)
- )
- }
+ openSelectedStream(
+ stream = stream,
+ resolvedResumePositionMs = resolvedResumePositionMs,
+ resolvedResumeProgressFraction = resolvedResumeProgressFraction,
+ forceExternal = false,
+ forceInternal = false,
+ )
+ },
+ onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
+ openSelectedStream(
+ stream = stream,
+ resolvedResumePositionMs = resolvedResumePositionMs,
+ resolvedResumeProgressFraction = resolvedResumeProgressFraction,
+ forceExternal = openExternally,
+ forceInternal = !openExternally,
+ )
},
onBack = {
StreamsRepository.clear()
@@ -1674,8 +1749,7 @@ private fun MainAppContent(
?.let(WatchProgressRepository::progressForVideo)
?.takeIf { it.isResumable }
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = item.title,
sourceUrl = sourceUrl,
sourceHeaders = emptyMap(),
@@ -1697,8 +1771,12 @@ private fun MainAppContent(
parentMetaType = item.parentMetaType,
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
- ),
)
+ if (playerSettingsUiState.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return@DownloadsScreen
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
},
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
new file mode 100644
index 00000000..10a03f51
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
@@ -0,0 +1,29 @@
+package com.nuvio.app.features.player
+
+data class ExternalPlayerApp(
+ val id: String,
+ val name: String,
+)
+
+data class ExternalPlayerPlaybackRequest(
+ val sourceUrl: String,
+ val title: String,
+ val streamTitle: String? = null,
+ val sourceHeaders: Map = emptyMap(),
+)
+
+enum class ExternalPlayerOpenResult {
+ Opened,
+ NotConfigured,
+ NoPlayerAvailable,
+ Failed,
+}
+
+internal expect object ExternalPlayerPlatform {
+ fun defaultPlayerId(): String?
+ fun availablePlayers(): List
+ fun open(
+ request: ExternalPlayerPlaybackRequest,
+ playerId: String?,
+ ): ExternalPlayerOpenResult
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index ec58911e..15f4f4d7 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -13,6 +13,8 @@ data class PlayerSettingsUiState(
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
val holdToSpeedEnabled: Boolean = true,
val holdToSpeedValue: Float = 2f,
+ val externalPlayerEnabled: Boolean = false,
+ val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
@@ -52,6 +54,8 @@ object PlayerSettingsRepository {
private var resizeMode = PlayerResizeMode.Fit
private var holdToSpeedEnabled = true
private var holdToSpeedValue = 2f
+ private var externalPlayerEnabled = false
+ private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
private var preferredAudioLanguage = AudioLanguageOption.DEVICE
private var secondaryPreferredAudioLanguage: String? = null
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
@@ -96,6 +100,8 @@ object PlayerSettingsRepository {
resizeMode = PlayerResizeMode.Fit
holdToSpeedEnabled = true
holdToSpeedValue = 2f
+ externalPlayerEnabled = false
+ externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage = AudioLanguageOption.DEVICE
secondaryPreferredAudioLanguage = null
preferredSubtitleLanguage = SubtitleLanguageOption.NONE
@@ -135,6 +141,9 @@ object PlayerSettingsRepository {
?: PlayerResizeMode.Fit
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
+ externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
+ externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
+ ?: ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage =
normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage())
?: AudioLanguageOption.DEVICE
@@ -231,6 +240,31 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveHoldToSpeedValue(normalized)
}
+ fun setExternalPlayerEnabled(enabled: Boolean) {
+ ensureLoaded()
+ if (enabled && externalPlayerId.isNullOrBlank()) {
+ externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
+ ?: ExternalPlayerPlatform.availablePlayers().firstOrNull()?.id
+ PlayerSettingsStorage.saveExternalPlayerId(externalPlayerId)
+ }
+ if (externalPlayerEnabled == enabled) {
+ publish()
+ return
+ }
+ externalPlayerEnabled = enabled
+ publish()
+ PlayerSettingsStorage.saveExternalPlayerEnabled(enabled)
+ }
+
+ fun setExternalPlayerId(playerId: String?) {
+ ensureLoaded()
+ val normalized = playerId?.takeIf { it.isNotBlank() }
+ if (externalPlayerId == normalized) return
+ externalPlayerId = normalized
+ publish()
+ PlayerSettingsStorage.saveExternalPlayerId(normalized)
+ }
+
fun setPreferredAudioLanguage(language: String) {
ensureLoaded()
val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE
@@ -470,6 +504,8 @@ object PlayerSettingsRepository {
resizeMode = resizeMode,
holdToSpeedEnabled = holdToSpeedEnabled,
holdToSpeedValue = holdToSpeedValue,
+ externalPlayerEnabled = externalPlayerEnabled,
+ externalPlayerId = externalPlayerId,
preferredAudioLanguage = preferredAudioLanguage,
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
preferredSubtitleLanguage = preferredSubtitleLanguage,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
index efc6b6c2..5c3b3756 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
@@ -11,6 +11,10 @@ internal expect object PlayerSettingsStorage {
fun saveHoldToSpeedEnabled(enabled: Boolean)
fun loadHoldToSpeedValue(): Float?
fun saveHoldToSpeedValue(speed: Float)
+ fun loadExternalPlayerEnabled(): Boolean?
+ fun saveExternalPlayerEnabled(enabled: Boolean)
+ fun loadExternalPlayerId(): String?
+ fun saveExternalPlayerId(playerId: String?)
fun loadPreferredAudioLanguage(): String?
fun savePreferredAudioLanguage(language: String)
fun loadSecondaryPreferredAudioLanguage(): String?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
index 18a9c422..042d592d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
@@ -53,6 +53,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions
+import com.nuvio.app.features.player.ExternalPlayerApp
+import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
@@ -169,6 +171,7 @@ private fun PlaybackSettingsSection(
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
+ var showExternalPlayerDialog by remember { mutableStateOf(false) }
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showHoldToSpeedValueDialog by remember { mutableStateOf(false) }
@@ -180,6 +183,10 @@ private fun PlaybackSettingsSection(
var showAutoPlayRegexDialog by remember { mutableStateOf(false) }
val pluginsEnabled = AppFeaturePolicy.pluginsEnabled
val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
+ val availableExternalPlayers = ExternalPlayerPlatform.availablePlayers()
+ val selectedExternalPlayer = availableExternalPlayers.firstOrNull {
+ it.id == autoPlayPlayerSettings.externalPlayerId
+ }
val addonUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val pluginUiState = if (pluginsEnabled) {
val state by PluginRepository.uiState.collectAsStateWithLifecycle()
@@ -206,6 +213,39 @@ 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
+ }
+ },
+ )
+ if (isIos && autoPlayPlayerSettings.externalPlayerEnabled) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_external_player_app),
+ description = selectedExternalPlayer?.name
+ ?: if (availableExternalPlayers.isEmpty()) {
+ stringResource(Res.string.settings_playback_external_player_none_available)
+ } else {
+ stringResource(Res.string.settings_playback_not_set)
+ },
+ isTablet = isTablet,
+ onClick = { showExternalPlayerDialog = true },
+ )
+ }
+ SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_hold_to_speed),
description = stringResource(Res.string.settings_playback_hold_to_speed_description),
@@ -780,6 +820,18 @@ private fun PlaybackSettingsSection(
)
}
+ if (showExternalPlayerDialog) {
+ ExternalPlayerSelectionDialog(
+ players = availableExternalPlayers,
+ selectedPlayerId = autoPlayPlayerSettings.externalPlayerId,
+ onPlayerSelected = { playerId ->
+ PlayerSettingsRepository.setExternalPlayerId(playerId)
+ showExternalPlayerDialog = false
+ },
+ onDismiss = { showExternalPlayerDialog = false },
+ )
+ }
+
if (showDecoderPriorityDialog) {
DecoderPriorityDialog(
selectedPriority = decoderPriority,
@@ -904,6 +956,100 @@ private data class LanguageSelectionOption(
val label: String,
)
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun ExternalPlayerSelectionDialog(
+ players: List,
+ selectedPlayerId: String?,
+ onPlayerSelected: (String) -> 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_external_player_app),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ if (players.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.settings_playback_external_player_none_available),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ players.forEach { player ->
+ val isSelected = player.id == selectedPlayerId
+ val containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onPlayerSelected(player.id) },
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = player.name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSelected) {
+ 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 LanguageSelectionDialog(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 381ba569..978ea2e2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -427,12 +427,21 @@ internal fun settingsSearchEntries(
pageLabel = playbackPage,
section = playbackPlayer,
icon = Icons.Rounded.PlayArrow,
- rows = listOf(
+ rows = listOfNotNull(
PlaybackSearchRow(
"loading-overlay",
stringResource(Res.string.settings_playback_show_loading_overlay),
stringResource(Res.string.settings_playback_show_loading_overlay_description),
),
+ PlaybackSearchRow(
+ "external-player",
+ stringResource(Res.string.settings_playback_external_player),
+ stringResource(Res.string.settings_playback_external_player_description_android),
+ ),
+ if (isIos) PlaybackSearchRow(
+ "external-player-app",
+ stringResource(Res.string.settings_playback_external_player_app),
+ ) else null,
PlaybackSearchRow(
"hold-to-speed",
stringResource(Res.string.settings_playback_hold_to_speed),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
index 22e877bb..68eeca73 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
@@ -38,6 +38,7 @@ import androidx.compose.foundation.rememberScrollState
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.ContentCopy
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Refresh
@@ -84,6 +85,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
+import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch
import kotlin.math.round
@@ -114,10 +116,20 @@ fun StreamsScreen(
manualSelection: Boolean = false,
startFromBeginning: Boolean = false,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> },
+ onStreamActionOpen: (
+ stream: StreamItem,
+ openExternally: Boolean,
+ resumePositionMs: Long?,
+ resumeProgressFraction: Float?,
+ ) -> Unit = { _, _, _, _ -> },
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
+ val playerSettings by remember {
+ PlayerSettingsRepository.ensureLoaded()
+ PlayerSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
@@ -323,6 +335,7 @@ fun StreamsScreen(
StreamActionsSheet(
stream = streamActionsTarget,
+ externalPlayerEnabled = playerSettings.externalPlayerEnabled,
onDismiss = { streamActionsTarget = null },
onCopyLink = { stream ->
val directUrl = stream.directPlaybackUrl
@@ -351,6 +364,14 @@ fun StreamsScreen(
)
NuvioToastController.show(result.toastMessage())
},
+ onOpen = { stream, openExternally ->
+ onStreamActionOpen(
+ stream,
+ openExternally,
+ effectiveResumePositionMs,
+ effectiveResumeProgressFraction,
+ )
+ },
)
}
}
@@ -1008,9 +1029,11 @@ private fun StreamCard(
@Composable
private fun StreamActionsSheet(
stream: StreamItem?,
+ externalPlayerEnabled: Boolean,
onDismiss: () -> Unit,
onCopyLink: (StreamItem) -> Unit,
onDownload: (StreamItem) -> Unit,
+ onOpen: (StreamItem, openExternally: Boolean) -> Unit,
) {
if (stream == null) return
@@ -1069,6 +1092,23 @@ private fun StreamActionsSheet(
},
)
NuvioBottomSheetDivider()
+ NuvioBottomSheetActionRow(
+ icon = Icons.AutoMirrored.Rounded.OpenInNew,
+ title = stringResource(
+ if (externalPlayerEnabled) {
+ Res.string.streams_open_internal_player
+ } else {
+ Res.string.streams_open_external_player
+ },
+ ),
+ onClick = {
+ onOpen(stream, !externalPlayerEnabled)
+ coroutineScope.launch {
+ dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
+ }
+ },
+ )
+ NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Rounded.Download,
title = stringResource(Res.string.streams_download_file),
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt
new file mode 100644
index 00000000..738848c6
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt
@@ -0,0 +1,112 @@
+package com.nuvio.app.features.player
+
+import platform.Foundation.NSURL
+import platform.UIKit.UIApplication
+
+private data class IosExternalPlayerSpec(
+ val id: String,
+ val name: String,
+ val scheme: String,
+ val buildUrl: (ExternalPlayerPlaybackRequest) -> String,
+)
+
+private val iosExternalPlayerSpecs = listOf(
+ IosExternalPlayerSpec(
+ id = "infuse",
+ name = "Infuse",
+ scheme = "infuse",
+ buildUrl = { request ->
+ buildString {
+ append("infuse://x-callback-url/play?url=")
+ append(request.sourceUrl.urlQueryEncode())
+ append("&filename=")
+ append((request.streamTitle ?: request.title).urlQueryEncode())
+ }
+ },
+ ),
+ IosExternalPlayerSpec(
+ id = "vlc",
+ name = "VLC",
+ scheme = "vlc-x-callback",
+ buildUrl = { request ->
+ "vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}"
+ },
+ ),
+ IosExternalPlayerSpec(
+ id = "outplayer",
+ name = "Outplayer",
+ scheme = "outplayer",
+ buildUrl = { request ->
+ buildString {
+ append("outplayer://x-callback-url/play?url=")
+ append(request.sourceUrl.urlQueryEncode())
+ append("&filename=")
+ append((request.streamTitle ?: request.title).urlQueryEncode())
+ }
+ },
+ ),
+ IosExternalPlayerSpec(
+ id = "vidhub",
+ name = "VidHub",
+ scheme = "open-vidhub",
+ buildUrl = { request ->
+ "open-vidhub://x-callback-url/open?url=${request.sourceUrl.urlQueryEncode()}"
+ },
+ ),
+)
+
+internal actual object ExternalPlayerPlatform {
+ actual fun defaultPlayerId(): String? = null
+
+ actual fun availablePlayers(): List =
+ iosExternalPlayerSpecs
+ .filter { spec -> UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl()) }
+ .map { spec -> ExternalPlayerApp(spec.id, spec.name) }
+
+ actual fun open(
+ request: ExternalPlayerPlaybackRequest,
+ playerId: String?,
+ ): ExternalPlayerOpenResult {
+ if (playerId.isNullOrBlank()) return ExternalPlayerOpenResult.NotConfigured
+ val spec = iosExternalPlayerSpecs.firstOrNull { it.id == playerId }
+ ?: return ExternalPlayerOpenResult.NotConfigured
+ if (!UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl())) {
+ return ExternalPlayerOpenResult.NoPlayerAvailable
+ }
+ val url = NSURL.URLWithString(spec.buildUrl(request))
+ ?: return ExternalPlayerOpenResult.Failed
+ UIApplication.sharedApplication.openURL(
+ url = url,
+ options = emptyMap(),
+ completionHandler = null,
+ )
+ return ExternalPlayerOpenResult.Opened
+ }
+}
+
+private fun IosExternalPlayerSpec.schemeProbeUrl(): NSURL =
+ NSURL.URLWithString("$scheme://") ?: NSURL.URLWithString("nuvio://")!!
+
+private fun String.urlQueryEncode(): String {
+ val hex = "0123456789ABCDEF"
+ return buildString {
+ encodeToByteArray().forEach { byte ->
+ val value = byte.toInt() and 0xFF
+ val char = value.toChar()
+ val safe = char in 'A'..'Z' ||
+ char in 'a'..'z' ||
+ char in '0'..'9' ||
+ char == '-' ||
+ char == '_' ||
+ char == '.' ||
+ char == '~'
+ if (safe) {
+ append(char)
+ } else {
+ append('%')
+ append(hex[value ushr 4])
+ append(hex[value and 0x0F])
+ }
+ }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
index 3f63f5db..0aedbb30 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
@@ -21,6 +21,8 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode"
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 externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
@@ -57,6 +59,8 @@ actual object PlayerSettingsStorage {
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
+ externalPlayerEnabledKey,
+ externalPlayerIdKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey,
@@ -140,6 +144,36 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey))
}
+ actual fun loadExternalPlayerEnabled(): Boolean? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerEnabledKey)
+ return if (defaults.objectForKey(key) != null) {
+ defaults.boolForKey(key)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveExternalPlayerEnabled(enabled: Boolean) {
+ NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerEnabledKey))
+ }
+
+ actual fun loadExternalPlayerId(): String? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerIdKey)
+ return defaults.stringForKey(key)
+ }
+
+ actual fun saveExternalPlayerId(playerId: String?) {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerIdKey)
+ if (playerId.isNullOrBlank()) {
+ defaults.removeObjectForKey(key)
+ } else {
+ defaults.setObject(playerId, forKey = key)
+ }
+ }
+
actual fun loadPreferredAudioLanguage(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(preferredAudioLanguageKey)
@@ -523,6 +557,8 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
+ loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
+ loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
@@ -563,6 +599,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
+ payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
+ payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
index 7ecac2c5..440a17c4 100644
--- a/iosApp/iosApp/Info.plist
+++ b/iosApp/iosApp/Info.plist
@@ -22,6 +22,13 @@
NSAllowsArbitraryLoads
+ LSApplicationQueriesSchemes
+
+ infuse
+ vlc-x-callback
+ outplayer
+ open-vidhub
+
NSSupportsLiveActivities