feat: add embedded streams support in video details and player repositories

This commit is contained in:
tapframe 2026-03-30 13:36:04 +05:30
parent 375f13f5f6
commit f01427fea3
7 changed files with 150 additions and 20 deletions

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamItem
data class MetaDetails(
val id: String,
val type: String,
@ -44,6 +46,7 @@ data class MetaVideo(
val season: Int? = null,
val episode: Int? = null,
val overview: String? = null,
val streams: List<StreamItem> = emptyList(),
)
data class MetaDetailsUiState(

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@ -8,6 +10,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.jsonPrimitive
internal object MetaDetailsParser {
@ -203,8 +206,43 @@ internal object MetaDetailsParser {
season = video.int("season"),
episode = video.int("episode"),
overview = video.string("overview") ?: video.string("description"),
streams = video.embeddedStreams(),
)
}
private fun JsonObject.embeddedStreams(): List<StreamItem> {
val arr = this["streams"] as? JsonArray ?: return emptyList()
return arr.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val streamData = obj["streamData"] as? JsonObject
val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded"
StreamItem(
name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"),
url = url,
infoHash = infoHash,
fileIdx = obj.int("fileIdx"),
externalUrl = externalUrl,
addonName = addonName,
addonId = "embedded",
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = hintsObj?.boolean("notWebReady") ?: false,
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
),
)
}
}
private fun JsonObject.long(name: String): Long? =
this[name]?.jsonPrimitive?.longOrNull
}
private fun JsonElement?.asJsonObjectOrNull(): JsonObject? = this as? JsonObject

View file

@ -130,7 +130,7 @@ object MetaDetailsRepository {
log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" }
if (result.videos.isNotEmpty()) {
val first = result.videos.first()
log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode}" }
log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode} embeddedStreams=${first.streams.size}" }
}
result
} catch (e: Throwable) {
@ -138,4 +138,37 @@ object MetaDetailsRepository {
null
}
}
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList()
val videosWithStreams = meta.videos.filter { it.streams.isNotEmpty() }
if (videosWithStreams.isEmpty()) return emptyList()
val directMatch = videosWithStreams.firstOrNull { it.id == videoId }
if (directMatch != null) return directMatch.streams
val parts = videoId.split(":")
if (parts.size >= 3) {
val season = parts[parts.size - 2].toIntOrNull()
val episode = parts[parts.size - 1].toIntOrNull()
if (season != null && episode != null) {
val episodeMatch = videosWithStreams.firstOrNull { it.season == season && it.episode == episode }
if (episodeMatch != null) return episodeMatch.streams
}
}
val prefixMatch = videosWithStreams.firstOrNull { it.id.startsWith("$videoId:") }
if (prefixMatch != null) return prefixMatch.streams
if (videoId == meta.id && videosWithStreams.size == 1) {
return videosWithStreams.first().streams
}
if (videoId == meta.id && videosWithStreams.isNotEmpty()) {
return videosWithStreams.flatMap { it.streams }
}
return emptyList()
}
}

View file

@ -147,11 +147,12 @@ fun MetaDetailsScreen(
) == action.videoId
}?.overview
}
val playButtonLabel = remember(movieProgress, seriesAction, meta.type) {
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) {
when {
meta.type == "series" && seriesAction != null ->
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
seriesAction.label
meta.type != "series" && movieProgress != null ->
meta.type != "series" && !hasEpisodes && movieProgress != null ->
"Resume"
else -> "Play"
}
@ -200,7 +201,7 @@ fun MetaDetailsScreen(
isSaved = isSaved,
onPlayClick = {
when {
meta.type == "series" && seriesAction != null -> {
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
onPlay?.invoke(
meta.type,
seriesAction.videoId,

View file

@ -57,7 +57,8 @@ fun DetailSeriesContent(
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
) {
if (meta.type != "series") return
val hasVideos = meta.videos.isNotEmpty()
if (meta.type != "series" && !hasVideos) return
if (meta.videos.isEmpty()) {
DetailSection(
@ -85,21 +86,30 @@ fun DetailSeriesContent(
if (meta.videos.isNotEmpty() && withSeasonOrEp.isEmpty()) {
log.w { "All videos lack season/episode fields! First: ${meta.videos.first()}" }
}
withSeasonOrEp
.sortedWith(metaVideoSeasonEpisodeComparator)
.groupBy { normalizeSeasonNumber(it.season) }
if (withSeasonOrEp.isNotEmpty()) {
withSeasonOrEp
.sortedWith(metaVideoSeasonEpisodeComparator)
.groupBy { normalizeSeasonNumber(it.season) }
} else if (meta.type != "series" && meta.videos.isNotEmpty()) {
// For non-series types (e.g. "other"), show videos without season/episode as a flat list
mapOf(normalizeSeasonNumber(null) to meta.videos)
} else {
emptyMap()
}
}
if (groupedEpisodes.isEmpty()) {
DetailSection(
title = "Episodes",
modifier = modifier,
) {
Text(
text = "This addon returned videos for the series, but none included season or episode numbers.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (meta.type == "series") {
DetailSection(
title = "Episodes",
modifier = modifier,
) {
Text(
text = "This addon returned videos for the series, but none included season or episode numbers.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
@ -172,8 +182,13 @@ fun DetailSeriesContent(
}
}
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && currentSeason <= 0) {
"Videos"
} else {
currentSeason.label()
}
DetailSectionTitle(
title = currentSeason.label(),
title = sectionTitle,
)
Column(
@ -496,7 +511,11 @@ private fun Int.label(): String =
}
private fun MetaVideo.episodeBadge(): String =
episode?.let { "E${it.toString().padStart(2, '0')}" } ?: "EP"
when {
episode != null -> "E${episode.toString().padStart(2, '0')}"
season != null -> "S${season.toString().padStart(2, '0')}"
else -> "FILE"
}
private fun String.formattedDate(): String {
val isoDate = substringBefore('T')

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.StreamsUiState
@ -108,6 +109,23 @@ object PlayerStreamsRepository {
jobHolder()?.cancel()
stateFlow.value = StreamsUiState()
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
if (embeddedStreams.isNotEmpty()) {
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
val group = AddonStreamGroup(
addonName = embeddedStreams.first().addonName,
addonId = "embedded",
streams = embeddedStreams,
isLoading = false,
)
stateFlow.value = StreamsUiState(
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
)
return
}
val installedAddons = AddonRepository.uiState.value.addons
if (installedAddons.isEmpty()) {
stateFlow.value = StreamsUiState(

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -48,6 +49,23 @@ object StreamsRepository {
activeJob?.cancel()
_uiState.value = StreamsUiState()
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
if (embeddedStreams.isNotEmpty()) {
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
val group = AddonStreamGroup(
addonName = embeddedStreams.first().addonName,
addonId = "embedded",
streams = embeddedStreams,
isLoading = false,
)
_uiState.value = StreamsUiState(
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
)
return
}
val installedAddons = AddonRepository.uiState.value.addons
if (installedAddons.isEmpty()) {
_uiState.value = StreamsUiState(