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