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
|
package com.nuvio.app.features.details
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
|
||||||
data class MetaDetails(
|
data class MetaDetails(
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
|
@ -44,6 +46,7 @@ data class MetaVideo(
|
||||||
val season: Int? = null,
|
val season: Int? = null,
|
||||||
val episode: Int? = null,
|
val episode: Int? = null,
|
||||||
val overview: String? = null,
|
val overview: String? = null,
|
||||||
|
val streams: List<StreamItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MetaDetailsUiState(
|
data class MetaDetailsUiState(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.nuvio.app.features.details
|
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.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
@ -8,6 +10,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
internal object MetaDetailsParser {
|
internal object MetaDetailsParser {
|
||||||
|
|
@ -203,8 +206,43 @@ internal object MetaDetailsParser {
|
||||||
season = video.int("season"),
|
season = video.int("season"),
|
||||||
episode = video.int("episode"),
|
episode = video.int("episode"),
|
||||||
overview = video.string("overview") ?: video.string("description"),
|
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
|
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}" }
|
log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" }
|
||||||
if (result.videos.isNotEmpty()) {
|
if (result.videos.isNotEmpty()) {
|
||||||
val first = result.videos.first()
|
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
|
result
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
|
@ -138,4 +138,37 @@ object MetaDetailsRepository {
|
||||||
null
|
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
|
) == action.videoId
|
||||||
}?.overview
|
}?.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 {
|
when {
|
||||||
meta.type == "series" && seriesAction != null ->
|
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
|
||||||
seriesAction.label
|
seriesAction.label
|
||||||
meta.type != "series" && movieProgress != null ->
|
meta.type != "series" && !hasEpisodes && movieProgress != null ->
|
||||||
"Resume"
|
"Resume"
|
||||||
else -> "Play"
|
else -> "Play"
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +201,7 @@ fun MetaDetailsScreen(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
onPlayClick = {
|
onPlayClick = {
|
||||||
when {
|
when {
|
||||||
meta.type == "series" && seriesAction != null -> {
|
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
|
||||||
onPlay?.invoke(
|
onPlay?.invoke(
|
||||||
meta.type,
|
meta.type,
|
||||||
seriesAction.videoId,
|
seriesAction.videoId,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ fun DetailSeriesContent(
|
||||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (meta.type != "series") return
|
val hasVideos = meta.videos.isNotEmpty()
|
||||||
|
if (meta.type != "series" && !hasVideos) return
|
||||||
|
|
||||||
if (meta.videos.isEmpty()) {
|
if (meta.videos.isEmpty()) {
|
||||||
DetailSection(
|
DetailSection(
|
||||||
|
|
@ -85,21 +86,30 @@ fun DetailSeriesContent(
|
||||||
if (meta.videos.isNotEmpty() && withSeasonOrEp.isEmpty()) {
|
if (meta.videos.isNotEmpty() && withSeasonOrEp.isEmpty()) {
|
||||||
log.w { "All videos lack season/episode fields! First: ${meta.videos.first()}" }
|
log.w { "All videos lack season/episode fields! First: ${meta.videos.first()}" }
|
||||||
}
|
}
|
||||||
withSeasonOrEp
|
if (withSeasonOrEp.isNotEmpty()) {
|
||||||
.sortedWith(metaVideoSeasonEpisodeComparator)
|
withSeasonOrEp
|
||||||
.groupBy { normalizeSeasonNumber(it.season) }
|
.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()) {
|
if (groupedEpisodes.isEmpty()) {
|
||||||
DetailSection(
|
if (meta.type == "series") {
|
||||||
title = "Episodes",
|
DetailSection(
|
||||||
modifier = modifier,
|
title = "Episodes",
|
||||||
) {
|
modifier = modifier,
|
||||||
Text(
|
) {
|
||||||
text = "This addon returned videos for the series, but none included season or episode numbers.",
|
Text(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = "This addon returned videos for the series, but none included season or episode numbers.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -172,8 +182,13 @@ fun DetailSeriesContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && currentSeason <= 0) {
|
||||||
|
"Videos"
|
||||||
|
} else {
|
||||||
|
currentSeason.label()
|
||||||
|
}
|
||||||
DetailSectionTitle(
|
DetailSectionTitle(
|
||||||
title = currentSeason.label(),
|
title = sectionTitle,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -496,7 +511,11 @@ private fun Int.label(): String =
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MetaVideo.episodeBadge(): 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 {
|
private fun String.formattedDate(): String {
|
||||||
val isoDate = substringBefore('T')
|
val isoDate = substringBefore('T')
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.player
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
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.AddonStreamGroup
|
||||||
import com.nuvio.app.features.streams.StreamParser
|
import com.nuvio.app.features.streams.StreamParser
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
|
@ -108,6 +109,23 @@ object PlayerStreamsRepository {
|
||||||
jobHolder()?.cancel()
|
jobHolder()?.cancel()
|
||||||
stateFlow.value = StreamsUiState()
|
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
|
val installedAddons = AddonRepository.uiState.value.addons
|
||||||
if (installedAddons.isEmpty()) {
|
if (installedAddons.isEmpty()) {
|
||||||
stateFlow.value = StreamsUiState(
|
stateFlow.value = StreamsUiState(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.streams
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -48,6 +49,23 @@ object StreamsRepository {
|
||||||
activeJob?.cancel()
|
activeJob?.cancel()
|
||||||
_uiState.value = StreamsUiState()
|
_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
|
val installedAddons = AddonRepository.uiState.value.addons
|
||||||
if (installedAddons.isEmpty()) {
|
if (installedAddons.isEmpty()) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue