mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-04 10:36:56 +00:00
fix: open externalUrl streams externally
This commit is contained in:
parent
a002dec6ed
commit
0f7e9615e4
11 changed files with 127 additions and 12 deletions
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue