This commit is contained in:
tapframe 2026-03-01 08:25:43 +05:30
parent df035b6d77
commit 732363d66e
21 changed files with 1428 additions and 50 deletions

View file

@ -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,
};

View file

@ -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<Call>()
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<Any?>(bootstrap)
evaluate<Any?>("var module = { exports: {} }; var exports = module.exports;")
evaluate<Any?>(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<Any?>(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<Call>
): String {
return try {
val parsedHeaders = runCatching {
gson.fromJson(headersJson, Map::class.java)
}.getOrNull()
val headers = mutableMapOf<String, String>()
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<String, String>()
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<String, String>()
)
)
}
}
}

View file

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

View file

@ -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<String, String?>()
// Cache: "title|year|tmdbId|type" -> trailer playback source (null for negative cache)
private val cache = ConcurrentHashMap<String, TrailerPlaybackSource?>()
/**
* 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()
}

View file

@ -67,6 +67,7 @@ fun CatalogRowSection(
focusedPosterBackdropTrailerEnabled: Boolean = false,
focusedPosterBackdropTrailerMuted: Boolean = true,
trailerPreviewUrls: Map<String, String> = emptyMap(),
trailerPreviewAudioUrls: Map<String, String> = 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 ->

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -20,6 +20,7 @@ data class MetaDetailsUiState(
val nextToWatch: NextToWatch? = null,
val episodeProgressMap: Map<Pair<Int, Int>, WatchProgress> = emptyMap(),
val trailerUrl: String? = null,
val trailerAudioUrl: String? = null,
val isTrailerPlaying: Boolean = false,
val isTrailerLoading: Boolean = false,
val showTrailerControls: Boolean = false,

View file

@ -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
)
}
}

View file

@ -44,6 +44,7 @@ fun ClassicHomeContent(
posterCardStyle: PosterCardStyle,
focusState: HomeScreenFocusState,
trailerPreviewUrls: Map<String, String>,
trailerPreviewAudioUrls: Map<String, String>,
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,

View file

@ -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,

View file

@ -94,6 +94,7 @@ class HomeViewModel @Inject constructor(
internal val trailerPreviewLoadingIds = mutableSetOf<String>()
internal val trailerPreviewNegativeCache = mutableSetOf<String>()
internal val trailerPreviewUrlsState = mutableStateMapOf<String, String>()
internal val trailerPreviewAudioUrlsState = mutableStateMapOf<String, String>()
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<String, String>
get() = trailerPreviewUrlsState
val trailerPreviewAudioUrls: Map<String, String>
get() = trailerPreviewAudioUrlsState
init {
observeLayoutPreferences()

View file

@ -95,6 +95,7 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
trailerPreviewLoadingIds.clear()
trailerPreviewNegativeCache.clear()
trailerPreviewUrlsState.clear()
trailerPreviewAudioUrlsState.clear()
activeTrailerPreviewItemId = null
trailerPreviewRequestVersion = 0L
prefetchedExternalMetaIds.clear()

View file

@ -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
}
}

View file

@ -108,6 +108,7 @@ fun ModernHomeContent(
uiState: HomeUiState,
focusState: HomeScreenFocusState,
trailerPreviewUrls: Map<String, String>,
trailerPreviewAudioUrls: Map<String, String>,
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,

View file

@ -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,

View file

@ -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,

View file

@ -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<SettingsCategory?>(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
)
}
}
}

View file

@ -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<YouTubeExtractorTestUiState> = _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 }
)
}
}

View file

@ -139,9 +139,29 @@
<string name="account_stat_progress">progress</string>
<string name="account_stat_watched">watched</string>
<string name="settings_integrations_section">Integrations</string>
<string name="settings_integrations_section_subtitle">Choose TMDB or MDBList settings</string>
<string name="settings_integrations_section_subtitle">Choose TMDB, MDBList, or YouTube extractor test</string>
<string name="settings_tmdb_subtitle">Metadata enrichment controls</string>
<string name="settings_mdblist_subtitle">External ratings providers</string>
<string name="settings_youtube_extractor_test_title">YouTube Extractor Test</string>
<string name="settings_youtube_extractor_test_subtitle">Paste a YouTube URL, extract playback links, and preview in-app</string>
<string name="settings_youtube_extractor_test_hub_subtitle">Validate in-app trailer extraction and playback links</string>
<string name="settings_youtube_extractor_input_placeholder">https://www.youtube.com/watch?v=...</string>
<string name="settings_youtube_extractor_running">Extracting\u2026</string>
<string name="settings_youtube_extractor_extract_action">Extract and Preview</string>
<string name="settings_youtube_extractor_extract_action_subtitle">Run JS extractor and load video/audio streams</string>
<string name="settings_youtube_extractor_pause_preview">Pause Preview</string>
<string name="settings_youtube_extractor_play_preview">Play Preview</string>
<string name="settings_youtube_extractor_play_preview_subtitle">Toggle trailer playback in the embedded player</string>
<string name="settings_youtube_extractor_stream_single">Single stream</string>
<string name="settings_youtube_extractor_stream_split">Split A/V</string>
<string name="settings_youtube_extractor_video_url">Video URL</string>
<string name="settings_youtube_extractor_audio_url">Audio URL</string>
<string name="settings_youtube_extractor_audio_url_missing">No separate audio URL returned.</string>
<string name="settings_youtube_extractor_preview_idle">Preview paused. Use Play Preview to start playback.</string>
<string name="settings_youtube_extractor_error_enter_url">Enter a YouTube URL first.</string>
<string name="settings_youtube_extractor_error_failed">Extraction failed or no playable source found.</string>
<string name="settings_youtube_extractor_show_less">Show less</string>
<string name="settings_youtube_extractor_show_full_url">Show full URL</string>
<!-- PlaybackSettingsScreen -->
<string name="playback_title">Playback Settings</string>