Merge remote-tracking branch 'origin/pr/1326' into cmp-rewrite

# Conflicts:
#	composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt
#	composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt
This commit is contained in:
tapframe 2026-06-12 20:21:52 +05:30
commit e897ec2e9a
19 changed files with 144 additions and 2 deletions

View file

@ -7,11 +7,13 @@ import java.util.Locale
internal fun playbackMediaItemFromUrl(
url: String,
responseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
): MediaItem {
val builder = MediaItem.Builder().setUri(url)
inferPlaybackMimeType(
url = url,
responseHeaders = responseHeaders,
streamType = streamType,
)?.let(builder::setMimeType)
return builder.build()
}
@ -19,10 +21,26 @@ internal fun playbackMediaItemFromUrl(
private fun inferPlaybackMimeType(
url: String,
responseHeaders: Map<String, String>,
streamType: String?,
): String? =
inferMimeTypeFromResponseHeaders(responseHeaders)
inferMimeTypeFromStreamType(streamType)
?: inferMimeTypeFromResponseHeaders(responseHeaders)
?: inferMimeTypeFromPath(url)
private fun inferMimeTypeFromStreamType(streamType: String?): String? {
val normalized = streamType
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotBlank() }
?: return null
return when (normalized) {
"hls", "m3u8" -> MimeTypes.APPLICATION_M3U8
"dash", "mpd" -> MimeTypes.APPLICATION_MPD
"smoothstreaming", "ss" -> MimeTypes.APPLICATION_SS
else -> null
}
}
private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): String? {
if (headers.isEmpty()) return null

View file

@ -56,6 +56,7 @@ import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.CaptionStyleCompat
import com.nuvio.app.R
import com.nuvio.app.features.streams.normalizeStreamType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
@ -75,6 +76,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
@ -101,6 +103,9 @@ actual fun PlatformPlayerSurface(
val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) {
sanitizePlaybackResponseHeaders(sourceResponseHeaders)
}
val normalizedStreamType = remember(streamType) {
normalizeStreamType(streamType)
}
val useLibass = playerSettings.useLibass
val libassRenderType = runCatching {
LibassRenderType.valueOf(playerSettings.libassRenderType)
@ -110,6 +115,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl.orEmpty(),
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
normalizedStreamType.orEmpty(),
useYoutubeChunkedPlayback,
)
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
@ -162,6 +168,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl,
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
normalizedStreamType,
useYoutubeChunkedPlayback,
effectiveDecoderPriority,
) {
@ -226,6 +233,7 @@ actual fun PlatformPlayerSurface(
videoMediaItem = playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
),
startPositionMs = fallbackStartPositionMs,
)

View file

@ -1770,6 +1770,7 @@ private fun MainAppContent(
sourceUrl = sentinelUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1889,6 +1890,7 @@ private fun MainAppContent(
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
streamType = cached.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -2014,6 +2016,7 @@ private fun MainAppContent(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}
val playerLaunch = PlayerLaunch(
@ -2021,6 +2024,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -2138,6 +2142,7 @@ private fun MainAppContent(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}
val playerLaunch = PlayerLaunch(
@ -2145,6 +2150,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -2303,6 +2309,7 @@ private fun MainAppContent(
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
streamType = launch.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders
import com.nuvio.app.features.streams.normalizeStreamType
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@ -288,6 +289,7 @@ internal object MetaDetailsParser {
externalUrl = externalUrl,
addonName = addonName,
addonId = "embedded",
streamType = normalizeStreamType(obj.string("type")),
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,

View file

@ -58,6 +58,7 @@ expect fun PlatformPlayerSurface(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
useYoutubeChunkedPlayback: Boolean = false,
modifier: Modifier = Modifier,
playWhenReady: Boolean = true,

View file

@ -25,6 +25,7 @@ data class PlayerLaunch(
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val sourceResponseHeaders: Map<String, String> = emptyMap(),
val streamType: String? = null,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,

View file

@ -10,6 +10,7 @@ fun PlayerScreen(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
providerName: String,
streamTitle: String,
streamSubtitle: String?,
@ -44,6 +45,7 @@ fun PlayerScreen(
sourceAudioUrl = sourceAudioUrl,
sourceHeaders = sourceHeaders,
sourceResponseHeaders = sourceResponseHeaders,
streamType = streamType,
providerName = providerName,
streamTitle = streamTitle,
streamSubtitle = streamSubtitle,

View file

@ -8,6 +8,7 @@ internal data class PlayerScreenArgs(
val sourceAudioUrl: String?,
val sourceHeaders: Map<String, String>,
val sourceResponseHeaders: Map<String, String>,
val streamType: String?,
val providerName: String,
val streamTitle: String,
val streamSubtitle: String?,

View file

@ -555,6 +555,7 @@ internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message:
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName

View file

@ -112,6 +112,7 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.fileIdx
activeTorrentFilename = stream.behaviorHints.filename
@ -153,6 +154,7 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream(
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.fileIdx
activeTorrentFilename = stream.behaviorHints.filename
@ -186,7 +188,11 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
return
}
val url = stream.playableDirectUrl ?: return
if (url == activeSourceUrl) return
val sourceIdentityKey = stream.playerSourceIdentityKey()
if (url == activeSourceUrl) {
activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey
return
}
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
flushWatchProgress()
stopActiveP2pStream()
@ -198,6 +204,8 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
activeSourceIdentityKey = sourceIdentityKey
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -244,6 +252,7 @@ internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episo
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
applyEpisodeStreamMetadata(stream, episode, resume)
}
@ -271,6 +280,8 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeSourceIdentityKey = null
activeStreamTitle = downloadItem.streamTitle.ifBlank {
episode.title.ifBlank { title }
}
@ -416,5 +427,6 @@ private fun PlayerScreenRuntime.saveDirectStreamForReuse(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}

View file

@ -32,6 +32,7 @@ internal class PlayerScreenRuntime(
val sourceAudioUrl: String? get() = args.sourceAudioUrl
val sourceHeaders: Map<String, String> get() = args.sourceHeaders
val sourceResponseHeaders: Map<String, String> get() = args.sourceResponseHeaders
val streamType: String? get() = args.streamType
val providerName: String get() = args.providerName
val streamTitle: String get() = args.streamTitle
val streamSubtitle: String? get() = args.streamSubtitle
@ -95,6 +96,7 @@ internal class PlayerScreenRuntime(
var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl)
var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
var activeStreamType by mutableStateOf(streamType)
var activeTorrentInfoHash by mutableStateOf(torrentInfoHash)
var activeTorrentFileIdx by mutableStateOf(torrentFileIdx)
var activeTorrentFilename by mutableStateOf(torrentFilename)

View file

@ -120,6 +120,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
streamType = activeStreamType,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,

View file

@ -23,6 +23,7 @@ import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.normalizeStreamType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -455,6 +456,7 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streamType = normalizeStreamType(type),
behaviorHints = if (requestHeaders.isEmpty()) {
com.nuvio.app.features.streams.StreamBehaviorHints()
} else {

View file

@ -18,6 +18,7 @@ data class CachedStreamLink(
val fileIdx: Int? = null,
val sources: List<String> = emptyList(),
val bingeGroup: String? = null,
val streamType: String? = null,
)
internal expect fun epochMs(): Long
@ -54,6 +55,7 @@ object StreamLinkCacheRepository {
fileIdx: Int? = null,
sources: List<String> = emptyList(),
bingeGroup: String? = null,
streamType: String? = null,
) {
if (url.isNotBlank() && url.hasLikelyExpiringPlaybackCredentials()) {
remove(contentKey)
@ -74,6 +76,7 @@ object StreamLinkCacheRepository {
fileIdx = fileIdx,
sources = sources,
bingeGroup = bingeGroup,
streamType = streamType,
)
val payload = json.encodeToString(CachedStreamLink.serializer(), entry)
StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload)

View file

@ -18,6 +18,7 @@ data class StreamItem(
val addonName: String,
val addonId: String,
val addonLogo: String? = null,
val streamType: String? = null,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
val debridCacheStatus: StreamDebridCacheStatus? = null,
@ -89,6 +90,9 @@ data class StreamBadge(
val borderColor: String = "",
)
fun normalizeStreamType(raw: String?): String? =
raw?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
private fun String?.isMagnetLink(): Boolean =
this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true

View file

@ -48,6 +48,7 @@ object StreamParser {
addonName = addonName,
addonId = addonId,
addonLogo = addonLogo,
streamType = normalizeStreamType(obj.string("type")),
clientResolve = clientResolve,
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),

View file

@ -915,6 +915,7 @@ private fun PluginRuntimeResult.toStreamItem(
sourceName = scraper.name,
addonName = addonName,
addonId = addonId,
streamType = normalizeStreamType(type),
behaviorHints = if (requestHeaders.isEmpty()) {
StreamBehaviorHints()
} else {

View file

@ -171,4 +171,78 @@ class StreamParserTest {
assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution)
assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes)
}
@Test
fun `parse keeps addon-declared streamType`() {
val streams = StreamParser.parse(
payload =
"""
{
"streams": [
{
"url": "https://cdn.example.com/playlist?token=abc",
"name": "1080p",
"type": "hls"
}
]
}
""".trimIndent(),
addonName = "Addon",
addonId = "addon.id",
)
assertEquals("hls", streams.single().streamType)
}
@Test
fun `parse normalizes streamType casing and whitespace`() {
val streams = StreamParser.parse(
payload =
"""
{
"streams": [
{
"url": "https://cdn.example.com/playlist?token=abc",
"name": "1080p",
"type": " HLS "
}
]
}
""".trimIndent(),
addonName = "Addon",
addonId = "addon.id",
)
assertEquals("hls", streams.single().streamType)
}
@Test
fun `normalizeStreamType trims lowercases and blanks to null`() {
assertEquals("hls", normalizeStreamType(" Hls "))
assertEquals("dash", normalizeStreamType("DASH"))
assertEquals(null, normalizeStreamType(" "))
assertEquals(null, normalizeStreamType(""))
assertEquals(null, normalizeStreamType(null))
}
@Test
fun `parse leaves streamType null when addon omits it`() {
val streams = StreamParser.parse(
payload =
"""
{
"streams": [
{
"url": "https://example.com/video.mp4",
"name": "1080p"
}
]
}
""".trimIndent(),
addonName = "Addon",
addonId = "addon.id",
)
assertEquals(null, streams.single().streamType)
}
}

View file

@ -29,6 +29,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,