fix: open externalUrl streams externally

This commit is contained in:
tapframe 2026-07-06 02:16:29 +05:30
parent a002dec6ed
commit 0f7e9615e4
11 changed files with 127 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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