feat: Add trailer functionality to TMDB settings and playback

- Introduced a new toggle for trailers in TMDB settings.
- Updated TMDB metadata service to fetch and handle trailers.
- Enhanced TMDB settings repository and storage to persist trailer preferences.
- Implemented trailer playback resolver for iOS, integrating YouTube extractor.
- Modified player bridge to support loading video with audio.
- Added necessary data classes and methods for trailer management and playback.
This commit is contained in:
tapframe 2026-04-01 00:36:38 +05:30
parent 7f8f9c11a1
commit b2356ec6d3
29 changed files with 2501 additions and 13 deletions

@ -1 +0,0 @@
Subproject commit 3d432407da3665aa0636f131f6801c5bc35c50e3

View file

@ -106,6 +106,8 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")
implementation(libs.ktor.client.android)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)

View file

@ -32,6 +32,7 @@ import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory
@ -40,6 +41,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.CaptionStyleCompat
import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -55,6 +57,7 @@ private const val TAG = "NuvioPlayer"
@Composable
actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
modifier: Modifier,
playWhenReady: Boolean,
resizeMode: PlayerResizeMode,
@ -73,7 +76,7 @@ actual fun PlatformPlayerSurface(
PlayerSettingsRepository.uiState.value
}
val exoPlayer = remember(sourceUrl) {
val exoPlayer = remember(sourceUrl, sourceAudioUrl) {
val renderersFactory = DefaultRenderersFactory(context)
.setExtensionRendererMode(playerSettings.decoderPriority)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
@ -102,13 +105,24 @@ actual fun PlatformPlayerSurface(
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val mediaSourceFactory = DefaultMediaSourceFactory(
YoutubeChunkedDataSourceFactory(),
extractorsFactory,
)
ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory))
.setMediaSourceFactory(mediaSourceFactory)
.build().apply {
setMediaItem(MediaItem.fromUri(sourceUrl))
if (!sourceAudioUrl.isNullOrBlank()) {
val videoSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceUrl))
val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
setMediaSource(MergingMediaSource(videoSource, audioSource))
} else {
setMediaItem(MediaItem.fromUri(sourceUrl))
}
prepare()
this.playWhenReady = playWhenReady
}

View file

@ -8,6 +8,7 @@ actual object TmdbSettingsStorage {
private const val preferencesName = "nuvio_tmdb_settings"
private const val enabledKey = "tmdb_enabled"
private const val languageKey = "tmdb_language"
private const val useTrailersKey = "tmdb_use_trailers"
private const val useArtworkKey = "tmdb_use_artwork"
private const val useBasicInfoKey = "tmdb_use_basic_info"
private const val useDetailsKey = "tmdb_use_details"
@ -41,6 +42,12 @@ actual object TmdbSettingsStorage {
?.apply()
}
actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey)
actual fun saveUseTrailers(enabled: Boolean) {
saveBoolean(useTrailersKey, enabled)
}
actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey)
actual fun saveUseArtwork(enabled: Boolean) {

View file

@ -0,0 +1,740 @@
package com.nuvio.app.features.trailer
import android.net.Uri
import android.util.Log
import com.google.gson.Gson
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withTimeout
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URL
import java.util.concurrent.TimeUnit
private const val TAG = "InAppYouTubeExtractor"
private const val EXTRACTOR_TIMEOUT_MS = 30_000L
private const val VERBOSE_LOGS = false
private const val DEFAULT_USER_AGENT =
"Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
private const val PREFERRED_SEPARATE_CLIENT = "android_vr"
private val VIDEO_ID_REGEX = Regex("^[a-zA-Z0-9_-]{11}$")
private val API_KEY_REGEX = Regex("\"INNERTUBE_API_KEY\":\"([^\"]+)\"")
private val VISITOR_DATA_REGEX = Regex("\"VISITOR_DATA\":\"([^\"]+)\"")
private val QUALITY_LABEL_REGEX = Regex("(\\d{2,4})p")
private data class YouTubeClient(
val key: String,
val id: String,
val version: String,
val userAgent: String,
val context: Map<String, Any>,
val priority: Int
)
private data class WatchConfig(
val apiKey: String?,
val visitorData: String?
)
private data class StreamCandidate(
val client: String,
val priority: Int,
val url: String,
val score: Double,
val hasN: Boolean,
val itag: String,
val height: Int,
val fps: Int,
val ext: String
)
private data class ManifestBestVariant(
val url: String,
val width: Int,
val height: Int,
val bandwidth: Long
)
private data class ManifestCandidate(
val client: String,
val priority: Int,
val manifestUrl: String,
val selectedVariantUrl: String,
val height: Int,
val bandwidth: Long
)
private val DEFAULT_HEADERS = mapOf(
"accept-language" to "en-US,en;q=0.9",
"user-agent" to DEFAULT_USER_AGENT
)
private val CLIENTS = listOf(
YouTubeClient(
key = "android_vr",
id = "28",
version = "1.56.21",
userAgent = "com.google.android.apps.youtube.vr.oculus/1.56.21 " +
"(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip",
context = mapOf(
"clientName" to "ANDROID_VR",
"clientVersion" to "1.56.21",
"deviceMake" to "Oculus",
"deviceModel" to "Quest 3",
"osName" to "Android",
"osVersion" to "12",
"platform" to "MOBILE",
"androidSdkVersion" to 32,
"hl" to "en",
"gl" to "US"
),
priority = 0
),
YouTubeClient(
key = "android",
id = "3",
version = "20.10.35",
userAgent = "com.google.android.youtube/20.10.35 (Linux; U; Android 14; en_US) gzip",
context = mapOf(
"clientName" to "ANDROID",
"clientVersion" to "20.10.35",
"osName" to "Android",
"osVersion" to "14",
"platform" to "MOBILE",
"androidSdkVersion" to 34,
"hl" to "en",
"gl" to "US"
),
priority = 1
),
YouTubeClient(
key = "ios",
id = "5",
version = "20.10.1",
userAgent = "com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)",
context = mapOf(
"clientName" to "IOS",
"clientVersion" to "20.10.1",
"deviceModel" to "iPhone16,2",
"osName" to "iPhone",
"osVersion" to "17.4.0.21E219",
"platform" to "MOBILE",
"hl" to "en",
"gl" to "US"
),
priority = 2
)
)
class InAppYouTubeExtractor {
private val gson = Gson()
private val httpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
suspend fun extractPlaybackSource(youtubeUrl: String): TrailerPlaybackSource? = withContext(Dispatchers.IO) {
if (youtubeUrl.isBlank()) return@withContext null
Log.d(TAG, "Starting Kotlin extraction for ${summarizeUrl(youtubeUrl)}")
val source = try {
withTimeout(EXTRACTOR_TIMEOUT_MS) {
extractPlaybackSourceInternal(youtubeUrl)
}
} catch (error: Exception) {
Log.w(TAG, "Kotlin extractor failed for $youtubeUrl: ${error.message}")
null
}
if (source == null) {
Log.w(TAG, "Kotlin extraction returned no playable source for ${summarizeUrl(youtubeUrl)}")
} else {
Log.d(
TAG,
"Kotlin extraction success for ${summarizeUrl(youtubeUrl)} " +
"(video=${summarizeUrl(source.videoUrl)}, audioPresent=${!source.audioUrl.isNullOrBlank()})"
)
}
source
}
private suspend fun extractPlaybackSourceInternal(youtubeUrl: String): TrailerPlaybackSource? {
val videoId = extractVideoId(youtubeUrl) ?: return null
val watchUrl = "https://www.youtube.com/watch?v=$videoId&hl=en"
val watchResponse = performRequest(
url = watchUrl,
method = "GET",
headers = DEFAULT_HEADERS
)
if (!watchResponse.ok) {
throw IllegalStateException("Failed to fetch watch page (${watchResponse.status})")
}
val watchConfig = getWatchConfig(watchResponse.body)
val apiKey = watchConfig.apiKey
?: throw IllegalStateException("Unable to extract INNERTUBE_API_KEY")
val progressive = mutableListOf<StreamCandidate>()
val adaptiveVideo = mutableListOf<StreamCandidate>()
val adaptiveAudio = mutableListOf<StreamCandidate>()
val manifestUrls = mutableListOf<Triple<String, Int, String>>()
for (client in CLIENTS) {
try {
val playerResponse = fetchPlayerResponse(
apiKey = apiKey,
videoId = videoId,
client = client,
visitorData = watchConfig.visitorData,
cookieHeader = null
)
val streamingData = playerResponse.mapValue("streamingData") ?: continue
val hlsManifestUrl = streamingData.stringValue("hlsManifestUrl")
if (!hlsManifestUrl.isNullOrBlank()) {
manifestUrls += Triple(client.key, client.priority, hlsManifestUrl)
}
for (format in streamingData.listMapValue("formats")) {
val url = format.stringValue("url") ?: continue
val mimeType = format.stringValue("mimeType").orEmpty()
if (!mimeType.contains("video/") && mimeType.isNotBlank()) continue
val height = (format.numberValue("height")
?: parseQualityLabel(format.stringValue("qualityLabel"))?.toDouble()
?: 0.0).toInt()
val fps = (format.numberValue("fps") ?: 0.0).toInt()
val bitrate = format.numberValue("bitrate")
?: format.numberValue("averageBitrate")
?: 0.0
progressive += StreamCandidate(
client = client.key,
priority = client.priority,
url = url,
score = videoScore(height, fps, bitrate),
hasN = hasNParam(url),
itag = format.stringValue("itag").orEmpty(),
height = height,
fps = fps,
ext = if (mimeType.contains("webm")) "webm" else "mp4"
)
}
for (format in streamingData.listMapValue("adaptiveFormats")) {
val url = format.stringValue("url") ?: continue
val mimeType = format.stringValue("mimeType").orEmpty()
val hasVideo = mimeType.contains("video/")
val hasAudio = mimeType.contains("audio/") || mimeType.startsWith("audio/")
if (hasVideo) {
val height = (format.numberValue("height")
?: parseQualityLabel(format.stringValue("qualityLabel"))?.toDouble()
?: 0.0).toInt()
val fps = (format.numberValue("fps") ?: 0.0).toInt()
val bitrate = format.numberValue("bitrate")
?: format.numberValue("averageBitrate")
?: 0.0
adaptiveVideo += StreamCandidate(
client = client.key,
priority = client.priority,
url = url,
score = videoScore(height, fps, bitrate),
hasN = hasNParam(url),
itag = format.stringValue("itag").orEmpty(),
height = height,
fps = fps,
ext = if (mimeType.contains("webm")) "webm" else "mp4"
)
} else if (hasAudio) {
val bitrate = format.numberValue("bitrate")
?: format.numberValue("averageBitrate")
?: 0.0
val asr = format.numberValue("audioSampleRate") ?: 0.0
adaptiveAudio += StreamCandidate(
client = client.key,
priority = client.priority,
url = url,
score = audioScore(bitrate, asr),
hasN = hasNParam(url),
itag = format.stringValue("itag").orEmpty(),
height = 0,
fps = 0,
ext = if (mimeType.contains("webm")) "webm" else "m4a"
)
}
}
} catch (error: Exception) {
if (VERBOSE_LOGS) {
Log.w(TAG, "Client ${client.key} failed: ${error.message}")
}
}
}
if (manifestUrls.isEmpty() && progressive.isEmpty() && adaptiveVideo.isEmpty() && adaptiveAudio.isEmpty()) {
return null
}
var bestManifest: ManifestCandidate? = null
for ((clientKey, priority, manifestUrl) in manifestUrls) {
try {
val variant = parseHlsManifest(manifestUrl) ?: continue
val candidate = ManifestCandidate(
client = clientKey,
priority = priority,
manifestUrl = manifestUrl,
selectedVariantUrl = variant.url,
height = variant.height,
bandwidth = variant.bandwidth
)
if (
bestManifest == null ||
candidate.height > bestManifest.height ||
(candidate.height == bestManifest.height && candidate.bandwidth > bestManifest.bandwidth)
) {
bestManifest = candidate
}
} catch (error: Exception) {
if (VERBOSE_LOGS) {
Log.w(TAG, "Manifest parse failed: ${error.message}")
}
}
}
val bestProgressive = sortCandidates(progressive).firstOrNull()
val bestVideo = pickBestForClient(adaptiveVideo, PREFERRED_SEPARATE_CLIENT)
val bestAudio = pickBestForClient(adaptiveAudio, PREFERRED_SEPARATE_CLIENT)
val bestCombinedIsManifest = bestManifest != null &&
(bestProgressive == null || bestManifest.height > bestProgressive.height)
val combinedUrl = if (bestCombinedIsManifest) {
bestManifest.manifestUrl
} else {
bestProgressive?.url
}
val videoUrl = resolveReachableUrl(bestVideo?.url ?: combinedUrl ?: return null)
val audioUrl = bestAudio?.url?.let { resolveReachableUrl(it) }
if (VERBOSE_LOGS) {
Log.d(
TAG,
"Kotlin selection video=${summarizeUrl(videoUrl)} " +
"audioPresent=${!audioUrl.isNullOrBlank()} " +
"progressiveCount=${progressive.size} " +
"adaptiveVideoCount=${adaptiveVideo.size} adaptiveAudioCount=${adaptiveAudio.size}"
)
}
return TrailerPlaybackSource(
videoUrl = videoUrl,
audioUrl = audioUrl
)
}
private fun extractVideoId(input: String): String? {
val trimmed = input.trim()
if (VIDEO_ID_REGEX.matches(trimmed)) return trimmed
val normalized = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://$trimmed"
}
return runCatching {
val uri = Uri.parse(normalized)
val host = uri.host?.lowercase().orEmpty()
if (host.endsWith("youtu.be")) {
val id = uri.pathSegments.firstOrNull()
if (!id.isNullOrBlank() && VIDEO_ID_REGEX.matches(id)) {
return id
}
}
val queryId = uri.getQueryParameter("v")
if (!queryId.isNullOrBlank() && VIDEO_ID_REGEX.matches(queryId)) {
return queryId
}
val segments = uri.pathSegments
if (segments.size >= 2) {
val first = segments[0]
val second = segments[1]
if ((first == "embed" || first == "shorts" || first == "live") && VIDEO_ID_REGEX.matches(second)) {
return second
}
}
null
}.getOrNull()
}
private fun getWatchConfig(html: String): WatchConfig {
val apiKey = API_KEY_REGEX.find(html)?.groupValues?.getOrNull(1)
val visitorData = VISITOR_DATA_REGEX.find(html)?.groupValues?.getOrNull(1)
return WatchConfig(apiKey = apiKey, visitorData = visitorData)
}
private fun fetchPlayerResponse(
apiKey: String,
videoId: String,
client: YouTubeClient,
visitorData: String?,
cookieHeader: String?
): Map<*, *> {
val endpoint = "https://www.youtube.com/youtubei/v1/player?key=${Uri.encode(apiKey)}"
val headers = buildMap {
putAll(DEFAULT_HEADERS)
put("content-type", "application/json")
put("origin", "https://www.youtube.com")
put("x-youtube-client-name", client.id)
put("x-youtube-client-version", client.version)
put("user-agent", client.userAgent)
if (!visitorData.isNullOrBlank()) put("x-goog-visitor-id", visitorData)
if (!cookieHeader.isNullOrBlank()) put("cookie", cookieHeader)
}
val payload = mapOf(
"videoId" to videoId,
"contentCheckOk" to true,
"racyCheckOk" to true,
"context" to mapOf("client" to client.context),
"playbackContext" to mapOf(
"contentPlaybackContext" to mapOf("html5Preference" to "HTML5_PREF_WANTS")
)
)
val response = performRequest(
url = endpoint,
method = "POST",
headers = headers,
body = gson.toJson(payload)
)
if (!response.ok) {
val preview = response.body.take(200)
throw IllegalStateException("player API ${client.key} failed (${response.status}): $preview")
}
val parsed = gson.fromJson(response.body, Map::class.java)
return parsed ?: emptyMap<String, Any>()
}
private fun parseHlsManifest(manifestUrl: String): ManifestBestVariant? {
val response = performRequest(
url = manifestUrl,
method = "GET",
headers = DEFAULT_HEADERS
)
if (!response.ok) {
throw IllegalStateException("Failed to fetch HLS manifest (${response.status})")
}
val lines = response.body
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.toList()
var bestVariant: ManifestBestVariant? = null
for (i in lines.indices) {
val line = lines[i]
if (!line.startsWith("#EXT-X-STREAM-INF:")) continue
val attrs = parseHlsAttributeList(line)
val nextLine = lines.getOrNull(i + 1) ?: continue
if (nextLine.startsWith("#")) continue
val resolution = attrs["RESOLUTION"].orEmpty()
val (width, height) = parseResolution(resolution)
val bandwidth = attrs["BANDWIDTH"]?.toLongOrNull() ?: 0L
val candidate = ManifestBestVariant(
url = absolutizeUrl(manifestUrl, nextLine),
width = width,
height = height,
bandwidth = bandwidth
)
if (
bestVariant == null ||
candidate.height > bestVariant.height ||
(candidate.height == bestVariant.height && candidate.bandwidth > bestVariant.bandwidth) ||
(
candidate.height == bestVariant.height &&
candidate.bandwidth == bestVariant.bandwidth &&
candidate.width > bestVariant.width
)
) {
bestVariant = candidate
}
}
return bestVariant
}
private fun parseHlsAttributeList(line: String): Map<String, String> {
val index = line.indexOf(':')
if (index == -1) return emptyMap()
val raw = line.substring(index + 1)
val out = LinkedHashMap<String, String>()
val key = StringBuilder()
val value = StringBuilder()
var inKey = true
var inQuote = false
for (ch in raw) {
if (inKey) {
if (ch == '=') {
inKey = false
} else {
key.append(ch)
}
continue
}
if (ch == '"') {
inQuote = !inQuote
continue
}
if (ch == ',' && !inQuote) {
val k = key.toString().trim()
if (k.isNotEmpty()) {
out[k] = value.toString().trim()
}
key.clear()
value.clear()
inKey = true
continue
}
value.append(ch)
}
val lastKey = key.toString().trim()
if (lastKey.isNotEmpty()) {
out[lastKey] = value.toString().trim()
}
return out
}
private fun parseResolution(raw: String): Pair<Int, Int> {
val parts = raw.split('x')
if (parts.size != 2) return 0 to 0
val width = parts[0].toIntOrNull() ?: 0
val height = parts[1].toIntOrNull() ?: 0
return width to height
}
private fun parseQualityLabel(label: String?): Int? {
if (label.isNullOrBlank()) return null
val match = QUALITY_LABEL_REGEX.find(label) ?: return null
return match.groupValues.getOrNull(1)?.toIntOrNull()
}
private fun hasNParam(url: String): Boolean {
return runCatching {
!Uri.parse(url).getQueryParameter("n").isNullOrBlank()
}.getOrDefault(false)
}
private fun videoScore(height: Int, fps: Int, bitrate: Double): Double {
return height * 1_000_000_000.0 + fps * 1_000_000.0 + bitrate
}
private fun audioScore(bitrate: Double, audioSampleRate: Double): Double {
return bitrate * 1_000_000.0 + audioSampleRate
}
private fun sortCandidates(items: List<StreamCandidate>): List<StreamCandidate> {
return items.sortedWith(
compareByDescending<StreamCandidate> { it.score }
.thenBy { if (it.hasN) 1 else 0 }
.thenBy { containerPreference(it.ext) }
.thenBy { it.priority }
)
}
private fun containerPreference(ext: String): Int {
return when (ext.lowercase()) {
"mp4", "m4a" -> 0
"webm" -> 1
else -> 2
}
}
private fun pickBestForClient(items: List<StreamCandidate>, clientKey: String): StreamCandidate? {
val sameClient = items.filter { it.client == clientKey }
if (sameClient.isNotEmpty()) {
return sortCandidates(sameClient).firstOrNull()
}
return sortCandidates(items).firstOrNull()
}
private suspend fun resolveReachableUrl(url: String): String {
if (!url.contains("googlevideo.com")) return url
val uri = Uri.parse(url)
val mnParam = uri.getQueryParameter("mn") ?: return url
val servers = mnParam.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (servers.size < 2) return url
val candidates = mutableListOf(url)
for (server in servers) {
val mviIndex = servers.indexOf(server)
val altHost = uri.host?.replaceFirst(
Regex("^rr\\d+---"),
"rr${mviIndex + 1}---"
)?.replaceFirst(
Regex("sn-[a-z0-9]+-[a-z0-9]+"),
server
) ?: continue
if (altHost == uri.host) continue
candidates += url.replace(uri.host!!, altHost)
}
if (candidates.size == 1) return candidates[0]
val result = CompletableDeferred<String>()
val probeScope = CoroutineScope(Dispatchers.IO)
candidates.forEach { candidate ->
probeScope.launch {
val reachable = isUrlReachable(candidate)
Log.d(TAG, "CDN probe: ${Uri.parse(candidate).host} -> $reachable")
if (reachable) result.complete(candidate)
}
}
return try {
withTimeoutOrNull(2_000L) { result.await() } ?: url
} finally {
probeScope.cancel()
}
}
private val probeClient = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
private fun isUrlReachable(url: String): Boolean {
return runCatching {
val request = Request.Builder()
.url(url)
.get()
.header("Range", "bytes=0-0")
.headers(buildHeaders(DEFAULT_HEADERS))
.build()
probeClient.newCall(request).execute().use { val code = it.code; Log.d(TAG, "CDN probe code: ${Uri.parse(url).host} -> $code"); code == 200 }
}.getOrDefault(false)
}
private fun absolutizeUrl(baseUrl: String, maybeRelative: String): String {
return runCatching {
URL(URL(baseUrl), maybeRelative).toString()
}.getOrElse { maybeRelative }
}
private fun summarizeUrl(url: String): String {
return runCatching {
val parsed = URL(url)
val host = parsed.host ?: "unknown-host"
val path = parsed.path ?: "/"
"$host$path"
}.getOrDefault(url.take(80))
}
private fun performRequest(
url: String,
method: String,
headers: Map<String, String>,
body: String? = null
): RequestResponse {
val requestBuilder = Request.Builder()
.url(url)
.headers(buildHeaders(headers))
when (method.uppercase()) {
"POST" -> requestBuilder.post((body ?: "").toRequestBody())
"PUT" -> requestBuilder.put((body ?: "").toRequestBody())
"DELETE" -> requestBuilder.delete()
else -> requestBuilder.get()
}
httpClient.newCall(requestBuilder.build()).execute().use { response ->
return RequestResponse(
ok = response.isSuccessful,
status = response.code,
statusText = response.message,
url = response.request.url.toString(),
body = response.body?.string().orEmpty()
)
}
}
private fun buildHeaders(source: Map<String, String>): Headers {
val headers = Headers.Builder()
source.forEach { (name, value) ->
if (!name.equals("Accept-Encoding", ignoreCase = true)) {
headers.add(name, value)
}
}
if (source.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
headers.add("User-Agent", DEFAULT_USER_AGENT)
}
return headers.build()
}
}
private data class RequestResponse(
val ok: Boolean,
val status: Int,
val statusText: String,
val url: String,
val body: String
)
private fun Map<*, *>.mapValue(key: String): Map<*, *>? {
return this[key] as? Map<*, *>
}
private fun Map<*, *>.listMapValue(key: String): List<Map<*, *>> {
val raw = this[key] as? List<*> ?: return emptyList()
return raw.mapNotNull { it as? Map<*, *> }
}
private fun Map<*, *>.stringValue(key: String): String? {
val value = this[key] ?: return null
return value.toString()
}
private fun Map<*, *>.numberValue(key: String): Double? {
val value = this[key] ?: return null
return when (value) {
is Number -> value.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
}

View file

@ -0,0 +1,10 @@
package com.nuvio.app.features.trailer
actual object TrailerPlaybackResolver {
private val extractor by lazy { InAppYouTubeExtractor() }
actual suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? {
if (youtubeUrl.isBlank()) return null
return extractor.extractPlaybackSource(youtubeUrl)
}
}

View file

@ -0,0 +1,143 @@
package com.nuvio.app.features.trailer
import android.net.Uri
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.TransferListener
/**
* A DataSource.Factory that wraps DefaultHttpDataSource and appends YouTube's
* `&range=start-end` query parameter on each request. YouTube throttles (and
* kills) connections that try to download full adaptive streams in one shot,
* but honours chunked range-param requests at full speed.
*
* Only activates for googlevideo.com URLs; all other URLs pass through untouched.
*/
@UnstableApi
class YoutubeChunkedDataSourceFactory(
private val chunkSizeBytes: Long = CHUNK_SIZE
) : DataSource.Factory {
companion object {
private const val TAG = "YTChunkedDS"
/** 10 MB chunks large enough to avoid too many requests, small enough to dodge throttle. */
private const val CHUNK_SIZE = 10L * 1024 * 1024
}
override fun createDataSource(): DataSource {
val upstream = DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
.createDataSource()
return YoutubeChunkedDataSource(upstream, chunkSizeBytes)
}
private class YoutubeChunkedDataSource(
private val upstream: DefaultHttpDataSource,
private val chunkSize: Long
) : DataSource {
private var currentUri: Uri? = null
private var isYouTubeStream = false
private var totalContentLength = C.LENGTH_UNSET.toLong()
private var currentChunkStart = 0L
private var currentChunkEnd = 0L
private var bytesReadInChunk = 0L
private var originalDataSpec: DataSpec? = null
override fun addTransferListener(transferListener: TransferListener) {
upstream.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
val uri = dataSpec.uri
val host = uri.host.orEmpty()
isYouTubeStream = host.contains("googlevideo.com")
if (!isYouTubeStream) {
return upstream.open(dataSpec)
}
originalDataSpec = dataSpec
currentChunkStart = dataSpec.position
totalContentLength = dataSpec.length
return openNextChunk()
}
private fun openNextChunk(): Long {
val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec")
val end = if (totalContentLength != C.LENGTH_UNSET.toLong()) {
minOf(currentChunkStart + chunkSize - 1, currentChunkStart + totalContentLength - 1)
} else {
currentChunkStart + chunkSize - 1
}
currentChunkEnd = end
// Append &range=start-end to the URL (YouTube's own range param, not HTTP Range header)
val rangedUri = spec.uri.buildUpon()
.appendQueryParameter("range", "$currentChunkStart-$currentChunkEnd")
.build()
val chunkedSpec = spec.buildUpon()
.setUri(rangedUri)
.setPosition(0) // position within this chunk's response
.setLength(C.LENGTH_UNSET.toLong()) // let the server decide
.build()
bytesReadInChunk = 0
upstream.open(chunkedSpec)
return if (totalContentLength != C.LENGTH_UNSET.toLong()) totalContentLength else C.LENGTH_UNSET.toLong()
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (!isYouTubeStream) {
return upstream.read(buffer, offset, length)
}
val bytesRead = upstream.read(buffer, offset, length)
if (bytesRead == C.RESULT_END_OF_INPUT) {
// Current chunk exhausted — open the next one
val chunkBytesReceived = bytesReadInChunk
upstream.close()
// If this chunk returned fewer bytes than requested, the stream is done
if (chunkBytesReceived < (currentChunkEnd - currentChunkStart + 1)) {
return C.RESULT_END_OF_INPUT
}
currentChunkStart += chunkBytesReceived
if (totalContentLength != C.LENGTH_UNSET.toLong()) {
totalContentLength -= chunkBytesReceived
if (totalContentLength <= 0) {
return C.RESULT_END_OF_INPUT
}
}
return try {
openNextChunk()
upstream.read(buffer, offset, length)
} catch (e: Exception) {
Log.w(TAG, "Failed to open next chunk at $currentChunkStart: ${e.message}")
C.RESULT_END_OF_INPUT
}
}
bytesReadInChunk += bytesRead
return bytesRead
}
override fun getUri(): Uri? = upstream.uri ?: currentUri
override fun close() {
upstream.close()
currentUri = null
originalDataSpec = null
}
}
}

View file

@ -638,6 +638,7 @@ private fun MainAppContent(
PlayerScreen(
title = launch.title,
sourceUrl = launch.sourceUrl,
sourceAudioUrl = launch.sourceAudioUrl,
logo = launch.logo,
poster = launch.poster,
background = launch.background,

View file

@ -32,10 +32,24 @@ data class MetaDetails(
val moreLikeThis: List<MetaPreview> = emptyList(),
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val trailers: List<MetaTrailer> = emptyList(),
val links: List<MetaLink> = emptyList(),
val videos: List<MetaVideo> = emptyList(),
)
data class MetaTrailer(
val id: String,
val key: String,
val name: String,
val site: String,
val size: Int? = null,
val type: String = "Trailer",
val official: Boolean = false,
val publishedAt: String? = null,
val seasonNumber: Int? = null,
val displayName: String? = null,
)
data class MetaPerson(
val name: String,
val role: String? = null,

View file

@ -46,6 +46,7 @@ internal object MetaDetailsParser {
language = meta.string("language"),
website = meta.string("website"),
hasScheduledVideos = meta.behaviorHints().boolean("hasScheduledVideos") == true,
trailers = meta.trailers(),
links = links,
videos = meta.videos(),
)
@ -231,6 +232,32 @@ internal object MetaDetailsParser {
)
}
private fun JsonObject.trailers(): List<MetaTrailer> =
array("trailers").mapNotNull { element ->
val trailer = element as? JsonObject ?: return@mapNotNull null
val key = trailer.string("key")
?: trailer.string("source")
?: trailer.string("ytId")
?: trailer.string("ytid")
?: return@mapNotNull null
val normalizedKey = key.trim()
if (normalizedKey.isEmpty()) return@mapNotNull null
MetaTrailer(
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
key = normalizedKey,
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: "Trailer",
site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
size = trailer.int("size"),
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "Trailer",
official = trailer.boolean("official") == true,
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
displayName = trailer.string("displayName")?.takeIf(String::isNotBlank),
)
}
private fun JsonObject.embeddedStreams(): List<StreamItem> {
val arr = this["streams"] as? JsonArray ?: return emptyList()
return arr.mapNotNull { element ->

View file

@ -27,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -45,10 +46,14 @@ import com.nuvio.app.features.details.components.DetailMetaInfo
import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.details.components.DetailProductionSection
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.details.components.DetailTrailersSection
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
import com.nuvio.app.features.watched.releasedEpisodesForSeason
@ -58,6 +63,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch
@Composable
fun MetaDetailsScreen(
@ -187,6 +193,43 @@ fun MetaDetailsScreen(
val hasMoreLikeThisSection = remember(meta) {
meta.moreLikeThis.isNotEmpty()
}
val hasTrailersSection = remember(meta) {
meta.trailers.isNotEmpty()
}
val trailerScope = rememberCoroutineScope()
var selectedTrailer by remember(meta.id) { mutableStateOf<MetaTrailer?>(null) }
var trailerPlaybackSource by remember(meta.id) { mutableStateOf<TrailerPlaybackSource?>(null) }
var trailerLoading by remember(meta.id) { mutableStateOf(false) }
var trailerErrorMessage by remember(meta.id) { mutableStateOf<String?>(null) }
var trailerRequestToken by remember(meta.id) { mutableIntStateOf(0) }
val resolveTrailer: (MetaTrailer) -> Unit = remember(meta.id) {
{ trailer ->
selectedTrailer = trailer
trailerPlaybackSource = null
trailerErrorMessage = null
trailerLoading = true
trailerRequestToken += 1
val currentRequestToken = trailerRequestToken
trailerScope.launch {
val youtubeUrl = trailer.key.takeIf {
it.startsWith("http://") || it.startsWith("https://")
} ?: "https://www.youtube.com/watch?v=${trailer.key}"
val resolvedSource = runCatching {
TrailerPlaybackResolver.resolveFromYouTubeUrl(youtubeUrl)
}.getOrNull()
if (currentRequestToken != trailerRequestToken) {
return@launch
}
trailerPlaybackSource = resolvedSource
trailerErrorMessage = if (resolvedSource == null) {
"No playable trailer stream found."
} else {
null
}
trailerLoading = false
}
}
}
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) {
when {
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
@ -290,6 +333,13 @@ fun MetaDetailsScreen(
DetailCastSection(cast = meta.cast)
if (hasTrailersSection) {
DetailTrailersSection(
trailers = meta.trailers,
onTrailerClick = resolveTrailer,
)
}
if (!hasEpisodes && hasProductionSection) {
DetailProductionSection(meta = meta)
}
@ -449,6 +499,26 @@ fun MetaDetailsScreen(
},
)
}
TrailerPlayerPopup(
visible = selectedTrailer != null,
trailerTitle = selectedTrailer?.displayName ?: selectedTrailer?.name.orEmpty(),
trailerType = selectedTrailer?.type.orEmpty(),
contentTitle = meta.name,
playbackSource = trailerPlaybackSource,
isLoading = trailerLoading,
errorMessage = trailerErrorMessage,
onDismiss = {
trailerRequestToken += 1
trailerLoading = false
trailerPlaybackSource = null
trailerErrorMessage = null
selectedTrailer = null
},
onRetry = selectedTrailer?.let { trailer ->
{ resolveTrailer(trailer) }
},
)
}
}
}

View file

@ -0,0 +1,290 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaTrailer
@Composable
fun DetailTrailersSection(
trailers: List<MetaTrailer>,
onTrailerClick: (MetaTrailer) -> Unit,
modifier: Modifier = Modifier,
) {
if (trailers.isEmpty()) return
val grouped = remember(trailers) {
linkedMapOf<String, MutableList<MetaTrailer>>().apply {
trailers.forEach { trailer ->
val category = trailer.type.ifBlank { "Trailer" }
getOrPut(category) { mutableListOf() }.add(trailer)
}
}
}
if (grouped.isEmpty()) return
val initialCategory = remember(grouped) {
grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) }
?: grouped.keys.first()
}
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
var menuExpanded by remember { mutableStateOf(false) }
val selectedTrailers = grouped[selectedCategory].orEmpty()
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val sizing = trailerSectionSizing(maxWidth.value)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DetailSectionTitle(
title = "Trailers",
modifier = Modifier.weight(1f),
)
Box {
Surface(
shape = RoundedCornerShape(sizing.selectorRadius),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
tonalElevation = 0.dp,
modifier = Modifier
.clip(RoundedCornerShape(sizing.selectorRadius))
.clickable { menuExpanded = true },
) {
Row(
modifier = Modifier.padding(
horizontal = sizing.selectorHorizontalPadding,
vertical = sizing.selectorVerticalPadding,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = selectedCategory,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurface,
)
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(sizing.selectorIconSize),
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
grouped.keys.forEach { category ->
val count = grouped[category]?.size ?: 0
DropdownMenuItem(
text = {
Text(
text = "$category ($count)",
style = MaterialTheme.typography.bodyMedium,
)
},
onClick = {
selectedCategory = category
menuExpanded = false
},
)
}
}
}
}
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val sizing = trailerSectionSizing(maxWidth.value)
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
) {
items(
items = selectedTrailers,
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" },
) { trailer ->
TrailerCard(
trailer = trailer,
cardWidth = sizing.cardWidth,
cornerRadius = sizing.cardRadius,
titleFontSize = sizing.titleFontSize,
metaFontSize = sizing.metaFontSize,
onClick = { onTrailerClick(trailer) },
)
}
}
}
}
}
@Composable
private fun TrailerCard(
trailer: MetaTrailer,
cardWidth: androidx.compose.ui.unit.Dp,
cornerRadius: androidx.compose.ui.unit.Dp,
titleFontSize: androidx.compose.ui.unit.TextUnit,
metaFontSize: androidx.compose.ui.unit.TextUnit,
onClick: () -> Unit,
) {
Column(
modifier = Modifier.width(cardWidth),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(cornerRadius))
.clickable(onClick = onClick),
) {
AsyncImage(
model = "https://img.youtube.com/vi/${trailer.key}/hqdefault.jpg",
contentDescription = trailer.displayName ?: trailer.name,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clip(RoundedCornerShape(cornerRadius)),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.2f))
)
}
Text(
text = trailer.displayName ?: trailer.name,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold,
fontSize = titleFontSize,
),
color = MaterialTheme.colorScheme.onSurface,
)
val year = trailer.publishedAt?.take(4).orEmpty()
if (year.isNotBlank()) {
Text(
text = year,
style = MaterialTheme.typography.bodySmall.copy(fontSize = metaFontSize),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private data class TrailerSectionSizing(
val cardWidth: androidx.compose.ui.unit.Dp,
val cardSpacing: androidx.compose.ui.unit.Dp,
val cardRadius: androidx.compose.ui.unit.Dp,
val selectorRadius: androidx.compose.ui.unit.Dp,
val selectorHorizontalPadding: androidx.compose.ui.unit.Dp,
val selectorVerticalPadding: androidx.compose.ui.unit.Dp,
val selectorIconSize: androidx.compose.ui.unit.Dp,
val titleFontSize: androidx.compose.ui.unit.TextUnit,
val metaFontSize: androidx.compose.ui.unit.TextUnit,
)
private fun trailerSectionSizing(maxWidthDp: Float): TrailerSectionSizing =
when {
maxWidthDp >= 1200f -> TrailerSectionSizing(
cardWidth = 280.dp,
cardSpacing = 16.dp,
cardRadius = 20.dp,
selectorRadius = 20.dp,
selectorHorizontalPadding = 14.dp,
selectorVerticalPadding = 8.dp,
selectorIconSize = 22.dp,
titleFontSize = 16.sp,
metaFontSize = 14.sp,
)
maxWidthDp >= 1024f -> TrailerSectionSizing(
cardWidth = 260.dp,
cardSpacing = 14.dp,
cardRadius = 18.dp,
selectorRadius = 18.dp,
selectorHorizontalPadding = 12.dp,
selectorVerticalPadding = 6.dp,
selectorIconSize = 20.dp,
titleFontSize = 15.sp,
metaFontSize = 13.sp,
)
maxWidthDp >= 768f -> TrailerSectionSizing(
cardWidth = 240.dp,
cardSpacing = 12.dp,
cardRadius = 16.dp,
selectorRadius = 16.dp,
selectorHorizontalPadding = 10.dp,
selectorVerticalPadding = 5.dp,
selectorIconSize = 18.dp,
titleFontSize = 14.sp,
metaFontSize = 12.sp,
)
else -> TrailerSectionSizing(
cardWidth = 200.dp,
cardSpacing = 12.dp,
cardRadius = 16.dp,
selectorRadius = 16.dp,
selectorHorizontalPadding = 10.dp,
selectorVerticalPadding = 5.dp,
selectorIconSize = 18.dp,
titleFontSize = 12.sp,
metaFontSize = 10.sp,
)
}

View file

@ -0,0 +1,182 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.player.PlatformPlayerSurface
import com.nuvio.app.features.player.PlayerResizeMode
import com.nuvio.app.features.trailer.TrailerPlaybackSource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailerPlayerPopup(
visible: Boolean,
trailerTitle: String,
trailerType: String,
contentTitle: String,
playbackSource: TrailerPlaybackSource?,
isLoading: Boolean,
errorMessage: String?,
onDismiss: () -> Unit,
onRetry: (() -> Unit)? = null,
) {
if (!visible) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var playerError by remember(playbackSource?.videoUrl, playbackSource?.audioUrl) {
mutableStateOf<String?>(null)
}
val activeError = errorMessage ?: playerError
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 14.dp + nuvioPlatformExtraBottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = trailerTitle,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
text = trailerType.ifBlank { "Trailer" },
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
Text(
text = contentTitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Close trailer",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.scrim)
.aspectRatio(16f / 9f),
contentAlignment = Alignment.Center,
) {
when {
isLoading -> {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
activeError != null -> {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Unable to play trailer",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = activeError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (onRetry != null) {
TextButton(onClick = onRetry) {
Text("Retry")
}
}
}
}
playbackSource != null -> {
PlatformPlayerSurface(
sourceUrl = playbackSource.videoUrl,
sourceAudioUrl = playbackSource.audioUrl,
modifier = Modifier.fillMaxWidth(),
playWhenReady = true,
resizeMode = PlayerResizeMode.Fit,
onControllerReady = {},
onSnapshot = {},
onError = { playerError = it },
)
}
}
}
}
}
}

View file

@ -23,6 +23,7 @@ interface PlayerEngineController {
@Composable
expect fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String? = null,
modifier: Modifier = Modifier,
playWhenReady: Boolean = true,
resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,

View file

@ -10,6 +10,7 @@ data class PlayerRoute(
data class PlayerLaunch(
val title: String,
val sourceUrl: String,
val sourceAudioUrl: String? = null,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,

View file

@ -50,6 +50,7 @@ private const val PlaybackProgressPersistIntervalMs = 60_000L
fun PlayerScreen(
title: String,
sourceUrl: String,
sourceAudioUrl: String? = null,
providerName: String,
streamTitle: String,
streamSubtitle: String?,
@ -88,6 +89,7 @@ fun PlayerScreen(
var controlsVisible by rememberSaveable { mutableStateOf(true) }
// Active playback state (mutable to support source/episode switching)
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) }
var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) }
var activeProviderName by rememberSaveable { mutableStateOf(providerName) }
@ -148,6 +150,7 @@ fun PlayerScreen(
activeStreamSubtitle,
pauseDescription,
activeSourceUrl,
activeSourceAudioUrl,
) {
WatchProgressPlaybackSession(
contentType = contentType ?: parentMetaType,
@ -349,6 +352,7 @@ fun PlayerScreen(
)
}
activeSourceUrl = url
activeSourceAudioUrl = null
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -379,6 +383,7 @@ fun PlayerScreen(
)
}
activeSourceUrl = url
activeSourceAudioUrl = null
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -414,7 +419,7 @@ fun PlayerScreen(
controlsVisible = false
}
LaunchedEffect(activeSourceUrl) {
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl) {
errorMessage = null
scrubbingPositionMs = null
initialLoadCompleted = false
@ -512,7 +517,7 @@ fun PlayerScreen(
)
}
DisposableEffect(playbackSession.videoId, activeSourceUrl) {
DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
onDispose {
flushWatchProgress()
}
@ -551,6 +556,7 @@ fun PlayerScreen(
) {
PlatformPlayerSurface(
sourceUrl = activeSourceUrl,
sourceAudioUrl = activeSourceAudioUrl,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,

View file

@ -64,6 +64,15 @@ internal fun LazyListScope.tmdbSettingsContent(
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
TmdbToggleRow(
isTablet = isTablet,
title = "Trailers",
description = "Fetch and show TMDB trailer videos section on detail pages.",
checked = settings.useTrailers,
enabled = settings.enabled,
onCheckedChange = TmdbSettingsRepository::setUseTrailers,
)
SettingsGroupDivider(isTablet = isTablet)
TmdbToggleRow(
isTablet = isTablet,
title = "Artwork",

View file

@ -5,6 +5,7 @@ import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaCompany
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.MetaTrailer
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
@ -25,6 +26,7 @@ object TmdbMetadataService {
private val episodeCache = mutableMapOf<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
private val moreLikeThisCache = mutableMapOf<String, List<MetaPreview>>()
private val collectionCache = mutableMapOf<String, Pair<String?, List<MetaPreview>>>()
private val trailerCache = mutableMapOf<String, List<MetaTrailer>>()
suspend fun enrichMeta(
meta: MetaDetails,
@ -184,6 +186,10 @@ object TmdbMetadataService {
)
}
if (enrichment != null && settings.useTrailers && enrichment.trailers.isNotEmpty()) {
updated = updated.copy(trailers = enrichment.trailers)
}
return updated
}
@ -245,11 +251,26 @@ object TmdbMetadataService {
emptyList()
}
}
val trailers = async {
if (settings.useTrailers && (mediaType == "movie" || mediaType == "tv")) {
fetchTrailers(
tmdbId = numericId,
mediaType = mediaType,
language = normalizedLanguage,
)
} else {
emptyList()
}
}
Quadruple(
first = details.await(),
second = credits.await(),
third = images.await(),
fourth = Pair(ageRating.await(), moreLikeThis.await()),
fourth = EnrichmentPayload(
ageRating = ageRating.await(),
moreLikeThis = moreLikeThis.await(),
trailers = trailers.await(),
),
)
}
@ -280,7 +301,7 @@ object TmdbMetadataService {
lastAirDate = lastAirDate,
rating = details.voteAverage,
runtimeMinutes = details.runtime ?: details.episodeRunTime.firstOrNull(),
ageRating = response.fourth.first,
ageRating = response.fourth.ageRating,
status = details.status?.trim()?.takeIf(String::isNotBlank),
countries = details.productionCountries
.mapNotNull { it.iso31661?.trim()?.takeIf(String::isNotBlank) }
@ -297,7 +318,8 @@ object TmdbMetadataService {
} else {
emptyList()
},
moreLikeThis = response.fourth.second,
moreLikeThis = response.fourth.moreLikeThis,
trailers = response.fourth.trailers,
)
if (!enrichment.hasContent()) return@withContext null
@ -444,6 +466,107 @@ object TmdbMetadataService {
collectionCache[cacheKey] = result
return result
}
private suspend fun fetchTrailers(
tmdbId: Int,
mediaType: String,
language: String,
): List<MetaTrailer> {
val cacheKey = "$tmdbId:$mediaType:$language:trailers"
trailerCache[cacheKey]?.let { return it }
val allVideos = mutableListOf<MetaTrailer>()
val primaryVideos = fetchTmdbVideos(
endpoint = "$mediaType/$tmdbId/videos",
language = language,
)
allVideos += primaryVideos.map { video ->
video.toMetaTrailer(
seasonNumber = null,
displayName = video.name,
)
}
if (mediaType == "tv") {
val details = fetch<TmdbDetailsResponse>(
endpoint = "tv/$tmdbId",
query = mapOf("language" to language),
)
val seasonCount = (details?.numberOfSeasons ?: 0).coerceAtLeast(0)
if (seasonCount > 0) {
val seasonVideos = coroutineScope {
(1..seasonCount).map { seasonNumber ->
async {
seasonNumber to fetchTmdbVideos(
endpoint = "tv/$tmdbId/season/$seasonNumber/videos",
language = language,
)
}
}.awaitAll()
}
seasonVideos.forEach { (seasonNumber, videos) ->
allVideos += videos.map { video ->
video.toMetaTrailer(
seasonNumber = seasonNumber,
displayName = "Season $seasonNumber - ${video.name}",
)
}
}
}
}
val byCategory = linkedMapOf<String, MutableList<MetaTrailer>>()
allVideos
.asSequence()
.filter { trailer ->
trailer.site.equals("YouTube", ignoreCase = true) && trailer.key.isNotBlank()
}
.forEach { trailer ->
byCategory.getOrPut(trailer.type.ifBlank { "Trailer" }) { mutableListOf() }
.add(trailer)
}
byCategory.values.forEach { trailers ->
trailers.sortWith(
compareBy<MetaTrailer> {
when {
it.seasonNumber != null -> 0
else -> 1
}
}
.thenByDescending { it.seasonNumber ?: Int.MIN_VALUE }
.thenByDescending { it.official }
.thenByDescending { it.publishedAt.orEmpty() }
)
}
val sortedCategories = byCategory.keys.sortedWith(
compareBy<String> { category ->
when {
category.equals("Trailer", ignoreCase = true) -> 0
byCategory[category].orEmpty().any { it.official } -> 1
else -> 2
}
}.thenBy { it.lowercase() }
)
val result = sortedCategories.flatMap { byCategory[it].orEmpty() }
trailerCache[cacheKey] = result
return result
}
private suspend fun fetchTmdbVideos(
endpoint: String,
language: String,
): List<TmdbVideoResult> {
val response = fetch<TmdbVideosResponse>(
endpoint = endpoint,
query = mapOf("language" to language),
)
return response?.results.orEmpty()
}
}
internal data class TmdbEnrichment(
@ -469,6 +592,7 @@ internal data class TmdbEnrichment(
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val moreLikeThis: List<MetaPreview> = emptyList(),
val trailers: List<MetaTrailer> = emptyList(),
) {
fun hasContent(): Boolean =
localizedTitle != null ||
@ -491,9 +615,16 @@ internal data class TmdbEnrichment(
productionCompanies.isNotEmpty() ||
networks.isNotEmpty() ||
collectionItems.isNotEmpty() ||
moreLikeThis.isNotEmpty()
moreLikeThis.isNotEmpty() ||
trailers.isNotEmpty()
}
private data class EnrichmentPayload(
val ageRating: String?,
val moreLikeThis: List<MetaPreview>,
val trailers: List<MetaTrailer>,
)
internal data class TmdbEpisodeEnrichment(
val title: String?,
val overview: String?,
@ -746,8 +877,47 @@ private data class TmdbDetailsResponse(
@SerialName("production_companies") val productionCompanies: List<TmdbCompany> = emptyList(),
val networks: List<TmdbCompany> = emptyList(),
@SerialName("belongs_to_collection") val belongsToCollection: TmdbCollectionRef? = null,
@SerialName("number_of_seasons") val numberOfSeasons: Int? = null,
)
@Serializable
private data class TmdbVideosResponse(
val results: List<TmdbVideoResult> = emptyList(),
)
@Serializable
private data class TmdbVideoResult(
val id: String? = null,
val key: String? = null,
val name: String? = null,
val site: String? = null,
val size: Int? = null,
val type: String? = null,
val official: Boolean? = null,
@SerialName("published_at") val publishedAt: String? = null,
)
private fun TmdbVideoResult.toMetaTrailer(
seasonNumber: Int?,
displayName: String?,
): MetaTrailer {
val videoKey = key?.trim().orEmpty()
val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: "Trailer"
val trailerId = id?.trim().takeUnless { it.isNullOrBlank() } ?: videoKey
return MetaTrailer(
id = trailerId,
key = videoKey,
name = videoName,
site = site?.trim().takeUnless { it.isNullOrBlank() } ?: "YouTube",
size = size,
type = type?.trim().takeUnless { it.isNullOrBlank() } ?: "Trailer",
official = official == true,
publishedAt = publishedAt,
seasonNumber = seasonNumber,
displayName = displayName,
)
}
@Serializable
private data class TmdbNamedItem(
val name: String? = null,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.tmdb
data class TmdbSettings(
val enabled: Boolean = false,
val language: String = "en",
val useTrailers: Boolean = true,
val useArtwork: Boolean = true,
val useBasicInfo: Boolean = true,
val useDetails: Boolean = true,

View file

@ -12,6 +12,7 @@ object TmdbSettingsRepository {
private var enabled = false
private var language = "en"
private var useTrailers = true
private var useArtwork = true
private var useBasicInfo = true
private var useDetails = true
@ -54,6 +55,13 @@ object TmdbSettingsRepository {
TmdbSettingsStorage.saveLanguage(normalized)
}
fun setUseTrailers(value: Boolean) = setBoolean(
current = useTrailers,
next = value,
update = { useTrailers = it },
persist = TmdbSettingsStorage::saveUseTrailers,
)
fun setUseArtwork(value: Boolean) = setBoolean(
current = useArtwork,
next = value,
@ -142,6 +150,7 @@ object TmdbSettingsRepository {
enabled = TmdbSettingsStorage.loadEnabled() ?: false
val storedLanguage = TmdbSettingsStorage.loadLanguage()
language = if (storedLanguage == null) "en" else normalizeLanguage(storedLanguage)
useTrailers = TmdbSettingsStorage.loadUseTrailers() ?: true
useArtwork = TmdbSettingsStorage.loadUseArtwork() ?: true
useBasicInfo = TmdbSettingsStorage.loadUseBasicInfo() ?: true
useDetails = TmdbSettingsStorage.loadUseDetails() ?: true
@ -159,6 +168,7 @@ object TmdbSettingsRepository {
_uiState.value = TmdbSettings(
enabled = enabled,
language = language,
useTrailers = useTrailers,
useArtwork = useArtwork,
useBasicInfo = useBasicInfo,
useDetails = useDetails,

View file

@ -5,6 +5,8 @@ internal expect object TmdbSettingsStorage {
fun saveEnabled(enabled: Boolean)
fun loadLanguage(): String?
fun saveLanguage(language: String)
fun loadUseTrailers(): Boolean?
fun saveUseTrailers(enabled: Boolean)
fun loadUseArtwork(): Boolean?
fun saveUseArtwork(enabled: Boolean)
fun loadUseBasicInfo(): Boolean?

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.trailer
expect object TrailerPlaybackResolver {
suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource?
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.trailer
data class TrailerPlaybackSource(
val videoUrl: String,
val audioUrl: String? = null,
)

View file

@ -9,6 +9,7 @@ import platform.UIKit.UIViewController
interface NuvioPlayerBridge {
fun createPlayerViewController(): UIViewController
fun loadFile(url: String)
fun loadFileWithAudio(videoUrl: String, audioUrl: String?)
fun play()
fun pause()
fun seekTo(positionMs: Long)

View file

@ -19,6 +19,7 @@ private const val TAG = "NuvioiOSPlayer"
@Composable
actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
modifier: Modifier,
playWhenReady: Boolean,
resizeMode: PlayerResizeMode,
@ -203,8 +204,8 @@ actual fun PlatformPlayerSurface(
}
// Load file and set initial state
LaunchedEffect(bridge, sourceUrl) {
bridge.loadFile(sourceUrl)
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl) {
bridge.loadFileWithAudio(sourceUrl, sourceAudioUrl)
if (playWhenReady) {
bridge.play()
} else {

View file

@ -6,6 +6,7 @@ import platform.Foundation.NSUserDefaults
actual object TmdbSettingsStorage {
private const val enabledKey = "tmdb_enabled"
private const val languageKey = "tmdb_language"
private const val useTrailersKey = "tmdb_use_trailers"
private const val useArtworkKey = "tmdb_use_artwork"
private const val useBasicInfoKey = "tmdb_use_basic_info"
private const val useDetailsKey = "tmdb_use_details"
@ -30,6 +31,12 @@ actual object TmdbSettingsStorage {
NSUserDefaults.standardUserDefaults.setObject(language, forKey = ProfileScopedKey.of(languageKey))
}
actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey)
actual fun saveUseTrailers(enabled: Boolean) {
saveBoolean(useTrailersKey, enabled)
}
actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey)
actual fun saveUseArtwork(enabled: Boolean) {

View file

@ -0,0 +1,753 @@
package com.nuvio.app.features.trailer
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.timeout
import io.ktor.client.request.header
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.HttpMethod
import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import platform.Foundation.NSURLComponents
import platform.Foundation.NSURLQueryItem
private const val TAG = "InAppYouTubeExtractorIOS"
private const val EXTRACTOR_TIMEOUT_MS = 30_000L
private const val DEFAULT_REQUEST_TIMEOUT_MS = 20_000L
private const val DEFAULT_USER_AGENT =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1"
private const val PREFERRED_SEPARATE_CLIENT = "android_vr"
private val VIDEO_ID_REGEX = Regex("^[a-zA-Z0-9_-]{11}$")
private val API_KEY_REGEX = Regex("\"INNERTUBE_API_KEY\":\"([^\"]+)\"")
private val VISITOR_DATA_REGEX = Regex("\"VISITOR_DATA\":\"([^\"]+)\"")
private val QUALITY_LABEL_REGEX = Regex("(\\d{2,4})p")
private data class YouTubeClient(
val key: String,
val id: String,
val version: String,
val userAgent: String,
val context: JsonObject,
val priority: Int,
)
private data class WatchConfig(
val apiKey: String?,
val visitorData: String?,
)
private data class StreamCandidate(
val client: String,
val priority: Int,
val url: String,
val score: Double,
val hasN: Boolean,
val height: Int,
val ext: String,
)
private data class ManifestBestVariant(
val url: String,
val width: Int,
val height: Int,
val bandwidth: Long,
)
private data class ManifestCandidate(
val client: String,
val priority: Int,
val manifestUrl: String,
val height: Int,
val bandwidth: Long,
)
private data class RequestResponse(
val ok: Boolean,
val status: Int,
val statusText: String,
val url: String,
val body: String,
)
private val DEFAULT_HEADERS = mapOf(
"accept-language" to "en-US,en;q=0.9",
"user-agent" to DEFAULT_USER_AGENT,
)
private val JSON = Json { ignoreUnknownKeys = true }
private val CLIENTS = listOf(
YouTubeClient(
key = "android_vr",
id = "28",
version = "1.56.21",
userAgent = "com.google.android.apps.youtube.vr.oculus/1.56.21 " +
"(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip",
context = jsonObjectOf(
"clientName" to "ANDROID_VR",
"clientVersion" to "1.56.21",
"deviceMake" to "Oculus",
"deviceModel" to "Quest 3",
"osName" to "Android",
"osVersion" to "12",
"platform" to "MOBILE",
"androidSdkVersion" to 32,
"hl" to "en",
"gl" to "US",
),
priority = 0,
),
YouTubeClient(
key = "android",
id = "3",
version = "20.10.35",
userAgent = "com.google.android.youtube/20.10.35 (Linux; U; Android 14; en_US) gzip",
context = jsonObjectOf(
"clientName" to "ANDROID",
"clientVersion" to "20.10.35",
"osName" to "Android",
"osVersion" to "14",
"platform" to "MOBILE",
"androidSdkVersion" to 34,
"hl" to "en",
"gl" to "US",
),
priority = 1,
),
YouTubeClient(
key = "ios",
id = "5",
version = "20.10.1",
userAgent = "com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)",
context = jsonObjectOf(
"clientName" to "IOS",
"clientVersion" to "20.10.1",
"deviceModel" to "iPhone16,2",
"osName" to "iPhone",
"osVersion" to "17.4.0.21E219",
"platform" to "MOBILE",
"hl" to "en",
"gl" to "US",
),
priority = 2,
),
)
class InAppYouTubeExtractor {
private val log = Logger.withTag(TAG)
private val httpClient = HttpClient(Darwin) {
install(HttpTimeout)
followRedirects = true
expectSuccess = false
}
suspend fun extractPlaybackSource(youtubeUrl: String): TrailerPlaybackSource? = withContext(Dispatchers.Default) {
if (youtubeUrl.isBlank()) return@withContext null
runCatching {
withTimeout(EXTRACTOR_TIMEOUT_MS) {
extractPlaybackSourceInternal(youtubeUrl)
}
}.onFailure {
log.w { "iOS extractor failed for $youtubeUrl: ${it.message}" }
}.getOrNull()
}
private suspend fun extractPlaybackSourceInternal(youtubeUrl: String): TrailerPlaybackSource? {
val videoId = extractVideoId(youtubeUrl) ?: return null
val watchUrl = "https://www.youtube.com/watch?v=$videoId&hl=en"
val watchResponse = performRequest(
url = watchUrl,
method = "GET",
headers = DEFAULT_HEADERS,
)
if (!watchResponse.ok) {
throw IllegalStateException("Failed to fetch watch page (${watchResponse.status})")
}
val watchConfig = getWatchConfig(watchResponse.body)
val apiKey = watchConfig.apiKey
?: throw IllegalStateException("Unable to extract INNERTUBE_API_KEY")
val progressive = mutableListOf<StreamCandidate>()
val adaptiveVideo = mutableListOf<StreamCandidate>()
val adaptiveAudio = mutableListOf<StreamCandidate>()
val manifestUrls = mutableListOf<Triple<String, Int, String>>()
for (client in CLIENTS) {
runCatching {
val playerResponse = fetchPlayerResponse(
apiKey = apiKey,
videoId = videoId,
client = client,
visitorData = watchConfig.visitorData,
)
val streamingData = playerResponse.objectValue("streamingData") ?: return@runCatching
val hlsManifestUrl = streamingData.stringValue("hlsManifestUrl")
if (!hlsManifestUrl.isNullOrBlank()) {
manifestUrls += Triple(client.key, client.priority, hlsManifestUrl)
}
for (format in streamingData.listObjectValue("formats")) {
val url = format.stringValue("url") ?: continue
val mimeType = format.stringValue("mimeType").orEmpty()
if (!mimeType.contains("video/") && mimeType.isNotBlank()) continue
val height = (
format.numberValue("height")
?: parseQualityLabel(format.stringValue("qualityLabel"))?.toDouble()
?: 0.0
).toInt()
val fps = (format.numberValue("fps") ?: 0.0).toInt()
val bitrate = format.numberValue("bitrate")
?: format.numberValue("averageBitrate")
?: 0.0
progressive += StreamCandidate(
client = client.key,
priority = client.priority,
url = url,
score = videoScore(height, fps, bitrate),
hasN = hasNParam(url),
height = height,
ext = if (mimeType.contains("webm")) "webm" else "mp4",
)
}
for (format in streamingData.listObjectValue("adaptiveFormats")) {
val url = format.stringValue("url") ?: continue
val mimeType = format.stringValue("mimeType").orEmpty()
val hasVideo = mimeType.contains("video/")
val hasAudio = mimeType.contains("audio/") || mimeType.startsWith("audio/")
if (hasVideo) {
val height = (
format.numberValue("height")
?: parseQualityLabel(format.stringValue("qualityLabel"))?.toDouble()
?: 0.0
).toInt()
val fps = (format.numberValue("fps") ?: 0.0).toInt()
val bitrate = format.numberValue("bitrate")
?: format.numberValue("averageBitrate")
?: 0.0
adaptiveVideo += StreamCandidate(
client = client.key,
priority = client.priority,
url = url,
score = videoScore(height, fps, bitrate),
hasN = hasNParam(url),
height = height,
ext = if (mimeType.contains("webm")) "webm" else "mp4",
)
} else if (hasAudio) {
val bitrate = format.numberValue("bitrate")
?: format.numberValue("averageBitrate")
?: 0.0
val asr = format.numberValue("audioSampleRate") ?: 0.0
adaptiveAudio += StreamCandidate(
client = client.key,
priority = client.priority,
url = url,
score = audioScore(bitrate, asr),
hasN = hasNParam(url),
height = 0,
ext = if (mimeType.contains("webm")) "webm" else "m4a",
)
}
}
}
}
if (manifestUrls.isEmpty() && progressive.isEmpty() && adaptiveVideo.isEmpty() && adaptiveAudio.isEmpty()) {
return null
}
var bestManifest: ManifestCandidate? = null
for ((clientKey, priority, manifestUrl) in manifestUrls) {
runCatching {
val variant = parseHlsManifest(manifestUrl) ?: return@runCatching
val candidate = ManifestCandidate(
client = clientKey,
priority = priority,
manifestUrl = manifestUrl,
height = variant.height,
bandwidth = variant.bandwidth,
)
if (
bestManifest == null ||
candidate.height > bestManifest.height ||
(candidate.height == bestManifest.height && candidate.bandwidth > bestManifest.bandwidth)
) {
bestManifest = candidate
}
}
}
val bestProgressive = sortCandidates(progressive).firstOrNull()
val bestVideo = pickBestForClient(adaptiveVideo, PREFERRED_SEPARATE_CLIENT)
val bestAudio = pickBestForClient(adaptiveAudio, PREFERRED_SEPARATE_CLIENT)
val bestManifestHeight = bestManifest?.height ?: -1
val bestCombinedIsManifest = bestManifest != null &&
(bestProgressive == null || bestManifestHeight > bestProgressive.height)
val combinedUrl = if (bestCombinedIsManifest) {
bestManifest.manifestUrl
} else {
bestProgressive?.url
}
val videoUrl = resolveReachableUrl(bestVideo?.url ?: combinedUrl ?: return null)
val audioUrl = bestAudio?.url?.let { resolveReachableUrl(it) }
return TrailerPlaybackSource(
videoUrl = videoUrl,
audioUrl = audioUrl,
)
}
private suspend fun fetchPlayerResponse(
apiKey: String,
videoId: String,
client: YouTubeClient,
visitorData: String?,
): JsonObject {
val endpoint = "https://www.youtube.com/youtubei/v1/player?key=${encodeUrlComponent(apiKey)}"
val headers = buildMap {
putAll(DEFAULT_HEADERS)
put("content-type", "application/json")
put("origin", "https://www.youtube.com")
put("x-youtube-client-name", client.id)
put("x-youtube-client-version", client.version)
put("user-agent", client.userAgent)
if (!visitorData.isNullOrBlank()) put("x-goog-visitor-id", visitorData)
}
val payload = jsonObjectOf(
"videoId" to videoId,
"contentCheckOk" to true,
"racyCheckOk" to true,
"context" to jsonObjectOf("client" to client.context),
"playbackContext" to jsonObjectOf(
"contentPlaybackContext" to jsonObjectOf("html5Preference" to "HTML5_PREF_WANTS"),
),
)
val response = performRequest(
url = endpoint,
method = "POST",
headers = headers,
body = payload.toString(),
)
if (!response.ok) {
val preview = response.body.take(200)
throw IllegalStateException("player API ${client.key} failed (${response.status}): $preview")
}
val parsed = JSON.parseToJsonElement(response.body)
return parsed as? JsonObject ?: JsonObject(emptyMap())
}
private suspend fun parseHlsManifest(manifestUrl: String): ManifestBestVariant? {
val response = performRequest(
url = manifestUrl,
method = "GET",
headers = DEFAULT_HEADERS,
)
if (!response.ok) {
throw IllegalStateException("Failed to fetch HLS manifest (${response.status})")
}
val lines = response.body
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.toList()
var bestVariant: ManifestBestVariant? = null
for (index in lines.indices) {
val line = lines[index]
if (!line.startsWith("#EXT-X-STREAM-INF:")) continue
val attrs = parseHlsAttributeList(line)
val nextLine = lines.getOrNull(index + 1) ?: continue
if (nextLine.startsWith("#")) continue
val resolution = attrs["RESOLUTION"].orEmpty()
val (width, height) = parseResolution(resolution)
val bandwidth = attrs["BANDWIDTH"]?.toLongOrNull() ?: 0L
val candidate = ManifestBestVariant(
url = absolutizeUrl(manifestUrl, nextLine),
width = width,
height = height,
bandwidth = bandwidth,
)
if (
bestVariant == null ||
candidate.height > bestVariant.height ||
(candidate.height == bestVariant.height && candidate.bandwidth > bestVariant.bandwidth) ||
(
candidate.height == bestVariant.height &&
candidate.bandwidth == bestVariant.bandwidth &&
candidate.width > bestVariant.width
)
) {
bestVariant = candidate
}
}
return bestVariant
}
private suspend fun resolveReachableUrl(url: String): String {
if (!url.contains("googlevideo.com")) return url
val mnParam = getQueryParameter(url, "mn") ?: return url
val servers = mnParam.split(',').map { it.trim() }.filter { it.isNotBlank() }
if (servers.size < 2) return url
val host = getHost(url) ?: return url
val candidates = mutableListOf(url)
servers.forEachIndexed { index, server ->
val altHost = host
.replaceFirst(Regex("^rr\\d+---"), "rr${index + 1}---")
.replaceFirst(Regex("sn-[a-z0-9]+-[a-z0-9]+"), server)
if (altHost != host) {
candidates += url.replace(host, altHost)
}
}
if (candidates.size == 1) return candidates.first()
return coroutineScope {
val probes = candidates.map { candidate ->
async {
if (isUrlReachable(candidate)) candidate else null
}
}
withTimeoutOrNull(2_000L) {
probes.awaitAll().firstOrNull { !it.isNullOrBlank() }
} ?: url
}
}
private suspend fun isUrlReachable(url: String): Boolean {
val response = runCatching {
performRequest(
url = url,
method = "GET",
headers = mapOf(
"range" to "bytes=0-0",
"user-agent" to DEFAULT_USER_AGENT,
),
timeoutMillis = 2_000L,
)
}.getOrNull() ?: return false
return response.status in 200..299
}
private fun extractVideoId(input: String): String? {
val trimmed = input.trim()
if (VIDEO_ID_REGEX.matches(trimmed)) return trimmed
val normalized = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://$trimmed"
}
val components = NSURLComponents(string = normalized) ?: return null
val host = components.host?.lowercase().orEmpty()
if (host.endsWith("youtu.be")) {
val id = components.path.orEmpty().trim('/').substringBefore('/')
if (id.isNotBlank() && VIDEO_ID_REGEX.matches(id)) {
return id
}
}
val queryId = queryItems(components)
.firstOrNull { it.name == "v" }
?.value
if (!queryId.isNullOrBlank() && VIDEO_ID_REGEX.matches(queryId)) {
return queryId
}
val segments = components.path
.orEmpty()
.trim('/')
.split('/')
.filter { it.isNotBlank() }
if (segments.size >= 2) {
val first = segments[0]
val second = segments[1]
if ((first == "embed" || first == "shorts" || first == "live") && VIDEO_ID_REGEX.matches(second)) {
return second
}
}
return null
}
private fun getWatchConfig(html: String): WatchConfig {
val apiKey = API_KEY_REGEX.find(html)?.groupValues?.getOrNull(1)
val visitorData = VISITOR_DATA_REGEX.find(html)?.groupValues?.getOrNull(1)
return WatchConfig(apiKey = apiKey, visitorData = visitorData)
}
private fun parseHlsAttributeList(line: String): Map<String, String> {
val index = line.indexOf(':')
if (index == -1) return emptyMap()
val raw = line.substring(index + 1)
val out = LinkedHashMap<String, String>()
val key = StringBuilder()
val value = StringBuilder()
var inKey = true
var inQuote = false
for (ch in raw) {
if (inKey) {
if (ch == '=') {
inKey = false
} else {
key.append(ch)
}
continue
}
if (ch == '"') {
inQuote = !inQuote
continue
}
if (ch == ',' && !inQuote) {
val k = key.toString().trim()
if (k.isNotEmpty()) {
out[k] = value.toString().trim()
}
key.clear()
value.clear()
inKey = true
continue
}
value.append(ch)
}
val lastKey = key.toString().trim()
if (lastKey.isNotEmpty()) {
out[lastKey] = value.toString().trim()
}
return out
}
private fun parseResolution(raw: String): Pair<Int, Int> {
val parts = raw.split('x')
if (parts.size != 2) return 0 to 0
val width = parts[0].toIntOrNull() ?: 0
val height = parts[1].toIntOrNull() ?: 0
return width to height
}
private fun parseQualityLabel(label: String?): Int? {
if (label.isNullOrBlank()) return null
return QUALITY_LABEL_REGEX.find(label)?.groupValues?.getOrNull(1)?.toIntOrNull()
}
private fun hasNParam(url: String): Boolean = !getQueryParameter(url, "n").isNullOrBlank()
private fun videoScore(height: Int, fps: Int, bitrate: Double): Double {
return height * 1_000_000_000.0 + fps * 1_000_000.0 + bitrate
}
private fun audioScore(bitrate: Double, audioSampleRate: Double): Double {
return bitrate * 1_000_000.0 + audioSampleRate
}
private fun sortCandidates(items: List<StreamCandidate>): List<StreamCandidate> {
return items.sortedWith(
compareByDescending<StreamCandidate> { it.score }
.thenBy { if (it.hasN) 1 else 0 }
.thenBy { containerPreference(it.ext) }
.thenBy { it.priority },
)
}
private fun pickBestForClient(items: List<StreamCandidate>, clientKey: String): StreamCandidate? {
val sameClient = items.filter { it.client == clientKey }
if (sameClient.isNotEmpty()) {
return sortCandidates(sameClient).firstOrNull()
}
return sortCandidates(items).firstOrNull()
}
private fun containerPreference(ext: String): Int {
return when (ext.lowercase()) {
"mp4", "m4a" -> 0
"webm" -> 1
else -> 2
}
}
private suspend fun performRequest(
url: String,
method: String,
headers: Map<String, String>,
body: String? = null,
timeoutMillis: Long = DEFAULT_REQUEST_TIMEOUT_MS,
): RequestResponse {
val response = httpClient.request(url) {
this.method = when (method.uppercase()) {
"POST" -> HttpMethod.Post
"PUT" -> HttpMethod.Put
"DELETE" -> HttpMethod.Delete
else -> HttpMethod.Get
}
headers.forEach { (name, value) ->
header(name, value)
}
if (body != null) {
setBody(body)
}
timeout {
requestTimeoutMillis = timeoutMillis
connectTimeoutMillis = timeoutMillis
socketTimeoutMillis = timeoutMillis
}
}
val bodyText = runCatching { response.bodyAsText() }.getOrElse { "" }
return RequestResponse(
ok = response.status.isSuccess(),
status = response.status.value,
statusText = response.status.description,
url = response.request.url.toString(),
body = bodyText,
)
}
private fun getHost(url: String): String? {
val components = NSURLComponents(string = url) ?: return null
return components.host
}
private fun getQueryParameter(url: String, name: String): String? {
val components = NSURLComponents(string = url) ?: return null
return queryItems(components).firstOrNull { it.name == name }?.value
}
private fun queryItems(components: NSURLComponents): List<NSURLQueryItem> {
val raw = components.queryItems as? List<*> ?: return emptyList()
return raw.mapNotNull { it as? NSURLQueryItem }
}
private fun absolutizeUrl(baseUrl: String, maybeRelative: String): String {
if (maybeRelative.startsWith("http://") || maybeRelative.startsWith("https://")) {
return maybeRelative
}
if (maybeRelative.startsWith('/')) {
val origin = Regex("^(https?://[^/]+)").find(baseUrl)?.groupValues?.getOrNull(1)
return if (origin != null) origin + maybeRelative else maybeRelative
}
val baseDir = baseUrl.substringBeforeLast('/', missingDelimiterValue = baseUrl)
return "$baseDir/$maybeRelative"
}
private fun encodeUrlComponent(value: String): String {
return value
.replace("%", "%25")
.replace("+", "%2B")
.replace(" ", "%20")
.replace("&", "%26")
.replace("=", "%3D")
}
}
private fun JsonObject.objectValue(key: String): JsonObject? {
return this[key] as? JsonObject
}
private fun JsonObject.listObjectValue(key: String): List<JsonObject> {
return (this[key] as? JsonArray)
?.mapNotNull { it as? JsonObject }
.orEmpty()
}
private fun JsonObject.stringValue(key: String): String? {
val primitive = this[key] as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.content else primitive.toString().trim('"')
}
private fun JsonObject.numberValue(key: String): Double? {
val primitive = this[key] as? JsonPrimitive ?: return null
return primitive.toString().trim('"').toDoubleOrNull()
}
private fun jsonObjectOf(vararg pairs: Pair<String, Any?>): JsonObject {
val mapped = LinkedHashMap<String, JsonElement>()
pairs.forEach { (key, value) ->
value?.let { mapped[key] = toJsonElement(it) }
}
return JsonObject(mapped)
}
private fun toJsonElement(value: Any): JsonElement {
return when (value) {
is JsonElement -> value
is JsonObject -> value
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Int -> JsonPrimitive(value)
is Long -> JsonPrimitive(value)
is Double -> JsonPrimitive(value)
is Float -> JsonPrimitive(value)
is Number -> JsonPrimitive(value.toDouble())
is Map<*, *> -> {
val map = LinkedHashMap<String, JsonElement>()
value.forEach { (k, v) ->
val key = k?.toString() ?: return@forEach
if (v != null) {
map[key] = toJsonElement(v)
}
}
JsonObject(map)
}
is List<*> -> JsonArray(value.mapNotNull { it?.let(::toJsonElement) })
else -> JsonPrimitive(value.toString())
}
}

View file

@ -0,0 +1,10 @@
package com.nuvio.app.features.trailer
actual object TrailerPlaybackResolver {
private val extractor by lazy { InAppYouTubeExtractor() }
actual suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? {
if (youtubeUrl.isBlank()) return null
return extractor.extractPlaybackSource(youtubeUrl)
}
}

View file

@ -16,6 +16,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
}
func loadFile(url: String) { playerVC?.loadFile(url) }
func loadFileWithAudio(videoUrl: String, audioUrl: String?) { playerVC?.loadFile(videoUrl, audioUrl: audioUrl) }
func play() { playerVC?.playPlayback() }
func pause() { playerVC?.pausePlayback() }
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
@ -220,12 +221,17 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API
func loadFile(_ urlString: String) {
func loadFile(_ urlString: String, audioUrl: String? = nil) {
guard mpv != nil else { return }
clearPlaybackError()
isPlayerLoading = true
isPlayerEnded = false
command("loadfile", args: [urlString, "replace"])
if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
}
}
}
func playPlayback() {