mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 11:23:02 +00:00
feat: add embedded streams support in video details and player repositories
This commit is contained in:
parent
375f13f5f6
commit
f01427fea3
7 changed files with 150 additions and 20 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue