diff --git a/JustPlayer b/JustPlayer deleted file mode 160000 index 3d432407d..000000000 --- a/JustPlayer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3d432407da3665aa0636f131f6801c5bc35c50e3 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4fc96303d..490ead69f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 88752fba7..82d7345f6 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -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 } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt index 962443c2e..0e777f4ed 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -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) { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/InAppYouTubeExtractor.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/InAppYouTubeExtractor.kt new file mode 100644 index 000000000..05b3941e1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/InAppYouTubeExtractor.kt @@ -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, + 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() + val adaptiveVideo = mutableListOf() + val adaptiveAudio = mutableListOf() + val manifestUrls = mutableListOf>() + + 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() + } + + 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 { + val index = line.indexOf(':') + if (index == -1) return emptyMap() + + val raw = line.substring(index + 1) + val out = LinkedHashMap() + 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 { + 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): List { + return items.sortedWith( + compareByDescending { 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, 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() + 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, + 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): 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> { + 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 + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.android.kt new file mode 100644 index 000000000..fcfb84e0c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.android.kt @@ -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) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt new file mode 100644 index 000000000..66a3f5c96 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt @@ -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 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index b94ef2c61..3523bbbe4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index f31e4fa0a..dda93f308 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -32,10 +32,24 @@ data class MetaDetails( val moreLikeThis: List = emptyList(), val collectionName: String? = null, val collectionItems: List = emptyList(), + val trailers: List = emptyList(), val links: List = emptyList(), val videos: List = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 3a1f06f96..f21a2ee43 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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 = + 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 { val arr = this["streams"] as? JsonArray ?: return emptyList() return arr.mapNotNull { element -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index f7bbff5e0..66273faab 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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(null) } + var trailerPlaybackSource by remember(meta.id) { mutableStateOf(null) } + var trailerLoading by remember(meta.id) { mutableStateOf(false) } + var trailerErrorMessage by remember(meta.id) { mutableStateOf(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) } + }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt new file mode 100644 index 000000000..cdd7ba115 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -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, + onTrailerClick: (MetaTrailer) -> Unit, + modifier: Modifier = Modifier, +) { + if (trailers.isEmpty()) return + + val grouped = remember(trailers) { + linkedMapOf>().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, + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt new file mode 100644 index 000000000..ce948ca24 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -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(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 }, + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index e5a443d95..531709136 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 3f9d4193b..8297f2954 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index f5847a4d4..3d6ddc2b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index 06997147e..56c4df1ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -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", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index ba1f944f7..d0e3650ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -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, TmdbEpisodeEnrichment>>() private val moreLikeThisCache = mutableMapOf>() private val collectionCache = mutableMapOf>>() + private val trailerCache = mutableMapOf>() 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 { + val cacheKey = "$tmdbId:$mediaType:$language:trailers" + trailerCache[cacheKey]?.let { return it } + + val allVideos = mutableListOf() + + 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( + 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>() + 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 { + 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 { 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 { + val response = fetch( + 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 = emptyList(), val moreLikeThis: List = emptyList(), + val trailers: List = 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, + val trailers: List, +) + internal data class TmdbEpisodeEnrichment( val title: String?, val overview: String?, @@ -746,8 +877,47 @@ private data class TmdbDetailsResponse( @SerialName("production_companies") val productionCompanies: List = emptyList(), val networks: List = 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 = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt index 661b0bae5..010081379 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt index 3a1027329..cd4952559 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt index bdf6dcce2..ce284d1cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -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? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.kt new file mode 100644 index 000000000..b15dc7b99 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.trailer + +expect object TrailerPlaybackResolver { + suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackSource.kt new file mode 100644 index 000000000..f740673db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackSource.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.trailer + +data class TrailerPlaybackSource( + val videoUrl: String, + val audioUrl: String? = null, +) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 8259f7214..4423a7151 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -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) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index ad76ae8f2..cf4a9e1b5 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -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 { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt index e9387d896..917ca5c85 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -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) { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trailer/InAppYouTubeExtractor.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trailer/InAppYouTubeExtractor.kt new file mode 100644 index 000000000..f89b66173 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trailer/InAppYouTubeExtractor.kt @@ -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() + val adaptiveVideo = mutableListOf() + val adaptiveAudio = mutableListOf() + val manifestUrls = mutableListOf>() + + 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 { + val index = line.indexOf(':') + if (index == -1) return emptyMap() + + val raw = line.substring(index + 1) + val out = LinkedHashMap() + 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 { + 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): List { + return items.sortedWith( + compareByDescending { it.score } + .thenBy { if (it.hasN) 1 else 0 } + .thenBy { containerPreference(it.ext) } + .thenBy { it.priority }, + ) + } + + private fun pickBestForClient(items: List, 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, + 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 { + 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 { + 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): JsonObject { + val mapped = LinkedHashMap() + 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() + 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()) + } +} + diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.ios.kt new file mode 100644 index 000000000..fcfb84e0c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.ios.kt @@ -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) + } +} diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 3fbf6d09e..195d86cda 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -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() {