mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-04 10:36:56 +00:00
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:
parent
7f8f9c11a1
commit
b2356ec6d3
29 changed files with 2501 additions and 13 deletions
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 3d432407da3665aa0636f131f6801c5bc35c50e3
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.trailer
|
||||
|
||||
expect object TrailerPlaybackResolver {
|
||||
suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource?
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.trailer
|
||||
|
||||
data class TrailerPlaybackSource(
|
||||
val videoUrl: String,
|
||||
val audioUrl: String? = null,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue