init
This commit is contained in:
parent
df035b6d77
commit
732363d66e
21 changed files with 1428 additions and 50 deletions
492
app/src/main/assets/js/simple-youtube-extractor.js
Normal file
492
app/src/main/assets/js/simple-youtube-extractor.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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>()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.tv.data.trailer
|
||||
|
||||
data class TrailerPlaybackSource(
|
||||
val videoUrl: String,
|
||||
val audioUrl: String? = null
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
|
|||
trailerPreviewLoadingIds.clear()
|
||||
trailerPreviewNegativeCache.clear()
|
||||
trailerPreviewUrlsState.clear()
|
||||
trailerPreviewAudioUrlsState.clear()
|
||||
activeTrailerPreviewItemId = null
|
||||
trailerPreviewRequestVersion = 0L
|
||||
prefetchedExternalMetaIds.clear()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue