From 0f7e9615e45d35e656e7083be5f176d7c3dcea9c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:16:29 +0530 Subject: [PATCH] fix: open externalUrl streams externally --- .../commonMain/kotlin/com/nuvio/app/App.kt | 23 ++++++++++ .../features/player/PlayerEpisodesPanel.kt | 2 +- .../nuvio/app/features/player/PlayerScreen.kt | 2 + .../app/features/player/PlayerScreenArgs.kt | 1 + .../PlayerScreenRuntimeSourceActions.kt | 13 ++++++ .../app/features/player/PlayerSourcesPanel.kt | 2 +- .../app/features/streams/StreamModels.kt | 21 +++++++--- .../app/features/streams/StreamParser.kt | 2 +- .../app/features/streams/StreamsScreen.kt | 8 +++- .../streams/StreamAutoPlaySelectorTest.kt | 23 ++++++++++ .../app/features/streams/StreamModelsTest.kt | 42 ++++++++++++++++++- 11 files changed, 127 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4ff6f2f68..d26d922d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -659,6 +660,7 @@ private fun MainAppContent( } val hapticFeedback = LocalHapticFeedback.current val focusManager = LocalFocusManager.current + val uriHandler = LocalUriHandler.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var searchFocusRequestCount by remember { mutableStateOf(0) } @@ -728,6 +730,7 @@ private fun MainAppContent( 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 failedOpenBrowserText = stringResource(Res.string.settings_trakt_failed_open_browser) val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) @@ -1120,6 +1123,16 @@ private fun MainAppContent( } } + fun openExternalStreamUrl(url: String): Boolean { + val opened = runCatching { + uriHandler.openUri(url) + }.isSuccess + if (!opened) { + NuvioToastController.show(failedOpenBrowserText) + } + return opened + } + suspend fun launchCloudLibraryFile( item: CloudLibraryItem, file: CloudLibraryFile, @@ -2269,6 +2282,13 @@ private fun MainAppContent( ) return } + if (stream.shouldOpenExternally) { + val opened = stream.externalOpenUrl?.let(::openExternalStreamUrl) == true + if (opened) { + StreamsRepository.cancelLoading() + } + return + } val sourceUrl = stream.playableDirectUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -2537,6 +2557,9 @@ private fun MainAppContent( } } }, + onOpenExternalUrl = { url -> + openExternalStreamUrl(url) + }, modifier = Modifier.fillMaxSize(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 7a0af09c1..ee13781b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -608,7 +608,7 @@ private fun EpisodeStreamsSubView( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> StreamCard( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 5abffd254..c2232c34a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -20,6 +20,7 @@ fun PlayerScreen( pauseDescription: String? = null, onBack: () -> Unit, onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)? = null, + onOpenExternalUrl: ((String) -> Unit)? = null, modifier: Modifier = Modifier, logo: String? = null, poster: String? = null, @@ -58,6 +59,7 @@ fun PlayerScreen( pauseDescription = pauseDescription, onBack = onBack, onOpenInExternalPlayer = onOpenInExternalPlayer, + onOpenExternalUrl = onOpenExternalUrl, modifier = modifier, logo = logo, poster = poster, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt index 4ff977344..bc381ecc6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -17,6 +17,7 @@ internal data class PlayerScreenArgs( val pauseDescription: String?, val onBack: () -> Unit, val onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)?, + val onOpenExternalUrl: ((String) -> Unit)?, val modifier: Modifier, val logo: String?, val poster: String?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 43095cdc8..e9a877023 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -49,6 +49,17 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?) internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = stream.needsLocalDebridResolve && stream.p2pInfoHash != null +internal fun PlayerScreenRuntime.openExternalSourceUrl(stream: StreamItem): Boolean { + if (!stream.shouldOpenExternally) return false + val url = stream.externalOpenUrl ?: return false + val openExternalUrl = args.onOpenExternalUrl ?: return false + openExternalUrl(url) + showSourcesPanel = false + showEpisodesPanel = false + controlsVisible = true + return true +} + internal fun StreamItem.playerSourceIdentityKey(): String? { p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> return "torrent:$hash:${p2pFileIdx ?: -1}" @@ -240,6 +251,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { switchToP2pSourceStream(stream) return } + if (openExternalSourceUrl(stream)) return val url = stream.playableDirectUrl ?: return val sourceIdentityKey = stream.playerSourceIdentityKey() if (url == activeSourceUrl) { @@ -292,6 +304,7 @@ internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episo switchToP2pEpisodeStream(stream, episode) return } + if (openExternalSourceUrl(stream)) return val url = stream.playableDirectUrl ?: return resetEpisodePanelAndNextEpisodeState() flushWatchProgress() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index b370fb3f0..154482412 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -208,7 +208,7 @@ fun PlayerSourcesPanel( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> val isCurrent = isCurrentStream( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index a16a172b2..87349ffa3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -41,16 +41,26 @@ data class StreamItem( get() = description val directPlaybackUrl: String? - get() = url ?: externalUrl + get() = url?.trim()?.takeIf { it.isNotEmpty() } /** * First URL that can be handed directly to a player or HTTP consumer. - * `magnet:` and `torrent://` URLs are filtered out, falling back to - * [externalUrl] when [url] carries one of those schemes. + * `magnet:` and `torrent://` URLs are filtered out. `externalUrl` is not + * a media URL in the Stremio SDK contract and must be opened externally. */ val playableDirectUrl: String? - get() = listOfNotNull(url, externalUrl) - .firstOrNull { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } + get() = directPlaybackUrl?.takeIf { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } + + val externalOpenUrl: String? + get() = externalUrl + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.isMagnetLink() && !it.isTorrentSchemeUrl() } + + val shouldOpenExternally: Boolean + get() = url.isNullOrBlank() && + infoHash.isNullOrBlank() && + clientResolve == null && + externalOpenUrl != null val torrentMagnetUri: String? get() = listOfNotNull(url, externalUrl) @@ -166,6 +176,7 @@ private fun String?.extractBtihInfoHash(): String? { fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = playableDirectUrl != null || + shouldOpenExternally || (AppFeaturePolicy.p2pEnabled && needsLocalDebridResolve && p2pInfoHash != null) || (debridEnabled && isAddonDebridCandidate) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index ab1df9593..a9566bedb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -29,7 +29,7 @@ object StreamParser { val externalUrl = obj.string("externalUrl") val clientResolve = obj.objectValue("clientResolve")?.toClientResolve() - // Must have at least one playable source + // Must have at least one source or external target. if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null val hintsObj = obj["behaviorHints"] as? JsonObject 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 bdf8f0ece..454a8dbd9 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 @@ -356,7 +356,7 @@ fun StreamsScreen( externalPlayerEnabled = playerSettings.externalPlayerEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> - val directUrl = stream.playableDirectUrl + val directUrl = stream.playableDirectUrl ?: stream.externalOpenUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) NuvioToastController.show(streamLinkCopiedText) @@ -987,7 +987,7 @@ private fun LazyListScope.streamSection( } }, onLongClick = { - if (stream.playableDirectUrl != null || stream.isAddonDebridCandidate) { + if (stream.playableDirectUrl != null || stream.shouldOpenExternally || stream.isAddonDebridCandidate) { onStreamLongPress(stream) } }, @@ -1015,6 +1015,10 @@ internal fun streamCardRenderKey( append(itemIndex) append(':') append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel) + stream.externalUrl?.let { + append(':') + append(it) + } } // --------------------------------------------------------------------------- diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 000d00da2..c057e4142 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -169,6 +169,27 @@ class StreamAutoPlaySelectorTest { assertEquals(directDebrid, selected) } + @Test + fun `first stream mode does not auto select external url browser link`() { + val external = stream( + addonName = "External Addon", + externalUrl = "https://example.com/watch", + name = "Watch on site", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(external), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("External Addon"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + ) + + assertNull(selected) + } + @Test fun `timeout evaluation keeps pending regex debrid candidate open`() { val pending = stream( @@ -252,6 +273,7 @@ class StreamAutoPlaySelectorTest { private fun stream( addonName: String, url: String? = null, + externalUrl: String? = null, name: String? = null, bingeGroup: String? = null, directDebrid: Boolean = false, @@ -261,6 +283,7 @@ class StreamAutoPlaySelectorTest { ): StreamItem = StreamItem( name = name, url = url, + externalUrl = externalUrl, infoHash = infoHash, addonName = addonName, addonId = "addon:$addonName", diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt index d0241a33a..b36301092 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt @@ -101,13 +101,27 @@ class StreamModelsTest { } @Test - fun `torrent url falls through to http externalUrl`() { + fun `plain http externalUrl is surfaced only for external opening`() { + val url = "https://example.com/watch" + val stream = stream(externalUrl = url) + + assertNull(stream.playableDirectUrl) + assertEquals(url, stream.externalOpenUrl) + assertTrue(stream.shouldOpenExternally) + assertTrue(stream.isSelectableForPlayback(debridEnabled = false)) + } + + @Test + fun `torrent url does not fall through to http externalUrl as playable`() { val httpUrl = "https://cdn.example.com/video.mp4" val stream = stream( url = "torrent://$hexHash", externalUrl = httpUrl, ) - assertEquals(httpUrl, stream.playableDirectUrl) + + assertNull(stream.playableDirectUrl) + assertEquals(httpUrl, stream.externalOpenUrl) + assertFalse(stream.shouldOpenExternally) } // ----------------------------------------------------------------------- @@ -269,6 +283,30 @@ class StreamModelsTest { assertNull(stream.p2pInfoHash) } + @Test + fun `parser keeps externalUrl as external target instead of playable URL`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "externalUrl": "https://megogo.net/ua/search-extended?query=Barbie", + "name": "Watch on Megogo" + } + ] + } + """.trimIndent(), + addonName = "Ukrainian Streams", + addonId = "addon.ukrainian.streams", + ) + + val stream = streams.single() + assertNull(stream.playableDirectUrl) + assertEquals("https://megogo.net/ua/search-extended?query=Barbie", stream.externalOpenUrl) + assertTrue(stream.shouldOpenExternally) + assertTrue(stream.isSelectableForPlayback(debridEnabled = false)) + } + // ----------------------------------------------------------------------- // Helper // -----------------------------------------------------------------------