diff --git a/app/src/main/assets/js/simple-youtube-extractor.js b/app/src/main/assets/js/simple-youtube-extractor.js new file mode 100644 index 00000000..a1529de0 --- /dev/null +++ b/app/src/main/assets/js/simple-youtube-extractor.js @@ -0,0 +1,492 @@ +const RuntimeURL = + (typeof globalThis !== 'undefined' && typeof globalThis.URL !== 'undefined') + ? globalThis.URL + : (typeof require === 'function' ? require('url').URL : null); + +const RuntimeFetch = + (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') + ? globalThis.fetch.bind(globalThis) + : (typeof require === 'function' ? require('node-fetch') : null); + +const DEFAULT_HEADERS = { + 'accept-language': 'en-US,en;q=0.9', + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', +}; + +const CLIENTS = [ + { + 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: { + clientName: 'IOS', + clientVersion: '20.10.1', + deviceModel: 'iPhone16,2', + osName: 'iPhone', + osVersion: '17.4.0.21E219', + platform: 'MOBILE', + hl: 'en', + gl: 'US', + }, + priority: 0, + }, + { + 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: { + clientName: 'ANDROID_VR', + clientVersion: '1.56.21', + deviceMake: 'Oculus', + deviceModel: 'Quest 3', + osName: 'Android', + osVersion: '12', + platform: 'MOBILE', + androidSdkVersion: 32, + hl: 'en', + gl: 'US', + }, + priority: 1, + }, + { + key: 'android', + id: '3', + version: '20.10.35', + userAgent: 'com.google.android.youtube/20.10.35 (Linux; U; Android 14; en_US) gzip', + context: { + clientName: 'ANDROID', + clientVersion: '20.10.35', + osName: 'Android', + osVersion: '14', + platform: 'MOBILE', + androidSdkVersion: 34, + hl: 'en', + gl: 'US', + }, + priority: 2, + }, +]; + +function extractVideoId(input) { + if (!input || typeof input !== 'string') return null; + if (/^[a-zA-Z0-9_-]{11}$/.test(input)) return input; + if (!RuntimeURL) return null; + + try { + const parsed = new RuntimeURL(input); + if (parsed.hostname === 'youtu.be') { + const id = parsed.pathname.split('/').filter(Boolean)[0]; + return /^[a-zA-Z0-9_-]{11}$/.test(id || '') ? id : null; + } + const fromQuery = parsed.searchParams.get('v'); + if (/^[a-zA-Z0-9_-]{11}$/.test(fromQuery || '')) return fromQuery; + const embedMatch = parsed.pathname.match(/\/(?:embed|shorts|live)\/([a-zA-Z0-9_-]{11})/); + if (embedMatch) return embedMatch[1]; + } catch { + return null; + } + + return null; +} + +function getWatchConfig(html) { + const apiKey = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/)?.[1] || null; + const visitorData = html.match(/"VISITOR_DATA":"([^"]+)"/)?.[1] || null; + const clientVersion = html.match(/"INNERTUBE_CLIENT_VERSION":"([^"]+)"/)?.[1] || null; + return { apiKey, visitorData, clientVersion }; +} + +function hasNParam(urlStr) { + if (!RuntimeURL) return false; + try { + return new RuntimeURL(urlStr).searchParams.has('n'); + } catch { + return false; + } +} + +function parseQualityLabel(label) { + if (!label) return 0; + const m = String(label).match(/(\d{2,4})p/); + return m ? Number(m[1]) : 0; +} + +function getCodecFlags(mimeType) { + const mt = String(mimeType || ''); + return { + video: mt.includes('video/'), + audio: mt.includes('audio/'), + }; +} + +function videoScore(fmt) { + const height = Number(fmt.height || parseQualityLabel(fmt.qualityLabel) || 0); + const fps = Number(fmt.fps || 0); + const bitrate = Number(fmt.bitrate || fmt.averageBitrate || 0); + return height * 1e9 + fps * 1e6 + bitrate; +} + +function audioScore(fmt) { + const bitrate = Number(fmt.bitrate || fmt.averageBitrate || 0); + const asr = Number(fmt.audioSampleRate || 0); + return bitrate * 1e6 + asr; +} + +function sortCandidates(items) { + return [...items].sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const aHasN = a.hasN ? 1 : 0; + const bHasN = b.hasN ? 1 : 0; + if (aHasN !== bHasN) return aHasN - bHasN; + return (a.priority ?? 99) - (b.priority ?? 99); + }); +} + +function pickBestForClient(items, clientKey) { + const sameClient = items.filter((x) => x.client === clientKey); + if (sameClient.length > 0) return sortCandidates(sameClient)[0] || null; + return sortCandidates(items)[0] || null; +} + +function absolutizeUrl(baseUrl, maybeRelative) { + if (!RuntimeURL) return maybeRelative; + try { + return new RuntimeURL(maybeRelative, baseUrl).toString(); + } catch { + return maybeRelative; + } +} + +function parseHlsAttributeList(line) { + const idx = line.indexOf(':'); + if (idx === -1) return {}; + const raw = line.slice(idx + 1); + const out = {}; + let key = ''; + let val = ''; + let inKey = true; + let inQuote = false; + + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (inKey) { + if (ch === '=') { + inKey = false; + } else { + key += ch; + } + continue; + } + + if (ch === '"') { + inQuote = !inQuote; + continue; + } + if (ch === ',' && !inQuote) { + out[key.trim()] = val.trim(); + key = ''; + val = ''; + inKey = true; + continue; + } + val += ch; + } + if (key.trim()) out[key.trim()] = val.trim(); + return out; +} + +async function parseHlsManifest(manifestUrl, fetchImpl) { + const resp = await fetchImpl(manifestUrl, { headers: DEFAULT_HEADERS }); + if (!resp.ok) throw new Error(`Failed to fetch HLS manifest (${resp.status})`); + const text = await resp.text(); + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); + + const variants = []; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (!line.startsWith('#EXT-X-STREAM-INF:')) continue; + const attrs = parseHlsAttributeList(line); + const nextLine = lines[i + 1]; + if (!nextLine || nextLine.startsWith('#')) continue; + + const [w, h] = String(attrs.RESOLUTION || '') + .split('x') + .map((n) => Number(n || 0)); + const bandwidth = Number(attrs.BANDWIDTH || 0); + variants.push({ + width: Number.isFinite(w) ? w : 0, + height: Number.isFinite(h) ? h : 0, + bandwidth: Number.isFinite(bandwidth) ? bandwidth : 0, + audioGroup: attrs.AUDIO || null, + codecs: attrs.CODECS || '', + url: absolutizeUrl(manifestUrl, nextLine), + }); + } + + variants.sort((a, b) => { + if (b.height !== a.height) return b.height - a.height; + if (b.bandwidth !== a.bandwidth) return b.bandwidth - a.bandwidth; + return b.width - a.width; + }); + + return { + variants, + bestVariant: variants[0] || null, + }; +} + +async function fetchPlayerResponse({ apiKey, videoId, client, visitorData, cookieHeader, fetchImpl }) { + const endpoint = `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}`; + const headers = { + ...DEFAULT_HEADERS, + 'content-type': 'application/json', + origin: 'https://www.youtube.com', + 'x-youtube-client-name': client.id, + 'x-youtube-client-version': client.version, + 'user-agent': client.userAgent, + }; + if (visitorData) headers['x-goog-visitor-id'] = visitorData; + if (cookieHeader) headers.cookie = cookieHeader; + + const payload = { + videoId, + contentCheckOk: true, + racyCheckOk: true, + context: { client: client.context }, + playbackContext: { + contentPlaybackContext: { + html5Preference: 'HTML5_PREF_WANTS', + }, + }, + }; + + const resp = await fetchImpl(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`player API ${client.key} failed (${resp.status}): ${text.slice(0, 200)}`); + } + return resp.json(); +} + +async function extractBestUrlsNoResolver(youtubeUrl, options = {}) { + const fetchImpl = options.fetchImpl || RuntimeFetch; + if (typeof fetchImpl !== 'function') { + throw new Error('No fetch implementation available in current runtime'); + } + const videoId = extractVideoId(youtubeUrl); + if (!videoId) throw new Error('Invalid YouTube URL or video id'); + + const watchResp = await fetchImpl(`https://www.youtube.com/watch?v=${videoId}&hl=en`, { + headers: DEFAULT_HEADERS, + }); + if (!watchResp.ok) throw new Error(`Failed to fetch watch page (${watchResp.status})`); + const watchHtml = await watchResp.text(); + const cfg = getWatchConfig(watchHtml); + if (!cfg.apiKey) throw new Error('Unable to extract INNERTUBE_API_KEY from watch page'); + + const clients = CLIENTS.map((c, idx) => { + if (idx === 0 && cfg.clientVersion) { + return { + ...c, + version: c.key === 'ios' ? c.version : c.version, + }; + } + return c; + }); + + const titleCandidates = []; + const progressive = []; + const adaptiveVideo = []; + const adaptiveAudio = []; + const manifests = []; + + for (const client of clients) { + try { + const pr = await fetchPlayerResponse({ + apiKey: cfg.apiKey, + videoId, + client, + visitorData: cfg.visitorData, + cookieHeader: options.cookieHeader || '', + fetchImpl, + }); + if (pr?.videoDetails?.title) titleCandidates.push(pr.videoDetails.title); + const sd = pr?.streamingData; + if (!sd) continue; + + if (sd.hlsManifestUrl) { + manifests.push({ + client: client.key, + priority: client.priority, + url: sd.hlsManifestUrl, + }); + } + + for (const fmt of sd.formats || []) { + if (!fmt?.url) continue; + const codecs = getCodecFlags(fmt.mimeType); + if (!codecs.video && fmt.mimeType) continue; + progressive.push({ + client: client.key, + priority: client.priority, + itag: String(fmt.itag || ''), + height: Number(fmt.height || parseQualityLabel(fmt.qualityLabel) || 0), + fps: Number(fmt.fps || 0), + ext: String(fmt.mimeType || '').includes('webm') ? 'webm' : 'mp4', + score: videoScore(fmt), + hasN: hasNParam(fmt.url), + url: fmt.url, + }); + } + + for (const fmt of sd.adaptiveFormats || []) { + if (!fmt?.url) continue; + const codecs = getCodecFlags(fmt.mimeType); + if (codecs.video) { + adaptiveVideo.push({ + client: client.key, + priority: client.priority, + itag: String(fmt.itag || ''), + height: Number(fmt.height || parseQualityLabel(fmt.qualityLabel) || 0), + fps: Number(fmt.fps || 0), + ext: String(fmt.mimeType || '').includes('webm') ? 'webm' : 'mp4', + score: videoScore(fmt), + hasN: hasNParam(fmt.url), + url: fmt.url, + }); + } else if (codecs.audio || String(fmt.mimeType || '').startsWith('audio/')) { + adaptiveAudio.push({ + client: client.key, + priority: client.priority, + itag: String(fmt.itag || ''), + ext: String(fmt.mimeType || '').includes('webm') ? 'webm' : 'm4a', + score: audioScore(fmt), + hasN: hasNParam(fmt.url), + url: fmt.url, + }); + } + } + } catch (err) { + if (options.debug) { + // eslint-disable-next-line no-console + console.error(`[simple-extractor] client ${client.key} failed: ${err.message}`); + } + } + } + + if ( + manifests.length === 0 && + progressive.length === 0 && + adaptiveVideo.length === 0 && + adaptiveAudio.length === 0 + ) { + throw new Error('No playable URLs returned by API clients'); + } + + let bestManifest = null; + for (const m of manifests) { + try { + const parsed = await parseHlsManifest(m.url, fetchImpl); + const v = parsed.bestVariant; + if (!v) continue; + const candidate = { + client: m.client, + priority: m.priority, + manifestUrl: m.url, + selectedVariantUrl: v.url, + height: Number(v.height || 0), + bandwidth: Number(v.bandwidth || 0), + }; + if ( + !bestManifest || + candidate.height > bestManifest.height || + (candidate.height === bestManifest.height && candidate.bandwidth > bestManifest.bandwidth) + ) { + bestManifest = candidate; + } + } catch (err) { + if (options.debug) { + // eslint-disable-next-line no-console + console.error(`[simple-extractor] manifest parse failed: ${err.message}`); + } + } + } + + const bestProgressive = sortCandidates(progressive)[0] || null; + const separateClient = options.separateClient || 'android_vr'; + const bestVideo = pickBestForClient(adaptiveVideo, separateClient); + const bestAudio = pickBestForClient(adaptiveAudio, separateClient); + + const bestCombinedIsManifest = + bestManifest && (!bestProgressive || bestManifest.height > (bestProgressive.height || 0)); + + return { + ok: true, + mode: 'no_resolver', + videoId, + title: titleCandidates[0] || null, + combined: bestCombinedIsManifest + ? { + type: 'hls_manifest', + client: bestManifest.client, + url: bestManifest.manifestUrl, + selectedVariantUrl: bestManifest.selectedVariantUrl, + quality: { + height: bestManifest.height, + bandwidth: bestManifest.bandwidth, + }, + } + : bestProgressive + ? { + type: 'progressive', + client: bestProgressive.client, + url: bestProgressive.url, + quality: { + itag: bestProgressive.itag, + height: bestProgressive.height, + fps: bestProgressive.fps, + ext: bestProgressive.ext, + hasN: bestProgressive.hasN, + }, + } + : null, + separate: { + video: bestVideo + ? { + client: bestVideo.client, + url: bestVideo.url, + itag: bestVideo.itag, + height: bestVideo.height, + fps: bestVideo.fps, + ext: bestVideo.ext, + hasN: bestVideo.hasN, + } + : null, + audio: bestAudio + ? { + client: bestAudio.client, + url: bestAudio.url, + itag: bestAudio.itag, + ext: bestAudio.ext, + hasN: bestAudio.hasN, + } + : null, + }, + preferredSeparateClient: separateClient, + note: + 'No JS signature/n resolver is used. Some URLs may still fail if YouTube requires n transform for that specific format/client.', + }; +} + +module.exports = { + extractVideoId, + extractBestUrlsNoResolver, +}; diff --git a/app/src/main/java/com/nuvio/tv/data/trailer/InAppYouTubeExtractor.kt b/app/src/main/java/com/nuvio/tv/data/trailer/InAppYouTubeExtractor.kt new file mode 100644 index 00000000..e2e65a65 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/trailer/InAppYouTubeExtractor.kt @@ -0,0 +1,379 @@ +package com.nuvio.tv.data.trailer + +import android.content.Context +import android.util.Log +import com.dokar.quickjs.binding.asyncFunction +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function +import com.dokar.quickjs.quickJs +import com.google.gson.Gson +import com.nuvio.tv.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.Call +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URL +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "InAppYouTubeExtractor" +private const val EXTRACTOR_TIMEOUT_MS = 45_000L + +@Singleton +class InAppYouTubeExtractor @Inject constructor( + @ApplicationContext private val context: Context +) { + 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() + + @Volatile + private var cachedScript: String? = null + + suspend fun extractPlaybackSource(youtubeUrl: String): TrailerPlaybackSource? = withContext(Dispatchers.IO) { + if (youtubeUrl.isBlank()) return@withContext null + + Log.d(TAG, "Starting in-app extraction for ${summarizeUrl(youtubeUrl)}") + val script = loadExtractorScript() ?: return@withContext null + var resultJson = "" + + val inFlightCalls = ConcurrentHashMap.newKeySet() + + try { + withTimeout(EXTRACTOR_TIMEOUT_MS) { + quickJs(Dispatchers.IO) { + define("console") { + function("log") { args -> + if (BuildConfig.DEBUG) { + Log.d(TAG, args.joinToString(" ") { it?.toString() ?: "null" }) + } + null + } + function("error") { args -> + Log.e(TAG, args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + function("warn") { args -> + Log.w(TAG, args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + } + + asyncFunction("__native_fetch") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + val method = args.getOrNull(1)?.toString() ?: "GET" + val headersJson = args.getOrNull(2)?.toString() ?: "{}" + val body = args.getOrNull(3)?.toString() ?: "" + performNativeFetch(url, method, headersJson, body, inFlightCalls) + } + + function("__parse_url") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + parseUrl(url) + } + + function("__capture_result") { args -> + resultJson = args.getOrNull(0)?.toString() ?: "" + null + } + + val bootstrap = """ + var fetch = async function(url, options) { + options = options || {}; + var method = (options.method || 'GET').toUpperCase(); + var headers = options.headers || {}; + var body = options.body || ''; + if (!headers['User-Agent'] && !headers['user-agent']) { + headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + } + + var raw = await __native_fetch(url, method, JSON.stringify(headers), body); + var parsed = JSON.parse(raw); + + return { + ok: parsed.ok, + status: parsed.status, + statusText: parsed.statusText, + url: parsed.url, + headers: { + get: function(name) { + return parsed.headers[(name || '').toLowerCase()] || null; + } + }, + text: function() { return Promise.resolve(parsed.body || ''); }, + json: function() { + try { + return Promise.resolve(JSON.parse(parsed.body || '{}')); + } catch (e) { + return Promise.reject(e); + } + } + }; + }; + + var URLSearchParams = function(init) { + this._params = {}; + var self = this; + if (typeof init === 'string') { + init.replace(/^\?/, '').split('&').forEach(function(pair) { + if (!pair) return; + var parts = pair.split('='); + var key = decodeURIComponent(parts[0] || ''); + if (!key) return; + var value = decodeURIComponent(parts[1] || ''); + self._params[key] = value; + }); + } + }; + URLSearchParams.prototype.get = function(k) { + return this._params.hasOwnProperty(k) ? this._params[k] : null; + }; + URLSearchParams.prototype.has = function(k) { + return this._params.hasOwnProperty(k); + }; + + var URL = function(urlString, base) { + var fullUrl = urlString; + if (base && !/^https?:\/\//i.test(urlString)) { + var b = typeof base === 'string' ? base : base.href; + if (urlString.charAt(0) === '/') { + var m = b.match(/^(https?:\/\/[^\/]+)/); + fullUrl = m ? m[1] + urlString : urlString; + } else { + fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; + } + } + var parsed = JSON.parse(__parse_url(fullUrl)); + this.href = fullUrl; + this.protocol = parsed.protocol; + this.host = parsed.host; + this.hostname = parsed.hostname; + this.port = parsed.port; + this.pathname = parsed.pathname; + this.search = parsed.search; + this.hash = parsed.hash; + this.origin = this.protocol + '//' + this.host; + this.searchParams = new URLSearchParams(parsed.search || ''); + }; + URL.prototype.toString = function() { return this.href; }; + + var require = function(moduleName) { + if (moduleName === 'url') { + return { URL: URL }; + } + if (moduleName === 'node-fetch') { + return fetch; + } + throw new Error("Module '" + moduleName + "' is not available"); + }; + """.trimIndent() + + evaluate(bootstrap) + evaluate("var module = { exports: {} }; var exports = module.exports;") + evaluate(script) + + val callCode = """ + (async function() { + try { + var fn = module.exports.extractBestUrlsNoResolver || globalThis.extractBestUrlsNoResolver; + if (!fn) { + __capture_result(''); + return; + } + var result = await fn(${gson.toJson(youtubeUrl)}, { debug: false, separateClient: 'android_vr' }); + __capture_result(JSON.stringify(result || {})); + } catch (e) { + __capture_result(''); + } + })(); + """.trimIndent() + evaluate(callCode) + } + } + } catch (error: Exception) { + Log.w(TAG, "Extractor failed for $youtubeUrl: ${error.message}") + } finally { + inFlightCalls.forEach { call -> call.cancel() } + inFlightCalls.clear() + } + + val source = parsePlaybackSource(resultJson) + if (source == null) { + Log.w(TAG, "In-app extraction returned no playable source for ${summarizeUrl(youtubeUrl)}") + } else { + Log.d( + TAG, + "In-app extraction success for ${summarizeUrl(youtubeUrl)} " + + "(video=${summarizeUrl(source.videoUrl)}, audioPresent=${!source.audioUrl.isNullOrBlank()})" + ) + } + source + } + + private fun parsePlaybackSource(resultJson: String): TrailerPlaybackSource? { + if (resultJson.isBlank()) return null + + return runCatching { + val root = gson.fromJson(resultJson, Map::class.java) ?: return null + val combined = root["combined"] as? Map<*, *> + val separate = root["separate"] as? Map<*, *> + val separateVideo = separate?.get("video") as? Map<*, *> + val separateAudio = separate?.get("audio") as? Map<*, *> + + val combinedUrl = combined?.get("url")?.toString()?.takeIf { it.startsWith("http") } + val videoUrl = (separateVideo?.get("url")?.toString() ?: combinedUrl) + ?.takeIf { it.startsWith("http") } + ?: return null + val audioUrl = separateAudio?.get("url")?.toString()?.takeIf { it.startsWith("http") } + + TrailerPlaybackSource( + videoUrl = videoUrl, + audioUrl = audioUrl + ) + }.getOrNull() + } + + private fun loadExtractorScript(): String? { + cachedScript?.let { + return it + } + + val loaded = runCatching { + context.assets.open("js/simple-youtube-extractor.js").bufferedReader().use { it.readText() } + }.getOrElse { + Log.e(TAG, "Failed to load extractor script: ${it.message}") + return null + } + + cachedScript = loaded + Log.d(TAG, "Loaded extractor script from assets (chars=${loaded.length})") + return loaded + } + + 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 parseUrl(urlString: String): String { + return try { + val url = URL(urlString) + gson.toJson( + mapOf( + "protocol" to "${url.protocol}:", + "host" to if (url.port > 0) "${url.host}:${url.port}" else url.host, + "hostname" to url.host, + "port" to if (url.port > 0) url.port.toString() else "", + "pathname" to (url.path ?: "/"), + "search" to if (url.query != null) "?${url.query}" else "", + "hash" to if (url.ref != null) "#${url.ref}" else "" + ) + ) + } catch (_: Exception) { + gson.toJson( + mapOf( + "protocol" to "", + "host" to "", + "hostname" to "", + "port" to "", + "pathname" to "/", + "search" to "", + "hash" to "" + ) + ) + } + } + + private fun performNativeFetch( + url: String, + method: String, + headersJson: String, + body: String, + inFlightCalls: MutableSet + ): String { + return try { + val parsedHeaders = runCatching { + gson.fromJson(headersJson, Map::class.java) + }.getOrNull() + + val headers = mutableMapOf() + parsedHeaders?.forEach { (k, v) -> + if (k != null && v != null) { + val key = k.toString() + if (!key.equals("Accept-Encoding", ignoreCase = true)) { + headers[key] = v.toString() + } + } + } + + if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + headers["User-Agent"] = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" + } + + val requestBuilder = Request.Builder() + .url(url) + .headers(Headers.headersOf(*headers.flatMap { listOf(it.key, it.value) }.toTypedArray())) + + when (method.uppercase()) { + "POST" -> requestBuilder.post(body.toRequestBody()) + "PUT" -> requestBuilder.put(body.toRequestBody()) + "DELETE" -> requestBuilder.delete() + else -> requestBuilder.get() + } + + val call = httpClient.newCall(requestBuilder.build()) + inFlightCalls.add(call) + + try { + call.execute().use { response -> + val responseHeaders = mutableMapOf() + response.headers.forEach { (name, value) -> + responseHeaders[name.lowercase()] = value + } + + gson.toJson( + mapOf( + "ok" to response.isSuccessful, + "status" to response.code, + "statusText" to response.message, + "url" to response.request.url.toString(), + "body" to (response.body?.string() ?: ""), + "headers" to responseHeaders + ) + ) + } + } finally { + inFlightCalls.remove(call) + } + } catch (error: Exception) { + gson.toJson( + mapOf( + "ok" to false, + "status" to 0, + "statusText" to (error.message ?: "Fetch failed"), + "url" to url, + "body" to "", + "headers" to emptyMap() + ) + ) + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/trailer/TrailerPlaybackSource.kt b/app/src/main/java/com/nuvio/tv/data/trailer/TrailerPlaybackSource.kt new file mode 100644 index 00000000..19209cb7 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/trailer/TrailerPlaybackSource.kt @@ -0,0 +1,6 @@ +package com.nuvio.tv.data.trailer + +data class TrailerPlaybackSource( + val videoUrl: String, + val audioUrl: String? = null +) diff --git a/app/src/main/java/com/nuvio/tv/data/trailer/TrailerService.kt b/app/src/main/java/com/nuvio/tv/data/trailer/TrailerService.kt index 4e6e56b3..ced9a8c2 100644 --- a/app/src/main/java/com/nuvio/tv/data/trailer/TrailerService.kt +++ b/app/src/main/java/com/nuvio/tv/data/trailer/TrailerService.kt @@ -5,6 +5,7 @@ import com.nuvio.tv.data.remote.api.TrailerApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap +import java.net.URI import javax.inject.Inject import javax.inject.Singleton @@ -12,21 +13,22 @@ private const val TAG = "TrailerService" @Singleton class TrailerService @Inject constructor( - private val trailerApi: TrailerApi + private val trailerApi: TrailerApi, + private val inAppYouTubeExtractor: InAppYouTubeExtractor ) { - // Cache: "title|year|tmdbId|type" -> streaming URL (null for negative cache) - private val cache = ConcurrentHashMap() + // Cache: "title|year|tmdbId|type" -> trailer playback source (null for negative cache) + private val cache = ConcurrentHashMap() /** * Search for a trailer by title, year, tmdbId, and type. - * Returns a direct streaming URL or null. + * Returns the trailer playback source (video URL + optional separate audio URL) or null. */ - suspend fun getTrailerUrl( + suspend fun getTrailerPlaybackSource( title: String, year: String? = null, tmdbId: String? = null, type: String? = null - ): String? = withContext(Dispatchers.IO) { + ): TrailerPlaybackSource? = withContext(Dispatchers.IO) { val cacheKey = "$title|$year|$tmdbId|$type" if (cache.containsKey(cacheKey)) { @@ -46,10 +48,11 @@ class TrailerService @Inject constructor( if (response.isSuccessful) { val url = response.body()?.url - if (isValidUrl(url)) { - Log.d(TAG, "Found trailer URL for $title") - cache[cacheKey] = url - return@withContext url + val source = resolvePlaybackSource(url, title, year) + if (source != null) { + Log.d(TAG, "Found trailer playback source for $title") + cache[cacheKey] = source + return@withContext source } } @@ -63,32 +66,103 @@ class TrailerService @Inject constructor( } /** - * Get a direct streaming URL from a YouTube URL. + * Search for a trailer and return its primary video URL for existing call sites. + */ + suspend fun getTrailerUrl( + title: String, + year: String? = null, + tmdbId: String? = null, + type: String? = null + ): String? { + return getTrailerPlaybackSource( + title = title, + year = year, + tmdbId = tmdbId, + type = type + )?.videoUrl + } + + /** + * Resolve a YouTube trailer URL to a playback source (prefers in-app extraction). + */ + suspend fun getTrailerPlaybackSourceFromYouTubeUrl( + youtubeUrl: String, + title: String? = null, + year: String? = null + ): TrailerPlaybackSource? = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Attempting in-app YouTube extraction for ${summarizeUrl(youtubeUrl)}") + val localSource = inAppYouTubeExtractor.extractPlaybackSource(youtubeUrl) + if (localSource != null) { + Log.d( + TAG, + "Using in-app YouTube source for ${summarizeUrl(youtubeUrl)} " + + "(audioPresent=${!localSource.audioUrl.isNullOrBlank()})" + ) + return@withContext localSource + } + + // Fallback to remote trailer resolver if in-app extraction fails. + Log.w(TAG, "In-app extraction failed, falling back to backend resolver for ${summarizeUrl(youtubeUrl)}") + val response = trailerApi.getTrailer(youtubeUrl = youtubeUrl, title = title, year = year) + if (!response.isSuccessful) { + Log.w(TAG, "Backend trailer fallback failed (${response.code()}) for ${summarizeUrl(youtubeUrl)}") + return@withContext null + } + + val fallbackUrl = response.body()?.url ?: return@withContext null + if (!isValidUrl(fallbackUrl)) return@withContext null + + Log.d(TAG, "Using backend fallback source for ${summarizeUrl(youtubeUrl)}") + TrailerPlaybackSource(videoUrl = fallbackUrl) + } catch (e: Exception) { + Log.e(TAG, "Error getting trailer from YouTube: ${e.message}", e) + null + } + } + + /** + * Compatibility method for existing callers expecting a single URL. */ suspend fun getTrailerFromYouTubeUrl( youtubeUrl: String, title: String? = null, year: String? = null - ): String? = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Getting trailer from YouTube URL: $youtubeUrl") - val response = trailerApi.getTrailer( - youtubeUrl = youtubeUrl, + ): String? { + return getTrailerPlaybackSourceFromYouTubeUrl( + youtubeUrl = youtubeUrl, + title = title, + year = year + )?.videoUrl + } + + private suspend fun resolvePlaybackSource( + rawUrl: String?, + title: String?, + year: String? + ): TrailerPlaybackSource? { + if (!isValidUrl(rawUrl)) return null + val url = rawUrl!! + + if (isLikelyYouTubeUrl(url)) { + Log.d(TAG, "Trailer URL is YouTube, extracting in-app: ${summarizeUrl(url)}") + val extracted = getTrailerPlaybackSourceFromYouTubeUrl( + youtubeUrl = url, title = title, year = year ) - - if (response.isSuccessful) { - val url = response.body()?.url - if (isValidUrl(url)) { - return@withContext url - } - } - null - } catch (e: Exception) { - Log.e(TAG, "Error getting trailer from YouTube: ${e.message}", e) - null + if (extracted != null) return extracted } + + Log.d(TAG, "Using direct trailer URL (non-YouTube or extraction unavailable): ${summarizeUrl(url)}") + return TrailerPlaybackSource(videoUrl = url) + } + + private fun isLikelyYouTubeUrl(url: String): Boolean { + return runCatching { + val host = URI(url).host?.lowercase()?.removePrefix("www.") ?: return@runCatching false + host == "youtube.com" || host.endsWith(".youtube.com") || host == "youtu.be" + }.getOrDefault(false) } private fun isValidUrl(url: String?): Boolean { @@ -96,6 +170,15 @@ class TrailerService @Inject constructor( return url.startsWith("http://") || url.startsWith("https://") } + private fun summarizeUrl(url: String): String { + return runCatching { + val uri = URI(url) + val host = uri.host ?: "unknown-host" + val path = uri.path ?: "/" + "$host$path" + }.getOrDefault(url.take(80)) + } + fun clearCache() { cache.clear() } diff --git a/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt b/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt index 6eed793f..d2b22bcd 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt @@ -67,6 +67,7 @@ fun CatalogRowSection( focusedPosterBackdropTrailerEnabled: Boolean = false, focusedPosterBackdropTrailerMuted: Boolean = true, trailerPreviewUrls: Map = emptyMap(), + trailerPreviewAudioUrls: Map = emptyMap(), onRequestTrailerPreview: (MetaPreview) -> Unit = {}, onItemFocus: (MetaPreview) -> Unit = {}, isItemWatched: (MetaPreview) -> Boolean = { false }, @@ -207,6 +208,7 @@ fun CatalogRowSection( focusedPosterBackdropTrailerEnabled = focusedPosterBackdropTrailerEnabled, focusedPosterBackdropTrailerMuted = focusedPosterBackdropTrailerMuted, trailerPreviewUrl = trailerPreviewUrls[item.id], + trailerPreviewAudioUrl = trailerPreviewAudioUrls[item.id], onRequestTrailerPreview = onRequestTrailerPreview, isWatched = isItemWatched(item), onFocus = { focusedItem -> diff --git a/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt b/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt index e1478abc..5deeb95e 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt @@ -75,6 +75,7 @@ fun ContentCard( focusedPosterBackdropTrailerEnabled: Boolean = false, focusedPosterBackdropTrailerMuted: Boolean = true, trailerPreviewUrl: String? = null, + trailerPreviewAudioUrl: String? = null, onRequestTrailerPreview: (MetaPreview) -> Unit = {}, isWatched: Boolean = false, onFocus: (MetaPreview) -> Unit = {}, @@ -339,6 +340,7 @@ fun ContentCard( if (shouldPlayTrailerPreview) { TrailerPlayer( trailerUrl = trailerPreviewUrl, + trailerAudioUrl = trailerPreviewAudioUrl, isPlaying = true, onEnded = { trailerFirstFrameRendered = false diff --git a/app/src/main/java/com/nuvio/tv/ui/components/TrailerPlayer.kt b/app/src/main/java/com/nuvio/tv/ui/components/TrailerPlayer.kt index f71f2b0e..984950e3 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/TrailerPlayer.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/TrailerPlayer.kt @@ -26,7 +26,10 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import java.util.concurrent.atomic.AtomicBoolean @@ -36,6 +39,7 @@ import kotlinx.coroutines.delay @Composable fun TrailerPlayer( trailerUrl: String?, + trailerAudioUrl: String? = null, isPlaying: Boolean, onEnded: () -> Unit, onFirstFrameRendered: () -> Unit = {}, @@ -54,6 +58,7 @@ fun TrailerPlayer( val lifecycleOwner = LocalLifecycleOwner.current val currentIsPlaying by rememberUpdatedState(isPlaying) val currentTrailerUrl by rememberUpdatedState(trailerUrl) + val currentTrailerAudioUrl by rememberUpdatedState(trailerAudioUrl) val currentOnEnded by rememberUpdatedState(onEnded) val currentOnFirstFrameRendered by rememberUpdatedState(onFirstFrameRendered) val currentOnProgressChanged by rememberUpdatedState(onProgressChanged) @@ -66,7 +71,7 @@ fun TrailerPlayer( label = "trailerFirstFrameAlpha" ) - val trailerPlayer = remember(trailerUrl) { + val trailerPlayer = remember(trailerUrl, trailerAudioUrl) { if (trailerUrl != null) { ExoPlayer.Builder(context) .build() @@ -85,12 +90,19 @@ fun TrailerPlayer( } val releaseCalled = remember(trailerPlayer) { AtomicBoolean(false) } - LaunchedEffect(isPlaying, trailerUrl, muted) { + LaunchedEffect(isPlaying, trailerUrl, trailerAudioUrl, muted) { val player = trailerPlayer ?: return@LaunchedEffect player.volume = if (muted) 0f else 1f if (isPlaying && trailerUrl != null) { hasRenderedFirstFrame = false - player.setMediaItem(MediaItem.fromUri(trailerUrl)) + if (!trailerAudioUrl.isNullOrBlank()) { + val mediaSourceFactory = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + val videoSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(trailerUrl)) + val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(trailerAudioUrl)) + player.setMediaSource(MergingMediaSource(videoSource, audioSource)) + } else { + player.setMediaItem(MediaItem.fromUri(trailerUrl)) + } player.prepare() player.playWhenReady = true } else { @@ -148,7 +160,14 @@ fun TrailerPlayer( Lifecycle.Event.ON_RESUME -> { if (currentIsPlaying && !currentTrailerUrl.isNullOrBlank()) { if (player.currentMediaItem == null) { - player.setMediaItem(MediaItem.fromUri(currentTrailerUrl!!)) + if (!currentTrailerAudioUrl.isNullOrBlank()) { + val mediaSourceFactory = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + val videoSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(currentTrailerUrl!!)) + val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(currentTrailerAudioUrl!!)) + player.setMediaSource(MergingMediaSource(videoSource, audioSource)) + } else { + player.setMediaItem(MediaItem.fromUri(currentTrailerUrl!!)) + } player.prepare() } player.playWhenReady = true diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index 484eeb0a..683589af 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt @@ -390,6 +390,7 @@ fun MetaDetailsScreen( viewModel.isSeasonFullyWatched(season) }, trailerUrl = uiState.trailerUrl, + trailerAudioUrl = uiState.trailerAudioUrl, isTrailerPlaying = uiState.isTrailerPlaying, showTrailerControls = uiState.showTrailerControls, hideLogoDuringTrailer = uiState.hideLogoDuringTrailer, @@ -543,6 +544,7 @@ private fun MetaDetailsContent( onMarkPreviousEpisodesWatched: (Video) -> Unit, isSeasonFullyWatched: (Int) -> Boolean, trailerUrl: String?, + trailerAudioUrl: String?, isTrailerPlaying: Boolean, showTrailerControls: Boolean, hideLogoDuringTrailer: Boolean, @@ -972,6 +974,7 @@ private fun MetaDetailsContent( // Trailer video (fades in when trailer plays) TrailerPlayer( trailerUrl = trailerUrl, + trailerAudioUrl = trailerAudioUrl, isPlaying = isTrailerPlaying, seekRequestToken = if (showTrailerControls) trailerSeekToken else 0, seekDeltaMs = if (showTrailerControls) trailerSeekDeltaMs else 0L, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt index d1504e1b..b9b50867 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt @@ -20,6 +20,7 @@ data class MetaDetailsUiState( val nextToWatch: NextToWatch? = null, val episodeProgressMap: Map, WatchProgress> = emptyMap(), val trailerUrl: String? = null, + val trailerAudioUrl: String? = null, val isTrailerPlaying: Boolean = false, val isTrailerLoading: Boolean = false, val showTrailerControls: Boolean = false, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt index 95276905..7df0afa8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt @@ -1363,7 +1363,10 @@ class MetaDetailsViewModel @Inject constructor( if (state.isTrailerLoading) state else state.copy(isTrailerLoading = true) } - val year = meta.releaseInfo?.split("-")?.firstOrNull() + val year = meta.releaseInfo?.let { info -> + if (info.isBlank()) null + else Regex("""\b(19|20)\d{2}\b""").find(info)?.value + } val tmdbId = try { tmdbService.ensureTmdbId(meta.id, meta.apiType) @@ -1371,25 +1374,27 @@ class MetaDetailsViewModel @Inject constructor( null } - val type = when (meta.type) { - com.nuvio.tv.domain.model.ContentType.MOVIE -> "movie" - com.nuvio.tv.domain.model.ContentType.SERIES, - com.nuvio.tv.domain.model.ContentType.TV -> "tv" - else -> null - } - - val url = trailerService.getTrailerUrl( + val source = trailerService.getTrailerPlaybackSource( title = meta.name, year = year, tmdbId = tmdbId, - type = type + type = meta.apiType ) + val url = source?.videoUrl + val audioUrl = source?.audioUrl _uiState.update { state -> - if (state.trailerUrl == url && !state.isTrailerLoading) { + if (state.trailerUrl == url && + state.trailerAudioUrl == audioUrl && + !state.isTrailerLoading + ) { state } else { - state.copy(trailerUrl = url, isTrailerLoading = false) + state.copy( + trailerUrl = url, + trailerAudioUrl = audioUrl, + isTrailerLoading = false + ) } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt index 0d5e80ae..e704dd29 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt @@ -44,6 +44,7 @@ fun ClassicHomeContent( posterCardStyle: PosterCardStyle, focusState: HomeScreenFocusState, trailerPreviewUrls: Map, + trailerPreviewAudioUrls: Map, onNavigateToDetail: (String, String, String) -> Unit, onContinueWatchingClick: (ContinueWatchingItem) -> Unit, onContinueWatchingStartFromBeginning: (ContinueWatchingItem) -> Unit = {}, @@ -260,6 +261,7 @@ fun ClassicHomeContent( focusedPosterBackdropTrailerEnabled = uiState.focusedPosterBackdropTrailerEnabled, focusedPosterBackdropTrailerMuted = uiState.focusedPosterBackdropTrailerMuted, trailerPreviewUrls = trailerPreviewUrls, + trailerPreviewAudioUrls = trailerPreviewAudioUrls, onRequestTrailerPreview = onRequestTrailerPreview, onItemFocus = onItemFocus, isItemWatched = isCatalogItemWatched, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt index 036c46ef..f44fabad 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt @@ -297,6 +297,7 @@ private fun ClassicHomeRoute( posterCardStyle = posterCardStyle, focusState = focusState, trailerPreviewUrls = viewModel.trailerPreviewUrls, + trailerPreviewAudioUrls = viewModel.trailerPreviewAudioUrls, onNavigateToDetail = onNavigateToDetail, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingStartFromBeginning = onContinueWatchingStartFromBeginning, @@ -388,6 +389,7 @@ private fun ModernHomeRoute( uiState = uiState, focusState = focusState, trailerPreviewUrls = viewModel.trailerPreviewUrls, + trailerPreviewAudioUrls = viewModel.trailerPreviewAudioUrls, onNavigateToDetail = onNavigateToDetail, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingStartFromBeginning = onContinueWatchingStartFromBeginning, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index cb4ef350..302d57cd 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt @@ -94,6 +94,7 @@ class HomeViewModel @Inject constructor( internal val trailerPreviewLoadingIds = mutableSetOf() internal val trailerPreviewNegativeCache = mutableSetOf() internal val trailerPreviewUrlsState = mutableStateMapOf() + internal val trailerPreviewAudioUrlsState = mutableStateMapOf() internal var activeTrailerPreviewItemId: String? = null internal var trailerPreviewRequestVersion: Long = 0L internal var currentTmdbSettings: TmdbSettings = TmdbSettings() @@ -113,6 +114,8 @@ class HomeViewModel @Inject constructor( internal var startupGracePeriodActive: Boolean = true val trailerPreviewUrls: Map get() = trailerPreviewUrlsState + val trailerPreviewAudioUrls: Map + get() = trailerPreviewAudioUrlsState init { observeLayoutPreferences() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt index 451df0d2..250f7ef3 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt @@ -95,6 +95,7 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline( trailerPreviewLoadingIds.clear() trailerPreviewNegativeCache.clear() trailerPreviewUrlsState.clear() + trailerPreviewAudioUrlsState.clear() activeTrailerPreviewItemId = null trailerPreviewRequestVersion = 0L prefetchedExternalMetaIds.clear() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt index 81b78f79..fd5646ae 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt @@ -227,10 +227,16 @@ internal fun HomeViewModel.requestTrailerPreviewPipeline( val requestVersion = trailerPreviewRequestVersion viewModelScope.launch { - val trailerUrl = trailerService.getTrailerUrl( + val tmdbId = try { + tmdbService.ensureTmdbId(itemId, apiType) + } catch (_: Exception) { + null + } + + val trailerSource = trailerService.getTrailerPlaybackSource( title = title, year = extractYear(releaseInfo), - tmdbId = null, + tmdbId = tmdbId, type = apiType ) @@ -241,11 +247,20 @@ internal fun HomeViewModel.requestTrailerPreviewPipeline( return@launch } - if (trailerUrl.isNullOrBlank()) { + if (trailerSource?.videoUrl.isNullOrBlank()) { trailerPreviewNegativeCache.add(itemId) + trailerPreviewUrlsState.remove(itemId) + trailerPreviewAudioUrlsState.remove(itemId) } else { - if (trailerPreviewUrlsState[itemId] != trailerUrl) { - trailerPreviewUrlsState[itemId] = trailerUrl + val videoUrl = trailerSource.videoUrl + if (trailerPreviewUrlsState[itemId] != videoUrl) { + trailerPreviewUrlsState[itemId] = videoUrl + } + val audioUrl = trailerSource.audioUrl + if (audioUrl.isNullOrBlank()) { + trailerPreviewAudioUrlsState.remove(itemId) + } else if (trailerPreviewAudioUrlsState[itemId] != audioUrl) { + trailerPreviewAudioUrlsState[itemId] = audioUrl } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt index ca7c2db8..04f8df4c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt @@ -108,6 +108,7 @@ fun ModernHomeContent( uiState: HomeUiState, focusState: HomeScreenFocusState, trailerPreviewUrls: Map, + trailerPreviewAudioUrls: Map, onNavigateToDetail: (String, String, String) -> Unit, onContinueWatchingClick: (ContinueWatchingItem) -> Unit, onContinueWatchingStartFromBeginning: (ContinueWatchingItem) -> Unit = {}, @@ -569,7 +570,13 @@ fun ModernHomeContent( expandedFocusedSelection?.payload?.itemId?.let { trailerPreviewUrls[it] } } } + val heroTrailerAudioUrl by remember(expandedFocusedSelection, trailerPreviewAudioUrls) { + derivedStateOf { + expandedFocusedSelection?.payload?.itemId?.let { trailerPreviewAudioUrls[it] } + } + } val expandedCatalogTrailerUrl = heroTrailerUrl + val expandedCatalogTrailerAudioUrl = heroTrailerAudioUrl val shouldPlayHeroTrailer by remember( effectiveAutoplayEnabled, trailerPlaybackTarget, @@ -635,6 +642,7 @@ fun ModernHomeContent( heroBackdropAlpha = heroBackdropAlpha, shouldPlayHeroTrailer = shouldPlayHeroTrailer, heroTrailerUrl = heroTrailerUrl, + heroTrailerAudioUrl = heroTrailerAudioUrl, heroTrailerAlpha = heroTrailerAlpha, muted = uiState.focusedPosterBackdropTrailerMuted, bgColor = bgColor, @@ -738,6 +746,7 @@ fun ModernHomeContent( trailerPlaybackTarget = trailerPlaybackTarget, expandedCatalogFocusKey = expandedCatalogFocusKey, expandedTrailerPreviewUrl = expandedCatalogTrailerUrl, + expandedTrailerPreviewAudioUrl = expandedCatalogTrailerAudioUrl, modernCatalogCardWidth = modernCatalogCardWidth, modernCatalogCardHeight = modernCatalogCardHeight, continueWatchingCardWidth = continueWatchingCardWidth, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt index 8f5ff5d3..d225b24e 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt @@ -48,6 +48,7 @@ internal fun ModernHeroMediaLayer( heroBackdropAlpha: Float, shouldPlayHeroTrailer: Boolean, heroTrailerUrl: String?, + heroTrailerAudioUrl: String?, heroTrailerAlpha: Float, muted: Boolean, bgColor: Color, @@ -86,6 +87,7 @@ internal fun ModernHeroMediaLayer( if (shouldPlayHeroTrailer) { TrailerPlayer( trailerUrl = heroTrailerUrl, + trailerAudioUrl = heroTrailerAudioUrl, isPlaying = true, onEnded = onTrailerEnded, onFirstFrameRendered = onFirstFrameRendered, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt index 9d0979b2..96f6d942 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt @@ -123,6 +123,7 @@ private fun ModernCatalogRowItem( trailerPlaybackTarget: FocusedPosterTrailerPlaybackTarget, expandedCatalogFocusKey: String?, expandedTrailerPreviewUrl: String?, + expandedTrailerPreviewAudioUrl: String?, isWatched: Boolean, onFocused: () -> Unit, onItemFocus: (MetaPreview) -> Unit, @@ -149,6 +150,11 @@ private fun ModernCatalogRowItem( } else { null } + val trailerPreviewAudioUrl = if (playTrailerInExpandedCard) { + expandedTrailerPreviewAudioUrl + } else { + null + } ModernCarouselCard( item = item, @@ -162,6 +168,7 @@ private fun ModernCatalogRowItem( playTrailerInExpandedCard = playTrailerInExpandedCard, focusedPosterBackdropTrailerMuted = focusedPosterBackdropTrailerMuted, trailerPreviewUrl = trailerPreviewUrl, + trailerPreviewAudioUrl = trailerPreviewAudioUrl, isWatched = isWatched, focusRequester = requester, onFocused = { @@ -209,6 +216,7 @@ internal fun ModernRowSection( trailerPlaybackTarget: FocusedPosterTrailerPlaybackTarget, expandedCatalogFocusKey: String?, expandedTrailerPreviewUrl: String?, + expandedTrailerPreviewAudioUrl: String?, modernCatalogCardWidth: Dp, modernCatalogCardHeight: Dp, continueWatchingCardWidth: Dp, @@ -429,6 +437,7 @@ internal fun ModernRowSection( trailerPlaybackTarget = trailerPlaybackTarget, expandedCatalogFocusKey = expandedCatalogFocusKey, expandedTrailerPreviewUrl = expandedTrailerPreviewUrl, + expandedTrailerPreviewAudioUrl = expandedTrailerPreviewAudioUrl, isWatched = item.metaPreview?.let(isCatalogItemWatched) == true, onFocused = onFocused, onItemFocus = onItemFocus, @@ -465,6 +474,7 @@ private fun ModernCarouselCard( playTrailerInExpandedCard: Boolean, focusedPosterBackdropTrailerMuted: Boolean, trailerPreviewUrl: String?, + trailerPreviewAudioUrl: String?, isWatched: Boolean, focusRequester: FocusRequester, onFocused: () -> Unit, @@ -648,6 +658,7 @@ private fun ModernCarouselCard( if (shouldPlayTrailerInCard) { TrailerPlayer( trailerUrl = trailerPreviewUrl, + trailerAudioUrl = trailerPreviewAudioUrl, isPlaying = true, onEnded = onTrailerEnded, muted = focusedPosterBackdropTrailerMuted, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt index cfa40cc1..bf9aa170 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt @@ -74,7 +74,8 @@ internal enum class SettingsCategory { private enum class IntegrationSettingsSection { Hub, Tmdb, - MdbList + MdbList, + YouTubeExtractorTest } internal enum class SettingsSectionDestination { @@ -212,6 +213,7 @@ fun SettingsScreen( val integrationHubFocusRequester = remember { FocusRequester() } val integrationTmdbFocusRequester = remember { FocusRequester() } val integrationMdbListFocusRequester = remember { FocusRequester() } + val integrationYouTubeExtractorFocusRequester = remember { FocusRequester() } var integrationSection by remember { mutableStateOf(IntegrationSettingsSection.Hub) } var pendingContentFocusCategory by remember { mutableStateOf(null) } var pendingContentFocusRequestId by remember { mutableLongStateOf(0L) } @@ -391,6 +393,7 @@ fun SettingsScreen( hubFocusRequester = integrationHubFocusRequester, tmdbFocusRequester = integrationTmdbFocusRequester, mdbListFocusRequester = integrationMdbListFocusRequester, + youtubeExtractorFocusRequester = integrationYouTubeExtractorFocusRequester, autoFocusEnabled = allowDetailAutofocus ) SettingsCategory.ABOUT -> AboutSettingsContent( @@ -476,6 +479,7 @@ private fun IntegrationSettingsContent( hubFocusRequester: FocusRequester, tmdbFocusRequester: FocusRequester, mdbListFocusRequester: FocusRequester, + youtubeExtractorFocusRequester: FocusRequester, autoFocusEnabled: Boolean ) { BackHandler(enabled = selectedSection != IntegrationSettingsSection.Hub) { @@ -489,6 +493,7 @@ private fun IntegrationSettingsContent( IntegrationSettingsSection.Hub -> hubEntryFocusRequester IntegrationSettingsSection.Tmdb -> tmdbFocusRequester IntegrationSettingsSection.MdbList -> mdbListFocusRequester + IntegrationSettingsSection.YouTubeExtractorTest -> youtubeExtractorFocusRequester } runCatching { requester.requestFocus() } } @@ -527,6 +532,15 @@ private fun IntegrationSettingsContent( onClick = { onSelectSection(IntegrationSettingsSection.MdbList) } ) } + item(key = "integration_hub_youtube_extractor_test") { + SettingsActionRow( + title = stringResource(R.string.settings_youtube_extractor_test_title), + subtitle = stringResource(R.string.settings_youtube_extractor_test_hub_subtitle), + onClick = { + onSelectSection(IntegrationSettingsSection.YouTubeExtractorTest) + } + ) + } } } } @@ -543,5 +557,11 @@ private fun IntegrationSettingsContent( initialFocusRequester = mdbListFocusRequester ) } + + IntegrationSettingsSection.YouTubeExtractorTest -> { + YouTubeExtractorTestContent( + initialFocusRequester = youtubeExtractorFocusRequester + ) + } } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/YouTubeExtractorTestSettings.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/YouTubeExtractorTestSettings.kt new file mode 100644 index 00000000..faae28ea --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/YouTubeExtractorTestSettings.kt @@ -0,0 +1,301 @@ +@file:OptIn(ExperimentalTvMaterial3Api::class) + +package com.nuvio.tv.ui.screens.settings + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.foundation.shape.RoundedCornerShape +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.nuvio.tv.R +import com.nuvio.tv.data.trailer.InAppYouTubeExtractor +import com.nuvio.tv.ui.components.TrailerPlayer +import com.nuvio.tv.ui.screens.account.InputField +import com.nuvio.tv.ui.theme.NuvioColors +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal data class YouTubeExtractorTestUiState( + val youtubeUrl: String = "", + val isLoading: Boolean = false, + val videoUrl: String? = null, + val audioUrl: String? = null, + val isPlaying: Boolean = false, + val errorMessageResId: Int? = null +) + +@HiltViewModel +internal class YouTubeExtractorTestViewModel @Inject constructor( + private val inAppYouTubeExtractor: InAppYouTubeExtractor +) : ViewModel() { + + private val _uiState = MutableStateFlow(YouTubeExtractorTestUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onUrlChanged(url: String) { + _uiState.update { it.copy(youtubeUrl = url, errorMessageResId = null) } + } + + fun onExtract() { + val inputUrl = _uiState.value.youtubeUrl.trim() + if (inputUrl.isBlank()) { + _uiState.update { + it.copy( + errorMessageResId = R.string.settings_youtube_extractor_error_enter_url, + videoUrl = null, + audioUrl = null, + isPlaying = false + ) + } + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = true, + errorMessageResId = null, + videoUrl = null, + audioUrl = null, + isPlaying = false + ) + } + + val source = runCatching { + inAppYouTubeExtractor.extractPlaybackSource(inputUrl) + }.getOrNull() + + if (source == null) { + _uiState.update { + it.copy( + isLoading = false, + errorMessageResId = R.string.settings_youtube_extractor_error_failed + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + videoUrl = source.videoUrl, + audioUrl = source.audioUrl, + isPlaying = true + ) + } + } + } + } + + fun togglePlayback() { + _uiState.update { state -> + if (state.videoUrl.isNullOrBlank()) { + state.copy(isPlaying = false) + } else { + state.copy(isPlaying = !state.isPlaying) + } + } + } + + fun stopPlayback() { + _uiState.update { it.copy(isPlaying = false) } + } +} + +@Composable +internal fun YouTubeExtractorTestContent( + initialFocusRequester: FocusRequester, + viewModel: YouTubeExtractorTestViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val videoUrl = uiState.videoUrl + val audioUrl = uiState.audioUrl + val hasExtractedSource = !videoUrl.isNullOrBlank() + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + SettingsDetailHeader( + title = stringResource(R.string.settings_youtube_extractor_test_title), + subtitle = stringResource(R.string.settings_youtube_extractor_test_subtitle) + ) + + SettingsGroupCard( + modifier = Modifier + .fillMaxWidth() + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item(key = "yt_extractor_input") { + InputField( + value = uiState.youtubeUrl, + onValueChange = viewModel::onUrlChanged, + placeholder = stringResource(R.string.settings_youtube_extractor_input_placeholder), + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + onImeAction = viewModel::onExtract + ) + } + + item(key = "yt_extractor_run") { + SettingsActionRow( + title = if (uiState.isLoading) { + stringResource(R.string.settings_youtube_extractor_running) + } else { + stringResource(R.string.settings_youtube_extractor_extract_action) + }, + subtitle = stringResource(R.string.settings_youtube_extractor_extract_action_subtitle), + onClick = viewModel::onExtract, + enabled = !uiState.isLoading, + modifier = Modifier.focusRequester(initialFocusRequester) + ) + } + + if (hasExtractedSource) { + item(key = "yt_extractor_toggle_playback") { + SettingsActionRow( + title = if (uiState.isPlaying) { + stringResource(R.string.settings_youtube_extractor_pause_preview) + } else { + stringResource(R.string.settings_youtube_extractor_play_preview) + }, + subtitle = stringResource(R.string.settings_youtube_extractor_play_preview_subtitle), + value = if (audioUrl.isNullOrBlank()) { + stringResource(R.string.settings_youtube_extractor_stream_single) + } else { + stringResource(R.string.settings_youtube_extractor_stream_split) + }, + onClick = viewModel::togglePlayback, + trailingIcon = if (uiState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow + ) + } + + item(key = "yt_extractor_video_url") { + UrlInfoCard( + label = stringResource(R.string.settings_youtube_extractor_video_url), + value = videoUrl.orEmpty() + ) + } + + item(key = "yt_extractor_audio_url") { + UrlInfoCard( + label = stringResource(R.string.settings_youtube_extractor_audio_url), + value = audioUrl ?: stringResource(R.string.settings_youtube_extractor_audio_url_missing) + ) + } + + item(key = "yt_extractor_preview") { + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .clip(RoundedCornerShape(SettingsSecondaryCardRadius)) + .background(NuvioColors.Background) + ) { + TrailerPlayer( + trailerUrl = videoUrl, + trailerAudioUrl = audioUrl, + isPlaying = uiState.isPlaying, + onEnded = viewModel::stopPlayback, + muted = false, + modifier = Modifier.fillMaxSize() + ) + + if (!uiState.isPlaying) { + Text( + text = stringResource(R.string.settings_youtube_extractor_preview_idle), + style = MaterialTheme.typography.bodyMedium, + color = NuvioColors.TextSecondary, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 16.dp) + ) + } + } + } + } + + val errorMessageResId = uiState.errorMessageResId + if (errorMessageResId != null) { + item(key = "yt_extractor_error") { + Text( + text = stringResource(errorMessageResId), + style = MaterialTheme.typography.bodyMedium, + color = NuvioColors.Error, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + ) + } + } + } + } + } +} + +@Composable +private fun UrlInfoCard( + label: String, + value: String +) { + var expanded by remember { mutableStateOf(false) } + SettingsGroupCard( + title = label + ) { + val maxLines = if (expanded) 8 else 2 + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = NuvioColors.TextSecondary, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + ) + SettingsActionRow( + title = if (expanded) { + stringResource(R.string.settings_youtube_extractor_show_less) + } else { + stringResource(R.string.settings_youtube_extractor_show_full_url) + }, + subtitle = null, + onClick = { expanded = !expanded } + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc387dc6..52c9c32c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,9 +139,29 @@ progress watched Integrations - Choose TMDB or MDBList settings + Choose TMDB, MDBList, or YouTube extractor test Metadata enrichment controls External ratings providers + YouTube Extractor Test + Paste a YouTube URL, extract playback links, and preview in-app + Validate in-app trailer extraction and playback links + https://www.youtube.com/watch?v=... + Extracting\u2026 + Extract and Preview + Run JS extractor and load video/audio streams + Pause Preview + Play Preview + Toggle trailer playback in the embedded player + Single stream + Split A/V + Video URL + Audio URL + No separate audio URL returned. + Preview paused. Use Play Preview to start playback. + Enter a YouTube URL first. + Extraction failed or no playable source found. + Show less + Show full URL Playback Settings