diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index 035aa743..3ff4f415 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -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 = emptyList(), ) data class MetaDetailsUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 0e42ab8c..a05e4b9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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 { + 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index f182f683..f7346f4a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -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 { + 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() + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 2d28ffe4..d9a614b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 5edb88b8..8d5da165 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -57,7 +57,8 @@ fun DetailSeriesContent( progressByVideoId: Map = 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') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 9a5f5575..e519f9ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index c42157a3..14775db8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -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(