mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-14 12:07:21 -05:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
97c45f14c2
|
|||
|
3d59612d6e
|
|||
|
bd7d443f21
|
|||
|
|
8cd9ec3671 | ||
|
|
d6e7ded9ac | ||
|
|
dd2445c86b | ||
|
|
7f68419c2a | ||
|
2028219f74
|
|||
|
bb1c084c4c
|
|||
| 1c4ee48141 | |||
| 09ddbe6bae | |||
| 494d8e6835 | |||
| 1c3ffacccd | |||
| 44a6e3cef9 | |||
| 9c0c5bf17d | |||
| 01e532a6a5 | |||
| 58c7889b8a | |||
| 102c3bdc84 | |||
| 112d405e75 | |||
| c3358fee44 | |||
| 818b03cfd3 | |||
| 09b03508ed | |||
| 30d21ef39b | |||
| a0621f725a | |||
| ecbf5953d6 |
@@ -1,49 +0,0 @@
|
||||
import SOURCES from './sdk/config.js';
|
||||
import { createStreamArgs } from './sdk/utils/helpers.js';
|
||||
import { fetchSubtitles, getSubPathsMovie, getSubPathsTv } from './sdk/subtitles.js';
|
||||
import * as m0 from './sdk/sources/123anime.js';
|
||||
import * as m1 from './sdk/sources/111477.js';
|
||||
import * as m2 from './sdk/sources/anihq.js';
|
||||
import * as m3 from './sdk/sources/anineko.js';
|
||||
import * as m4 from './sdk/sources/bcine.js';
|
||||
import * as m5 from './sdk/sources/dulo.js';
|
||||
import * as m6 from './sdk/sources/flaxmovies.js';
|
||||
import * as m7 from './sdk/sources/fsonline.js';
|
||||
import * as m8 from './sdk/sources/kisskh.js';
|
||||
import * as m9 from './sdk/sources/lmscript.js';
|
||||
import * as m10 from './sdk/sources/lookmovie.js';
|
||||
import * as m11 from './sdk/sources/movienight.js';
|
||||
import * as m12 from './sdk/sources/movy.js';
|
||||
import * as m13 from './sdk/sources/pengu.js';
|
||||
import * as m14 from './sdk/sources/pstream.js';
|
||||
import * as m15 from './sdk/sources/purstream.js';
|
||||
import * as m16 from './sdk/sources/rivestream.js';
|
||||
import * as m17 from './sdk/sources/streamaggregator.js';
|
||||
import * as m18 from './sdk/sources/vidapi.js';
|
||||
import * as m19 from './sdk/sources/vidcore.js';
|
||||
import * as m20 from './sdk/sources/vidfast.js';
|
||||
import * as m21 from './sdk/sources/vidgod.js';
|
||||
import * as m22 from './sdk/sources/vidlink.js';
|
||||
import * as m23 from './sdk/sources/vidnest.js';
|
||||
import * as m24 from './sdk/sources/vidrock.js';
|
||||
import * as m25 from './sdk/sources/vidup.js';
|
||||
import * as m26 from './sdk/sources/vidvault.js';
|
||||
import * as m27 from './sdk/sources/vidzee.js';
|
||||
import * as m28 from './sdk/sources/vixsrc.js';
|
||||
import * as m29 from './sdk/sources/xdownloader.js';
|
||||
const MODS = { '123anime': m0, '111477': m1, 'anihq': m2, 'anineko': m3, 'bcine': m4, 'dulo': m5, 'flaxmovies': m6, 'fsonline': m7, 'kisskh': m8, 'lmscript': m9, 'lookmovie': m10, 'movienight': m11, 'movy': m12, 'pengu': m13, 'pstream': m14, 'purstream': m15, 'rivestream': m16, 'streamaggregator': m17, 'vidapi': m18, 'vidcore': m19, 'vidfast': m20, 'vidgod': m21, 'vidlink': m22, 'vidnest': m23, 'vidrock': m24, 'vidup': m25, 'vidvault': m26, 'vidzee': m27, 'vixsrc': m28, 'xdownloader': m29 };
|
||||
const core = { tmdbApiKey: null, penguManifest: null };
|
||||
const live = () => SOURCES.filter(c => !c.disabled && MODS[c.sourceFile]);
|
||||
globalThis.VylaLocal = {
|
||||
init(key) { core.tmdbApiKey = key; globalThis.process.env.TMDB_API_KEY = key; },
|
||||
sources() { return live(); },
|
||||
async stream(key, id, s, e, ip) {
|
||||
const cfg = live().find(c => c.key === key);
|
||||
if (!cfg) throw new Error('Source not found');
|
||||
return await MODS[cfg.sourceFile].getStream(await createStreamArgs(cfg, core, id, s, e, ip));
|
||||
},
|
||||
async subtitles(id, s, e) {
|
||||
const paths = s != null && e != null ? await getSubPathsTv(id, s, e) : await getSubPathsMovie(id);
|
||||
return await fetchSubtitles(paths);
|
||||
}
|
||||
};
|
||||
@@ -1,487 +0,0 @@
|
||||
// Currently active sources reported on 6:18:22 PM PST 9/13/2026
|
||||
|
||||
// None Anime Tested: 936075
|
||||
// Anime Tested: 37854
|
||||
|
||||
// Fshare/Fsonic Tested: 155 ( they don't have Michael )
|
||||
|
||||
export const SOURCES = [
|
||||
{
|
||||
key: '123anime',
|
||||
label: '123Anime',
|
||||
sourceFile: '123anime',
|
||||
proxyParam: 'a1',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: false,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /hlsx\d+cdn\.|burntburst\d+\.store|echovideo\.ru/i,
|
||||
headers: {
|
||||
Referer: 'https://play2.echovideo.ru/',
|
||||
Origin: 'https://play2.echovideo.ru',
|
||||
},
|
||||
},],
|
||||
},
|
||||
|
||||
{
|
||||
key: '111477',
|
||||
label: '111477',
|
||||
sourceFile: '111477',
|
||||
proxyParam: 'a11',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /111477\.xyz/i,
|
||||
headers: {
|
||||
Referer: 'https://st.111477.xyz/',
|
||||
Origin: 'https://st.111477.xyz',
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'anihq-sub',
|
||||
label: 'AniHQ (Sub)',
|
||||
sourceFile: 'anihq',
|
||||
proxyParam: 'ahsub',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true
|
||||
},
|
||||
|
||||
{
|
||||
key: 'anihq-dub',
|
||||
label: 'AniHQ (Dub)',
|
||||
sourceFile: 'anihq',
|
||||
proxyParam: 'ahdub',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true
|
||||
},
|
||||
|
||||
{
|
||||
key: 'anineko-sub',
|
||||
sourceFile: 'anineko',
|
||||
label: 'AniNeko (Sub)',
|
||||
proxyParam: 'anksub',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2
|
||||
},
|
||||
|
||||
{
|
||||
key: 'anineko-dub',
|
||||
sourceFile: 'anineko',
|
||||
label: 'AniNeko (Dub)',
|
||||
proxyParam: 'ankdub',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2
|
||||
},
|
||||
|
||||
{
|
||||
key: 'bcine',
|
||||
label: 'Bcine',
|
||||
sourceFile: 'bcine',
|
||||
proxyParam: 'bc',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /1embed\.cc|videasy\.to/i,
|
||||
headers: {
|
||||
Referer: 'https://bcine.ru/',
|
||||
Origin: 'https://bcine.ru',
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'dulo',
|
||||
label: 'Dulo',
|
||||
sourceFile: 'dulo',
|
||||
proxyParam: 'dl',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /dulo\.mov/i,
|
||||
headers: {
|
||||
Referer: 'https://dulo.mov/',
|
||||
Origin: 'https://dulo.mov',
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'flaxmovies',
|
||||
label: 'FlaxMovies',
|
||||
sourceFile: 'flaxmovies',
|
||||
proxyParam: 'fx',
|
||||
timeout: 20000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /flix2watch\.pro/i,
|
||||
headers: {
|
||||
Referer: 'https://flaxmovies.xyz/',
|
||||
Origin: 'https://flaxmovies.xyz',
|
||||
},
|
||||
},],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'fsonline',
|
||||
label: 'FSOnline',
|
||||
sourceFile: 'fsonline',
|
||||
proxyParam: 'fo',
|
||||
timeout: 20000,
|
||||
retries: 1,
|
||||
jitter: 0,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'kisskh',
|
||||
label: 'KissKH',
|
||||
sourceFile: 'kisskh',
|
||||
proxyParam: 'kk',
|
||||
timeout: 30000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'lmscript',
|
||||
label: 'LMScript',
|
||||
sourceFile: 'lmscript',
|
||||
proxyParam: 'lmsc',
|
||||
timeout: 20000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'lookmovie',
|
||||
label: 'LookMovie',
|
||||
sourceFile: 'lookmovie',
|
||||
proxyParam: 'lm',
|
||||
timeout: 20000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
verifyHeaders: {
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'movienight',
|
||||
label: 'MovieNight',
|
||||
sourceFile: 'movienight',
|
||||
proxyParam: 'mn',
|
||||
timeout: 20000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /movienig\.ht/i,
|
||||
headers: {
|
||||
Referer: 'https://movienig.ht/',
|
||||
Origin: 'https://movienig.ht',
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'movy',
|
||||
label: 'Movy',
|
||||
sourceFile: 'movy',
|
||||
proxyParam: 'mv',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /wecollege\.net|movy\.bz/i,
|
||||
headers: {
|
||||
Referer: 'https://www.movy.bz/',
|
||||
Origin: 'https://www.movy.bz',
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'pengu',
|
||||
label: 'Pengu',
|
||||
sourceFile: 'pengu',
|
||||
proxyParam: 'pg',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
disabled: false, // Enabled, but only returns streams when a manifest is provided
|
||||
},
|
||||
|
||||
{
|
||||
key: 'pstream',
|
||||
label: 'PStream',
|
||||
sourceFile: 'pstream',
|
||||
proxyParam: 'ps',
|
||||
timeout: 35000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'purstream',
|
||||
sourceFile: 'purstream',
|
||||
label: 'Purstream',
|
||||
proxyParam: 'ps',
|
||||
timeout: 20000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
skipProxy: true
|
||||
},
|
||||
|
||||
{
|
||||
key: 'rivestream',
|
||||
label: 'RiveStream',
|
||||
sourceFile: 'rivestream',
|
||||
proxyParam: 'rs',
|
||||
timeout: 30000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'streamaggregator',
|
||||
label: 'StreamAggregator',
|
||||
sourceFile: 'streamaggregator',
|
||||
proxyParam: 'sa',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /streamaggregator\.in/i,
|
||||
headers: {
|
||||
Referer: 'https://streamaggregator.in/',
|
||||
Origin: 'https://streamaggregator.in',
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
key: 'vidapi',
|
||||
label: 'VidAPI',
|
||||
sourceFile: 'vidapi',
|
||||
proxyParam: 'va',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
multiUrl: true
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidcore',
|
||||
label: 'VidCore',
|
||||
sourceFile: 'vidcore',
|
||||
proxyParam: 'vc',
|
||||
timeout: 35000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
multiUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'vidfast',
|
||||
label: 'VidFast',
|
||||
sourceFile: 'vidfast',
|
||||
proxyParam: 'vf',
|
||||
timeout: 35000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidgod',
|
||||
label: 'VidGod',
|
||||
sourceFile: 'vidgod',
|
||||
proxyParam: 'vg',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidlink',
|
||||
label: 'Vidlink',
|
||||
sourceFile: 'vidlink',
|
||||
proxyParam: 'vl',
|
||||
timeout: 20000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
skipProxy: true,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidnest',
|
||||
label: 'VidNest',
|
||||
sourceFile: 'vidnest',
|
||||
proxyParam: 'vdn',
|
||||
timeout: 20000,
|
||||
retries: 1,
|
||||
jitter: 0,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidnest-sub',
|
||||
label: 'VidNest (Sub)',
|
||||
sourceFile: 'vidnest',
|
||||
proxyParam: 'vdn',
|
||||
timeout: 20000,
|
||||
retries: 1,
|
||||
jitter: 0,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidnest-dub',
|
||||
label: 'VidNest (Dub)',
|
||||
sourceFile: 'vidnest',
|
||||
proxyParam: 'vdn',
|
||||
timeout: 20000,
|
||||
retries: 1,
|
||||
jitter: 0,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidrock',
|
||||
label: 'VidRock',
|
||||
sourceFile: 'vidrock',
|
||||
proxyParam: 'vr',
|
||||
timeout: 20000,
|
||||
jitter: 800,
|
||||
retries: 3,
|
||||
multiUrl: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /./,
|
||||
headers: {
|
||||
Accept: '/',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Referer: 'https://vidrock.ru/',
|
||||
Origin: 'https://vidrock.ru',
|
||||
},
|
||||
},],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidup',
|
||||
label: 'VidUp',
|
||||
sourceFile: 'vidup',
|
||||
proxyParam: 'vu',
|
||||
timeout: 35000,
|
||||
jitter: 500,
|
||||
retries: 1,
|
||||
multiUrl: true
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidvault',
|
||||
label: 'VidVault',
|
||||
sourceFile: 'vidvault',
|
||||
proxyParam: 'vv',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vidzee',
|
||||
label: 'VidZee',
|
||||
sourceFile: 'vidzee',
|
||||
proxyParam: 'vz',
|
||||
timeout: 20000,
|
||||
sourcesTimeout: 10000,
|
||||
jitter: 400,
|
||||
retries: 3,
|
||||
verifyHeaders: {
|
||||
Accept: '/',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Referer: 'https://player.vidzee.wtf',
|
||||
Origin: 'https://player.vidzee.wtf',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'vixsrc',
|
||||
label: 'VixSrc',
|
||||
sourceFile: 'vixsrc',
|
||||
proxyParam: 'vx',
|
||||
timeout: 35000,
|
||||
jitter: 0,
|
||||
retries: 2,
|
||||
multiUrl: false,
|
||||
skipProxy: true,
|
||||
verifyHeaders: {
|
||||
Accept: 'application/json, text/javascript, /; q=0.01',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Referer: 'https://vixsrc.to/',
|
||||
Origin: 'https://vixsrc.to',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'xdownloader',
|
||||
label: 'XDownloader',
|
||||
sourceFile: 'xdownloader',
|
||||
proxyParam: 'xd',
|
||||
timeout: 25000,
|
||||
jitter: 500,
|
||||
retries: 2,
|
||||
multiUrl: true,
|
||||
skipProxy: true,
|
||||
cdnHeaders: [{
|
||||
pattern: /films365\.org/i,
|
||||
headers: {
|
||||
Referer: 'https://www.films365.org/',
|
||||
Origin: 'https://www.films365.org',
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
export const HEALTH_PROBE_ID = '155';
|
||||
export const SOURCE_MAP = Object.fromEntries(SOURCES.map(s => [s.key, s]));
|
||||
export default SOURCES;
|
||||
@@ -1,53 +0,0 @@
|
||||
import SOURCES from "../config.js";
|
||||
|
||||
export async function fetchDownloads(sdkOrId, idOrS = null, sOrE = null, maybeE = null) {
|
||||
let sdk = null;
|
||||
let id = sdkOrId;
|
||||
let s = idOrS;
|
||||
let e = sOrE;
|
||||
|
||||
if (sdkOrId && typeof sdkOrId === 'object' && typeof sdkOrId.getStream === 'function') {
|
||||
sdk = sdkOrId;
|
||||
id = idOrS;
|
||||
s = sOrE;
|
||||
e = maybeE;
|
||||
}
|
||||
|
||||
if (!sdk || !id) return [];
|
||||
|
||||
const activeSources = SOURCES.filter(cfg => !cfg.disabled && cfg.skipProxy);
|
||||
const seenUrls = new Set();
|
||||
const downloads = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
activeSources.map(async (cfg) => {
|
||||
try {
|
||||
const res = await sdk.getStream(cfg.key, id, s, e);
|
||||
if (!res) return;
|
||||
|
||||
const items = Array.isArray(res.allUrls) && res.allUrls.length > 0
|
||||
? res.allUrls
|
||||
: (res.url ? [{ url: res.url, label: cfg.label }] : []);
|
||||
|
||||
for (const item of items) {
|
||||
if (!item?.url || typeof item.url !== 'string') continue;
|
||||
const url = item.url.trim();
|
||||
|
||||
if (!url.startsWith('http') || url.includes('.m3u8')) continue;
|
||||
if (!/\.mp4(\?|$)/i.test(url) && !url.includes('.mp4')) continue;
|
||||
if (seenUrls.has(url)) continue;
|
||||
|
||||
seenUrls.add(url);
|
||||
|
||||
downloads.push({
|
||||
source: cfg.key,
|
||||
label: item.label || cfg.label,
|
||||
url,
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
})
|
||||
);
|
||||
|
||||
return downloads;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { HEALTH_PROBE_ID } from "./config.js";
|
||||
|
||||
const withTimeout = (promise, ms) => Promise.race([promise, new Promise(r => setTimeout(() => r(null), ms))]);
|
||||
async function withRetry(fn, attempts = 2, delay = 300) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const result = await fn();
|
||||
if (result != null) return result;
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) throw err;
|
||||
await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.5));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function probeSource(sdk, cfg, mod) {
|
||||
const audio = /dub$/.test(cfg.key) ? 'dub' : 'sub';
|
||||
const streamArgs = {
|
||||
id: HEALTH_PROBE_ID,
|
||||
s: null,
|
||||
e: null,
|
||||
clientIP: null,
|
||||
absoluteBase: null,
|
||||
audio,
|
||||
sdk,
|
||||
config: cfg,
|
||||
};
|
||||
|
||||
const t = Date.now();
|
||||
const result = await withTimeout(
|
||||
withRetry(() => mod.getStream(streamArgs), cfg.retries, 300),
|
||||
cfg.timeout
|
||||
);
|
||||
|
||||
const firstUrl = result?.url ?? result?.allUrls?.[0]?.url;
|
||||
return { ok: !!(typeof firstUrl === 'string' && firstUrl.startsWith('http')), ms: Date.now() - t };
|
||||
}
|
||||
122
.build/sdk/sdk.d.ts
vendored
122
.build/sdk/sdk.d.ts
vendored
@@ -1,122 +0,0 @@
|
||||
export interface SourceConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
sourceFile: string;
|
||||
proxyParam: string;
|
||||
timeout: number;
|
||||
jitter: number;
|
||||
retries: number;
|
||||
multiUrl: boolean;
|
||||
skipProxy?: boolean;
|
||||
cdnHeaders?: {
|
||||
pattern: RegExp;
|
||||
headers: {
|
||||
Referer: string;
|
||||
Origin: string;
|
||||
};
|
||||
}[];
|
||||
skipVerify?: boolean;
|
||||
verifyHeaders?: boolean;
|
||||
disabled?: boolean;
|
||||
sourcesTimeout?: number;
|
||||
}
|
||||
export interface ProbeResult {
|
||||
ok: boolean;
|
||||
ms: number | null;
|
||||
}
|
||||
export interface StreamResult {
|
||||
url: string;
|
||||
headers: {
|
||||
"User-Agent": string;
|
||||
Referer: string;
|
||||
Origin: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
server: string;
|
||||
quality: string;
|
||||
type: "mp4" | "hls";
|
||||
skipVerify?: boolean;
|
||||
skipProxy?: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
url: string;
|
||||
quality: string;
|
||||
size: number | null;
|
||||
type: "mkv";
|
||||
active: boolean;
|
||||
}
|
||||
export interface SubtitleResult {
|
||||
label: string;
|
||||
file: string;
|
||||
type: "vtt" | "srt";
|
||||
source: string;
|
||||
}
|
||||
|
||||
export type ProbeAllResult = Record<string, ProbeResult>;
|
||||
|
||||
export interface VylaSDKOptions {
|
||||
tmdbApiKey?: string;
|
||||
}
|
||||
|
||||
export default class VylaSDK {
|
||||
tmdbApiKey?: string;
|
||||
|
||||
constructor(options?: VylaSDKOptions);
|
||||
|
||||
/**
|
||||
* Get the list of configured sources.
|
||||
* @param excludeDisabled If true, filters out disabled sources.
|
||||
*/
|
||||
getSources(excludeDisabled?: boolean): SourceConfig[];
|
||||
|
||||
/**
|
||||
* Probe all active (non-disabled) sources for availability.
|
||||
*/
|
||||
probeAllSources(): Promise<ProbeAllResult>;
|
||||
|
||||
/**
|
||||
* Probe a single source by key.
|
||||
*/
|
||||
probeSource(key: string): Promise<ProbeResult>;
|
||||
|
||||
/**
|
||||
* Get a playable stream for a given source/media id.
|
||||
* @param key Source key
|
||||
* @param id Media id (e.g. TMDB id)
|
||||
* @param s Season number (for TV), or null for movies
|
||||
* @param e Episode number (for TV), or null for movies
|
||||
* @param clientIP Optional client IP for source-specific logic
|
||||
*/
|
||||
getStream(
|
||||
key: string,
|
||||
id: string | number,
|
||||
s?: number | null,
|
||||
e?: number | null,
|
||||
clientIP?: string | null
|
||||
): Promise<StreamResult | { allUrls: StreamResult[] }>;
|
||||
|
||||
/**
|
||||
* Get subtitles for a movie or TV episode.
|
||||
* @param id Media id
|
||||
* @param s Season number (for TV), or null for movies
|
||||
* @param e Episode number (for TV), or null for movies
|
||||
*/
|
||||
getSubtitles(
|
||||
id: string | number,
|
||||
s?: number | null,
|
||||
e?: number | null
|
||||
): Promise<SubtitleResult[]>;
|
||||
|
||||
/**
|
||||
* Get download links for a movie or TV episode.
|
||||
* @param id Media id
|
||||
* @param s Season number (for TV), or null for movies
|
||||
* @param e Episode number (for TV), or null for movies
|
||||
*/
|
||||
getDownloads(
|
||||
id: string | number,
|
||||
s?: number | null,
|
||||
e?: number | null
|
||||
): Promise<DownloadResult[]>;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import SOURCES, { SOURCE_MAP } from "./config.js";
|
||||
import { fetchDownloads } from "./downloads/downloads.js";
|
||||
import { probeSource } from "./health.js";
|
||||
import { fetchSubtitles, getSubPathsMovie, getSubPathsTv } from "./subtitles.js";
|
||||
import { createStreamArgs } from "./utils/helpers.js";
|
||||
|
||||
const ALL_SOURCE_MODULES = Object.fromEntries(
|
||||
await Promise.all(SOURCES.map(async cfg => [cfg.key, await import(`./sources/${cfg.sourceFile}.js`)]))
|
||||
);
|
||||
|
||||
const SOURCE_MODULES = Object.fromEntries(
|
||||
Object.entries(ALL_SOURCE_MODULES).filter(([key]) => !SOURCE_MAP[key]?.disabled)
|
||||
);
|
||||
|
||||
export default class VylaSDK {
|
||||
tmdbApiKey = null;
|
||||
penguManifest = null;
|
||||
constructor({ tmdbApiKey, penguManifest } = {}) {
|
||||
this.tmdbApiKey = tmdbApiKey || null;
|
||||
this.penguManifest = penguManifest || null;
|
||||
}
|
||||
getSources(excludeDisabled = false) {
|
||||
if (excludeDisabled) {
|
||||
return SOURCES.filter(cfg => !cfg.disabled);
|
||||
}
|
||||
return SOURCES;
|
||||
}
|
||||
async probeAllSources() {
|
||||
const active = SOURCES.filter(cfg => !cfg.disabled);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
active.map(async cfg => await probeSource(cfg, SOURCE_MODULES[cfg.key]))
|
||||
);
|
||||
|
||||
const sources = Object.fromEntries(
|
||||
active.map((cfg, i) => [
|
||||
cfg.key,
|
||||
results[i].status === 'fulfilled' ? results[i].value : { ok: false, ms: null },
|
||||
])
|
||||
);
|
||||
|
||||
return sources;
|
||||
}
|
||||
async probeSource(key) {
|
||||
const cfg = SOURCES.find(cfg => cfg.key === key);
|
||||
if (!cfg) throw new Error(`Source with key "${key}" not found`);
|
||||
const mod = SOURCE_MODULES[key];
|
||||
if (!mod) throw new Error(`Source module for key "${key}" not found`);
|
||||
return await probeSource(this, cfg, mod);
|
||||
}
|
||||
async getStream(key, id, s = null, e = null, clientIP = null) {
|
||||
const cfg = SOURCES.find(cfg => cfg.key === key);
|
||||
if (!cfg) throw new Error(`Source with key "${key}" not found`);
|
||||
const mod = SOURCE_MODULES[key];
|
||||
if (!mod) throw new Error(`Source module for key "${key}" not found`);
|
||||
const streamArgs = await createStreamArgs(cfg, this, id, s, e, clientIP);
|
||||
return await mod.getStream(streamArgs);
|
||||
}
|
||||
async getSubtitles(id, s = null, e = null) {
|
||||
const paths = s != null && e != null
|
||||
? await getSubPathsTv(id, s, e)
|
||||
: await getSubPathsMovie(id);
|
||||
return await fetchSubtitles(paths);
|
||||
}
|
||||
async getDownloads(id, s = null, e = null) {
|
||||
return await fetchDownloads(this, id, s, e);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const SERVICE_ORIGIN = 'https://st.111477.xyz';
|
||||
|
||||
function generateManifestBaseUrl(tmdbKey) {
|
||||
let config = 'https://a.111477.xyz/::sort=file-desc::limit=3';
|
||||
if (tmdbKey) {
|
||||
config += `::tmdb=${tmdbKey}`;
|
||||
}
|
||||
const b64 = Buffer.from(config, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `${SERVICE_ORIGIN}/config/${b64}`;
|
||||
}
|
||||
|
||||
async function resolveImdbId(sdk, id, isTv) {
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}&append_to_response=external_ids`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.imdb_id || data.external_ids?.imdb_id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const ua = getUA();
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
|
||||
const imdbId = await resolveImdbId(sdk, id, isTv);
|
||||
const targetIds = [];
|
||||
|
||||
if (imdbId && imdbId.startsWith('tt')) {
|
||||
targetIds.push(imdbId);
|
||||
}
|
||||
targetIds.push(`tmdb:${id}`);
|
||||
|
||||
const addonBase = generateManifestBaseUrl(apiKey);
|
||||
const seenUrls = new Set();
|
||||
const allUrls = [];
|
||||
|
||||
const defaultHeaders = {
|
||||
'User-Agent': ua,
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
};
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
const endpoint = isTv
|
||||
? `${addonBase}/stream/series/${targetId}:${s || 1}:${e || 1}.json`
|
||||
: `${addonBase}/stream/movie/${targetId}.json`;
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
headers: defaultHeaders,
|
||||
signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
|
||||
if (Array.isArray(data?.streams) && data.streams.length > 0) {
|
||||
for (const item of data.streams) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const url = String(item.url || '').trim();
|
||||
if (!url || !url.startsWith('http') || seenUrls.has(url)) continue;
|
||||
|
||||
seenUrls.add(url);
|
||||
|
||||
const rawTitle = item.title ? String(item.title).trim() : '';
|
||||
const rawName = item.name ? String(item.name).trim() : '111477';
|
||||
const label = rawTitle || rawName;
|
||||
|
||||
allUrls.push({
|
||||
url,
|
||||
label,
|
||||
headers: defaultHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
if (allUrls.length > 0) break;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { getTmdbInfo, fetchJson, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
async function resolveSlug(id, s, e, info) {
|
||||
const titles = info?.titles || [];
|
||||
const keywords = [];
|
||||
for (const title of titles) {
|
||||
const t = title.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!keywords.includes(t)) keywords.push(t);
|
||||
const words = title.split(/[\s:]+/).filter(w => w.length > 2);
|
||||
if (words.length >= 2) {
|
||||
const short = words.slice(0, 3).join(' ').toLowerCase();
|
||||
if (!keywords.includes(short)) keywords.push(short);
|
||||
}
|
||||
}
|
||||
const slugCandidates = [];
|
||||
for (const keyword of keywords) {
|
||||
try {
|
||||
const data = await fetchJson(`https://123animehub.cc/ajax/film/search?keyword=${encodeURIComponent(keyword)}&_=${Date.now()}`, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': 'https://123animehub.cc', 'Accept': 'application/json', 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(8000) });
|
||||
let html = data.html || '', idx = 0;
|
||||
while ((idx = html.indexOf('href="/anime/', idx)) !== -1) {
|
||||
const start = idx + 13;
|
||||
const end = html.indexOf('"', start);
|
||||
const slug = html.slice(start, end);
|
||||
if (!slugCandidates.includes(slug)) slugCandidates.push(slug);
|
||||
idx = end;
|
||||
}
|
||||
} catch { }
|
||||
if (slugCandidates.length) break;
|
||||
}
|
||||
for (const slug of slugCandidates) {
|
||||
try {
|
||||
const data = await fetchJson(`https://123animehub.cc/ajax/episode/info?epr=${encodeURIComponent(s ? `${slug}/${s}/${e}` : `${slug}/1`)}&ts=1&_=${Date.now()}`, { headers: { 'Referer': `https://123animehub.cc/anime/${slug}`, 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json, text/javascript, */*; q=0.01', 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(10000) });
|
||||
if (data?.target) return data.target;
|
||||
} catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
if (!s) return null;
|
||||
const info = await getTmdbInfo(id, 'tv', s);
|
||||
if (!info.isAnime) return null;
|
||||
const target = await resolveSlug(id, s, e, info);
|
||||
if (!target) return null;
|
||||
const embedMatch = target.match(/\/embed-[^/]+\/([A-Za-z0-9+/=]+)$/);
|
||||
if (!embedMatch) return null;
|
||||
const origin = new URL(target).origin;
|
||||
try {
|
||||
const embedRes = await fetch(target, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(10000) });
|
||||
if (!embedRes.ok) return null;
|
||||
const setCookie = embedRes.headers.get('set-cookie');
|
||||
const data = await fetchJson(`${origin}/hs/getSources?id=${embedMatch[1]}`, { headers: { 'Referer': target, 'Accept': '*/*', 'User-Agent': USER_AGENT, ...(setCookie ? { 'Cookie': setCookie.split(';')[0].trim() } : {}) }, signal: AbortSignal.timeout(10000) });
|
||||
const src = typeof data.sources === 'string' && data.sources.length ? data.sources : (Array.isArray(data.sources) && data.sources.length ? (data.sources[0].file || data.sources[0].src || data.sources[0].url) : null);
|
||||
if (src) return { url: src, headers: { 'Referer': 'https://play2.echovideo.ru/', 'Origin': 'https://play2.echovideo.ru' } };
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { getTmdbInfo, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
async function extractVoe(voeUrl) {
|
||||
try {
|
||||
let res = await fetch(voeUrl, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://anihq.cc/'
|
||||
}
|
||||
});
|
||||
|
||||
let html = await res.text();
|
||||
|
||||
const redirectMatch = html.match(/window\.location\.href\s*=\s*['"](https?:\/\/[^'"]+)['"]/);
|
||||
if (redirectMatch) {
|
||||
res = await fetch(redirectMatch[1], {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://anihq.cc/'
|
||||
}
|
||||
});
|
||||
html = await res.text();
|
||||
}
|
||||
|
||||
const mirrorUrl = res.url;
|
||||
|
||||
const scriptMatch = html.match(/<script type="application\/json">\["(.*?)"\]<\/script>/);
|
||||
if (!scriptMatch) return null;
|
||||
|
||||
let str = scriptMatch[1];
|
||||
|
||||
str = str.replace(/[a-zA-Z]/g, (c) => {
|
||||
return String.fromCharCode((c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26);
|
||||
});
|
||||
|
||||
const junk = ['@$', '^^', '~@', '%?', '*~', '!!', '#&'];
|
||||
junk.forEach(j => { str = str.split(j).join(''); });
|
||||
|
||||
str = Buffer.from(str, 'base64').toString('utf-8');
|
||||
|
||||
let shifted = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
shifted += String.fromCharCode(str.charCodeAt(i) - 3);
|
||||
}
|
||||
|
||||
let reversed = shifted.split('').reverse().join('');
|
||||
|
||||
let finalJsonStr = Buffer.from(reversed, 'base64').toString('utf-8');
|
||||
|
||||
const finalData = JSON.parse(finalJsonStr);
|
||||
|
||||
if (finalData.file || finalData.source) {
|
||||
return {
|
||||
url: finalData.file || finalData.source,
|
||||
mirror: mirrorUrl
|
||||
};
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanSlug(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, audio }) {
|
||||
if (!s) return null;
|
||||
|
||||
const info = await getTmdbInfo(id, 'tv', s);
|
||||
if (!info.isAnime) return null;
|
||||
|
||||
const seriesTitle = info.titles.find(t =>
|
||||
t.toLowerCase() !== 'east blue' &&
|
||||
t.length > 3
|
||||
) || info.titles[0];
|
||||
|
||||
const cleanTitle = cleanSlug(seriesTitle);
|
||||
const typeSuffix = audio === 'dub' ? 'english-dubbed' : 'english-subbed';
|
||||
const url = `https://anihq.cc/watch/${cleanTitle}-episode-${e}-${typeSuffix}/`;
|
||||
|
||||
try {
|
||||
let pageRes = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
|
||||
let html = await pageRes.text();
|
||||
|
||||
if (!pageRes.ok) {
|
||||
const searchUrl = `https://anihq.cc/search?keyword=${encodeURIComponent(seriesTitle)}`;
|
||||
const searchRes = await fetch(searchUrl, { headers: { 'User-Agent': USER_AGENT } });
|
||||
const searchHtml = await searchRes.text();
|
||||
const searchRegex = new RegExp(`href="(https://anihq.cc/watch/[^"]+-episode-${e}-${typeSuffix}/)"`, 'i');
|
||||
const match = searchHtml.match(searchRegex);
|
||||
if (match) {
|
||||
const retryRes = await fetch(match[1], { headers: { 'User-Agent': USER_AGENT } });
|
||||
html = await retryRes.text();
|
||||
} else return null;
|
||||
}
|
||||
|
||||
const iframeMatch = html.match(/iframe[^>]+src=["'](https:\/\/(?:[a-z0-9.]+|ellenpoliticalfollow\.com)\/e\/[a-zA-Z0-9]+)["']/i);
|
||||
if (!iframeMatch) return null;
|
||||
|
||||
const result = await extractVoe(iframeMatch[1]);
|
||||
|
||||
if (result && result.url) {
|
||||
const mirrorOrigin = new URL(result.mirror).origin;
|
||||
return {
|
||||
url: result.url,
|
||||
headers: {
|
||||
'Referer': mirrorOrigin + '/',
|
||||
'User-Agent': USER_AGENT,
|
||||
'Origin': mirrorOrigin
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { getTmdbInfo, USER_AGENT, fetchText } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = "https://anineko.to";
|
||||
|
||||
function cleanTitle(t) { return t ? t.toLowerCase().replace(/[^a-z0-9]/g, '') : ''; }
|
||||
|
||||
function decodeEntities(str) {
|
||||
return str.replace(/&(nbsp|amp|quot|lt|gt);/g, (m, e) => ({ "nbsp": " ", "amp": "&", "quot": "\"", "lt": "<", "gt": ">" })[e]).replace(/&#(\d+);/gi, (m, n) => String.fromCharCode(parseInt(n, 10)));
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
try {
|
||||
const html = await fetchText(`${BASE_URL}/browser?keyword=${encodeURIComponent(query)}`, {
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
while ((idx = html.indexOf('nv-anime-thumb', idx)) !== -1) {
|
||||
const startHref = html.lastIndexOf('<a', idx);
|
||||
if (startHref === -1) { idx += 14; continue; }
|
||||
const hrefMatch = html.slice(startHref, html.indexOf('>', startHref)).match(/href=["']([^"']+)["']/i);
|
||||
if (!hrefMatch) { idx += 14; continue; }
|
||||
const slug = hrefMatch[1].split('/').pop();
|
||||
const h3Start = html.indexOf('nv-anime-title', idx);
|
||||
if (h3Start !== -1) {
|
||||
const contentStart = html.indexOf('>', h3Start) + 1;
|
||||
const contentEnd = html.indexOf('<', contentStart);
|
||||
if (contentEnd !== -1) results.push({ slug, text: html.slice(contentStart, contentEnd).trim() });
|
||||
}
|
||||
idx += 14;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
const linkMatches = [...html.matchAll(/href=["']\/watch\/([^"']+)["']/gi)];
|
||||
for (const match of linkMatches) {
|
||||
if (!results.find(r => r.slug === match[1])) {
|
||||
const linkIdx = html.indexOf(match[0]);
|
||||
const titleStart = html.lastIndexOf('>', linkIdx);
|
||||
const titleEnd = html.indexOf('<', linkIdx);
|
||||
if (titleStart !== -1 && titleEnd !== -1 && titleEnd > linkIdx) {
|
||||
const title = html.slice(titleStart + 1, Math.min(titleEnd, linkIdx + 200)).trim();
|
||||
if (title.length > 2 && title.length < 100) {
|
||||
results.push({ slug: match[1], text: title });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function extractHls(embedUrl) {
|
||||
try {
|
||||
const html = await fetchText(embedUrl, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
Referer: `${BASE_URL}/`
|
||||
},
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
const m = html.match(/const\s+src\s*=\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i) ||
|
||||
html.match(/file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i) ||
|
||||
html.match(/["'](https?:\/\/[^"']+\/master\.m3u8[^"']*)["']/i) ||
|
||||
html.match(/["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i) ||
|
||||
html.match(/src:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i);
|
||||
if (m) return decodeEntities(m[1]);
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, audio, sdk }) {
|
||||
const tmdbApiKey = sdk?.tmdbApiKey || null;
|
||||
if (!tmdbApiKey) return null;
|
||||
try {
|
||||
const info = await getTmdbInfo(tmdbApiKey, id, s ? 'tv' : 'movie', s);
|
||||
if (!info || !info.isAnime) return null;
|
||||
let seriesSlug = null;
|
||||
for (const title of info.titles) {
|
||||
const potentialSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!potentialSlug) continue;
|
||||
try {
|
||||
const checkHtml = await fetchText(`${BASE_URL}/watch/${potentialSlug}/ep-${e || 1}`, {
|
||||
headers: { 'User-Agent': USER_AGENT, Referer: `${BASE_URL}/watch/${potentialSlug}` },
|
||||
signal: AbortSignal.timeout(4000)
|
||||
});
|
||||
if (checkHtml && checkHtml.includes('nv-watch-page')) {
|
||||
seriesSlug = potentialSlug;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!seriesSlug) {
|
||||
for (const title of info.titles) {
|
||||
const results = await search(title);
|
||||
const targetClean = cleanTitle(title);
|
||||
|
||||
const exactSlugMatch = results.find(r => {
|
||||
const slugClean = cleanTitle(r.slug);
|
||||
return slugClean === targetClean || r.slug === targetClean;
|
||||
});
|
||||
|
||||
if (exactSlugMatch) {
|
||||
seriesSlug = exactSlugMatch.slug;
|
||||
break;
|
||||
}
|
||||
|
||||
const sortedResults = [...results].sort((a, b) => a.slug.length - b.slug.length);
|
||||
|
||||
for (const r of sortedResults) {
|
||||
const resultClean = cleanTitle(r.text);
|
||||
const isExactMatch = resultClean === targetClean;
|
||||
const isCloseMatch = r.text.toLowerCase().includes(title.toLowerCase());
|
||||
if (isExactMatch || isCloseMatch) {
|
||||
seriesSlug = r.slug;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (seriesSlug) break;
|
||||
}
|
||||
}
|
||||
if (!seriesSlug && info.titles.length) {
|
||||
const fSearch = await search(info.titles[0]);
|
||||
const sortedResults = [...fSearch].sort((a, b) => a.slug.length - b.slug.length);
|
||||
if (sortedResults.length) seriesSlug = sortedResults[0].slug;
|
||||
}
|
||||
if (!seriesSlug) return null;
|
||||
const watchHtml = await fetchText(`${BASE_URL}/watch/${seriesSlug}/ep-${e || 1}`, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
Referer: `${BASE_URL}/watch/${seriesSlug}`
|
||||
},
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
const byAudio = { sub: [], dub: [] };
|
||||
|
||||
const iframeMatch = watchHtml.match(/<iframe[^>]*src=["']([^"']+)["'][^>]*>/i);
|
||||
if (iframeMatch) {
|
||||
const iframeSrc = decodeEntities(iframeMatch[1]);
|
||||
const hls = await extractHls(iframeSrc);
|
||||
if (hls) {
|
||||
const audioType = audio === 'dub' ? 'dub' : 'sub';
|
||||
byAudio[audioType].push(iframeSrc);
|
||||
}
|
||||
}
|
||||
|
||||
if (byAudio.sub.length === 0 && byAudio.dub.length === 0) {
|
||||
const tabMatches = [...watchHtml.matchAll(/<button[^>]*class=["'][^"']*nv-server-tab[^"']*["'][^>]*data-id=["']([^"']+)["'][^>]*>/gi)];
|
||||
const tabMap = {};
|
||||
for (const match of tabMatches) {
|
||||
const tabId = match[1].toLowerCase();
|
||||
const tabClassMatch = match[0].match(/class=["'][^"']*(tab_\d+)[^"']/i);
|
||||
if (tabClassMatch) {
|
||||
const tabClass = tabClassMatch[1];
|
||||
tabMap[tabClass] = tabId.includes('dub') ? 'dub' : 'sub';
|
||||
}
|
||||
}
|
||||
|
||||
const btnRegex = /<button\s+[^>]*class=["'][^"']*server-video[^"']*["'][^>]*>/gi;
|
||||
const btnMatches = [...watchHtml.matchAll(btnRegex)];
|
||||
for (const match of btnMatches) {
|
||||
const btnHtml = match[0];
|
||||
const videoMatch = btnHtml.match(/data-video=["']([^"']+)["']/i);
|
||||
const tabMatch = btnHtml.match(/data-tab=["']([^"']+)["']/i);
|
||||
if (videoMatch && tabMatch) {
|
||||
const videoUrl = decodeEntities(videoMatch[1]);
|
||||
const tabClass = tabMatch[1];
|
||||
const audioType = tabMap[tabClass] || 'sub';
|
||||
byAudio[audioType].push(videoUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
const audiosToTry = audio === "all" ? ["sub", "dub"] : (audio === "dub" ? ["dub", "sub"] : ["sub", "dub"]);
|
||||
const allUrls = [];
|
||||
for (const aud of audiosToTry) {
|
||||
for (const embed of byAudio[aud]) {
|
||||
const hls = await extractHls(embed);
|
||||
if (hls) allUrls.push({ url: hls, type: "hls", audio: aud, server: "AniNeko", skipProxy: false });
|
||||
}
|
||||
if (allUrls.length) break;
|
||||
}
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getSources(args) {
|
||||
const stream = await getStream(args);
|
||||
return stream?.allUrls ? [...new Set(stream.allUrls.map(u => u.server))] : [];
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const _sInit = [23, 27, 30, 31, 86, 28, 19, 86, 9, 18, 19, 21, 26, 27, 86, 120, 114, 120, 116, 86, 9, 18, 19, 21, 26, 27, 122, 89, 25, 23];
|
||||
const _vParam = `_v=${(_sInit[15] ^ 0x5a)}${(_sInit[16] ^ 0x5a)}${(_sInit[17] ^ 0x5a)}${(_sInit[18] ^ 0x5a)}`;
|
||||
|
||||
const nD = '4860ac8bfddb';
|
||||
const aD = '224eff10e662e9635c9f671cf46351dcd69af42b1edd56f5e5fa21751f44b9c8';
|
||||
const Ls = [17, 91, 203, 44, 8, 177, 62, 239, 119, 3, 154, 81, 28, 210, 101, 7];
|
||||
const wa = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
const SERVERS = [
|
||||
{ id: 'NIGHT', endpoint: '/server/night' },
|
||||
{ id: 'EMP', endpoint: '/server/emp' },
|
||||
{ id: 'MAIN', endpoint: '/server/vidsrc' }
|
||||
];
|
||||
|
||||
const CORS_PROXY = 'https://eve.pstream.cfd';
|
||||
let cachedToken = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
function ab(e) {
|
||||
let t = e >>> 0;
|
||||
t ^= t >>> 16;
|
||||
t = Math.imul(t, 2146121005) >>> 0;
|
||||
t ^= t >>> 15;
|
||||
t = Math.imul(t, 2221713035) >>> 0;
|
||||
return (t ^ (t >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function sD(e) {
|
||||
const t = new TextEncoder().encode(aD);
|
||||
const r = Math.max(32, Math.min(128, e + 17));
|
||||
const n = new Uint8Array(r);
|
||||
let a = 2166136261;
|
||||
for (let s = 0; s < r; s += 1) {
|
||||
a ^= t[s % t.length] ?? s;
|
||||
a = ab((a + Ls[s % Ls.length] + ((2654435761 * s) >>> 0)) >>> 0);
|
||||
n[s] = a & 255;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function iD(e) {
|
||||
let t = '';
|
||||
for (let r = 0; r < e.length; r += 3) {
|
||||
const n = e[r];
|
||||
const a = e[r + 1];
|
||||
const s = e[r + 2];
|
||||
t += wa[n >>> 2];
|
||||
t += wa[((3 & n) << 4) | ((a ?? 0) >>> 4)];
|
||||
if (a === undefined) break;
|
||||
t += wa[((15 & a) << 2) | ((s ?? 0) >>> 6)];
|
||||
if (s === undefined) break;
|
||||
t += wa[63 & s];
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function generateDirectHlsUrl(id, s, e) {
|
||||
const isTv = s != null && e != null;
|
||||
const tmdbId = Math.floor(Number(id));
|
||||
const season = isTv ? Math.floor(Number(s || 1)) : 0;
|
||||
const episode = isTv ? Math.floor(Number(e || 1)) : 0;
|
||||
|
||||
const str = `${nD}:${isTv ? 's' : 'm'}:${tmdbId}:${season}:${episode}`;
|
||||
const a = new TextEncoder().encode(str);
|
||||
const sArr = sD(a.length);
|
||||
const i = new Uint8Array(a.length + 2);
|
||||
i[0] = a.length & 255;
|
||||
i[1] = (a.length >>> 8) & 255;
|
||||
let o = (2654435769 ^ a.length) >>> 0;
|
||||
for (let l = 0; l < a.length; l += 1) {
|
||||
o = ab((o + sArr[l % sArr.length] + Ls[l % Ls.length] + l) >>> 0);
|
||||
i[l + 2] = (a[l] ^ (255 & o)) ^ sArr[(7 * l + 3) % sArr.length];
|
||||
}
|
||||
|
||||
return `https://glendale-plumbing.com/c/v1/${iD(i)}/master.m3u8`;
|
||||
}
|
||||
|
||||
async function fetchInternalToken() {
|
||||
if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
|
||||
try {
|
||||
const res = await fetch('https://1embed.cc/api/token', {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://1embed.cc/',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
signal: AbortSignal.timeout(4000)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data?.token) {
|
||||
cachedToken = data.token;
|
||||
tokenExpiry = Date.now() + 1800 * 1000;
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const proxiedUrl = `${CORS_PROXY}/?destination=${encodeURIComponent('https://1embed.cc/api/token')}`;
|
||||
const res = await fetch(proxiedUrl, {
|
||||
headers: {
|
||||
'X-Referer': 'https://1embed.cc/',
|
||||
'X-User-Agent': USER_AGENT
|
||||
},
|
||||
signal: AbortSignal.timeout(4000)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data?.token) {
|
||||
cachedToken = data.token;
|
||||
tokenExpiry = Date.now() + 1800 * 1000;
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, clientIP } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const isMovie = !isTv;
|
||||
const allUrls = [];
|
||||
|
||||
const directHlsUrl = generateDirectHlsUrl(id, s, e);
|
||||
const masterUrlWithMark = `${directHlsUrl}?${_vParam}`;
|
||||
|
||||
allUrls.push({
|
||||
url: masterUrlWithMark,
|
||||
server: 'Bcine (Direct Master)',
|
||||
quality: 'Auto',
|
||||
type: 'hls',
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://bcine.ru/',
|
||||
'Origin': 'https://bcine.ru',
|
||||
...(clientIP && { 'X-Forwarded-For': clientIP })
|
||||
}
|
||||
});
|
||||
|
||||
const token = await fetchInternalToken();
|
||||
|
||||
await Promise.allSettled(
|
||||
SERVERS.map(async (server) => {
|
||||
const query = isTv
|
||||
? `id=${id}?type=tv&s=${s || 1}&e=${e || 1}`
|
||||
: `id=${id}?type=movie`;
|
||||
|
||||
const stParam = token ? `&_st=${token}` : '';
|
||||
const targetUrl = `https://1embed.cc${server.endpoint}/${query}${stParam}`;
|
||||
|
||||
const headers = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://bcine.ru/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
...(token && { 'X-Stream-Token': token }),
|
||||
...(clientIP && { 'X-Forwarded-For': clientIP })
|
||||
};
|
||||
|
||||
let res = await fetch(targetUrl, { headers, signal: AbortSignal.timeout(5000) }).catch(() => null);
|
||||
if (!res || !res.ok) {
|
||||
const proxiedUrl = `${CORS_PROXY}/?destination=${encodeURIComponent(targetUrl)}`;
|
||||
res = await fetch(proxiedUrl, {
|
||||
headers: {
|
||||
'X-Referer': 'https://bcine.ru/',
|
||||
'X-User-Agent': USER_AGENT
|
||||
},
|
||||
signal: AbortSignal.timeout(5000)
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
if (res && res.ok) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && data.success !== false) {
|
||||
const streamUrl = data.streams?.proxy_m3u8 || data.streams?.raw_m3u8 || data.streams?.m3u8 || data.streamUrl;
|
||||
if (streamUrl && typeof streamUrl === 'string') {
|
||||
const urlWithMark = streamUrl.includes('?') ? `${streamUrl}&${_vParam}` : `${streamUrl}?${_vParam}`;
|
||||
allUrls.push({
|
||||
url: urlWithMark,
|
||||
server: `Bcine (${data.selectedSource || data.provider || server.id})`,
|
||||
quality: 'Auto',
|
||||
type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://bcine.ru/',
|
||||
'Origin': 'https://bcine.ru'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://dulo.mov';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function resolveDetails(sdk, id, isTv) {
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const title = isTv ? data.name : data.title;
|
||||
const releaseDate = isTv ? data.first_air_date : data.release_date;
|
||||
const year = releaseDate ? releaseDate.split('-')[0] : '';
|
||||
const runtime = isTv ? (data.episode_run_time?.[0] || 24) : (data.runtime || 90);
|
||||
return { title, year, runtime };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSource(url, headers) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const mediaType = isTv ? 'tv' : 'movie';
|
||||
const ua = getUA();
|
||||
|
||||
const details = await resolveDetails(sdk, id, isTv);
|
||||
const title = details?.title || '';
|
||||
const year = details?.year || '';
|
||||
const runtime = details?.runtime || '';
|
||||
|
||||
const referer = isTv
|
||||
? `${BASE_URL}/watch/tv/${id}/${s}/${e}`
|
||||
: `${BASE_URL}/watch/movie/${id}`;
|
||||
|
||||
const defaultHeaders = {
|
||||
'User-Agent': ua,
|
||||
Referer: referer,
|
||||
Origin: BASE_URL,
|
||||
Accept: '*/*',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
};
|
||||
|
||||
const baseParams = new URLSearchParams({
|
||||
tmdbId: String(id),
|
||||
type: mediaType,
|
||||
});
|
||||
|
||||
const additionalParams = new URLSearchParams({
|
||||
tmdbId: String(id),
|
||||
type: mediaType,
|
||||
progressive: 'true',
|
||||
});
|
||||
|
||||
if (title) additionalParams.set('title', title);
|
||||
if (year) additionalParams.set('year', String(year));
|
||||
if (runtime) additionalParams.set('runtime', String(runtime));
|
||||
|
||||
if (isTv) {
|
||||
baseParams.set('season', String(s));
|
||||
baseParams.set('episode', String(e));
|
||||
additionalParams.set('season', String(s));
|
||||
additionalParams.set('episode', String(e));
|
||||
}
|
||||
|
||||
const endpoints = [
|
||||
`${BASE_URL}/api/sources?${baseParams.toString()}`,
|
||||
`${BASE_URL}/api/sources/willow?${baseParams.toString()}`,
|
||||
`${BASE_URL}/api/sources/additional?${additionalParams.toString()}`,
|
||||
];
|
||||
|
||||
const seenUrls = new Set();
|
||||
const allUrls = [];
|
||||
|
||||
let pendingUrls = [...endpoints];
|
||||
const maxPollTime = Date.now() + 16000;
|
||||
|
||||
while (pendingUrls.length > 0 && Date.now() < maxPollTime) {
|
||||
const results = await Promise.all(
|
||||
pendingUrls.map((url) => fetchSource(url, defaultHeaders))
|
||||
);
|
||||
|
||||
const nextPending = [];
|
||||
|
||||
results.forEach((data, index) => {
|
||||
if (!data) return;
|
||||
|
||||
if (Array.isArray(data.sources) && data.sources.length > 0) {
|
||||
for (const src of data.sources) {
|
||||
let streamUrl = String(src?.url || '').trim();
|
||||
if (!streamUrl) continue;
|
||||
|
||||
if (streamUrl.startsWith('/')) {
|
||||
streamUrl = `${BASE_URL}${streamUrl}`;
|
||||
}
|
||||
|
||||
if (!streamUrl.startsWith('http') || seenUrls.has(streamUrl)) continue;
|
||||
seenUrls.add(streamUrl);
|
||||
|
||||
allUrls.push({
|
||||
url: streamUrl,
|
||||
label: src.label || 'Dulo',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
Referer: referer,
|
||||
},
|
||||
captions: Array.isArray(src.captions) ? src.captions : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.pending) {
|
||||
nextPending.push(pendingUrls[index]);
|
||||
}
|
||||
});
|
||||
|
||||
pendingUrls = nextPending;
|
||||
if (pendingUrls.length > 0) {
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { USER_AGENT, fetchJson } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://flaxmovies.xyz';
|
||||
|
||||
async function getWorkerUrls(embedUrl) {
|
||||
const foundUrls = [];
|
||||
try {
|
||||
const res = await fetch(embedUrl, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Origin': BASE_URL
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const directMatch = html.match(/WORKER_URL\s*[:=]\s*["'](https?:\/\/[^"']+)["']/i);
|
||||
if (directMatch && directMatch[1]) {
|
||||
foundUrls.push(directMatch[1].replace(/\/+$/, ''));
|
||||
}
|
||||
|
||||
const scriptMatches = [...html.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)];
|
||||
for (const sm of scriptMatches) {
|
||||
let scriptUrl = sm[1];
|
||||
if (scriptUrl.startsWith('//')) scriptUrl = `https:${scriptUrl}`;
|
||||
else if (scriptUrl.startsWith('/')) scriptUrl = `${BASE_URL}${scriptUrl}`;
|
||||
else if (!scriptUrl.startsWith('http')) scriptUrl = `${BASE_URL}/${scriptUrl}`;
|
||||
|
||||
if (scriptUrl.includes('jsdelivr') || scriptUrl.includes('google') || scriptUrl.includes('cloudflare')) continue;
|
||||
|
||||
try {
|
||||
const sRes = await fetch(scriptUrl, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Referer': embedUrl },
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
if (sRes.ok) {
|
||||
const sCode = await sRes.text();
|
||||
const m = sCode.match(/WORKER_URL\s*[:=]\s*["'](https?:\/\/[^"']+)["']/i);
|
||||
if (m && m[1]) {
|
||||
const wUrl = m[1].replace(/\/+$/, '');
|
||||
if (!foundUrls.includes(wUrl)) foundUrls.push(wUrl);
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const defaults = ['https://freakyniki.elaxo.lol', 'https://vidlove.nabilekson.workers.dev'];
|
||||
for (const d of defaults) {
|
||||
if (!foundUrls.includes(d)) foundUrls.push(d);
|
||||
}
|
||||
return foundUrls;
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, server } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const embedUrl = isTv
|
||||
? `${BASE_URL}/embed/tv/${id}/${s}/${e}`
|
||||
: `${BASE_URL}/embed/movie/${id}`;
|
||||
|
||||
const workerUrls = await getWorkerUrls(embedUrl);
|
||||
const query = isTv
|
||||
? `tmdb_id=${id}&tmdbId=${id}&season=${s}&episode=${e}`
|
||||
: `tmdb_id=${id}&tmdbId=${id}`;
|
||||
|
||||
for (const worker of workerUrls) {
|
||||
try {
|
||||
const url = `${worker}/?${query}`;
|
||||
const data = await fetchJson(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Origin': BASE_URL,
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
signal: AbortSignal.timeout(12000)
|
||||
});
|
||||
|
||||
if (data && Array.isArray(data.streams) && data.streams.length > 0) {
|
||||
let allUrls = data.streams
|
||||
.filter(st => st && typeof st.url === 'string')
|
||||
.map(st => {
|
||||
const isHls = (st.format || '').toLowerCase().includes('hls') || st.url.includes('.m3u8');
|
||||
return {
|
||||
url: st.url,
|
||||
server: `FlaxMovies - ${st.provider || 'Default'}`,
|
||||
quality: st.resolution || 'Auto',
|
||||
type: isHls ? 'hls' : 'mp4',
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Origin': BASE_URL
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (server && server !== 'all') {
|
||||
const clean = server.toLowerCase().replace('flaxmovies - ', '');
|
||||
const filtered = allUrls.filter(u => u.server.toLowerCase().includes(clean));
|
||||
if (filtered.length) allUrls = filtered;
|
||||
}
|
||||
|
||||
if (allUrls.length) {
|
||||
return { allUrls };
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getSources() {
|
||||
return ['FlaxMovies - Airflix', 'FlaxMovies - Xpass-VIP 1'];
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { fetchText, USER_AGENT, getTmdbInfo } from '../utils/helpers.js';
|
||||
|
||||
const ORIGIN = 'https://www3.fsonline.app';
|
||||
const AJAX_URL = `${ORIGIN}/wp-admin/admin-ajax.php`;
|
||||
const HEADERS = { 'User-Agent': USER_AGENT, 'Origin': ORIGIN, 'Referer': `${ORIGIN}/` };
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const tmdbApiKey = sdk?.tmdbApiKey || null;
|
||||
if (!tmdbApiKey) return null;
|
||||
try {
|
||||
const isTv = s != null && e != null;
|
||||
const info = await getTmdbInfo(tmdbApiKey, id, isTv ? 'tv' : 'movie');
|
||||
if (!info?.titles?.length) return null;
|
||||
const searchHtml = await fetchText(`${ORIGIN}/?s=${encodeURIComponent(info.titles[0] + (info.year ? ' ' + info.year : ''))}`, { headers: HEADERS });
|
||||
const typeFolder = isTv ? 'seriale' : 'film';
|
||||
const linkMatch = searchHtml.match(new RegExp(`href=["'](https?://www3\\.fsonline\\.app/${typeFolder}/([^"'/]+)/)["']`, 'i'));
|
||||
if (!linkMatch) return null;
|
||||
const targetPageUrl = isTv ? `${ORIGIN}/episoade/${linkMatch[2].replace(/-\d{4}$/, '')}-sezonul-${s}-episodul-${e}/` : linkMatch[1];
|
||||
const pageHtml = await fetchText(targetPageUrl, { headers: HEADERS });
|
||||
const movieId = (pageHtml.match(/movie-id=['"]([^'"]+)['"]/) || pageHtml.match(/movie-id=([^ >]+)/))?.[1];
|
||||
if (!movieId) return null;
|
||||
const ajaxHtml = await fetchText(AJAX_URL, { method: 'POST', headers: { ...HEADERS, 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'Referer': targetPageUrl }, body: `action=lazy_player&movieID=${movieId}` });
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
while ((idx = ajaxHtml.indexOf('data-vs="', idx)) !== -1) {
|
||||
const embedStart = idx + 9;
|
||||
const embedEnd = ajaxHtml.indexOf('"', embedStart);
|
||||
const embedUrl = ajaxHtml.slice(embedStart, embedEnd);
|
||||
const spanStart = ajaxHtml.indexOf('<span>', embedEnd);
|
||||
const spanEnd = ajaxHtml.indexOf('</span>', spanStart);
|
||||
const serverLabel = ajaxHtml.slice(spanStart + 6, spanEnd).trim().toLowerCase();
|
||||
if (serverLabel.includes('filesun')) {
|
||||
try {
|
||||
const rHtml = await fetchText(embedUrl, { headers: { 'Referer': ORIGIN, 'User-Agent': USER_AGENT } });
|
||||
const m3u8Match = rHtml.match(/file:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/) || rHtml.match(/["']?file["']?:\s*["'](https?:\/\/[^"']+)["']/);
|
||||
if (m3u8Match) results.push({ url: m3u8Match[1].replace(/\\\//g, '/'), server: 'FSOnline - FileSuN', headers: { 'Referer': embedUrl, 'Origin': 'https://player.fsonline.app', 'User-Agent': USER_AGENT } });
|
||||
} catch { }
|
||||
}
|
||||
idx = spanEnd;
|
||||
}
|
||||
return results.length ? { allUrls: results } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getSources(args) {
|
||||
const res = await getStream(args);
|
||||
return res?.allUrls ? [...new Set(res.allUrls.map(u => u.server))] : [];
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { fetchJson, USER_AGENT, getTmdbInfo } from '../utils/helpers.js';
|
||||
|
||||
export const SKIP_VERIFY = true;
|
||||
export const MULTI_URL = false;
|
||||
|
||||
const ENC_API = "https://enc-dec.app/api";
|
||||
const BASE = "https://kisskh.do";
|
||||
const HEADERS = { 'User-Agent': USER_AGENT, Accept: 'application/json' };
|
||||
|
||||
function cleanTitle(t) { return t ? t.toLowerCase().replace(/\(\d{4}\)/g, '').replace(/[^a-z0-9]/g, '') : ''; }
|
||||
|
||||
async function encKey(episodeId, type) {
|
||||
try {
|
||||
const data = await fetchJson(`${ENC_API}/enc-kisskh?text=${episodeId}&type=${type}`, { signal: AbortSignal.timeout(6000) });
|
||||
return data?.status === 200 ? data.result : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
try {
|
||||
const info = await getTmdbInfo(id, s ? 'tv' : 'movie', s);
|
||||
if (!info) return null;
|
||||
let drama = null, bestScore = -1;
|
||||
for (const title of (info.titles.length > 1 ? [...info.titles].reverse() : info.titles)) {
|
||||
const results = await fetchJson(`${BASE}/api/DramaList/Search?q=${encodeURIComponent(title)}`, { headers: HEADERS, signal: AbortSignal.timeout(6000) }).catch(() => []);
|
||||
const targetClean = cleanTitle(title);
|
||||
for (const item of results) {
|
||||
const parts = (item.title || '').split(/\s*-\s*/).map(cleanTitle);
|
||||
let score = 0;
|
||||
if (parts.some(p => p === targetClean)) score += 5;
|
||||
else if (parts.some(p => p.includes(targetClean) || targetClean.includes(p))) score += 3;
|
||||
else continue;
|
||||
if (score > bestScore) { bestScore = score; drama = item; }
|
||||
}
|
||||
if (drama && bestScore >= 5) break;
|
||||
}
|
||||
if (!drama) return null;
|
||||
const detail = await fetchJson(`${BASE}/api/DramaList/Drama/${drama.id}`, { headers: HEADERS, signal: AbortSignal.timeout(8000) });
|
||||
const episodeId = detail?.episodes?.find(ep => Math.floor(ep.number) === Number(e || 1))?.id;
|
||||
if (!episodeId) return null;
|
||||
const vidKey = await encKey(episodeId, 'vid');
|
||||
if (!vidKey) return null;
|
||||
const videoData = await fetchJson(`${BASE}/api/DramaList/Episode/${episodeId}.png?err=false&ts=&time=&kkey=${vidKey}`, { headers: HEADERS, signal: AbortSignal.timeout(10000) });
|
||||
if (!videoData?.Video) return null;
|
||||
let subtitles = [];
|
||||
const subKey = await encKey(episodeId, 'sub');
|
||||
if (subKey) {
|
||||
try {
|
||||
const subList = await fetchJson(`${BASE}/api/Sub/${episodeId}?kkey=${subKey}`, { headers: HEADERS, signal: AbortSignal.timeout(8000) });
|
||||
if (Array.isArray(subList)) subtitles = subList.map(sub => ({ url: sub.src, label: sub.label, lang: sub.land, default: !!sub.default }));
|
||||
} catch { }
|
||||
}
|
||||
return { allUrls: [{ url: videoData.Video, type: videoData.Video.includes('.m3u8') ? 'hls' : 'mp4', audio: 'sub', server: 'KissKH', skipProxy: false, subtitles }] };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getSources(args) {
|
||||
const stream = await getStream(args);
|
||||
return stream?.allUrls ? [...new Set(stream.allUrls.map(u => u.server))] : [];
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getTmdbInfo } from '../utils/helpers.js';
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, tmdbApiKey } = args;
|
||||
const type = (s != null && e != null) ? 'tv' : 'movie';
|
||||
|
||||
try {
|
||||
const tmdbInfo = await getTmdbInfo(tmdbApiKey, id, type, s);
|
||||
const name = tmdbInfo.titles[0] || '';
|
||||
if (!name) return null;
|
||||
|
||||
if (type === 'tv') {
|
||||
return null;
|
||||
} else {
|
||||
const url = `https://lmscript.xyz/v1/movies?filters[q]=${encodeURIComponent(name)}&expand=streams`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
const movie = data.items?.find(item => `${item.tmdb_prefix}` === `${id}` || `${item.tmdb_id}` === `${id}`);
|
||||
if (!movie) return null;
|
||||
|
||||
const streams = movie.streams || {};
|
||||
const SOURCE_ORDER = ["1080p", "720p", "480p", "360p"];
|
||||
const sortedStreams = Object.keys(streams).sort((a, b) => SOURCE_ORDER.indexOf(a) - SOURCE_ORDER.indexOf(b));
|
||||
|
||||
if (!sortedStreams.length) return null;
|
||||
|
||||
const allUrls = sortedStreams.map(quality => {
|
||||
const streamUrl = streams[quality];
|
||||
return {
|
||||
url: streamUrl,
|
||||
quality,
|
||||
type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
|
||||
headers: {}
|
||||
};
|
||||
});
|
||||
|
||||
return { allUrls };
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { USER_AGENT, fetchText, fetchJson } from '../utils/helpers.js';
|
||||
|
||||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||
const LM_DOMAINS = ['https://www.lookmovie2.to', 'https://lookmovie2.to', 'https://lookmovie.foundation'];
|
||||
const HEADERS_BASE = { 'User-Agent': USER_AGENT, 'Accept-Language': 'en-US,en;q=0.9' };
|
||||
|
||||
async function searchLookMovie(type, title, year, clientIP) {
|
||||
for (const base of LM_DOMAINS) {
|
||||
try {
|
||||
const headers = { ...HEADERS_BASE, 'Accept': 'application/json', 'Referer': `${base}/`, 'X-Requested-With': 'XMLHttpRequest', ...(clientIP && { 'X-Forwarded-For': clientIP }) };
|
||||
const data = await fetchJson(`${base}/api/v1/${type}/do-search/?q=${encodeURIComponent(title)}`, { headers, signal: AbortSignal.timeout(4000) });
|
||||
const results = data?.result;
|
||||
if (results?.length) {
|
||||
const match = results.find(r => String(r.year) === String(year)) ?? results.find(r => r.title?.toLowerCase() === title.toLowerCase()) ?? results[0];
|
||||
if (match) return { match, base };
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const STORAGE_RE = /window\[['"](?:movie|show)_storage['"]\]\s*=\s*\{([^}]+)\}/s;
|
||||
const ID_MOVIE_RE = /['"]?(?:id_movie|movieId)['"]?\s*[:=]\s*['"]?(\d+)['"]?/i;
|
||||
const SEASONS_RE = /seasons\s*:\s*(\[[\s\S]+?\])\s*[,}]/;
|
||||
|
||||
function getEpisodeIdFast(html, s, e) {
|
||||
const sMatch = html.match(STORAGE_RE);
|
||||
if (sMatch) {
|
||||
const block = sMatch[1], sm = block.match(SEASONS_RE);
|
||||
if (sm) {
|
||||
try {
|
||||
const seasons = JSON.parse(sm[1]);
|
||||
const season = seasons.find(x => String(x?.season ?? x?.meta?.season) === String(s));
|
||||
if (season) {
|
||||
const ep = Array.isArray(season.episodes) ? season.episodes.find(x => String(x.episode) === String(e)) : (season.episodes?.[String(e)] || Object.values(season.episodes || {}).find(x => String(x.episode) === String(e)));
|
||||
if (ep) return String(ep.id_episode ?? ep.id);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
const parts = html.split(/id_episode['"]?\s*[:=]\s*['"]?(\d+)['"]?/i);
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const context = parts[i - 1].slice(-300) + parts[i + 1].slice(0, 300);
|
||||
if (context.match(new RegExp(`['"]?episode['"]?\\s*[:=]\\s*['"]?${e}['"]?`, 'i')) && context.match(new RegExp(`['"]?season['"]?\\s*[:=]\\s*['"]?${s}['"]?`, 'i'))) return parts[i];
|
||||
}
|
||||
const am = html.match(new RegExp(`data-season=["']${s}["'][^>]*?data-episode=["']${e}["'][^>]*?data-id=["'](\\d+)["']`, 'i')) || html.match(new RegExp(`data-episode=["']${e}["'][^>]*?data-season=["']${s}["'][^>]*?data-id=["'](\\d+)["']`, 'i'));
|
||||
return am ? am[1] : null;
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk, clientIP }) {
|
||||
const tmdbApiKey = sdk?.tmdbApiKey || null;
|
||||
if (!tmdbApiKey) return null;
|
||||
try {
|
||||
const tmdbKey = tmdbApiKey;
|
||||
if (!tmdbKey) return null;
|
||||
const isTV = s != null && e != null;
|
||||
const typeStr = isTV ? 'shows' : 'movies';
|
||||
const tmdbData = await fetchJson(`${TMDB_BASE}/${isTV ? 'tv' : 'movie'}/${id}?api_key=${tmdbKey}`, { signal: AbortSignal.timeout(3000) });
|
||||
const title = tmdbData?.title || tmdbData?.name;
|
||||
const year = (tmdbData?.first_air_date || tmdbData?.release_date || '').slice(0, 4);
|
||||
if (!title) return null;
|
||||
const searchRes = await searchLookMovie(typeStr, title, year, clientIP);
|
||||
if (!searchRes?.match?.slug) return null;
|
||||
const { match, base } = searchRes;
|
||||
const headers = { ...HEADERS_BASE, 'Accept': 'text/html', 'Referer': `${base}/`, ...(clientIP && { 'X-Forwarded-For': clientIP }) };
|
||||
const html = await fetchText(`${base}/${typeStr}/play/${match.slug}`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
const storageMatch = html.match(STORAGE_RE);
|
||||
if (!storageMatch) return null;
|
||||
const hashMatch = storageMatch[1].match(/hash\s*:\s*['"]([^'"]+)['"]/);
|
||||
const expiresMatch = storageMatch[1].match(/expires\s*:\s*(\d+)/);
|
||||
if (!hashMatch || !expiresMatch) return null;
|
||||
const streamId = isTV ? getEpisodeIdFast(html, s, e) : (match.id_movie || match.id || html.match(ID_MOVIE_RE)?.[1]);
|
||||
if (!streamId) return null;
|
||||
const accessHeaders = { ...HEADERS_BASE, 'Accept': 'application/json', 'Referer': `${base}/`, 'X-Requested-With': 'XMLHttpRequest', ...(clientIP && { 'X-Forwarded-For': clientIP }) };
|
||||
const data = await fetchJson(`${base}/api/v1/security/${isTV ? 'episode' : 'movie'}-access?id_${isTV ? 'episode' : 'movie'}=${streamId}&hash=${hashMatch[1]}&expires=${expiresMatch[1]}`, { headers: accessHeaders, signal: AbortSignal.timeout(8000) });
|
||||
const streams = data?.streams ?? data?.result?.streams ?? data?.data?.streams ?? data;
|
||||
const allUrls = Object.entries(streams || {}).filter(([, v]) => typeof v === 'string' && v.includes('.m3u8')).map(([quality, url]) => ({ url, quality }));
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://movienig.ht';
|
||||
|
||||
const PRIORITY_SERVERS = [
|
||||
{ id: 'dallas', label: 'Dallas 4K' },
|
||||
{ id: 'austin', label: 'Austin' },
|
||||
{ id: 'helena', label: 'Helena' },
|
||||
{ id: 'seattle', label: 'Seattle 4K' },
|
||||
{ id: 'vixsrc-1', label: 'Newport Beach' },
|
||||
{ id: 'tucson', label: 'Tucson' },
|
||||
{ id: 'salem', label: 'Salem' },
|
||||
];
|
||||
|
||||
async function resolveDetails(sdk, id, isTv) {
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}&append_to_response=external_ids`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const title = isTv ? data.name : data.title;
|
||||
const releaseDate = isTv ? data.first_air_date : data.release_date;
|
||||
const year = releaseDate ? parseInt(releaseDate.split('-')[0], 10) : null;
|
||||
const imdbId = data.imdb_id || data.external_ids?.imdb_id || null;
|
||||
return { title, year, imdbId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const ua = getUA();
|
||||
|
||||
const details = await resolveDetails(sdk, id, isTv);
|
||||
const title = details?.title || String(id);
|
||||
const year = details?.year;
|
||||
const imdbId = details?.imdbId;
|
||||
|
||||
const encTitle = encodeURIComponent(title);
|
||||
const yearQuery = year ? `&year=${year}` : '';
|
||||
const imdbQuery = imdbId ? `&imdbId=${imdbId}` : '';
|
||||
|
||||
const seenUrls = new Set();
|
||||
const allUrls = [];
|
||||
|
||||
const defaultHeaders = {
|
||||
'User-Agent': ua,
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
Accept: 'text/event-stream',
|
||||
};
|
||||
|
||||
const tasks = PRIORITY_SERVERS.map(async (server) => {
|
||||
try {
|
||||
const url = isTv
|
||||
? `${BASE_URL}/api/stream/v1/tv/${id}/${s}/${e}?title=${encTitle}${yearQuery}${imdbQuery}&server=${server.id}&only=1`
|
||||
: `${BASE_URL}/api/stream/v1/movie/${id}?title=${encTitle}${yearQuery}${imdbQuery}&server=${server.id}&only=1`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: defaultHeaders,
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
const bodyStr = await res.text();
|
||||
|
||||
if (bodyStr.includes('event: done')) {
|
||||
const doneIdx = bodyStr.indexOf('event: done');
|
||||
const dataIdx = bodyStr.indexOf('data: ', doneIdx);
|
||||
|
||||
if (dataIdx !== -1) {
|
||||
const jsonStart = dataIdx + 6;
|
||||
const jsonEnd = bodyStr.indexOf('\n', jsonStart);
|
||||
const jsonText = (jsonEnd !== -1 ? bodyStr.substring(jsonStart, jsonEnd) : bodyStr.substring(jsonStart)).trim();
|
||||
|
||||
const data = JSON.parse(jsonText);
|
||||
if (Array.isArray(data?.sources)) {
|
||||
for (const src of data.sources) {
|
||||
if (!src || typeof src !== 'object') continue;
|
||||
const rawUrl = String(src.url || '').trim();
|
||||
if (!rawUrl || seenUrls.has(rawUrl)) continue;
|
||||
|
||||
seenUrls.add(rawUrl);
|
||||
|
||||
const quality = src.quality ? String(src.quality) : 'Auto';
|
||||
const titleQuality = quality !== 'Auto' ? ` (${quality})` : '';
|
||||
|
||||
allUrls.push({
|
||||
url: rawUrl,
|
||||
label: `MovieNight ${server.label}${titleQuality}`,
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
Referer: `${BASE_URL}/`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const API_BASE = 'https://api.wecollege.net';
|
||||
const REFERER = 'https://www.movy.bz/';
|
||||
const ORIGIN = 'https://www.movy.bz';
|
||||
const MAGIC = [109, 118, 109, 49];
|
||||
|
||||
const SERVERS = [
|
||||
{ endpoint: 'miami', name: 'Miami', note: 'Original audio (Up to 4K)' },
|
||||
{ endpoint: 'seattle', name: 'Seattle', note: 'Original audio' },
|
||||
{ endpoint: 'denver', name: 'Denver', note: 'Original audio' },
|
||||
{ endpoint: 'chicago', name: 'Chicago', note: 'Original audio' },
|
||||
{ endpoint: 'dallas', name: 'Dallas', note: 'Original audio' },
|
||||
{ endpoint: 'atlanta', name: 'Atlanta', note: 'Original audio' },
|
||||
{ endpoint: 'houston', name: 'Houston', note: 'Original audio' },
|
||||
{ endpoint: 'austin', name: 'Austin', note: 'Original audio' },
|
||||
{ endpoint: 'boston', name: 'Boston', note: 'Original audio' },
|
||||
{ endpoint: 'munich', name: 'Munich', note: 'German audio', extra: 'language=german' },
|
||||
{ endpoint: 'berlin', name: 'Berlin', note: 'German audio' },
|
||||
{ endpoint: 'paris', name: 'Paris', note: 'French audio' },
|
||||
{ endpoint: 'delhi', name: 'Delhi', note: 'Hindi audio' },
|
||||
{ endpoint: 'cancun', name: 'Cancun', note: 'Spanish audio' },
|
||||
];
|
||||
|
||||
const seedCache = new Map();
|
||||
|
||||
function _l(e) {
|
||||
let v = e >>> 0;
|
||||
v = (v ^ (v >>> 16)) >>> 0;
|
||||
v = Math.imul(v, 0x85ebca6b) >>> 0;
|
||||
v = (v ^ (v >>> 13)) >>> 0;
|
||||
v = Math.imul(v, 0xc2b2ae35) >>> 0;
|
||||
return (v ^ (v >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function _u(e, t) {
|
||||
const shift = t & 31;
|
||||
if (shift === 0) return e >>> 0;
|
||||
return (((e << shift) >>> 0) | (e >>> (32 - shift))) >>> 0;
|
||||
}
|
||||
|
||||
function _fnv1a(str) {
|
||||
let t = 0x811c9dc5 >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
t = Math.imul((t ^ code) >>> 0, 0x1000193) >>> 0;
|
||||
}
|
||||
return _l(t);
|
||||
}
|
||||
|
||||
function _initKeyState(seed, tmdbId) {
|
||||
const s = new Array(61).fill(0);
|
||||
const isSet = new Array(61).fill(false);
|
||||
const idVal = ((tmdbId >>> 0) ^ 0x9e3779b9) >>> 0;
|
||||
let r = _l((_fnv1a(seed) ^ _l(idVal)) >>> 0);
|
||||
|
||||
for (let e = 0; e < 8; e++) {
|
||||
const t = r % 61;
|
||||
r = _u((r + 0x9e3779b9) >>> 0, 7 + (7 & e));
|
||||
s[t] = (r ^ _l(r)) >>> 0;
|
||||
isSet[t] = true;
|
||||
r = _l((r + t) >>> 0);
|
||||
}
|
||||
|
||||
const acc = _l((0xa5a5a5a5 ^ r) >>> 0);
|
||||
return { s, isSet, acc };
|
||||
}
|
||||
|
||||
function _nextKeystreamWord(state, t) {
|
||||
const r = state.s;
|
||||
let nState = state.acc;
|
||||
const i = nState % 61;
|
||||
const d = state.isSet[i] ? r[i] : 0;
|
||||
const c = Math.imul((t + 1) >>> 0, 0x9e3779b9) >>> 0;
|
||||
const a = nState;
|
||||
const sVal = (d ^ c) >>> 0;
|
||||
const h = state.isSet[i] ? ((a | sVal) >>> 0) : ((a ^ sVal) >>> 0);
|
||||
const term1 = _u((h + nState) >>> 0, 31 & i);
|
||||
const term2 = _u(nState, 31 & (i * 7));
|
||||
nState = _l((((term1 ^ term2) >>> 0) + 0x9e3779b9) >>> 0);
|
||||
r[i] = nState;
|
||||
state.isSet[i] = true;
|
||||
state.acc = nState;
|
||||
return nState >>> 0;
|
||||
}
|
||||
|
||||
function _generateKeyStream(seed, tmdbId, len) {
|
||||
const state = _initKeyState(seed, tmdbId);
|
||||
const out = new Uint8Array(len);
|
||||
let wordIdx = 0;
|
||||
let byteIdx = 0;
|
||||
|
||||
while (byteIdx < len) {
|
||||
const word = _nextKeystreamWord(state, wordIdx++);
|
||||
out[byteIdx++] = word & 0xFF;
|
||||
if (byteIdx < len) out[byteIdx++] = (word >>> 8) & 0xFF;
|
||||
if (byteIdx < len) out[byteIdx++] = (word >>> 16) & 0xFF;
|
||||
if (byteIdx < len) out[byteIdx++] = (word >>> 24) & 0xFF;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decrypt(cipherB64, seed, tmdbId) {
|
||||
try {
|
||||
let normalized = cipherB64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (normalized.length % 4 !== 0) normalized += '=';
|
||||
const cipherBytes = Buffer.from(normalized, 'base64');
|
||||
if (cipherBytes.length <= MAGIC.length) return null;
|
||||
|
||||
const ks = _generateKeyStream(seed, tmdbId, cipherBytes.length);
|
||||
const decrypted = new Uint8Array(cipherBytes.length);
|
||||
for (let i = 0; i < cipherBytes.length; i++) {
|
||||
decrypted[i] = cipherBytes[i] ^ ks[i];
|
||||
}
|
||||
|
||||
for (let k = 0; k < MAGIC.length; k++) {
|
||||
if (decrypted[k] !== MAGIC[k]) return null;
|
||||
}
|
||||
|
||||
return Buffer.from(decrypted.subarray(MAGIC.length)).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeed(tmdbId, ua) {
|
||||
const now = Date.now();
|
||||
const cached = seedCache.get(tmdbId);
|
||||
if (cached && cached.expiresAt > now + 5000) {
|
||||
return cached.seed;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/seed?mediaId=${tmdbId}`, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
Referer: REFERER,
|
||||
Origin: ORIGIN,
|
||||
},
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const seed = data?.seed ? String(data.seed) : '';
|
||||
const ttlMs = typeof data?.ttlMs === 'number' ? data.ttlMs : 30000;
|
||||
if (seed) {
|
||||
seedCache.set(tmdbId, { seed, expiresAt: now + ttlMs });
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveDetails(sdk, id, isTv) {
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}&append_to_response=external_ids`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const title = isTv ? data.name : data.title;
|
||||
const releaseDate = isTv ? data.first_air_date : data.release_date;
|
||||
const year = releaseDate ? parseInt(releaseDate.split('-')[0], 10) : null;
|
||||
const imdbId = data.imdb_id || data.external_ids?.imdb_id || null;
|
||||
return { title, year, imdbId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatQuality(raw) {
|
||||
const lower = String(raw).toLowerCase();
|
||||
if (lower.includes('2160') || lower.includes('4k')) return '4K';
|
||||
if (lower.includes('1080')) return '1080p';
|
||||
if (lower.includes('720')) return '720p';
|
||||
if (lower.includes('480')) return '480p';
|
||||
if (lower.includes('360')) return '360p';
|
||||
if (raw) return String(raw);
|
||||
return 'Auto';
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const mediaType = isTv ? 'tv' : 'movie';
|
||||
const tmdbId = parseInt(id, 10);
|
||||
const ua = getUA();
|
||||
|
||||
const details = await resolveDetails(sdk, id, isTv);
|
||||
const title = details?.title || String(id);
|
||||
const year = details?.year;
|
||||
const imdbId = details?.imdbId;
|
||||
|
||||
const seed = await getSeed(tmdbId, ua);
|
||||
if (!seed) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
title,
|
||||
mediaType,
|
||||
tmdbId: String(tmdbId),
|
||||
enc: '2',
|
||||
seed,
|
||||
});
|
||||
|
||||
if (year) params.set('year', String(year));
|
||||
if (isTv) {
|
||||
if (s) params.set('seasonId', String(s));
|
||||
if (e) params.set('episodeId', String(e));
|
||||
}
|
||||
if (imdbId) params.set('imdbId', imdbId);
|
||||
|
||||
const baseQuery = params.toString();
|
||||
const seenUrls = new Set();
|
||||
const allUrls = [];
|
||||
|
||||
const defaultHeaders = {
|
||||
'User-Agent': ua,
|
||||
Referer: REFERER,
|
||||
Origin: ORIGIN,
|
||||
};
|
||||
|
||||
const tasks = SERVERS.map(async (server) => {
|
||||
let fullUrl = `${API_BASE}/${server.endpoint}/sources?${baseQuery}`;
|
||||
if (server.extra) fullUrl += `&${server.extra}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: defaultHeaders,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
const encText = (await res.text()).trim();
|
||||
if (!encText || encText.startsWith('<')) return;
|
||||
|
||||
const decJsonStr = decrypt(encText, seed, tmdbId);
|
||||
if (!decJsonStr) return;
|
||||
|
||||
const parsed = JSON.parse(decJsonStr);
|
||||
if (!Array.isArray(parsed?.sources)) return;
|
||||
|
||||
for (const src of parsed.sources) {
|
||||
if (!src || typeof src !== 'object') continue;
|
||||
const streamUrl = String(src.url || '').trim();
|
||||
if (!streamUrl || seenUrls.has(streamUrl)) continue;
|
||||
|
||||
seenUrls.add(streamUrl);
|
||||
|
||||
const cleanQuality = formatQuality(src.quality || 'Auto');
|
||||
const isSpecificAudio = server.note.toLowerCase().includes('audio') && !server.note.toLowerCase().includes('original');
|
||||
const langLabel = isSpecificAudio ? server.note.split(' ')[0] : '';
|
||||
const label = isSpecificAudio
|
||||
? `${server.name} (${langLabel}) · ${cleanQuality}`
|
||||
: `${server.name} · ${cleanQuality}`;
|
||||
|
||||
allUrls.push({
|
||||
url: streamUrl,
|
||||
label,
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
Referer: REFERER,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { fetchJson } from '../utils/helpers.js';
|
||||
|
||||
// const PENGU_CONFIG = '%7B%22source_4khdhub%22%3A%22on%22%2C%22source_moviebox%22%3A%22on%22%2C%22source_moviesdrives%22%3A%22on%22%2C%22source_vaplayer%22%3A%22on%22%2C%22source_hdghartv%22%3A%22on%22%2C%22res_2160%22%3A%22on%22%2C%22res_1080%22%3A%22on%22%2C%22res_720%22%3A%22on%22%2C%22disable_direct%22%3A%22on%22%7D';
|
||||
// const BASE_URL = `https://pengu.uk/${PENGU_CONFIG}`;
|
||||
|
||||
async function getImdbId(tmdbApiKey, tmdbId, s, e) {
|
||||
const key = tmdbApiKey;
|
||||
if (!key) return null;
|
||||
try {
|
||||
const url = s != null && e != null
|
||||
? `https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?api_key=${key}`
|
||||
: `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${key}`;
|
||||
const res = await fetchJson(url);
|
||||
return res?.imdb_id || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const tmdbApiKey = sdk?.tmdbApiKey || null;
|
||||
const manifest = sdk?.penguManifest || null;
|
||||
if (!tmdbApiKey || !manifest || (manifest && !manifest.endsWith('manifest.json'))) return null;
|
||||
const BASE_URL = manifest ? manifest.split('/manifest.json')[0] : null;
|
||||
if (!BASE_URL) return null;
|
||||
try {
|
||||
const imdbId = await getImdbId(tmdbApiKey, id, s, e);
|
||||
if (!imdbId) return null;
|
||||
|
||||
const path = s != null && e != null
|
||||
? `/stream/series/${imdbId}:${s}:${e}.json`
|
||||
: `/stream/movie/${imdbId}.json`;
|
||||
|
||||
const res = await fetchJson(`${BASE_URL}${path}`);
|
||||
if (!res?.streams?.length) return null;
|
||||
|
||||
const allUrls = res.streams.map(stream => {
|
||||
if (!stream.url) return null;
|
||||
const headers = stream.behaviorHints?.proxyHeaders?.request || {};
|
||||
return {
|
||||
url: stream.url,
|
||||
server: `Pengu - ${stream.name || 'Server'}`,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
skipProxy: stream.behaviorHints?.notWebReady === false
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
async function safeImport(moduleName) {
|
||||
try {
|
||||
return await import(`./${moduleName}.js`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, clientIP, sdk } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
const allUrls = [];
|
||||
|
||||
const providers = [
|
||||
{ name: 'CineSu', key: 'cinesu', fnName: 'fetchCineSuStreams' },
|
||||
{ name: 'ZxcStream', key: 'zxcstream', fnName: 'fetchZxcStreams' },
|
||||
{ name: 'VidFast', key: 'vidfast', fnName: 'fetchVidFastStreams' },
|
||||
{ name: '1Embed', key: '1embed', fnName: 'fetchOneEmbedStreams' },
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
providers.map(async ({ name, key, fnName }) => {
|
||||
try {
|
||||
let streamResult = null;
|
||||
|
||||
if (sdk) {
|
||||
try {
|
||||
streamResult = await sdk.getStream(key, id, s, e, clientIP);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (!streamResult) {
|
||||
const mod = await safeImport(key);
|
||||
if (mod) {
|
||||
if (typeof mod.getStream === 'function') {
|
||||
streamResult = await mod.getStream(args);
|
||||
} else if (typeof mod[fnName] === 'function') {
|
||||
const raw = await mod[fnName]({ type, id, season: s, episode: e });
|
||||
if (Array.isArray(raw)) {
|
||||
streamResult = { allUrls: raw };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamResult) return;
|
||||
|
||||
const streams = streamResult.allUrls || (streamResult.url ? [streamResult] : []);
|
||||
for (const item of streams) {
|
||||
allUrls.push({
|
||||
url: item.url,
|
||||
server: `FlyStream (${item.source || item.server || name})`,
|
||||
quality: item.quality || 'Auto',
|
||||
type: item.type || item.format || (item.url?.includes('.m3u8') ? 'hls' : 'mp4'),
|
||||
headers: item.headers || {
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
})
|
||||
);
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { fetchJson, USER_AGENT, getTmdbInfo } from '../utils/helpers.js';
|
||||
|
||||
const DOMAIN = 'https://purstream.ad';
|
||||
const API_BASE = 'https://api.purstream.ad/api/v1';
|
||||
|
||||
const HEADERS = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Referer': `${DOMAIN}/`,
|
||||
'Origin': DOMAIN,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-site'
|
||||
};
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
try {
|
||||
const isTv = s != null;
|
||||
|
||||
const info = await getTmdbInfo(id, isTv ? 'tv' : 'movie');
|
||||
|
||||
|
||||
if (!info?.titles?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = info.titles[0];
|
||||
|
||||
const searchUrl = `${API_BASE}/search-bar/search/${encodeURIComponent(title)}`;
|
||||
|
||||
const searchData = await fetchJson(searchUrl, {
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
const items = searchData?.data?.items?.movies?.items || [];
|
||||
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
const lowerTitle = title.toLowerCase();
|
||||
|
||||
let match = items.find(item =>
|
||||
item.type === type &&
|
||||
item.title?.toLowerCase() === lowerTitle &&
|
||||
(!info.year || item.release_date?.startsWith(String(info.year)))
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
match = items.find(item => item.type === type);
|
||||
}
|
||||
|
||||
if (!match?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const streamUrl = isTv
|
||||
? `${API_BASE}/stream/${match.id}/episode?season=${s}&episode=${e}`
|
||||
: `${API_BASE}/stream/${match.id}`;
|
||||
|
||||
const json = await fetchJson(streamUrl, {
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
const sources = json?.data?.items?.sources;
|
||||
|
||||
if (json?.type !== 'success' || !Array.isArray(sources) || !sources.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chosen = sources.find(src => src.stream_url);
|
||||
|
||||
if (!chosen?.stream_url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = {
|
||||
url: chosen.stream_url,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${DOMAIN}/`
|
||||
},
|
||||
server: 'purstream',
|
||||
quality: chosen.source_name || 'Auto',
|
||||
type: chosen.format || 'hls',
|
||||
skipProxy: true
|
||||
};
|
||||
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSources(args) {
|
||||
const stream = await getStream(args);
|
||||
|
||||
return stream ? [stream] : [];
|
||||
}
|
||||
|
||||
export const VERIFY_HEADERS = {
|
||||
...HEADERS
|
||||
};
|
||||
@@ -1,179 +0,0 @@
|
||||
// im being salty
|
||||
|
||||
import { USER_AGENT, fetchJson } from '../utils/helpers.js';
|
||||
|
||||
const C_ARRAY = [
|
||||
'4Z7lUo', 'gwIVSMD', 'PLmz2elE2v', 'Z4OFV0', 'SZ6RZq6Zc', 'zhJEFYxrz8', 'FOm7b0', 'axHS3q4KDq', 'o9zuXQ', '4Aebt',
|
||||
'wgjjWwKKx', 'rY4VIxqSN', 'kfjbnSo', '2DyrFA1M', 'YUixDM9B', 'JQvgEj0', 'mcuFx6JIek', 'eoTKe26gL', 'qaI9EVO1rB', '0xl33btZL',
|
||||
'1fszuAU', 'a7jnHzst6P', 'wQuJkX', 'cBNhTJlEOf', 'KNcFWhDvgT', 'XipDGjST', 'PCZJlbHoyt', '2AYnMZkqd', 'HIpJh', 'KH0C3iztrG',
|
||||
'W81hjts92', 'rJhAT', 'NON7LKoMQ', 'NMdY3nsKzI', 't4En5v', 'Qq5cOQ9H', 'Y9nwrp', 'VX5FYVfsf', 'cE5SJG', 'x1vj1',
|
||||
'HegbLe', 'zJ3nmt4OA', 'gt7rxW57dq', 'clIE9b', 'jyJ9g', 'B5jXjMCSx', 'cOzZBZTV', 'FTXGy', 'Dfh1q1', 'ny9jqZ2POI',
|
||||
'X2NnMn', 'MBtoyD', 'qz4Ilys7wB', '68lbOMye', '3YUJnmxp', '1fv5Imona', 'PlfvvXD7mA', 'ZarKfHCaPR', 'owORnX', 'dQP1YU',
|
||||
'dVdkx', 'qgiK0E', 'cx9wQ', '5F9bGa', '7UjkKrp', 'Yvhrj', 'wYXez5Dg3', 'pG4GMU', 'MwMAu', 'rFRD5wlM'
|
||||
];
|
||||
|
||||
const DEFAULT_SERVICES = [
|
||||
'apex', 'pulse', 'solstice', 'quasar', 'horizon', 'primevids',
|
||||
'flowcast', 'asiacloud', 'citadel', 'hindicast', 'guru'
|
||||
];
|
||||
|
||||
function generateRiveSecretKey(e) {
|
||||
if (e === undefined || e === null) return 'rive';
|
||||
try {
|
||||
let t, n;
|
||||
const r = String(e);
|
||||
if (isNaN(Number(e))) {
|
||||
const sum = r.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
|
||||
t = C_ARRAY[sum % C_ARRAY.length] || Buffer.from(r).toString('base64');
|
||||
n = Math.floor((sum % r.length) / 2);
|
||||
} else {
|
||||
const i = Number(e);
|
||||
t = C_ARRAY[i % C_ARRAY.length] || Buffer.from(r).toString('base64');
|
||||
n = Math.floor((i % r.length) / 2);
|
||||
}
|
||||
const i = r.slice(0, n) + t + r.slice(n);
|
||||
const o = (function (str) {
|
||||
const t = String(str);
|
||||
let n = 3735928559 ^ t.length;
|
||||
for (let e = 0; e < t.length; e++) {
|
||||
const r = t.charCodeAt(e);
|
||||
const rXor = (131 * e + 89 ^ (r << (e % 5))) & 255;
|
||||
const r2 = r ^ rXor;
|
||||
n = (((n << 7) | (n >>> 25)) >>> 0) ^ r2;
|
||||
const idx = (65535 & n) * 60205;
|
||||
const o2 = ((n >>> 16) * 60205) << 16;
|
||||
n = (idx + o2) >>> 0;
|
||||
n ^= n >>> 11;
|
||||
}
|
||||
n ^= n >>> 15;
|
||||
n = ((65535 & n) * 49842 + (((n >>> 16) * 49842) << 16)) >>> 0;
|
||||
n ^= n >>> 13;
|
||||
n = ((65535 & n) * 40503 + (((n >>> 16) * 40503) << 16)) >>> 0;
|
||||
n ^= n >>> 16;
|
||||
n = ((65535 & n) * 10196 + (((n >>> 16) * 10196) << 16)) >>> 0;
|
||||
return ((n ^= n >>> 15)).toString(16).padStart(8, '0');
|
||||
})(
|
||||
(function (str) {
|
||||
const s = String(str);
|
||||
let t = 0;
|
||||
for (let n = 0; n < s.length; n++) {
|
||||
const r = s.charCodeAt(n);
|
||||
const i = (((t = (r + (t << 6) + (t << 16) - t) >>> 0) << (n % 5)) | (t >>> (32 - (n % 5)))) >>> 0;
|
||||
t ^= (i ^ ((r << (n % 7)) | (r >>> (8 - (n % 7))))) >>> 0;
|
||||
t = (t + ((t >>> 11) ^ (t << 3))) >>> 0;
|
||||
}
|
||||
t ^= t >>> 15;
|
||||
t = ((65535 & t) * 49842 + ((((t >>> 16) * 49842) & 65535) << 16)) >>> 0;
|
||||
t ^= t >>> 13;
|
||||
t = ((65535 & t) * 40503 + ((((t >>> 16) * 40503) & 65535) << 16)) >>> 0;
|
||||
return ((t ^= t >>> 16)).toString(16).padStart(8, '0');
|
||||
})(i)
|
||||
);
|
||||
return Buffer.from(o, 'utf-8').toString('base64');
|
||||
} catch {
|
||||
return 'topSecret';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRiveServices() {
|
||||
try {
|
||||
const secretKey = generateRiveSecretKey();
|
||||
const url = `https://www.rivestream.app/api/backendfetch?requestID=VideoProviderServices&secretKey=${encodeURIComponent(secretKey)}`;
|
||||
const res = await fetchJson(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': 'https://www.rivestream.app/',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
return Array.isArray(res?.data) && res.data.length ? res.data : DEFAULT_SERVICES;
|
||||
} catch {
|
||||
return DEFAULT_SERVICES;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServiceStreams(service, isMovie, id, s, e, secretKey, referer) {
|
||||
try {
|
||||
const cb = (service === 'primevids' || service === 'citadel')
|
||||
? `&cb=${Math.floor(Date.now() / 3e6)}`
|
||||
: '';
|
||||
|
||||
const reqID = isMovie ? 'movieVideoProvider' : 'tvVideoProvider';
|
||||
const params = isMovie
|
||||
? `requestID=${reqID}&id=${encodeURIComponent(id)}&service=${service}${cb}&secretKey=${encodeURIComponent(secretKey)}`
|
||||
: `requestID=${reqID}&id=${encodeURIComponent(id)}&season=${encodeURIComponent(s)}&episode=${encodeURIComponent(e)}&service=${service}${cb}&secretKey=${encodeURIComponent(secretKey)}`;
|
||||
|
||||
const url = `https://www.rivestream.app/api/backendfetch?${params}`;
|
||||
const res = await fetchJson(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': referer,
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!res?.data?.sources || !Array.isArray(res.data.sources)) return [];
|
||||
|
||||
const streams = [];
|
||||
for (const item of res.data.sources) {
|
||||
if (!item || !item.url) continue;
|
||||
|
||||
const format = String(item.format || '').toLowerCase();
|
||||
const urlStr = String(item.url || '').toLowerCase();
|
||||
const isM3U8 = format === 'hls' || format === 'm3u8' || urlStr.includes('.m3u8') || urlStr.includes('.txt');
|
||||
|
||||
streams.push({
|
||||
url: item.url,
|
||||
server: `Rive (${item.source || service})`,
|
||||
quality: String(item.quality || 'Auto'),
|
||||
type: isM3U8 ? 'hls' : 'mp4',
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': referer
|
||||
}
|
||||
});
|
||||
}
|
||||
return streams;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, server } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const isMovie = !isTv;
|
||||
|
||||
const secretKey = generateRiveSecretKey(id);
|
||||
const referer = isMovie
|
||||
? `https://www.rivestream.app/watch?type=movie&id=${id}`
|
||||
: `https://www.rivestream.app/watch?type=tv&id=${id}&season=${s}&episode=${e}`;
|
||||
|
||||
let services = await fetchRiveServices();
|
||||
|
||||
if (server && server !== 'all') {
|
||||
const clean = server.toLowerCase().replace('rive (', '').replace(')', '').replace('rive', '').trim();
|
||||
services = services.filter(srv => srv.toLowerCase().includes(clean));
|
||||
if (!services.length) services = await fetchRiveServices();
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
services.map(srv => fetchServiceStreams(srv, isMovie, id, s, e, secretKey, referer))
|
||||
);
|
||||
|
||||
const allUrls = [];
|
||||
for (const r of settled) {
|
||||
if (r.status === 'fulfilled' && Array.isArray(r.value)) {
|
||||
allUrls.push(...r.value);
|
||||
}
|
||||
}
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
}
|
||||
|
||||
export async function getSources() {
|
||||
const services = await fetchRiveServices();
|
||||
return services.map(s => `Rive (${s})`);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://streamaggregator.in';
|
||||
|
||||
async function resolveDetails(sdk, id, isTv) {
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const title = isTv ? data.name : data.title;
|
||||
const releaseDate = isTv ? data.first_air_date : data.release_date;
|
||||
const year = releaseDate ? releaseDate.split('-')[0] : null;
|
||||
return { title, year };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const mediaType = isTv ? 'tv' : 'movie';
|
||||
const ua = getUA();
|
||||
|
||||
const details = await resolveDetails(sdk, id, isTv);
|
||||
const title = details?.title || String(id);
|
||||
const year = details?.year || '';
|
||||
|
||||
const formattedTitle = encodeURIComponent(title).replaceAll('%20', '+');
|
||||
const params = new URLSearchParams({
|
||||
type: mediaType,
|
||||
stream: 'true',
|
||||
});
|
||||
|
||||
if (year) params.set('year', String(year));
|
||||
if (isTv) {
|
||||
params.set('season', String(s));
|
||||
params.set('episode', String(e));
|
||||
}
|
||||
|
||||
const scrapeUrl = `${BASE_URL}/api/scrape?title=${formattedTitle}&${params.toString()}`;
|
||||
|
||||
const res = await fetch(scrapeUrl, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
Accept: 'text/event-stream, application/x-ndjson, text/plain, */*',
|
||||
},
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const lines = [];
|
||||
if (res.body?.getReader) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n');
|
||||
buffer = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed) lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
if (buffer.trim()) lines.push(buffer.trim());
|
||||
} else {
|
||||
const text = await res.text();
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const seenUrls = new Set();
|
||||
const allUrls = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw = line.startsWith('data:') ? line.slice(5).trim() : line;
|
||||
if (!raw || raw === '[DONE]') continue;
|
||||
const item = JSON.parse(raw);
|
||||
const streamUrl = String(item?.url || '').trim();
|
||||
if (!streamUrl || !streamUrl.startsWith('http') || seenUrls.has(streamUrl)) continue;
|
||||
|
||||
seenUrls.add(streamUrl);
|
||||
|
||||
const serverName = item.server || item.providerKey || 'Stream';
|
||||
const quality = item.quality ? ` · ${item.quality}` : '';
|
||||
const size = item.size ? ` (${item.size})` : '';
|
||||
const label = `${serverName}${quality}${size}`;
|
||||
|
||||
const headers = {
|
||||
'User-Agent': ua,
|
||||
...(item.headers && typeof item.headers === 'object' ? item.headers : {}),
|
||||
};
|
||||
|
||||
if (item.referer) {
|
||||
headers.Referer = item.referer;
|
||||
}
|
||||
|
||||
allUrls.push({
|
||||
url: streamUrl,
|
||||
label,
|
||||
headers,
|
||||
});
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { fetchJson, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const API_BASE = 'https://streamdata.vaplayer.ru/api.php';
|
||||
const EMBED_ORIGIN = 'https://nextgencloudfabric.com';
|
||||
|
||||
export const VERIFY_HEADERS = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${EMBED_ORIGIN}/`,
|
||||
'Origin': EMBED_ORIGIN
|
||||
};
|
||||
|
||||
function extractStreamUrls(obj, urls = new Set()) {
|
||||
if (!obj) return urls;
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.startsWith('http') && (obj.includes('.m3u8') || obj.includes('.mp4') || obj.includes('.mpd'))) {
|
||||
urls.add(obj);
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractStreamUrls(item, urls);
|
||||
} else if (typeof obj === 'object') {
|
||||
for (const val of Object.values(obj)) {
|
||||
extractStreamUrls(val, urls);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
try {
|
||||
const isTv = s != null && e != null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
|
||||
const url = `${API_BASE}?tmdb=${id}&type=${type}${isTv ? `&season=${s}&episode=${e}` : ''}`;
|
||||
|
||||
const data = await fetchJson(url, {
|
||||
headers: {
|
||||
'Accept': '*/*',
|
||||
'Origin': EMBED_ORIGIN,
|
||||
'Referer': `${EMBED_ORIGIN}/`,
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
const streamUrls = Array.from(extractStreamUrls(data));
|
||||
if (!streamUrls.length) return null;
|
||||
|
||||
let subs = [];
|
||||
if (data.subtitles && Array.isArray(data.subtitles)) {
|
||||
subs = data.subtitles;
|
||||
} else if (data.captions && Array.isArray(data.captions)) {
|
||||
subs = data.captions;
|
||||
}
|
||||
|
||||
const subtitles = subs.map(sub => ({
|
||||
url: sub.url || sub.file || sub.src,
|
||||
lang: sub.display || sub.label || sub.language || sub.lang || 'Unknown'
|
||||
})).filter(s => s.url);
|
||||
|
||||
const allUrls = streamUrls.map(streamUrl => {
|
||||
let type = 'mp4';
|
||||
if (streamUrl.includes('.mpd')) type = 'dash';
|
||||
else if (streamUrl.includes('.m3u8')) type = 'hls';
|
||||
|
||||
return {
|
||||
url: streamUrl,
|
||||
server: 'VidAPI',
|
||||
quality: 'Auto',
|
||||
type,
|
||||
headers: VERIFY_HEADERS,
|
||||
subtitles: subtitles.length > 0 ? subtitles : undefined,
|
||||
skipProxy: false
|
||||
};
|
||||
});
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSources() {
|
||||
return ['VidAPI'];
|
||||
}
|
||||
|
||||
export async function proxyStream({ url, res, proxyUtils: { fetchUpstream, rewriteM3u8 } }) {
|
||||
try {
|
||||
const upstream = await fetchUpstream(url, 0, VERIFY_HEADERS);
|
||||
if (!upstream) return res.end();
|
||||
|
||||
const ct = (upstream.headers?.['content-type'] || '').toLowerCase();
|
||||
const isM3u8 = ct.includes('mpegurl') || ct.includes('m3u8') || /\.m3u8?(\?|$)/i.test(url);
|
||||
|
||||
if (isM3u8) {
|
||||
const chunks = [];
|
||||
for await (const c of upstream) chunks.push(c);
|
||||
let body = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return res.end(rewriteM3u8(body, url, '&va=1&tt=1'));
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', ct || 'application/octet-stream');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
upstream.pipe(res);
|
||||
} catch {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 502;
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://vidcore.io';
|
||||
const API = 'https://enc-dec.app/api';
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const targetUrl = isTv
|
||||
? `${BASE_URL}/tv/${id}/${s}/${e}/`
|
||||
: `${BASE_URL}/movie/${id}`;
|
||||
|
||||
const ua = getUA();
|
||||
const baseHeaders = {
|
||||
'User-Agent': ua,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
};
|
||||
|
||||
try {
|
||||
const pageRes = await fetch(targetUrl, {
|
||||
headers: { 'User-Agent': ua },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!pageRes.ok) return null;
|
||||
const html = await pageRes.text();
|
||||
|
||||
const match = html.match(/\\"(?:en|token)\\":\\"(.*?)\\"/);
|
||||
if (!match || !match[1]) return null;
|
||||
const enToken = match[1];
|
||||
|
||||
const s1Res = await fetch(`${API}/enc-vidcore?text=${encodeURIComponent(enToken)}&stage=1`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const s1Data = await s1Res.json();
|
||||
if (s1Data?.status !== 200 || !s1Data?.result?.stage1) return null;
|
||||
|
||||
const token1 = s1Data.result.token;
|
||||
const stage1Url = s1Data.result.stage1;
|
||||
|
||||
const post1Res = await fetch(stage1Url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'X-CSRF-Token': token1 },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!post1Res.ok) return null;
|
||||
const stage1Text = await post1Res.text();
|
||||
|
||||
const s2Res = await fetch(`${API}/enc-vidcore?text=${encodeURIComponent(stage1Text)}&stage=2`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const s2Data = await s2Res.json();
|
||||
if (s2Data?.status !== 200 || !s2Data?.result?.servers) return null;
|
||||
|
||||
const { servers: serversUrl, stream: streamBaseUrl, token: token2 } = s2Data.result;
|
||||
const currentHeaders = { ...baseHeaders, 'X-CSRF-Token': token2 };
|
||||
|
||||
const serversRes = await fetch(serversUrl, {
|
||||
method: 'POST',
|
||||
headers: currentHeaders,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!serversRes.ok) return null;
|
||||
const serversEncrypted = await serversRes.text();
|
||||
|
||||
const decServersRes = await fetch(`${API}/dec-vidcore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: serversEncrypted }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const decServersData = await decServersRes.json();
|
||||
if (decServersData?.status !== 200 || !Array.isArray(decServersData.result)) return null;
|
||||
|
||||
const allUrls = [];
|
||||
|
||||
for (const server of decServersData.result) {
|
||||
if (!server?.data) continue;
|
||||
try {
|
||||
const streamUrl = `${streamBaseUrl}/${server.data}`;
|
||||
const streamEncRes = await fetch(streamUrl, {
|
||||
method: 'POST',
|
||||
headers: currentHeaders,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!streamEncRes.ok) continue;
|
||||
const streamEncText = await streamEncRes.text();
|
||||
|
||||
const decStreamRes = await fetch(`${API}/dec-vidcore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: streamEncText }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const decStreamData = await decStreamRes.json();
|
||||
if (decStreamData?.status !== 200 || !decStreamData.result) continue;
|
||||
|
||||
const streamResult = decStreamData.result;
|
||||
const resultUrl = typeof streamResult === 'string' ? streamResult : streamResult?.url || streamResult?.file;
|
||||
|
||||
if (typeof resultUrl === 'string' && resultUrl.startsWith('http')) {
|
||||
allUrls.push({
|
||||
url: resultUrl,
|
||||
label: server.name || server.title || 'VidCore',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://vidfast.vc';
|
||||
const API = 'https://enc-dec.app/api';
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const targetUrl = isTv
|
||||
? `${BASE_URL}/tv/${id}/${s}/${e}/`
|
||||
: `${BASE_URL}/movie/${id}`;
|
||||
|
||||
const ua = getUA();
|
||||
const baseHeaders = {
|
||||
'User-Agent': ua,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
};
|
||||
|
||||
try {
|
||||
const pageRes = await fetch(targetUrl, {
|
||||
headers: { 'User-Agent': ua },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!pageRes.ok) return null;
|
||||
const html = await pageRes.text();
|
||||
|
||||
const match = html.match(/\\"(?:en|token)\\":\\"(.*?)\\"/);
|
||||
if (!match || !match[1]) return null;
|
||||
const enToken = match[1];
|
||||
|
||||
const s1Res = await fetch(`${API}/enc-vidfast?text=${encodeURIComponent(enToken)}&stage=1`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const s1Data = await s1Res.json();
|
||||
if (s1Data?.status !== 200 || !s1Data?.result?.stage1) return null;
|
||||
|
||||
const token1 = s1Data.result.token;
|
||||
const stage1Url = s1Data.result.stage1;
|
||||
|
||||
const post1Res = await fetch(stage1Url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'X-CSRF-Token': token1 },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!post1Res.ok) return null;
|
||||
const stage1Text = await post1Res.text();
|
||||
|
||||
const s2Res = await fetch(`${API}/enc-vidfast?text=${encodeURIComponent(stage1Text)}&stage=2`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const s2Data = await s2Res.json();
|
||||
if (s2Data?.status !== 200 || !s2Data?.result?.servers) return null;
|
||||
|
||||
const { servers: serversUrl, stream: streamBaseUrl, token: token2 } = s2Data.result;
|
||||
const currentHeaders = { ...baseHeaders, 'X-CSRF-Token': token2 };
|
||||
|
||||
const serversRes = await fetch(serversUrl, {
|
||||
method: 'POST',
|
||||
headers: currentHeaders,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!serversRes.ok) return null;
|
||||
const serversEncrypted = await serversRes.text();
|
||||
|
||||
const decServersRes = await fetch(`${API}/dec-vidfast`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: serversEncrypted }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const decServersData = await decServersRes.json();
|
||||
if (decServersData?.status !== 200 || !Array.isArray(decServersData.result)) return null;
|
||||
|
||||
const allUrls = [];
|
||||
|
||||
for (const server of decServersData.result) {
|
||||
if (!server?.data) continue;
|
||||
try {
|
||||
const streamUrl = `${streamBaseUrl}/${server.data}`;
|
||||
const streamEncRes = await fetch(streamUrl, {
|
||||
method: 'POST',
|
||||
headers: currentHeaders,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!streamEncRes.ok) continue;
|
||||
const streamEncText = await streamEncRes.text();
|
||||
|
||||
const decStreamRes = await fetch(`${API}/dec-vidfast`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: streamEncText }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const decStreamData = await decStreamRes.json();
|
||||
if (decStreamData?.status !== 200 || !decStreamData.result) continue;
|
||||
|
||||
const streamResult = decStreamData.result;
|
||||
const resultUrl = typeof streamResult === 'string' ? streamResult : streamResult?.url || streamResult?.file;
|
||||
|
||||
if (typeof resultUrl === 'string' && resultUrl.startsWith('http')) {
|
||||
allUrls.push({
|
||||
url: resultUrl,
|
||||
label: server.name || server.title || 'VidFast',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const REDIS_URL = 'https://vidnest-redis-fell-prism-rest.cloud.layerbase.dev/';
|
||||
const REDIS_AUTH = 'Bearer ve8z9XSKatu74M7FjLU8eQ29';
|
||||
const ORIGIN = 'https://vidgod.space';
|
||||
|
||||
const SERVERS = [
|
||||
{
|
||||
name: 'Pulsar',
|
||||
key: 'pulsar',
|
||||
base: 'https://vidnest-extractor.vividdubbing.workers.dev/api',
|
||||
serverParam: 'prime'
|
||||
},
|
||||
{
|
||||
name: 'Orion',
|
||||
key: 'orion',
|
||||
base: 'https://404-vidnest.lofiserver.workers.dev/api',
|
||||
serverParam: 'gama'
|
||||
},
|
||||
{
|
||||
name: 'Stellar',
|
||||
key: 'stellar',
|
||||
base: 'https://404-vidnest.lofiserver.workers.dev/api',
|
||||
serverParam: 'sigma'
|
||||
}
|
||||
];
|
||||
|
||||
function buildCacheKey(serverKey, type, id, s, e) {
|
||||
const parts = [serverKey, type, id];
|
||||
if (type === 'tv') {
|
||||
parts.push(s || '1', e || '1');
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
async function getFromCache(key) {
|
||||
try {
|
||||
const res = await fetch(REDIS_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': REDIS_AUTH,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': '*/*',
|
||||
'Origin': ORIGIN,
|
||||
'Referer': `${ORIGIN}/`,
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
body: JSON.stringify(['GET', key]),
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data || !data.result) return null;
|
||||
|
||||
const parsed = typeof data.result === 'string' ? JSON.parse(data.result) : data.result;
|
||||
return parsed?.streams?.length ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFromWorker(srv, type, id, s, e) {
|
||||
try {
|
||||
const endpoint = type === 'movie'
|
||||
? `${srv.base}/movie/${id}?server=${srv.serverParam}`
|
||||
: `${srv.base}/tv/${id}/${s || '1'}/${e || '1'}?server=${srv.serverParam}`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Origin': ORIGIN,
|
||||
'Referer': `${ORIGIN}/`,
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data?.streams?.length ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServer(srv, type, id, s, e) {
|
||||
const cacheKey = buildCacheKey(srv.key, type, id, s, e);
|
||||
const cached = await getFromCache(cacheKey);
|
||||
if (cached) return { server: srv.name, data: cached };
|
||||
|
||||
const live = await fetchFromWorker(srv, type, id, s, e);
|
||||
if (live) return { server: srv.name, data: live };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, server } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
|
||||
let targets = SERVERS;
|
||||
if (server && server !== 'all') {
|
||||
const clean = server.toLowerCase().trim();
|
||||
targets = SERVERS.filter(srv => srv.name.toLowerCase() === clean || srv.key.toLowerCase() === clean);
|
||||
if (!targets.length) targets = SERVERS;
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
targets.map(srv => resolveServer(srv, type, id, s, e))
|
||||
);
|
||||
|
||||
const allUrls = [];
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status !== 'fulfilled' || !r.value) continue;
|
||||
const { server: serverName, data } = r.value;
|
||||
|
||||
for (const item of data.streams || []) {
|
||||
if (!item || !item.url) continue;
|
||||
|
||||
const typeStr = String(item.type || '').toLowerCase();
|
||||
const urlStr = String(item.url || '').toLowerCase();
|
||||
const isM3U8 =
|
||||
typeStr === 'hls' ||
|
||||
typeStr === 'application/vnd.apple.mpegurl' ||
|
||||
typeStr === 'cloudflare' ||
|
||||
typeStr === 'tiktok' ||
|
||||
urlStr.includes('.m3u8') ||
|
||||
urlStr.includes('.txt');
|
||||
|
||||
allUrls.push({
|
||||
url: item.url,
|
||||
server: serverName,
|
||||
quality: item.quality || 'Auto',
|
||||
type: isM3U8 ? 'hls' : 'mp4',
|
||||
headers: item.headers || {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${ORIGIN}/`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { fetchJson, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const BASE = "https://vidlink.pro";
|
||||
const HEADERS = { 'User-Agent': USER_AGENT, Origin: BASE, Referer: `${BASE}/` };
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
try {
|
||||
const encData = await fetchJson(`https://enc-dec.app/api/enc-vidlink?text=${id}`, { signal: AbortSignal.timeout(6000) });
|
||||
if (encData?.status !== 200 || !encData?.result) return null;
|
||||
const data = await fetchJson(s ? `${BASE}/api/b/tv/${encData.result}/${s}/${e || 1}` : `${BASE}/api/b/movie/${encData.result}`, { headers: HEADERS, signal: AbortSignal.timeout(10000) });
|
||||
const stream = data?.stream;
|
||||
if (!stream) return null;
|
||||
if (stream.type === 'file' && stream.qualities) {
|
||||
let picked = null;
|
||||
for (const q of ['1080', '720', '480', '360']) if (stream.qualities[q]?.url) { picked = stream.qualities[q].url; break; }
|
||||
if (!picked) picked = stream.qualities[Object.keys(stream.qualities)[0]]?.url;
|
||||
if (picked) return { allUrls: [{ url: picked, type: 'mp4', audio: 'sub', server: 'Vidlink', skipHlsCheck: true, skipCache: true }] };
|
||||
} else if (stream.playlist) {
|
||||
return { allUrls: [{ url: stream.playlist, type: 'hls', audio: 'sub', server: 'Vidlink', skipHlsCheck: true, skipCache: true }] };
|
||||
}
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getSources(args) {
|
||||
const stream = await getStream(args);
|
||||
return stream?.allUrls ? [...new Set(stream.allUrls.map(u => u.server))] : [];
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { fetchJson, fetchText, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://vidnest.fun';
|
||||
const API_BASE_URL = 'https://new.vidnest.fun';
|
||||
const REQUEST_HEADERS = { 'User-Agent': USER_AGENT, 'Accept': 'application/json, text/javascript, */*; q=0.01', 'Accept-Language': 'en-US,en;q=0.9', 'Referer': `${BASE_URL}/`, 'Origin': BASE_URL };
|
||||
const CDN_PROXY_HEADERS = { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0', 'accept': '*/*', 'accept-language': 'en-US,en;q=0.5', 'origin': 'https://megaplay.buzz', 'referer': 'https://megaplay.buzz/' };
|
||||
|
||||
export const CDN_HEADERS = [
|
||||
{ pattern: /letsgocdn\d+\.shop/i, headers: { 'Referer': BASE_URL, 'Origin': BASE_URL, 'User-Agent': USER_AGENT } },
|
||||
{ pattern: /cdn\.mewstream\.buzz/i, headers: { 'Referer': BASE_URL, 'Origin': BASE_URL, 'User-Agent': USER_AGENT } }
|
||||
];
|
||||
|
||||
const ALPHABET = 'RB0fpH8ZEyVLkv7c2i6MAJ5u3IKFDxlS1NTsnGaqmXYdUrtzjwObCgQP94hoeW+/=';
|
||||
const REV_MAP = new Uint8Array(256);
|
||||
for (let i = 0; i < ALPHABET.length; i++) REV_MAP[ALPHABET.charCodeAt(i)] = i;
|
||||
|
||||
function decrypt(payload) {
|
||||
const len = payload.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
let j = 0;
|
||||
for (let i = 0; i < len; i += 4) {
|
||||
const c0 = REV_MAP[payload.charCodeAt(i)] ?? 64;
|
||||
const c1 = REV_MAP[payload.charCodeAt(i + 1)] ?? 64;
|
||||
const c2 = REV_MAP[payload.charCodeAt(i + 2)] ?? 64;
|
||||
const c3 = REV_MAP[payload.charCodeAt(i + 3)] ?? 64;
|
||||
bytes[j++] = (c0 << 2) | (c1 >> 4);
|
||||
if (c2 !== 64) bytes[j++] = ((c1 & 15) << 4) | (c2 >> 2);
|
||||
if (c3 !== 64) bytes[j++] = ((c2 & 3) << 6) | c3;
|
||||
}
|
||||
return JSON.parse(new TextDecoder().decode(bytes.subarray(0, j)));
|
||||
}
|
||||
|
||||
async function tmdbToAnilist(tmdbId, season) {
|
||||
try {
|
||||
const data = await fetchJson(`https://api.ani.zip/mappings?tmdb_id=${tmdbId}&type=tv&season=${season || 1}`, { signal: AbortSignal.timeout(6000) });
|
||||
return data?.mappings?.[0]?.anilist_id || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const SERVER_KEYS = ['hollymoviehd', 'allmovies', 'catflix', 'purstream', 'lamda', 'vidlink', 'klikxxi'];
|
||||
|
||||
export async function getStream({ id, s, e, sdk, audio }) {
|
||||
const tmdbApiKey = sdk?.tmdbApiKey || null;
|
||||
if (!tmdbApiKey) return null;
|
||||
if (s) {
|
||||
const k = tmdbApiKey;
|
||||
const showData = k ? await fetchJson(`https://api.themoviedb.org/3/tv/${id}?api_key=${k}`, { signal: AbortSignal.timeout(5000) }).catch(() => null) : null;
|
||||
if (showData?.genres?.some(g => g.id === 16)) {
|
||||
const anilistId = await tmdbToAnilist(id, s);
|
||||
if (anilistId) {
|
||||
try {
|
||||
const json = await fetchJson(`${API_BASE_URL}/hianime/anime/${anilistId}/${e || 1}/${audio === 'dub' ? 'dub' : 'sub'}`, { headers: REQUEST_HEADERS, signal: AbortSignal.timeout(15000) });
|
||||
const data = json.encrypted ? decrypt(json.data) : json.data;
|
||||
const file = data?.sources?.[0]?.file;
|
||||
if (file) return { url: `https://megacloud.animanga.fun/proxy?url=${encodeURIComponent(file)}&headers=${encodeURIComponent(JSON.stringify(CDN_PROXY_HEADERS))}`, headers: REQUEST_HEADERS };
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (audio === 'dub') return null;
|
||||
const segment = s ? `tv/${id}/${s}/${e || 1}` : `movie/${id}`;
|
||||
const settled = await Promise.allSettled(SERVER_KEYS.map(async server => {
|
||||
const json = await fetchJson(`${API_BASE_URL}/${server}/${segment}`, { headers: REQUEST_HEADERS, signal: AbortSignal.timeout(10000) });
|
||||
const data = json.encrypted ? decrypt(json.data) : json.data;
|
||||
const file = data?.sources?.[0]?.file ?? data?.streams?.[0]?.url ?? data?.url?.[0]?.link ?? data?.data?.stream?.playlist;
|
||||
if (file) return { url: file, server, headers: REQUEST_HEADERS };
|
||||
throw new Error();
|
||||
}));
|
||||
const allUrls = [];
|
||||
for (const r of settled) if (r.status === 'fulfilled') allUrls.push(r.value);
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
}
|
||||
|
||||
export async function getSources(args) {
|
||||
const res = await getStream(args);
|
||||
return res?.allUrls ? res.allUrls.map(u => u.server) : [];
|
||||
}
|
||||
|
||||
export const VERIFY_HEADERS = { ...REQUEST_HEADERS };
|
||||
@@ -1,145 +0,0 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { fetchJson, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const GCM_HEX_KEY = '7f3e9c2a8b5d1f4e6a9c3b7d2e5f8a1c4b6d9e2f5a8c1b4d7e9f2a5c8b1d4e7f';
|
||||
const BASE_URL = 'https://vidrock.ru/';
|
||||
const PROXY_PREFIX = 'https://proxy.vidrock.store/';
|
||||
|
||||
let cryptoKey = null;
|
||||
|
||||
function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value) {
|
||||
let base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const padding = base64.length % 4;
|
||||
|
||||
if (padding === 2) {
|
||||
base64 += '==';
|
||||
} else if (padding === 3) {
|
||||
base64 += '=';
|
||||
} else if (padding === 1) {
|
||||
throw new Error('Invalid base64url');
|
||||
}
|
||||
|
||||
return new Uint8Array(Buffer.from(base64, 'base64'));
|
||||
}
|
||||
|
||||
async function getKey() {
|
||||
if (cryptoKey) return cryptoKey;
|
||||
|
||||
const keyBytes = hexToBytes(GCM_HEX_KEY);
|
||||
|
||||
cryptoKey = await webcrypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
'AES-GCM',
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
return cryptoKey;
|
||||
}
|
||||
|
||||
async function decryptStreamUrl(value) {
|
||||
try {
|
||||
const data = base64UrlToBytes(value);
|
||||
|
||||
if (data.length < 28) {
|
||||
throw new Error('Ciphertext too short');
|
||||
}
|
||||
|
||||
const iv = data.slice(0, 12);
|
||||
const ciphertext = data.slice(12);
|
||||
|
||||
const key = await getKey();
|
||||
|
||||
const decrypted = await webcrypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv
|
||||
},
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
try {
|
||||
const path = s
|
||||
? `tv/${id}/${s}/${e || 1}`
|
||||
: `movie/${id}`;
|
||||
|
||||
const apiData = await fetchJson(`${BASE_URL}api/${path}`, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
Referer: BASE_URL,
|
||||
Origin: BASE_URL
|
||||
}
|
||||
});
|
||||
|
||||
if (!apiData || typeof apiData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const streams = [];
|
||||
|
||||
for (const [provider, info] of Object.entries(apiData)) {
|
||||
if (!info?.url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decryptedUrl = await decryptStreamUrl(info.url);
|
||||
|
||||
if (!decryptedUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let url = decryptedUrl;
|
||||
|
||||
if (url.startsWith(PROXY_PREFIX)) {
|
||||
url = decodeURIComponent(
|
||||
url.slice(PROXY_PREFIX.length).replace(/^\/+/, '')
|
||||
);
|
||||
}
|
||||
|
||||
const isXpass = url.includes('xpass.top');
|
||||
|
||||
streams.push({
|
||||
name: provider,
|
||||
url,
|
||||
file: url,
|
||||
stream: url,
|
||||
src: url,
|
||||
quality: info.type || 'auto',
|
||||
type: info.type || 'hls',
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
Referer: isXpass
|
||||
? 'https://play.xpass.top/'
|
||||
: BASE_URL,
|
||||
Origin: isXpass
|
||||
? 'https://play.xpass.top'
|
||||
: 'https://vidrock.ru'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return streams.length ? { streams } : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { getUA } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://vidup.to';
|
||||
const API = 'https://enc-dec.app/api';
|
||||
|
||||
export async function getStream({ id, s, e }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const targetUrl = isTv
|
||||
? `${BASE_URL}/tv/${id}/${s}/${e}/`
|
||||
: `${BASE_URL}/movie/${id}`;
|
||||
|
||||
const ua = getUA();
|
||||
const baseHeaders = {
|
||||
'User-Agent': ua,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
};
|
||||
|
||||
try {
|
||||
const pageRes = await fetch(targetUrl, {
|
||||
headers: { 'User-Agent': ua },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!pageRes.ok) return null;
|
||||
const html = await pageRes.text();
|
||||
|
||||
const match = html.match(/\\"(?:en|token)\\":\\"(.*?)\\"/);
|
||||
if (!match || !match[1]) return null;
|
||||
const enToken = match[1];
|
||||
|
||||
const s1Res = await fetch(`${API}/enc-vidup?text=${encodeURIComponent(enToken)}&stage=1`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const s1Data = await s1Res.json();
|
||||
if (s1Data?.status !== 200 || !s1Data?.result?.stage1) return null;
|
||||
|
||||
const token1 = s1Data.result.token;
|
||||
const stage1Url = s1Data.result.stage1;
|
||||
|
||||
const post1Res = await fetch(stage1Url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'X-CSRF-Token': token1 },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!post1Res.ok) return null;
|
||||
const stage1Text = await post1Res.text();
|
||||
|
||||
const s2Res = await fetch(`${API}/enc-vidup?text=${encodeURIComponent(stage1Text)}&stage=2`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const s2Data = await s2Res.json();
|
||||
if (s2Data?.status !== 200 || !s2Data?.result?.servers) return null;
|
||||
|
||||
const { servers: serversUrl, stream: streamBaseUrl, token: token2 } = s2Data.result;
|
||||
const currentHeaders = { ...baseHeaders, 'X-CSRF-Token': token2 };
|
||||
|
||||
const serversRes = await fetch(serversUrl, {
|
||||
method: 'POST',
|
||||
headers: currentHeaders,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!serversRes.ok) return null;
|
||||
const serversEncrypted = await serversRes.text();
|
||||
|
||||
const decServersRes = await fetch(`${API}/dec-vidup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: serversEncrypted }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const decServersData = await decServersRes.json();
|
||||
if (decServersData?.status !== 200 || !Array.isArray(decServersData.result)) return null;
|
||||
|
||||
const allUrls = [];
|
||||
|
||||
for (const server of decServersData.result) {
|
||||
if (!server?.data) continue;
|
||||
try {
|
||||
const streamUrl = `${streamBaseUrl}/${server.data}`;
|
||||
const streamEncRes = await fetch(streamUrl, {
|
||||
method: 'POST',
|
||||
headers: currentHeaders,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!streamEncRes.ok) continue;
|
||||
const streamEncText = await streamEncRes.text();
|
||||
|
||||
const decStreamRes = await fetch(`${API}/dec-vidup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: streamEncText }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const decStreamData = await decStreamRes.json();
|
||||
if (decStreamData?.status !== 200 || !decStreamData.result) continue;
|
||||
|
||||
const streamResult = decStreamData.result;
|
||||
const resultUrl = typeof streamResult === 'string' ? streamResult : streamResult?.url || streamResult?.file;
|
||||
|
||||
if (typeof resultUrl === 'string' && resultUrl.startsWith('http')) {
|
||||
allUrls.push({
|
||||
url: resultUrl,
|
||||
label: server.name || server.title || 'VidUp',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://vidvault.to';
|
||||
const API_BASE = `${BASE_URL}/api`;
|
||||
const DL_PROXY_BASE = 'https://dl.gemlelispe.workers.dev';
|
||||
const SUB_PROXY_BASE = 'https://sub.k5s7sjozpn.workers.dev';
|
||||
|
||||
const HEADERS = {
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Origin': BASE_URL,
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const res = await fetch(`${API_BASE}/get-token`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data?.t;
|
||||
}
|
||||
|
||||
function getCaptionType(url) {
|
||||
return String(url).toLowerCase().includes('.vtt') ? 'vtt' : 'srt';
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) return null;
|
||||
|
||||
const payload = {
|
||||
type,
|
||||
tmdbId: String(id),
|
||||
};
|
||||
|
||||
if (isTv) {
|
||||
payload.season = Number(s);
|
||||
payload.episode = Number(e);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/download-proxy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...HEADERS,
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-token': token,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
|
||||
const allUrls = [];
|
||||
const subtitles = [];
|
||||
const titleTag = encodeURIComponent(isTv ? `Show_${id}_S${s}E${e}` : `Movie_${id}`);
|
||||
|
||||
const mp4 = data?.mp4Data;
|
||||
const downloadData = mp4?.downloadInfo?.data || mp4?.data?.data || mp4?.data || mp4;
|
||||
|
||||
if (downloadData) {
|
||||
const streams = downloadData.streams || downloadData.downloads || [];
|
||||
for (const item of streams) {
|
||||
if (!item?.url) continue;
|
||||
|
||||
const res = item.resolutions || item.resolution || 'Auto';
|
||||
const sizeStr = item.size ? ` (${typeof item.size === 'number' ? formatBytes(item.size) : item.size})` : '';
|
||||
const proxiedUrl = `${DL_PROXY_BASE}/${encodeURIComponent(item.url)}?n=${titleTag}`;
|
||||
|
||||
allUrls.push({
|
||||
url: proxiedUrl,
|
||||
server: `VidVault - MP4 ${res}p${sizeStr}`.trim(),
|
||||
quality: typeof res === 'number' ? `${res}p` : res,
|
||||
type: 'mp4',
|
||||
headers: HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const captions = downloadData.captions || [];
|
||||
for (const cap of captions) {
|
||||
if (!cap?.url) continue;
|
||||
const subUrl = `${SUB_PROXY_BASE}/?url=${encodeURIComponent(cap.url)}&title=${titleTag}`;
|
||||
subtitles.push({
|
||||
label: cap.lanName || cap.lan || 'Unknown',
|
||||
type: getCaptionType(cap.url),
|
||||
language: cap.lan || '',
|
||||
url: subUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mkvFiles = data?.mkvData?.files || [];
|
||||
for (const file of mkvFiles) {
|
||||
if (!file?.url) continue;
|
||||
const sizeStr = file.size ? ` (${typeof file.size === 'number' ? formatBytes(file.size) : file.size})` : '';
|
||||
allUrls.push({
|
||||
url: file.url,
|
||||
server: `VidVault - MKV 480p${sizeStr}`.trim(),
|
||||
quality: '480p',
|
||||
type: 'mp4',
|
||||
headers: HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const mkvV2 = data?.mkvV2Data;
|
||||
const mkvV2List = Array.isArray(mkvV2)
|
||||
? mkvV2
|
||||
: Array.isArray(mkvV2?.files)
|
||||
? mkvV2.files
|
||||
: (mkvV2?.url ? [mkvV2] : []);
|
||||
|
||||
for (const file of mkvV2List) {
|
||||
if (!file?.url) continue;
|
||||
const quality = file.quality ? String(file.quality).replace(/p$/i, '') : 'Auto';
|
||||
const sizeStr = file.size ? ` (${typeof file.size === 'number' ? formatBytes(file.size) : file.size})` : '';
|
||||
const meta = [file.country, file.language].filter(Boolean).join(' • ');
|
||||
const metaStr = meta ? ` [${meta}]` : '';
|
||||
|
||||
allUrls.push({
|
||||
url: file.url,
|
||||
server: `VidVault - MKV v2 ${quality}p${sizeStr}${metaStr}`.trim(),
|
||||
quality: `${quality}p`,
|
||||
type: 'mp4',
|
||||
headers: HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const mkvV3 = data?.mkvV3Data;
|
||||
if (mkvV3?.downloads && Array.isArray(mkvV3.downloads)) {
|
||||
const country = mkvV3.country || '';
|
||||
const language = mkvV3.language || '';
|
||||
|
||||
for (const down of mkvV3.downloads) {
|
||||
if (Array.isArray(down?.qualities)) {
|
||||
for (const qObj of down.qualities) {
|
||||
for (const ep of qObj?.episodes || []) {
|
||||
if (!ep?.url) continue;
|
||||
const quality = qObj.quality ? String(qObj.quality).replace(/p$/i, '') : 'Auto';
|
||||
const sizeStr = ep.size ? ` (${typeof ep.size === 'number' ? formatBytes(ep.size) : ep.size})` : '';
|
||||
const meta = [ep.country || country, ep.language || language].filter(Boolean).join(' • ');
|
||||
const metaStr = meta ? ` [${meta}]` : '';
|
||||
|
||||
allUrls.push({
|
||||
url: ep.url,
|
||||
server: `VidVault - MKV v3 ${quality}p${sizeStr}${metaStr}`.trim(),
|
||||
quality: `${quality}p`,
|
||||
type: 'mp4',
|
||||
headers: HEADERS,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (down?.url) {
|
||||
const quality = down.quality ? String(down.quality).replace(/p$/i, '') : 'Auto';
|
||||
const sizeStr = down.size ? ` (${typeof down.size === 'number' ? formatBytes(down.size) : down.size})` : '';
|
||||
allUrls.push({
|
||||
url: down.url,
|
||||
server: `VidVault - MKV v3 ${quality}p${sizeStr}`.trim(),
|
||||
quality: `${quality}p`,
|
||||
type: 'mp4',
|
||||
headers: HEADERS,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allUrls.length ? { allUrls, subtitles: subtitles.length ? subtitles : undefined } : null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { USER_AGENT, fetchJson } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://core.vidzee.wtf';
|
||||
const PLAYER_URL = 'https://player.vidzee.wtf';
|
||||
const SERVICES = ['dcloud', 'ipcloud', 'tik', 'v6:Hindi', '3AHindi'];
|
||||
const WASM_B64 = 'AGFzbQEAAAABPgtgAX8Bf2ACf38Bf2ABfwBgAn9/AGADf39/AGAAAGAEf39/fwBgBH9/f38Bf2ADf39/AX9gA39/fgBgAAF/Ag0BA2VudgVhYm9ydAAGAygnAgEAAAQBAAEDBAADBQAAAgMHAAEAAAQIAwEJAgIBBQAAAgEABQoBBQMBAAEGJgd/AUEAC38BQQALfwFBAAt/AUEAC38BQQALfwFBAAt/AEGA2wALB0gHB2RlY3J5cHQAJwVfX25ldwAGBV9fcGluACQHX191bnBpbgAiCV9fY29sbGVjdAAlC19fcnR0aV9iYXNlAwYGbWVtb3J5AgAIAR8MAqkBCogkJyUAIABFBEAPCyAAQRRrIgAQByMCRgRAIAAQECAAIwUjAkUQCgsLCgAgACABai0AAAsKACAAKAIEQXxxCw0AIABBFGsoAhBBAXYLDAAgACABaiACOgAAC1MBAX8gAEHs////A0sEQEHwDEGwDUH9AEEeEAAACyMARQRAEA0LIwAgAEEQahAaIgIgATYCDCACIAA2AhAgAiMBIwIQCiACEAsjA2okAyACQRRqCwoAIAAoAgRBA3ELKQAgACAAIAFBCHZqLQAAQQV0aiABQf8BcUEDdmotAAAgAUEHcXZBAXELwwEBBH8gASgCAEF8cSIDQYACSQR/IANBBHYFQR9B/P///wMgAyADQfz///8DTxsiA2drIgRBB2shAiADIARBBGt2QRBzCyEEIAEoAgghBSABKAIEIgMEQCADIAU2AggLIAUEQCAFIAM2AgQLIAEgACACQQR0IARqQQJ0aiIBKAJgRgRAIAEgBTYCYCAFRQRAIAAgAkECdGoiASgCBEF+IAR3cSEDIAEgAzYCBCADRQRAIAAgACgCAEF+IAJ3cTYCAAsLCwspAQF/IAEoAgghAyAAIAEgAnI2AgQgACADNgIIIAMgABARIAEgADYCCAsNACAAKAIAQXxxQQRqC7wCAQV/IAEoAgAhAyABQQRqIAEoAgBBfHFqIgQoAgAiAkEBcQRAIAAgBBAJIAEgA0EEaiACQXxxaiIDNgIAIAFBBGogASgCAEF8cWoiBCgCACECCyADQQJxBEAgAUEEaygCACIBKAIAIQYgACABEAkgASAGQQRqIANBfHFqIgM2AgALIAQgAkECcjYCACAEQQRrIAE2AgAgACADQXxxIgJBgAJJBH8gAkEEdgVBH0H8////AyACIAJB/P///wNPGyICZ2siA0EHayEFIAIgA0EEa3ZBEHMLIgIgBUEEdGpBAnRqKAJgIQMgAUEANgIEIAEgAzYCCCADBEAgAyABNgIECyAAIAVBBHQgAmpBAnRqIAE2AmAgACAAKAIAQQEgBXRyNgIAIAAgBUECdGoiACAAKAIEQQEgAnRyNgIEC5cBAQJ/PwAiAEEATAR/QQEgAGtAAEEASAVBAAsEQAALQbDbAEEANgIAQdDnAEEANgIAA0AgAUEXSQRAIAFBAnRBsNsAakEANgIEQQAhAANAIABBEEkEQCABQQR0IABqQQJ0QbDbAGkBADYCYCAAQQFqIQAMAQsLIAFBAWohAQwBCwtBsNsAQdTnAD8ArEIQhhAbQbDbACQACyYBAX8gAEEEayEBIABBD3FBASAAGwR/QQEFIAEoAgBBAXELGiABCxIAIAAgADYCBCAAIAA2AgggAAsnAQF/IAAQAyIBRQRAIAAoAggaDwsgASAAKAIIIgA2AgggACABEBELEgAgACABIAAoAgRBA3FyNgIEC0kBAX8gACABQQF0aiEBA0AgAyIAQQFrIQMgAARAIAEvAQAiACACLwEAIgRHBEAgACAEaw8LIAFBAmohASACQQJqIQIMAQsLQQALbgECf0EMQQYQBiIBRQRAQQxBAxAGIQELIAFBADYCACABQQA2AgQgAUEANgIIIABB/P///wNLBEBB4NcAQZDYAEETQTkQAAALIABBARAGIgJBACAA/AsAIAEgAjYCACABIAI2AgQgASAANgIIIAELjgEBAn8gAUGAAkkEfyABQQR2BUEfIAEQFSIBZ2siA0EHayECIAEgA0EEa3ZBEHMLIgEgACACQQJ0aigCBEF/IAF0cSIBBH8gACABaCACQQR0akECdGooAmAFIAAoAgBBfyACQQFqdHEiAQR/IAAgACABaCIAQQJ0aigCBGggAEEEdGpBAnRqKAJgBUEACwsLHQAgAEEBQRsgAGdrdGpBAWsgACAAQf7///8BSRsLLwAgAEH8////A0sEQEHwDEHwDUHNA0EdEAAAC0EMIABBE2pBcHFBBGsgAEEMTRsLZwECfyABKAIAIgNBfHEgAmsiBEEQTwRAIAEgAiADQQJxcjYCACABQQRqIAJqIgEgBEEEa0EBcjYCACAAIAEQDAUgASADQX5xNgIAIAFBBGogASgCAEF8cWoiACAAKAIAQX1xNgIACwswACAAIAIQGiICQQRqIAFBBGogASgCAEF8cfwKAAAgAUGk2wBPBEAgACABEBkLIAILFQAgASABKAIAQQFyNgIAIAAgARAMC48BAQJ/IAAgARAWIgIQFCIBRQRAQQQgACgCoAw/ACIBQRB0QQRrR3QgAhAVIAIgAkGAAk8bakH//wNqQYCAfHFBEHYhAyABIAMgASADShtAAEEASARAIANAAEEASARAAAsLIAAgAUEQdD8ArEIQhhAbIAAgAhAUIQELIAEoAgAaIAAgARAJIAAgASACEBcgAQuGAQEDfyABQRNqQXBxQQRrIQEgACgCoAwiAwRAIAFBEGsiBSADRgRAIAMoAgAhBCAFIQELCyACp0FwcSABayIDQRRJBEAPCyABIARBAnEgA0EIayIDQQFycjYCACABQQA2AgQgAUEANgIIIAFBBGogA2oiA0ECNgIAIAAgAzYCoAwgACABEAwLhQEBA38CQAJAAkACQAJAAkACQAJAAkAgAEEIaygCAA4IAAECAwQFBgcICw8LDwsPCyAAEB0PCyAAKAIAEAEPCyAAKAIEIgEgACgCDEECdGohAgNAIAEgAkkEQCABKAIAIgMEQCADEAELIAFBBGohAQwBCwsgACgCABABDwsgABAdDwsPCwALCQAgACgCABABC0MAIAEgACgCDE8EQEHw1QBBsNYAQfIAQSoQAAALIAAoAgQgAUECdGooAgAiAEUEQEHg1gBBsNYAQfYAQSgQAAALIAALGQBBoA4QDyQBQYDaABAPJARB4NoAEA8kBQv9AQEGfyAAIgFBCHYiAkHMzgBqLQAAIAJBlDJqLQAAQdYAbEGUMmogAEH/AXEiA0EDbmotAAAgA0EDcEECdEGAxwBqKAIAbEELdkEGcGpBAnRBjbrowserigCACICQf8BcSEAIAJBCHUhAgJAIABBAkkNACACQf8BcSEAIAJBCHYhAgNAIAAEQCAAQQF2IgYgAmpBAXRBzNIAaiIELQAAIgUgA0YEfyAELQABQQJ0QYzHAGooAgAiAkH/AXEhACACQQh1IQIgAEECSQ0DIAFBAWoPBSADIAVJBH8gBgUgAiAGaiECIAAgBmsLCyEADAELCyABDwsgASACQQAgAGtxag==';
|
||||
|
||||
let wasmInstanceCache = null;
|
||||
|
||||
async function getWasmInstance() {
|
||||
if (wasmInstanceCache) return wasmInstanceCache;
|
||||
try {
|
||||
const wasmBuffer = Buffer.from(WASM_B64, 'base64');
|
||||
const wasmModule = await WebAssembly.compile(wasmBuffer);
|
||||
|
||||
let memory;
|
||||
let exports;
|
||||
|
||||
const env = {
|
||||
abort() {
|
||||
throw new Error('WASM abort');
|
||||
}
|
||||
};
|
||||
|
||||
const res = await WebAssembly.instantiate(wasmModule, { env });
|
||||
exports = res.exports;
|
||||
memory = exports.memory;
|
||||
|
||||
const refMap = new Map();
|
||||
function pin(ptr) {
|
||||
if (ptr) {
|
||||
const count = refMap.get(ptr);
|
||||
if (count) refMap.set(ptr, count + 1);
|
||||
else refMap.set(exports.__pin(ptr), 1);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
function unpin(ptr) {
|
||||
if (ptr) {
|
||||
const count = refMap.get(ptr);
|
||||
if (count === 1) {
|
||||
exports.__unpin(ptr);
|
||||
refMap.delete(ptr);
|
||||
} else if (count) {
|
||||
refMap.set(ptr, count - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wasmInstanceCache = {
|
||||
decrypt(encryptedBytes, keyStr) {
|
||||
let dataPtr = 0;
|
||||
let keyPtr = 0;
|
||||
try {
|
||||
const len = encryptedBytes.length;
|
||||
const pinPtr = exports.__pin(exports.__new(len, 1)) >>> 0;
|
||||
dataPtr = exports.__new(12, 6) >>> 0;
|
||||
|
||||
const view = new DataView(memory.buffer);
|
||||
view.setUint32(dataPtr, pinPtr, true);
|
||||
view.setUint32(dataPtr + 4, pinPtr, true);
|
||||
view.setUint32(dataPtr + 8, len, true);
|
||||
new Uint8Array(memory.buffer, pinPtr, len).set(encryptedBytes);
|
||||
exports.__unpin(pinPtr);
|
||||
|
||||
pin(dataPtr);
|
||||
|
||||
const kLen = keyStr.length;
|
||||
keyPtr = exports.__new(kLen << 1, 2) >>> 0;
|
||||
const u16View = new Uint16Array(memory.buffer);
|
||||
for (let i = 0; i < kLen; i++) {
|
||||
u16View[(keyPtr >>> 1) + i] = keyStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
const resPtr = exports.decrypt(dataPtr, keyPtr) >>> 0;
|
||||
if (!resPtr) return new Uint8Array(0);
|
||||
|
||||
const resView = new DataView(memory.buffer);
|
||||
const outPtr = resView.getUint32(resPtr + 4, true);
|
||||
const outLen = resView.getUint32(resPtr + 8, true);
|
||||
return new Uint8Array(memory.buffer, outPtr, outLen).slice();
|
||||
} finally {
|
||||
unpin(dataPtr);
|
||||
}
|
||||
}
|
||||
};
|
||||
return wasmInstanceCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestEndpoint(url, headers) {
|
||||
try {
|
||||
return await fetchJson(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(6000)
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServiceStream(service, isMovie, id, s, e, headers) {
|
||||
const directEndpoint = isMovie
|
||||
? `${BASE_URL}/streams/movie/${id}?s=${service}`
|
||||
: `${BASE_URL}/streams/tv/${id}/${s || 1}/${e || 1}?s=${service}`;
|
||||
|
||||
let res = await requestEndpoint(directEndpoint, headers);
|
||||
|
||||
if (!res || !res.url) {
|
||||
const encEndpoint = `${directEndpoint}&e=1`;
|
||||
const encRes = await requestEndpoint(encEndpoint, headers);
|
||||
|
||||
if (encRes && encRes.c) {
|
||||
const instance = await getWasmInstance();
|
||||
if (instance) {
|
||||
const encryptedBytes = new Uint8Array(Buffer.from(encRes.c, 'base64'));
|
||||
const decryptedBytes = instance.decrypt(encryptedBytes, 'player.vidzee.wtf');
|
||||
if (decryptedBytes.length > 0) {
|
||||
const str = new TextDecoder().decode(decryptedBytes);
|
||||
try {
|
||||
res = JSON.parse(str);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res && res.url) {
|
||||
const format = res.url.includes('.m3u8') ? 'hls' : 'mp4';
|
||||
return {
|
||||
url: res.url,
|
||||
server: `VidZee (${service})`,
|
||||
quality: res.language || 'Auto',
|
||||
type: format,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': `${PLAYER_URL}/`,
|
||||
'Origin': PLAYER_URL,
|
||||
...(res.headers || {})
|
||||
}
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getStream(args) {
|
||||
const { id, s, e, server, clientIP } = args;
|
||||
const isTv = s != null && e != null;
|
||||
const isMovie = !isTv;
|
||||
|
||||
const headers = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Referer': `${PLAYER_URL}/`,
|
||||
'Origin': PLAYER_URL,
|
||||
...(clientIP && { 'X-Forwarded-For': clientIP })
|
||||
};
|
||||
|
||||
let targets = SERVICES;
|
||||
if (server && server !== 'all') {
|
||||
const clean = server.toLowerCase().replace('vidzee (', '').replace(')', '').replace('vidzee', '').trim();
|
||||
targets = SERVICES.filter(srv => srv.toLowerCase().includes(clean));
|
||||
if (!targets.length) targets = SERVICES;
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
targets.map(service => resolveServiceStream(service, isMovie, id, s, e, headers))
|
||||
);
|
||||
|
||||
const allUrls = [];
|
||||
for (const r of settled) {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
allUrls.push(r.value);
|
||||
}
|
||||
}
|
||||
|
||||
return allUrls.length ? { allUrls } : null;
|
||||
}
|
||||
|
||||
export async function getSources() {
|
||||
return SERVICES.map(s => `VidZee (${s})`);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { fetchText, USER_AGENT } from '../utils/helpers.js';
|
||||
|
||||
const BASE_URL = 'https://vixsrc.to';
|
||||
|
||||
const HEADERS = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Origin': BASE_URL
|
||||
};
|
||||
|
||||
function extract(regex, text) {
|
||||
const m = text?.match(regex);
|
||||
if (!m) return null;
|
||||
let val = m[1];
|
||||
val = val.replace(/\\u0026/gi, '&');
|
||||
val = val.replace(/\\\//g, '/');
|
||||
val = val.replace(/\\/g, '');
|
||||
return val;
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, absoluteBase }) {
|
||||
try {
|
||||
const apiUrl = s && e
|
||||
? `${BASE_URL}/api/tv/${id}/${s}/${e}`
|
||||
: `${BASE_URL}/api/movie/${id}`;
|
||||
|
||||
const apiProxy = absoluteBase && absoluteBase !== '/'
|
||||
? `${absoluteBase.replace(/^https:\/\/(localhost|127\.0\.0\.1)/, 'http://$1')}/api?url=${encodeURIComponent(apiUrl)}&proxyHeaders=${encodeURIComponent(JSON.stringify(HEADERS))}`
|
||||
: apiUrl;
|
||||
|
||||
const apiText = await fetchText(apiProxy, { headers: HEADERS }).catch(() => null);
|
||||
const apiData = JSON.parse(apiText || '{}');
|
||||
if (!apiData.src) return null;
|
||||
|
||||
const embedUrl = apiData.src.startsWith('http') ? apiData.src : `${BASE_URL}${apiData.src}`;
|
||||
const embedProxy = absoluteBase && absoluteBase !== '/'
|
||||
? `${absoluteBase.replace(/^https:\/\/(localhost|127\.0\.0\.1)/, 'http://$1')}/api?url=${encodeURIComponent(embedUrl)}&proxyHeaders=${encodeURIComponent(JSON.stringify(HEADERS))}`
|
||||
: embedUrl;
|
||||
|
||||
const html = await fetchText(embedProxy, { headers: HEADERS }).catch(() => null);
|
||||
if (!html) return null;
|
||||
|
||||
const token = extract(/token["']\s*:\s*["']([^"']+)/, html);
|
||||
const expires = extract(/expires["']\s*:\s*["']([^"']+)/, html);
|
||||
let playlist = extract(/url["']\s*:\s*["']([^"']+)/, html);
|
||||
|
||||
if (!token || !expires || !playlist) return null;
|
||||
|
||||
if (playlist.startsWith('/')) {
|
||||
playlist = `${BASE_URL}${playlist}`;
|
||||
}
|
||||
|
||||
const lang = extract(/lang(?:uage)?["']\s*:\s*["']([a-z]{2,5})/i, html) || 'en';
|
||||
const rawMasterUrl = `${playlist}${playlist.includes('?') ? '&' : '?'}token=${token}&expires=${expires}&h=1&lang=${lang}`;
|
||||
|
||||
const masterProxy = absoluteBase && absoluteBase !== '/'
|
||||
? `${absoluteBase.replace(/^https:\/\/(localhost|127\.0\.0\.1)/, 'http://$1')}/api?url=${encodeURIComponent(rawMasterUrl)}&proxyHeaders=${encodeURIComponent(JSON.stringify({ ...HEADERS, Referer: embedUrl }))}`
|
||||
: rawMasterUrl;
|
||||
|
||||
const playlistText = await fetchText(masterProxy, { headers: { ...HEADERS, 'Referer': embedUrl } }).catch(() => null);
|
||||
if (!playlistText?.includes('#EXTM3U')) return null;
|
||||
|
||||
let finalUrl = null;
|
||||
let bestHeight = 0;
|
||||
const lines = playlistText.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.startsWith('#EXT-X-STREAM-INF')) continue;
|
||||
const height = parseInt(line.match(/RESOLUTION=\d+x(\d+)/)?.[1] || '0', 10);
|
||||
let next = lines[i + 1]?.trim();
|
||||
if (!next || next.startsWith('#')) continue;
|
||||
|
||||
if (height > bestHeight) {
|
||||
bestHeight = height;
|
||||
finalUrl = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalUrl) finalUrl = rawMasterUrl;
|
||||
|
||||
if (!finalUrl.startsWith('http')) {
|
||||
const baseMatch = rawMasterUrl.match(/^(https?:\/\/[^\/]+)/);
|
||||
const base = baseMatch ? baseMatch[1] : BASE_URL;
|
||||
if (finalUrl.startsWith('/')) {
|
||||
finalUrl = `${base}${finalUrl}`;
|
||||
} else {
|
||||
const pathParts = rawMasterUrl.split('/');
|
||||
pathParts.pop();
|
||||
finalUrl = `${pathParts.join('/')}/${finalUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allUrls: [
|
||||
{
|
||||
url: finalUrl,
|
||||
server: 'VixSrc',
|
||||
skipProxy: true,
|
||||
headers: {
|
||||
...HEADERS,
|
||||
'Referer': `${BASE_URL}/`
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
const BASE_URL = 'https://www.films365.org';
|
||||
const HEADERS = {
|
||||
Authorization: 'Bearer 79a02956be35835728a044b11e2ae793149d45fb2c89cb6d029ec01aac19bfdb',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MovieDownloader/1.0',
|
||||
};
|
||||
|
||||
async function resolveTitle(sdk, id, isTv) {
|
||||
const apiKey = sdk?.tmdbApiKey || process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return isTv ? data.name : data.title;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStream({ id, s, e, sdk }) {
|
||||
const isTv = Boolean(s && e);
|
||||
const targetType = isTv ? 'tv' : 'movie';
|
||||
|
||||
const title = await resolveTitle(sdk, id, isTv);
|
||||
const query = title || String(id);
|
||||
|
||||
const searchRes = await fetch(`${BASE_URL}/api/mobile/search`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: JSON.stringify({ query }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!searchRes.ok) return null;
|
||||
const searchJson = await searchRes.json();
|
||||
const resultsObj = searchJson?.results;
|
||||
if (!resultsObj) return null;
|
||||
|
||||
const items = resultsObj.all || (targetType === 'tv' ? resultsObj.tvs : resultsObj.movies) || [];
|
||||
if (!Array.isArray(items) || items.length === 0) return null;
|
||||
|
||||
const cleanSearchTitle = query.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
let matchedItem = null;
|
||||
|
||||
for (const item of items) {
|
||||
const itemType = String(item?.type || '').toLowerCase();
|
||||
if (itemType && itemType !== targetType) continue;
|
||||
|
||||
const itemTitle = String(item?.title || '');
|
||||
const cleanItemTitle = itemTitle.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
if (cleanItemTitle === cleanSearchTitle || cleanItemTitle.includes(cleanSearchTitle)) {
|
||||
matchedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedItem) {
|
||||
matchedItem = items.find(i => String(i?.type || '').toLowerCase() === targetType) || items[0];
|
||||
}
|
||||
|
||||
const itemId = matchedItem?.id || matchedItem?.tmdbId;
|
||||
if (!itemId) return null;
|
||||
|
||||
const detailsRes = await fetch(`${BASE_URL}/api/mobile/details?id=${itemId}&type=${targetType}`, {
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!detailsRes.ok) return null;
|
||||
const detailsJson = await detailsRes.json();
|
||||
const data = detailsJson?.data;
|
||||
if (!data) return null;
|
||||
|
||||
const spoken = Array.isArray(data.spokenLanguages)
|
||||
? data.spokenLanguages.map(l => String(l).trim()).filter(Boolean)
|
||||
: [];
|
||||
const langSuffix = spoken.length > 0 ? ` · ${spoken.join(', ')}` : '';
|
||||
|
||||
const allUrls = [];
|
||||
|
||||
if (!isTv) {
|
||||
const streamUrl = data.downloadUrl || data.videoUrl;
|
||||
if (streamUrl && typeof streamUrl === 'string') {
|
||||
allUrls.push({
|
||||
url: streamUrl.trim(),
|
||||
label: `X-Downloader${langSuffix}`,
|
||||
headers: {
|
||||
'User-Agent': HEADERS['User-Agent'],
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const seasonNum = Number(s);
|
||||
const episodeNum = Number(e);
|
||||
const seasons = Array.isArray(data.seasons) ? data.seasons : [];
|
||||
const targetSeason = seasons.find(sn => Number(sn.seasonNumber) === seasonNum);
|
||||
|
||||
if (targetSeason) {
|
||||
const episodes = Array.isArray(targetSeason.episodes) ? targetSeason.episodes : [];
|
||||
const targetEp = episodes.find(ep => Number(ep.episodeNumber) === episodeNum);
|
||||
|
||||
if (targetEp) {
|
||||
const streamUrl = targetEp.downloadUrl || targetEp.videoUrl;
|
||||
if (streamUrl && typeof streamUrl === 'string') {
|
||||
allUrls.push({
|
||||
url: streamUrl.trim(),
|
||||
label: `X-Downloader (S${seasonNum}E${episodeNum})${langSuffix}`,
|
||||
headers: {
|
||||
'User-Agent': HEADERS['User-Agent'],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: allUrls[0].url,
|
||||
allUrls,
|
||||
headers: allUrls[0].headers,
|
||||
};
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { getUA } from './utils/helpers.js';
|
||||
|
||||
const SUBTITLE_BASES = [
|
||||
'https://sub.vdrk.site/v1',
|
||||
'https://sub.vdrk.site/v2',
|
||||
'https://opensubtitles-v3.strem.io'
|
||||
];
|
||||
|
||||
async function resolveImdbId(id, isTv) {
|
||||
if (typeof id === 'string' && id.startsWith('tt')) return id;
|
||||
const apiKey = process.env.TMDB_API_KEY;
|
||||
if (!apiKey) return `tt${id}`;
|
||||
const type = isTv ? 'tv' : 'movie';
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}&append_to_response=external_ids`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return `tt${id}`;
|
||||
const data = await res.json();
|
||||
return data.imdb_id || data.external_ids?.imdb_id || `tt${id}`;
|
||||
} catch {
|
||||
return `tt${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSubtitles(paths = []) {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
paths.map(async ({ base, path }) => {
|
||||
try {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: { 'User-Agent': getUA() },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
res.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (base.includes('/v2')) {
|
||||
return Array.isArray(data)
|
||||
? data.map(x => ({
|
||||
label: x.label,
|
||||
file: x.file || x.url,
|
||||
type: 'vtt',
|
||||
source: 'v2'
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
if (base.includes('opensubtitles-v3.strem.io')) {
|
||||
if (!Array.isArray(data?.subtitles)) return [];
|
||||
return data.subtitles.map(sub => {
|
||||
if (!sub?.url) return null;
|
||||
const isSrt = sub.url.toLowerCase().endsWith('.srt');
|
||||
return {
|
||||
label: sub.lang ? sub.lang.toUpperCase() : 'OpenSubtitles',
|
||||
file: sub.url,
|
||||
type: isSrt ? 'srt' : 'vtt',
|
||||
source: 'opensubtitles'
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
if (base.includes('fed-subs.pstream.mov')) {
|
||||
if (!data?.subtitles || typeof data.subtitles !== 'object') return [];
|
||||
|
||||
return Object.entries(data.subtitles)
|
||||
.map(([language, sub]) => {
|
||||
if (!sub?.subtitle_link) return null;
|
||||
const ext = sub.subtitle_link.split('.').pop()?.toLowerCase();
|
||||
return {
|
||||
label: sub.subtitle_name || language,
|
||||
file: sub.subtitle_link,
|
||||
type: ext === 'vtt' ? 'vtt' : 'srt',
|
||||
source: 'febbox'
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const v1 = Array.isArray(data) ? data : [];
|
||||
return v1.map(x => ({
|
||||
label: x.label,
|
||||
file: x.file || x.url,
|
||||
type: 'vtt',
|
||||
source: 'v1'
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubPathsMovie(id) {
|
||||
const imdbId = await resolveImdbId(id, false);
|
||||
return [
|
||||
{ base: SUBTITLE_BASES[0], path: `/movie/${id}` },
|
||||
{ base: SUBTITLE_BASES[1], path: `/movie/${id}` },
|
||||
{ base: SUBTITLE_BASES[2], path: `/subtitles/movie/${imdbId}.json` }
|
||||
];
|
||||
}
|
||||
|
||||
export async function getSubPathsTv(id, season, episode) {
|
||||
const imdbId = await resolveImdbId(id, true);
|
||||
return [
|
||||
{ base: SUBTITLE_BASES[0], path: `/tv/${id}/${season}/${episode}` },
|
||||
{ base: SUBTITLE_BASES[1], path: `/tv/${id}/${season}/${episode}` },
|
||||
{ base: SUBTITLE_BASES[2], path: `/subtitles/series/${imdbId}:${season}:${episode}.json` }
|
||||
];
|
||||
}
|
||||
|
||||
export { SUBTITLE_BASES };
|
||||
@@ -1,230 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const UA_LIST = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
|
||||
];
|
||||
|
||||
export const getUA = () => UA_LIST[Math.floor(Math.random() * UA_LIST.length)];
|
||||
export const USER_AGENT = UA_LIST[0];
|
||||
|
||||
let sharedDcKey = null;
|
||||
|
||||
export function decryptPartner(responseText, keyEnv) {
|
||||
try {
|
||||
const sep = responseText.indexOf(':');
|
||||
if (sep === -1) return null;
|
||||
const iv = Buffer.from(responseText.slice(0, sep), 'base64');
|
||||
const encrypted = responseText.slice(sep + 1);
|
||||
if (!sharedDcKey) sharedDcKey = crypto.createHash('sha256').update(keyEnv || '').digest();
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', sharedDcKey, iv);
|
||||
return JSON.parse(decipher.update(encrypted, 'base64', 'utf8') + decipher.final('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchText(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const tmdbInfoCache = new Map();
|
||||
const anilistCache = new Map();
|
||||
const tmdbValidationCache = new Map();
|
||||
|
||||
function pruneHelperCache(cache) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) if (now - v.ts >= v.ttl) cache.delete(k);
|
||||
if (cache.size > 3000) {
|
||||
const overflow = cache.size - 3000;
|
||||
const it = cache.keys();
|
||||
for (let i = 0; i < overflow; i++) cache.delete(it.next().value);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
pruneHelperCache(tmdbValidationCache);
|
||||
pruneHelperCache(tmdbInfoCache);
|
||||
pruneHelperCache(anilistCache);
|
||||
}, 60000);
|
||||
|
||||
function cacheGet(cache, key) {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() - entry.ts >= entry.ttl) { cache.delete(key); return undefined; }
|
||||
return entry.val;
|
||||
}
|
||||
|
||||
function cacheSet(cache, key, val, ttl) { cache.set(key, { val, ts: Date.now(), ttl }); }
|
||||
|
||||
export async function createStreamArgs(source, sdk, id, s, e, clientIP = null) {
|
||||
const audio = /dub$/.test(source.key) ? 'dub' : 'sub';
|
||||
return {
|
||||
id: id,
|
||||
s: s,
|
||||
e: e,
|
||||
clientIP: clientIP,
|
||||
absoluteBase: '/',
|
||||
audio,
|
||||
sdk,
|
||||
config: source,
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateTmdbId(tmdbApiKey, tmdbId, mediaType = 'movie') {
|
||||
let apiKey = tmdbApiKey;
|
||||
let id = tmdbId;
|
||||
let type = mediaType;
|
||||
if (id === 'movie' || id === 'tv' || !id) {
|
||||
if (apiKey && apiKey !== 'movie' && apiKey !== 'tv') {
|
||||
type = id || 'movie';
|
||||
id = apiKey;
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
}
|
||||
}
|
||||
if (!apiKey) {
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
}
|
||||
const key = `${id}-${type}`;
|
||||
const cached = cacheGet(tmdbValidationCache, key);
|
||||
if (cached !== undefined) return cached;
|
||||
if (!apiKey) return { valid: false, error: 'TMDB API key not configured' };
|
||||
try {
|
||||
const res = await fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}`, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const isValid = !!(data?.id && (data?.title || data?.name));
|
||||
const result = { valid: isValid, error: isValid ? null : 'Invalid TMDB ID' };
|
||||
cacheSet(tmdbValidationCache, key, result, 300000);
|
||||
return result;
|
||||
}
|
||||
const result = { valid: false, error: `TMDB API error: ${res.status}` };
|
||||
cacheSet(tmdbValidationCache, key, result, 60000);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = { valid: false, error: `Failed to validate TMDB ID: ${error.message}` };
|
||||
cacheSet(tmdbValidationCache, key, result, 60000);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTmdbInfo(tmdbApiKey, tmdbId, mediaType, season) {
|
||||
let apiKey = tmdbApiKey;
|
||||
let id = tmdbId;
|
||||
let type = mediaType;
|
||||
let s = season;
|
||||
if (apiKey === 'movie' || apiKey === 'tv') {
|
||||
type = apiKey;
|
||||
id = tmdbId;
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
} else if (id === 'movie' || id === 'tv') {
|
||||
type = id;
|
||||
id = apiKey;
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
}
|
||||
if (!apiKey) {
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
}
|
||||
const key = `${id}-${type}-${s || ''}`;
|
||||
const cached = cacheGet(tmdbInfoCache, key);
|
||||
if (cached !== undefined) return cached;
|
||||
if (!apiKey) return { isAnime: false, titles: [], year: null, imdbId: null };
|
||||
try {
|
||||
const [mainRes, seasonRes] = await Promise.all([
|
||||
fetch(`https://api.themoviedb.org/3/${type}/${id}?api_key=${apiKey}&append_to_response=external_ids`, { signal: AbortSignal.timeout(5000) }),
|
||||
s && type === 'tv' ? fetch(`https://api.themoviedb.org/3/tv/${id}/season/${s}?api_key=${apiKey}`, { signal: AbortSignal.timeout(5000) }) : Promise.resolve(null),
|
||||
]);
|
||||
let mainData = null, seasonData = null;
|
||||
if (mainRes.ok) mainData = await mainRes.json(); else mainRes.body?.cancel();
|
||||
if (seasonRes) { if (seasonRes.ok) seasonData = await seasonRes.json(); else seasonRes.body?.cancel(); }
|
||||
const genres = mainData?.genres || [];
|
||||
const isAnime = genres.some(g => g.id === 16) && ((mainData?.origin_country || []).includes('JP') || mainData?.original_language === 'ja');
|
||||
const titles = [];
|
||||
if (seasonData?.name) titles.push(seasonData.name);
|
||||
const titleText = mainData?.title || mainData?.name || '';
|
||||
const ot = mainData?.original_title || mainData?.original_name || '';
|
||||
if (titleText) titles.push(titleText);
|
||||
if (ot && ot !== titleText) titles.push(ot);
|
||||
const dateStr = seasonData?.air_date || mainData?.release_date || mainData?.first_air_date || '';
|
||||
const result = { isAnime, titles: [...new Set(titles.filter(Boolean))], year: dateStr ? parseInt(dateStr.slice(0, 4), 10) : null, imdbId: mainData?.imdb_id || mainData?.external_ids?.imdb_id || null };
|
||||
cacheSet(tmdbInfoCache, key, result, 600000);
|
||||
return result;
|
||||
} catch { return { isAnime: false, titles: [], year: null, imdbId: null }; }
|
||||
}
|
||||
|
||||
export async function tmdbToAnilist(tmdbId, mediaType, season, titles = [], year = null) {
|
||||
const key = `${tmdbId}-${mediaType}-${season || ''}`;
|
||||
const cached = cacheGet(anilistCache, key);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const res = await fetch(`https://api.ani.zip/mappings?tmdb_id=${tmdbId}&type=${mediaType}&season=${season || 1}`, { signal: AbortSignal.timeout(6000) });
|
||||
if (res.ok) {
|
||||
const id = (await res.json())?.mappings?.[0]?.anilist_id;
|
||||
if (id) { cacheSet(anilistCache, key, id, 600000); return id; }
|
||||
} else res.body?.cancel();
|
||||
} catch { }
|
||||
if (!titles.length) return null;
|
||||
const query = `query ($s: String) { Page(page:1,perPage:10) { media(search:$s,type:ANIME) { id title { romaji english native } startDate { year } format } } }`;
|
||||
let bestId = null, bestScore = -1;
|
||||
for (const searchTitle of titles) {
|
||||
try {
|
||||
const res = await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query, variables: { s: searchTitle } }), signal: AbortSignal.timeout(6000) });
|
||||
if (!res.ok) { res.body?.cancel(); continue; }
|
||||
for (const entry of (await res.json())?.data?.Page?.media || []) {
|
||||
const entryTitles = [entry.title?.romaji, entry.title?.english, entry.title?.native].filter(Boolean).map(t => t.toLowerCase());
|
||||
const sl = searchTitle.toLowerCase();
|
||||
let score = 0;
|
||||
if (entryTitles.some(t => t === sl)) score += 5;
|
||||
else if (entryTitles.some(t => t.includes(sl) || sl.includes(t))) score += 3;
|
||||
else if (entryTitles.some(t => { const w = sl.split(/\s+/).filter(x => x.length > 3); return w.length > 0 && w.every(x => t.includes(x)); })) score += 2;
|
||||
else continue;
|
||||
if (year && entry.startDate?.year) {
|
||||
const diff = Math.abs(entry.startDate.year - year);
|
||||
if (diff === 0) score += 3; else if (diff === 1) score += 1; else if (diff > 2) score -= 3;
|
||||
}
|
||||
if (mediaType === 'tv' && ['TV', 'TV_SHORT', 'ONA', 'OVA'].includes(entry.format)) score += 1;
|
||||
if (mediaType === 'movie' && entry.format === 'MOVIE') score += 1;
|
||||
if (score > bestScore) { bestScore = score; bestId = entry.id; }
|
||||
}
|
||||
if (bestScore >= 8) break;
|
||||
} catch { }
|
||||
}
|
||||
if (bestId) cacheSet(anilistCache, key, bestId, 600000);
|
||||
return bestId;
|
||||
}
|
||||
|
||||
export async function imdbToTmdb(tmdbApiKey, imdbId, mediaType = 'movie') {
|
||||
let apiKey = tmdbApiKey;
|
||||
let id = imdbId;
|
||||
let type = mediaType;
|
||||
if (id === 'movie' || id === 'tv') {
|
||||
type = id;
|
||||
id = apiKey;
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
}
|
||||
if (!apiKey) {
|
||||
apiKey = process.env.TMDB_API_KEY;
|
||||
}
|
||||
const key = `imdb-${id}`;
|
||||
const cached = cacheGet(tmdbInfoCache, key);
|
||||
if (cached !== undefined) return cached;
|
||||
if (!apiKey) return null;
|
||||
try {
|
||||
const data = await fetchJson(`https://api.themoviedb.org/3/find/${id}?api_key=${apiKey}&external_source=imdb_id`, { signal: AbortSignal.timeout(5000) });
|
||||
let result = null;
|
||||
if (type === 'movie' && data.movie_results?.length) result = data.movie_results[0].id;
|
||||
else if (type === 'tv' && data.tv_results?.length) result = data.tv_results[0].id;
|
||||
if (result) cacheSet(tmdbInfoCache, key, result, 600000);
|
||||
return result;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const webcrypto = globalThis.crypto;
|
||||
export function createHash() { throw new Error('unsupported'); }
|
||||
export function createDecipheriv() { throw new Error('unsupported'); }
|
||||
export default { webcrypto, createHash, createDecipheriv };
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Buffer } from 'buffer';
|
||||
globalThis.Buffer = globalThis.Buffer || Buffer;
|
||||
globalThis.process = globalThis.process || { env: {} };
|
||||
if (!AbortSignal.timeout) {
|
||||
AbortSignal.timeout = ms => { const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal; };
|
||||
}
|
||||
export { Buffer };
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,4 +5,9 @@ android/app/build/
|
||||
android/.gradle/
|
||||
.gradle/
|
||||
*.apk
|
||||
.env
|
||||
.env
|
||||
.build
|
||||
www/
|
||||
android/app/src/main/assets/
|
||||
release/
|
||||
android/app/src/main/res/raw/*.crt
|
||||
@@ -1,7 +1,12 @@
|
||||
To apply changes to the app, run `npm run dev` to build the APK and `npm run android` to sync the changes to the android project.
|
||||
To build the native Android TV app APK, run:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
This builds the Vyla JS resolver asset and compiles the native Android TV app via `./gradlew assembleDebug` to produce `vyla.apk`.
|
||||
|
||||
To release a new version, run
|
||||
To open and develop in Android Studio, open the `android` folder directly.
|
||||
|
||||
To release a new version:
|
||||
```bash
|
||||
npm version patch
|
||||
git push origin main
|
||||
|
||||
20
README.md
20
README.md
@@ -1,9 +1,19 @@
|
||||
# Vyla TV
|
||||
|
||||
Vyla TV is an Android TV application built with HTML, CSS, JavaScript, and Capacitor.
|
||||
An Android TV streaming app built with Jetpack Compose and ExoPlayer.
|
||||
|
||||
## Prerequisites
|
||||
## Features
|
||||
|
||||
- **Node.js**: v18+ or v20+
|
||||
- **JDK**: Java Development Kit 17
|
||||
- **Android SDK**: Installed via Android Studio with `ANDROID_HOME` configured in your environment variables.
|
||||
- Browse movies and TV shows from TMDB
|
||||
- Stream content with multiple source providers
|
||||
- D-pad navigation optimized for TV
|
||||
- Playback speed control, quality selection, and subtitles
|
||||
- Watch Later and Favorites libraries
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This builds the APK as `vyla.apk`.
|
||||
|
||||
5
android/.gitignore
vendored
5
android/.gitignore
vendored
@@ -89,13 +89,8 @@ lint/tmp/
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
android {
|
||||
namespace "tv.vyla.app"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "tv.vyla.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionName "1.0.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
@@ -23,35 +22,58 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.10'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
implementation "androidx.core:core-ktx:1.12.0"
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.activity:activity-compose:1.8.2"
|
||||
|
||||
implementation platform("androidx.compose:compose-bom:2024.02.02")
|
||||
implementation "androidx.compose.ui:ui"
|
||||
implementation "androidx.compose.ui:ui-graphics"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview"
|
||||
implementation "androidx.compose.material3:material3"
|
||||
implementation "androidx.compose.material:material-icons-extended"
|
||||
|
||||
implementation "androidx.tv:tv-material:1.0.0"
|
||||
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.7.0"
|
||||
|
||||
implementation "io.coil-kt:coil-compose:2.6.0"
|
||||
|
||||
implementation "androidx.media3:media3-exoplayer:1.3.1"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:1.3.1"
|
||||
implementation "androidx.media3:media3-ui:1.3.1"
|
||||
implementation "androidx.media3:media3-common:1.3.1"
|
||||
implementation "androidx.media3:media3-session:1.3.1"
|
||||
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
|
||||
implementation "org.nanohttpd:nanohttpd:2.3.1"
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.5"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".VylaApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
@@ -21,7 +22,7 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
@@ -34,19 +35,5 @@
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
package tv.vyla.app;
|
||||
|
||||
import fi.iki.elonen.NanoHTTPD;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class LocalProxy extends NanoHTTPD {
|
||||
private static final Pattern STRIP = Pattern.compile("tiktokcdn\\.com|ibyteimg\\.com|seg\\.html|enproxy|letsgocdn\\d+\\.shop", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern SEGSTRIP = Pattern.compile("seg\\.html|enproxy|tiktokcdn|ibyteimg", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static class Code implements Response.IStatus {
|
||||
private final int code;
|
||||
|
||||
Code(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRequestStatus() {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return code + " Status";
|
||||
}
|
||||
}
|
||||
|
||||
public LocalProxy(int port) {
|
||||
super("127.0.0.1", port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response serve(IHTTPSession session) {
|
||||
Response r;
|
||||
if (session.getMethod() == Method.OPTIONS) r = newFixedLengthResponse(new Code(204), "text/plain", "");
|
||||
else if (!"/api/proxy".equals(session.getUri())) r = text(404, "Not found");
|
||||
else r = proxy(session);
|
||||
r.addHeader("Access-Control-Allow-Origin", "*");
|
||||
r.addHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||
r.addHeader("Access-Control-Allow-Headers", "*");
|
||||
r.addHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges");
|
||||
return r;
|
||||
}
|
||||
|
||||
private Response text(int code, String msg) {
|
||||
return newFixedLengthResponse(new Code(code), "text/plain; charset=utf-8", msg == null ? "" : msg);
|
||||
}
|
||||
|
||||
private static boolean nap(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static String origin(String u) {
|
||||
HttpUrl h = HttpUrl.parse(u);
|
||||
if (h == null) return "";
|
||||
return h.scheme() + "://" + h.host() + (h.port() == HttpUrl.defaultPort(h.scheme()) ? "" : ":" + h.port());
|
||||
}
|
||||
|
||||
private static String abs(String u, String base, String origin) {
|
||||
return u.startsWith("http") ? u : u.startsWith("/") ? origin + u : base + u;
|
||||
}
|
||||
|
||||
private Response proxy(IHTTPSession session) {
|
||||
Map<String, String> p = session.getParms();
|
||||
final String target = p.get("url");
|
||||
if (target == null || target.isEmpty()) return text(400, "Missing url parameter");
|
||||
|
||||
JSONObject ch = new JSONObject();
|
||||
try {
|
||||
if (p.get("headers") != null) ch = new JSONObject(p.get("headers"));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
ch.remove("Host");
|
||||
ch.remove("host");
|
||||
|
||||
final boolean strip = "1".equals(p.get("strip")) || "1".equals(p.get("tt")) || STRIP.matcher(target).find();
|
||||
|
||||
Request.Builder rb = new Request.Builder();
|
||||
try {
|
||||
rb.url(target);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return text(400, "Bad url");
|
||||
}
|
||||
rb.header("User-Agent", VylaPlugin.UA).header("Accept", "*/*").header("Accept-Language", "en-US,en;q=0.9");
|
||||
Iterator<String> it = ch.keys();
|
||||
while (it.hasNext()) {
|
||||
String k = it.next();
|
||||
if (k.equalsIgnoreCase("accept-encoding")) continue;
|
||||
try {
|
||||
rb.header(k, ch.optString(k));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
String range = session.getHeaders().get("range");
|
||||
if (range != null) rb.header("Range", range);
|
||||
if (session.getMethod() == Method.HEAD) rb.head();
|
||||
Request req = rb.build();
|
||||
|
||||
okhttp3.Response up = null;
|
||||
IOException last = null;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (up != null) {
|
||||
up.close();
|
||||
up = null;
|
||||
}
|
||||
try {
|
||||
up = VylaPlugin.CLIENT.newCall(req).execute();
|
||||
int c = up.code();
|
||||
if (up.isSuccessful() || c == 404) break;
|
||||
if (c == 403) {
|
||||
if (i == 2) break;
|
||||
if (!nap(1000)) break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
last = e;
|
||||
if (i == 2 || !nap(500L * (i + 1))) break;
|
||||
}
|
||||
}
|
||||
if (up == null) return text(502, last != null ? last.getMessage() : "Bad Gateway");
|
||||
|
||||
final okhttp3.Response upf = up;
|
||||
int code = up.code();
|
||||
if (!up.isSuccessful()) {
|
||||
up.close();
|
||||
return text(code, "Upstream error: " + code);
|
||||
}
|
||||
|
||||
try {
|
||||
String ctRaw = up.header("Content-Type", "");
|
||||
String ct = ctRaw.toLowerCase();
|
||||
boolean m3u8 = target.contains(".m3u8") || ct.contains("mpegurl") || ct.contains("m3u8");
|
||||
ResponseBody body = up.body();
|
||||
|
||||
if (target.contains(".srt") || ct.contains("subrip")) {
|
||||
String raw = body == null ? "" : body.string();
|
||||
String vtt = "WEBVTT\n\n" + raw.replaceAll("\r\n|\r", "\n").replaceAll("(\\d{2}:\\d{2}:\\d{2}),(\\d{3})", "$1.$2");
|
||||
up.close();
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/vtt; charset=utf-8", vtt);
|
||||
}
|
||||
|
||||
if (m3u8) {
|
||||
String txt = body == null ? "" : body.string();
|
||||
up.close();
|
||||
String base = target.substring(0, target.lastIndexOf('/') + 1);
|
||||
String origin = origin(target);
|
||||
String hp = ch.length() > 0 ? "&headers=" + enc(ch.toString()) : "";
|
||||
String[] lines = txt.split("\n", -1);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String raw = lines[i];
|
||||
String t = raw.trim();
|
||||
String out = raw;
|
||||
if (t.startsWith("#EXT-X-KEY") || t.startsWith("#EXT-X-MAP") || t.startsWith("#EXT-X-MEDIA")) {
|
||||
int u = t.indexOf("URI=\"");
|
||||
if (u != -1) {
|
||||
int end = t.indexOf('"', u + 5);
|
||||
if (end != -1) {
|
||||
String uri = t.substring(u + 5, end);
|
||||
out = t.substring(0, u + 5) + "/api/proxy?url=" + enc(abs(uri, base, origin)) + hp + "\"" + t.substring(end + 1);
|
||||
}
|
||||
}
|
||||
} else if (!t.isEmpty() && !t.startsWith("#")) {
|
||||
String a = abs(t, base, origin);
|
||||
String sp = strip || SEGSTRIP.matcher(a).find() ? "&strip=1" : "";
|
||||
out = "/api/proxy?url=" + enc(a) + hp + sp;
|
||||
}
|
||||
sb.append(out);
|
||||
if (i < lines.length - 1) sb.append('\n');
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/vnd.apple.mpegurl", sb.toString());
|
||||
}
|
||||
|
||||
if (strip) {
|
||||
byte[] b = body == null ? new byte[0] : body.bytes();
|
||||
up.close();
|
||||
if (b.length > 120 && (b[0] == (byte) 0x89 || b[0] == (byte) 0xFF || b[0] == 0x00)) b = Arrays.copyOfRange(b, 120, b.length);
|
||||
return newFixedLengthResponse(Response.Status.OK, "video/MP2T", new ByteArrayInputStream(b), b.length);
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
up.close();
|
||||
return text(code, "");
|
||||
}
|
||||
long n = body.contentLength();
|
||||
String mime = ctRaw.isEmpty() ? "application/octet-stream" : ctRaw;
|
||||
InputStream in = new FilterInputStream(body.byteStream()) {
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
upf.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
Response r = n >= 0 ? newFixedLengthResponse(new Code(code), mime, in, n) : newChunkedResponse(new Code(code), mime, in);
|
||||
String cr = up.header("Content-Range");
|
||||
String ar = up.header("Accept-Ranges");
|
||||
if (cr != null) r.addHeader("Content-Range", cr);
|
||||
if (ar != null) r.addHeader("Accept-Ranges", ar);
|
||||
return r;
|
||||
} catch (IOException e) {
|
||||
up.close();
|
||||
return text(502, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package tv.vyla.app;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.WindowManager;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(VylaPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
getBridge().getWebView().getSettings().setMediaPlaybackRequiresUserGesture(false);
|
||||
getBridge().getWebView().requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
||||
if (e.getKeyCode() == KeyEvent.KEYCODE_BACK) {
|
||||
if (e.getAction() == KeyEvent.ACTION_UP) {
|
||||
getBridge().getWebView().evaluateJavascript("window.vylaBack?window.vylaBack():false", v -> {
|
||||
if (!"true".equals(v)) finish();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(e);
|
||||
}
|
||||
}
|
||||
187
android/app/src/main/java/tv/vyla/app/MainActivity.kt
Normal file
187
android/app/src/main/java/tv/vyla/app/MainActivity.kt
Normal file
@@ -0,0 +1,187 @@
|
||||
package tv.vyla.app
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import tv.vyla.app.data.LibraryManager
|
||||
import tv.vyla.app.model.Episode
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.components.NavDestination
|
||||
import tv.vyla.app.ui.components.Sidebar
|
||||
import tv.vyla.app.ui.screens.*
|
||||
import tv.vyla.app.ui.theme.VylaTvTheme
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
insetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
val libraryManager = LibraryManager.getInstance(this)
|
||||
|
||||
setContent {
|
||||
VylaTvTheme {
|
||||
var currentDestination by remember { mutableStateOf(NavDestination.DISCOVERY) }
|
||||
var activeDetailItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var playingItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var playingEpisode by remember { mutableStateOf<Episode?>(null) }
|
||||
|
||||
val watchLaterItems by libraryManager.watchLater.collectAsState()
|
||||
val favoriteItems by libraryManager.favorites.collectAsState()
|
||||
val historyItems by libraryManager.history.collectAsState()
|
||||
|
||||
val sidebarFocusRequester = remember { FocusRequester() }
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
|
||||
BackHandler(enabled = playingItem == null && activeDetailItem == null) {
|
||||
if (currentDestination != NavDestination.DISCOVERY && currentDestination != NavDestination.ANIME) {
|
||||
currentDestination = NavDestination.DISCOVERY
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color(0xFF07070A))) {
|
||||
if (playingItem == null) {
|
||||
Sidebar(
|
||||
currentDestination = currentDestination,
|
||||
hasHistory = historyItems.isNotEmpty(),
|
||||
hasWatchLater = watchLaterItems.isNotEmpty(),
|
||||
hasFavorites = favoriteItems.isNotEmpty(),
|
||||
onNavigate = { dest ->
|
||||
currentDestination = dest
|
||||
activeDetailItem = null
|
||||
contentFocusRequester.requestFocus()
|
||||
},
|
||||
onRequestExitSidebar = {
|
||||
try {
|
||||
contentFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(sidebarFocusRequester)
|
||||
.zIndex(5f)
|
||||
)
|
||||
}
|
||||
|
||||
val contentCovered = playingItem != null || activeDetailItem != null
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(contentFocusRequester)
|
||||
.drawWithContent { if (!contentCovered) drawContent() }
|
||||
) {
|
||||
when (currentDestination) {
|
||||
NavDestination.DISCOVERY,
|
||||
NavDestination.MOVIES,
|
||||
NavDestination.TV_SHOWS,
|
||||
NavDestination.ANIME -> {
|
||||
BrowseScreen(
|
||||
mode = currentDestination,
|
||||
onOpenDetail = { item -> activeDetailItem = item },
|
||||
onPlay = { item ->
|
||||
libraryManager.addHistory(item)
|
||||
playingItem = item
|
||||
playingEpisode = null
|
||||
},
|
||||
onRequestSidebar = {
|
||||
try {
|
||||
sidebarFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
NavDestination.SEARCH -> {
|
||||
SearchScreen(
|
||||
onOpenDetail = { item -> activeDetailItem = item },
|
||||
onRequestSidebar = {
|
||||
try {
|
||||
sidebarFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
NavDestination.HISTORY -> {
|
||||
LibraryScreen(
|
||||
title = "Watch History",
|
||||
items = historyItems,
|
||||
onOpenDetail = { item -> activeDetailItem = item },
|
||||
emptyMessage = "Nothing watched yet. Press play on a title and it shows up here."
|
||||
)
|
||||
}
|
||||
NavDestination.WATCH_LATER -> {
|
||||
LibraryScreen(
|
||||
title = "Watch Later",
|
||||
items = watchLaterItems,
|
||||
onOpenDetail = { item -> activeDetailItem = item }
|
||||
)
|
||||
}
|
||||
NavDestination.FAVORITES -> {
|
||||
LibraryScreen(
|
||||
title = "Favorites",
|
||||
items = favoriteItems,
|
||||
onOpenDetail = { item -> activeDetailItem = item }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val detailItem = activeDetailItem
|
||||
if (detailItem != null && playingItem == null) {
|
||||
val isWl = watchLaterItems.any { it.id == detailItem.id && it.mediaType == detailItem.mediaType }
|
||||
val isFav = favoriteItems.any { it.id == detailItem.id && it.mediaType == detailItem.mediaType }
|
||||
|
||||
DetailScreen(
|
||||
item = detailItem,
|
||||
isWatchLater = isWl,
|
||||
isFavorite = isFav,
|
||||
onToggleWatchLater = { libraryManager.toggle("wl", it) },
|
||||
onToggleFavorite = { libraryManager.toggle("fav", it) },
|
||||
onPlay = { itm, ep ->
|
||||
libraryManager.addHistory(itm)
|
||||
playingItem = itm
|
||||
playingEpisode = ep
|
||||
},
|
||||
onOpenRelated = { related -> activeDetailItem = related },
|
||||
onBack = { activeDetailItem = null },
|
||||
modifier = Modifier.zIndex(15f)
|
||||
)
|
||||
}
|
||||
|
||||
val playerItem = playingItem
|
||||
if (playerItem != null) {
|
||||
PlayerScreen(
|
||||
item = playerItem,
|
||||
initialEpisode = playingEpisode,
|
||||
onBack = {
|
||||
playingItem = null
|
||||
playingEpisode = null
|
||||
},
|
||||
modifier = Modifier.zIndex(20f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
android/app/src/main/java/tv/vyla/app/VylaApp.kt
Normal file
20
android/app/src/main/java/tv/vyla/app/VylaApp.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package tv.vyla.app
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import tv.vyla.app.api.TmdbApi
|
||||
import tv.vyla.app.proxy.LocalProxy
|
||||
import tv.vyla.app.source.VylaResolver
|
||||
|
||||
class VylaApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
LocalProxy.startServer()
|
||||
VylaResolver.getInstance(this)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
TmdbApi.initGenres()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package tv.vyla.app;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Iterator;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.Dispatcher;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
@CapacitorPlugin(name = "VylaNative")
|
||||
public class VylaPlugin extends Plugin {
|
||||
static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
static final OkHttpClient CLIENT = buildClient();
|
||||
private LocalProxy proxy;
|
||||
|
||||
private static OkHttpClient buildClient() {
|
||||
Dispatcher d = new Dispatcher();
|
||||
d.setMaxRequests(128);
|
||||
d.setMaxRequestsPerHost(32);
|
||||
return new OkHttpClient.Builder()
|
||||
.dispatcher(d)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
try {
|
||||
proxy = new LocalProxy(38417);
|
||||
proxy.start(5000, true);
|
||||
} catch (IOException e) {
|
||||
proxy = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnDestroy() {
|
||||
if (proxy != null) proxy.stop();
|
||||
}
|
||||
|
||||
private static byte[] read(Response r, int max) throws IOException {
|
||||
if (r.body() == null) return new byte[0];
|
||||
InputStream in = r.body().byteStream();
|
||||
ByteArrayOutputStream bo = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
if (max > 0 && bo.size() + n >= max) {
|
||||
bo.write(buf, 0, Math.min(n, max - bo.size()));
|
||||
break;
|
||||
}
|
||||
bo.write(buf, 0, n);
|
||||
}
|
||||
return bo.toByteArray();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void fetch(final PluginCall call) {
|
||||
final String url = call.getString("url");
|
||||
if (url == null) {
|
||||
call.reject("Missing url");
|
||||
return;
|
||||
}
|
||||
final int max = call.getInt("maxBytes", 0);
|
||||
final int timeout = call.getInt("timeout", 30000);
|
||||
final boolean follow = !"manual".equals(call.getString("redirect", "follow"));
|
||||
String method = call.getString("method", "GET").toUpperCase();
|
||||
JSObject hd = call.getObject("headers", new JSObject());
|
||||
Request.Builder rb;
|
||||
try {
|
||||
rb = new Request.Builder().url(url);
|
||||
String ctype = null;
|
||||
Iterator<String> it = hd.keys();
|
||||
while (it.hasNext()) {
|
||||
String k = it.next();
|
||||
if (k.equalsIgnoreCase("accept-encoding")) continue;
|
||||
String v = hd.optString(k, null);
|
||||
if (v == null) continue;
|
||||
rb.header(k, v);
|
||||
if (k.equalsIgnoreCase("content-type")) ctype = v;
|
||||
}
|
||||
if (!hd.has("user-agent")) rb.header("User-Agent", UA);
|
||||
RequestBody rbody = null;
|
||||
if (!method.equals("GET") && !method.equals("HEAD")) {
|
||||
String body = call.getString("body");
|
||||
rbody = RequestBody.create(body == null ? "" : body, MediaType.parse(ctype == null ? "text/plain; charset=utf-8" : ctype));
|
||||
}
|
||||
rb.method(method, rbody);
|
||||
} catch (Exception e) {
|
||||
call.reject(String.valueOf(e.getMessage()));
|
||||
return;
|
||||
}
|
||||
OkHttpClient c = CLIENT.newBuilder()
|
||||
.callTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||
.followRedirects(follow)
|
||||
.followSslRedirects(follow)
|
||||
.build();
|
||||
c.newCall(rb.build()).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call c, IOException e) {
|
||||
call.reject(String.valueOf(e.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call c, Response r) {
|
||||
try {
|
||||
JSObject out = new JSObject();
|
||||
out.put("status", r.code());
|
||||
out.put("url", r.request().url().toString());
|
||||
JSObject h = new JSObject();
|
||||
for (String n : r.headers().names()) h.put(n.toLowerCase(), TextUtils.join(", ", r.headers(n)));
|
||||
out.put("headers", h);
|
||||
out.put("body", Base64.encodeToString(read(r, max), Base64.NO_WRAP));
|
||||
call.resolve(out);
|
||||
} catch (Exception e) {
|
||||
call.reject(String.valueOf(e.getMessage()));
|
||||
} finally {
|
||||
r.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
84
android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt
Normal file
84
android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt
Normal file
@@ -0,0 +1,84 @@
|
||||
package tv.vyla.app.api
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class SkipSegment(
|
||||
val type: String,
|
||||
val start: Double,
|
||||
val end: Double?,
|
||||
val label: String
|
||||
)
|
||||
|
||||
object IntroDbV3 {
|
||||
private const val BASE_URL = "https://api.theintrodb.org/v3/media"
|
||||
|
||||
suspend fun getSegments(
|
||||
tmdbId: Number,
|
||||
season: Int?,
|
||||
episode: Int?,
|
||||
durationMs: Long? = null
|
||||
): List<SkipSegment> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = StringBuilder("$BASE_URL?tmdb_id=$tmdbId")
|
||||
if (season != null && episode != null) {
|
||||
url.append("&season=$season&episode=$episode")
|
||||
}
|
||||
if (durationMs != null && durationMs > 0L) {
|
||||
url.append("&duration_ms=$durationMs")
|
||||
}
|
||||
val conn = URL(url.toString()).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
try {
|
||||
if (conn.responseCode != 200) {
|
||||
emptyList()
|
||||
} else {
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
parse(JSONObject(body))
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(json: JSONObject): List<SkipSegment> {
|
||||
val result = ArrayList<SkipSegment>()
|
||||
val kinds = listOf(
|
||||
Triple("intro", "Skip Intro", true),
|
||||
Triple("recap", "Skip Recap", true),
|
||||
Triple("credits", "Skip Credits", false),
|
||||
Triple("preview", "Skip Preview", false)
|
||||
)
|
||||
for ((key, label, startOptional) in kinds) {
|
||||
val array = json.optJSONArray(key) ?: continue
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val startMs = if (obj.isNull("start_ms")) null else obj.optLong("start_ms")
|
||||
val endMs = if (obj.isNull("end_ms")) null else obj.optLong("end_ms")
|
||||
if (startOptional && endMs == null) continue
|
||||
val resolvedStart = startMs ?: (if (startOptional) 0L else null) ?: continue
|
||||
if (endMs != null && endMs <= resolvedStart) continue
|
||||
result.add(
|
||||
SkipSegment(
|
||||
type = key,
|
||||
start = resolvedStart / 1000.0,
|
||||
end = endMs?.let { it / 1000.0 },
|
||||
label = label
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return result.sortedBy { it.start }
|
||||
}
|
||||
}
|
||||
335
android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt
Normal file
335
android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt
Normal file
@@ -0,0 +1,335 @@
|
||||
package tv.vyla.app.api
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.*
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object TmdbApi {
|
||||
private const val API_KEY = "3f6703f3e2a0b2b40fe4fbb3e1546244"
|
||||
private const val BASE_URL = "https://api.themoviedb.org/3"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val genreMap = ConcurrentHashMap<Int, String>()
|
||||
private val logoCache = ConcurrentHashMap<String, String>()
|
||||
|
||||
suspend fun initGenres() = withContext(Dispatchers.IO) {
|
||||
if (genreMap.isNotEmpty()) return@withContext
|
||||
val m = getGenreList("movie")
|
||||
val t = getGenreList("tv")
|
||||
(m + t).forEach { genreMap[it.id] = it.name }
|
||||
}
|
||||
|
||||
fun getGenreName(id: Int): String? = genreMap[id]
|
||||
|
||||
suspend fun getGenreList(mediaType: String): List<Genre> = withContext(Dispatchers.IO) {
|
||||
val json = fetchJson("/genre/$mediaType/list") ?: return@withContext emptyList()
|
||||
val genres = json.optJSONArray("genres") ?: return@withContext emptyList()
|
||||
val list = mutableListOf<Genre>()
|
||||
for (i in 0 until genres.length()) {
|
||||
val obj = genres.getJSONObject(i)
|
||||
val g = Genre(obj.getInt("id"), obj.getString("name"))
|
||||
list.add(g)
|
||||
genreMap[g.id] = g.name
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
suspend fun getDiscover(mediaType: String, genreId: Int, page: Int): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val path = "/discover/$mediaType?with_genres=$genreId&sort_by=popularity.desc&vote_count.gte=100&include_adult=false&page=$page"
|
||||
val json = fetchJson(path) ?: return@withContext emptyList()
|
||||
parseMediaList(json.optJSONArray("results"), mediaType)
|
||||
}
|
||||
|
||||
suspend fun getAnimeDiscover(genreId: Int, page: Int): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val path = "/discover/tv?with_genres=$genreId&with_original_language=ja&sort_by=popularity.desc&vote_count.gte=50&include_adult=false&page=$page"
|
||||
val json = fetchJson(path) ?: return@withContext emptyList()
|
||||
parseMediaList(json.optJSONArray("results"), "tv")
|
||||
}
|
||||
|
||||
suspend fun getTrending(mediaType: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList()
|
||||
parseMediaList(json.optJSONArray("results"), mediaType)
|
||||
}
|
||||
|
||||
suspend fun searchMulti(query: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val enc = URLEncoder.encode(query, "UTF-8")
|
||||
val json = fetchJson("/search/multi?query=$enc&include_adult=false") ?: return@withContext emptyList()
|
||||
val results = json.optJSONArray("results") ?: return@withContext emptyList()
|
||||
val list = mutableListOf<MediaItem>()
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val mt = obj.optString("media_type")
|
||||
if (mt == "movie" || mt == "tv") {
|
||||
val item = parseMediaItem(obj, mt)
|
||||
if (!item.posterPath.isNullOrEmpty()) {
|
||||
list.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
suspend fun getLogo(mediaType: String, id: Int): String? = withContext(Dispatchers.IO) {
|
||||
val cacheKey = "$mediaType$id"
|
||||
logoCache[cacheKey]?.let { return@withContext it }
|
||||
|
||||
val json = fetchJson("/$mediaType/$id/images?include_image_language=en,null")
|
||||
val logos = json?.optJSONArray("logos") ?: return@withContext null
|
||||
var foundPath: String? = null
|
||||
for (i in 0 until logos.length()) {
|
||||
val l = logos.getJSONObject(i)
|
||||
if (l.optString("iso_639_1") == "en") {
|
||||
foundPath = l.optString("file_path")
|
||||
break
|
||||
}
|
||||
}
|
||||
if (foundPath == null && logos.length() > 0) {
|
||||
foundPath = logos.getJSONObject(0).optString("file_path")
|
||||
}
|
||||
if (!foundPath.isNullOrEmpty()) {
|
||||
val fullUrl = "https://image.tmdb.org/t/p/w500$foundPath"
|
||||
logoCache[cacheKey] = fullUrl
|
||||
fullUrl
|
||||
} else null
|
||||
}
|
||||
|
||||
suspend fun getDetail(mediaType: String, id: Int): MediaDetail? = withContext(Dispatchers.IO) {
|
||||
val extra = if (mediaType == "movie") "release_dates" else "content_ratings"
|
||||
val path = "/$mediaType/$id?append_to_response=videos,credits,recommendations,images,$extra&include_image_language=en,null"
|
||||
val json = fetchJson(path) ?: return@withContext null
|
||||
|
||||
val baseItem = parseMediaItem(json, mediaType)
|
||||
val runtime = json.optInt("runtime", 0)
|
||||
val numberOfSeasons = json.optInt("number_of_seasons", 0)
|
||||
|
||||
val genresArr = json.optJSONArray("genres")
|
||||
val genreList = mutableListOf<Genre>()
|
||||
if (genresArr != null) {
|
||||
for (i in 0 until genresArr.length()) {
|
||||
val g = genresArr.getJSONObject(i)
|
||||
genreList.add(Genre(g.getInt("id"), g.getString("name")))
|
||||
}
|
||||
}
|
||||
|
||||
var cert: String? = null
|
||||
if (mediaType == "movie") {
|
||||
val rdResults = json.optJSONObject("release_dates")?.optJSONArray("results")
|
||||
if (rdResults != null) {
|
||||
for (i in 0 until rdResults.length()) {
|
||||
val r = rdResults.getJSONObject(i)
|
||||
if (r.optString("iso_3166_1") == "US") {
|
||||
val dates = r.optJSONArray("release_dates")
|
||||
if (dates != null) {
|
||||
for (j in 0 until dates.length()) {
|
||||
val c = dates.getJSONObject(j).optString("certification")
|
||||
if (c.isNotEmpty()) {
|
||||
cert = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val crResults = json.optJSONObject("content_ratings")?.optJSONArray("results")
|
||||
if (crResults != null) {
|
||||
for (i in 0 until crResults.length()) {
|
||||
val r = crResults.getJSONObject(i)
|
||||
if (r.optString("iso_3166_1") == "US") {
|
||||
cert = r.optString("rating")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var logoUrl: String? = null
|
||||
val logosArr = json.optJSONObject("images")?.optJSONArray("logos")
|
||||
if (logosArr != null && logosArr.length() > 0) {
|
||||
for (i in 0 until logosArr.length()) {
|
||||
val l = logosArr.getJSONObject(i)
|
||||
if (l.optString("iso_639_1") == "en") {
|
||||
logoUrl = "https://image.tmdb.org/t/p/w500" + l.optString("file_path")
|
||||
break
|
||||
}
|
||||
}
|
||||
if (logoUrl == null) {
|
||||
logoUrl = "https://image.tmdb.org/t/p/w500" + logosArr.getJSONObject(0).optString("file_path")
|
||||
}
|
||||
}
|
||||
|
||||
val videosArr = json.optJSONObject("videos")?.optJSONArray("results")
|
||||
val trailers = mutableListOf<VideoClip>()
|
||||
if (videosArr != null) {
|
||||
for (i in 0 until videosArr.length()) {
|
||||
val v = videosArr.getJSONObject(i)
|
||||
if (v.optString("site") == "YouTube") {
|
||||
val type = v.optString("type")
|
||||
if (type.contains("Trailer") || type.contains("Teaser") || type.contains("Clip")) {
|
||||
trailers.add(
|
||||
VideoClip(
|
||||
id = v.optString("id"),
|
||||
key = v.optString("key"),
|
||||
name = v.optString("name"),
|
||||
site = "YouTube",
|
||||
type = type
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val castArr = json.optJSONObject("credits")?.optJSONArray("cast")
|
||||
val cast = mutableListOf<CastMember>()
|
||||
if (castArr != null) {
|
||||
val maxCast = minOf(castArr.length(), 20)
|
||||
for (i in 0 until maxCast) {
|
||||
val c = castArr.getJSONObject(i)
|
||||
cast.add(
|
||||
CastMember(
|
||||
id = c.getInt("id"),
|
||||
name = c.getString("name"),
|
||||
character = c.optString("character", null),
|
||||
profilePath = c.optString("profile_path", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val recsArr = json.optJSONObject("recommendations")?.optJSONArray("results")
|
||||
val recommendations = parseMediaList(recsArr, mediaType)
|
||||
|
||||
val seasons = mutableListOf<Season>()
|
||||
if (mediaType == "tv") {
|
||||
val seasonsArr = json.optJSONArray("seasons")
|
||||
if (seasonsArr != null) {
|
||||
for (i in 0 until seasonsArr.length()) {
|
||||
val s = seasonsArr.getJSONObject(i)
|
||||
val sNum = s.getInt("season_number")
|
||||
val epCount = s.optInt("episode_count", 0)
|
||||
if (sNum > 0 && epCount > 0) {
|
||||
seasons.add(
|
||||
Season(
|
||||
id = s.getInt("id"),
|
||||
seasonNumber = sNum,
|
||||
name = s.optString("name", "Season $sNum"),
|
||||
episodeCount = epCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaDetail(
|
||||
item = baseItem,
|
||||
runtime = if (runtime > 0) runtime else null,
|
||||
numberOfSeasons = if (numberOfSeasons > 0) numberOfSeasons else null,
|
||||
genres = genreList,
|
||||
certification = cert,
|
||||
logoUrl = logoUrl,
|
||||
trailers = trailers.take(8),
|
||||
cast = cast,
|
||||
recommendations = recommendations,
|
||||
seasons = seasons
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getSeasonEpisodes(tvId: Int, seasonNumber: Int): List<Episode> = withContext(Dispatchers.IO) {
|
||||
val json = fetchJson("/tv/$tvId/season/$seasonNumber") ?: return@withContext emptyList()
|
||||
val eps = json.optJSONArray("episodes") ?: return@withContext emptyList()
|
||||
val list = mutableListOf<Episode>()
|
||||
for (i in 0 until eps.length()) {
|
||||
val ep = eps.getJSONObject(i)
|
||||
list.add(
|
||||
Episode(
|
||||
id = ep.getInt("id"),
|
||||
episodeNumber = ep.getInt("episode_number"),
|
||||
seasonNumber = seasonNumber,
|
||||
name = ep.optString("name", "Episode ${ep.getInt("episode_number")}"),
|
||||
overview = ep.optString("overview", null),
|
||||
stillPath = ep.optString("still_path", null),
|
||||
runtime = ep.optInt("runtime", 0).let { if (it > 0) it else null }
|
||||
)
|
||||
)
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
private fun parseMediaItem(obj: JSONObject, mediaType: String): MediaItem {
|
||||
val genreIds = mutableListOf<Int>()
|
||||
val arr = obj.optJSONArray("genre_ids")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
genreIds.add(arr.getInt(i))
|
||||
}
|
||||
}
|
||||
return MediaItem(
|
||||
id = obj.getInt("id"),
|
||||
mediaType = mediaType,
|
||||
title = obj.optString("title", null),
|
||||
name = obj.optString("name", null),
|
||||
overview = obj.optString("overview", null),
|
||||
posterPath = obj.optString("poster_path", null),
|
||||
backdropPath = obj.optString("backdrop_path", null),
|
||||
releaseDate = obj.optString("release_date", null),
|
||||
firstAirDate = obj.optString("first_air_date", null),
|
||||
voteAverage = obj.optDouble("vote_average", 0.0),
|
||||
genreIds = genreIds
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMediaList(arr: JSONArray?, mediaType: String): List<MediaItem> {
|
||||
if (arr == null) return emptyList()
|
||||
val list = mutableListOf<MediaItem>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
val poster = obj.optString("poster_path", null)
|
||||
if (!poster.isNullOrEmpty()) {
|
||||
list.add(parseMediaItem(obj, mediaType))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private const val CACHE_TTL_MS = 10 * 60 * 1000L
|
||||
private val responseCache = android.util.LruCache<String, Pair<Long, JSONObject>>(80)
|
||||
|
||||
private suspend fun fetchJson(path: String): JSONObject? = withContext(Dispatchers.IO) {
|
||||
val now = System.currentTimeMillis()
|
||||
responseCache.get(path)?.let { (at, cached) ->
|
||||
if (now - at < CACHE_TTL_MS) return@withContext cached
|
||||
}
|
||||
val separator = if (path.contains("?")) "&" else "?"
|
||||
val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US"
|
||||
val req = Request.Builder().url(url).build()
|
||||
try {
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body?.string()
|
||||
if (!body.isNullOrEmpty()) {
|
||||
JSONObject(body).also { responseCache.put(path, now to it) }
|
||||
} else null
|
||||
} else null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
142
android/app/src/main/java/tv/vyla/app/data/LibraryManager.kt
Normal file
142
android/app/src/main/java/tv/vyla/app/data/LibraryManager.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
package tv.vyla.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.MediaItem
|
||||
|
||||
class LibraryManager private constructor(context: Context) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: LibraryManager? = null
|
||||
|
||||
fun getInstance(context: Context): LibraryManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: LibraryManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("vyla_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _watchLater = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
val watchLater: StateFlow<List<MediaItem>> = _watchLater.asStateFlow()
|
||||
|
||||
private val _favorites = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
val favorites: StateFlow<List<MediaItem>> = _favorites.asStateFlow()
|
||||
|
||||
private val _history = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
val history: StateFlow<List<MediaItem>> = _history.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun addHistory(item: MediaItem) {
|
||||
val current = _history.value.toMutableList()
|
||||
current.removeAll { it.id == item.id && it.mediaType == item.mediaType }
|
||||
current.add(0, item)
|
||||
_history.value = current.take(100)
|
||||
save()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val raw = prefs.getString("vyla.lib", null)
|
||||
if (raw.isNullOrEmpty()) return
|
||||
try {
|
||||
val root = JSONObject(raw)
|
||||
_watchLater.value = parseList(root.optJSONArray("wl"))
|
||||
_favorites.value = parseList(root.optJSONArray("fav"))
|
||||
_history.value = parseList(root.optJSONArray("hist"))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
try {
|
||||
val root = JSONObject()
|
||||
root.put("wl", serializeList(_watchLater.value))
|
||||
root.put("fav", serializeList(_favorites.value))
|
||||
root.put("hist", serializeList(_history.value))
|
||||
prefs.edit().putString("vyla.lib", root.toString()).apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun has(type: String, item: MediaItem): Boolean {
|
||||
val list = if (type == "wl") _watchLater.value else _favorites.value
|
||||
return list.any { it.id == item.id && it.mediaType == item.mediaType }
|
||||
}
|
||||
|
||||
fun toggle(type: String, item: MediaItem): Boolean {
|
||||
val isWl = type == "wl"
|
||||
val current = (if (isWl) _watchLater.value else _favorites.value).toMutableList()
|
||||
val index = current.indexOfFirst { it.id == item.id && it.mediaType == item.mediaType }
|
||||
val added: Boolean
|
||||
if (index >= 0) {
|
||||
current.removeAt(index)
|
||||
added = false
|
||||
} else {
|
||||
current.add(0, item)
|
||||
added = true
|
||||
}
|
||||
|
||||
if (isWl) {
|
||||
_watchLater.value = current
|
||||
} else {
|
||||
_favorites.value = current
|
||||
}
|
||||
save()
|
||||
return added
|
||||
}
|
||||
|
||||
private fun parseList(arr: JSONArray?): List<MediaItem> {
|
||||
if (arr == null) return emptyList()
|
||||
val list = mutableListOf<MediaItem>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
val mt = obj.optString("_t").ifEmpty { obj.optString("media_type", "movie") }
|
||||
list.add(
|
||||
MediaItem(
|
||||
id = obj.getInt("id"),
|
||||
mediaType = mt,
|
||||
title = obj.optString("title", null),
|
||||
name = obj.optString("name", null),
|
||||
overview = obj.optString("overview", null),
|
||||
posterPath = obj.optString("poster_path", null),
|
||||
backdropPath = obj.optString("backdrop_path", null),
|
||||
releaseDate = obj.optString("release_date", null),
|
||||
firstAirDate = obj.optString("first_air_date", null),
|
||||
voteAverage = obj.optDouble("vote_average", 0.0)
|
||||
)
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private fun serializeList(items: List<MediaItem>): JSONArray {
|
||||
val arr = JSONArray()
|
||||
for (item in items) {
|
||||
val obj = JSONObject()
|
||||
obj.put("id", item.id)
|
||||
obj.put("_t", item.mediaType)
|
||||
obj.put("title", item.title)
|
||||
obj.put("name", item.name)
|
||||
obj.put("overview", item.overview)
|
||||
obj.put("poster_path", item.posterPath)
|
||||
obj.put("backdrop_path", item.backdropPath)
|
||||
obj.put("release_date", item.releaseDate)
|
||||
obj.put("first_air_date", item.firstAirDate)
|
||||
obj.put("vote_average", item.voteAverage)
|
||||
arr.put(obj)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/java/tv/vyla/app/model/Models.kt
Normal file
152
android/app/src/main/java/tv/vyla/app/model/Models.kt
Normal file
@@ -0,0 +1,152 @@
|
||||
package tv.vyla.app.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class MediaItem(
|
||||
val id: Int,
|
||||
val mediaType: String,
|
||||
val title: String? = null,
|
||||
val name: String? = null,
|
||||
val overview: String? = null,
|
||||
val posterPath: String? = null,
|
||||
val backdropPath: String? = null,
|
||||
val releaseDate: String? = null,
|
||||
val firstAirDate: String? = null,
|
||||
val voteAverage: Double? = null,
|
||||
val genreIds: List<Int> = emptyList()
|
||||
) : Parcelable {
|
||||
val displayTitle: String
|
||||
get() = title ?: name ?: "Untitled"
|
||||
|
||||
val year: String
|
||||
get() {
|
||||
val d = releaseDate ?: firstAirDate ?: ""
|
||||
return if (d.length >= 4) d.substring(0, 4) else ""
|
||||
}
|
||||
|
||||
val formattedRating: String
|
||||
get() = String.format("%.1f", voteAverage ?: 0.0)
|
||||
|
||||
val posterUrl: String?
|
||||
get() = posterPath?.let { "https://image.tmdb.org/t/p/w342$it" }
|
||||
|
||||
val backdropUrl: String?
|
||||
get() = (backdropPath ?: posterPath)?.let { "https://image.tmdb.org/t/p/w1280$it" }
|
||||
|
||||
val ambientBackdropUrl: String?
|
||||
get() = (backdropPath ?: posterPath)?.let { "https://image.tmdb.org/t/p/w780$it" }
|
||||
}
|
||||
|
||||
data class Genre(
|
||||
val id: Int,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class MediaDetail(
|
||||
val item: MediaItem,
|
||||
val runtime: Int? = null,
|
||||
val numberOfSeasons: Int? = null,
|
||||
val genres: List<Genre> = emptyList(),
|
||||
val certification: String? = null,
|
||||
val logoUrl: String? = null,
|
||||
val trailers: List<VideoClip> = emptyList(),
|
||||
val cast: List<CastMember> = emptyList(),
|
||||
val recommendations: List<MediaItem> = emptyList(),
|
||||
val seasons: List<Season> = emptyList()
|
||||
) {
|
||||
val runtimeOrSeasons: String
|
||||
get() = when {
|
||||
item.mediaType == "tv" -> {
|
||||
val s = numberOfSeasons ?: 1
|
||||
"$s Season${if (s > 1) "s" else ""}"
|
||||
}
|
||||
runtime != null && runtime > 0 -> {
|
||||
"${runtime / 60}h ${runtime % 60}m"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Episode(
|
||||
val id: Int,
|
||||
val episodeNumber: Int,
|
||||
val seasonNumber: Int,
|
||||
val name: String? = null,
|
||||
val overview: String? = null,
|
||||
val stillPath: String? = null,
|
||||
val runtime: Int? = null
|
||||
) : Parcelable {
|
||||
val displayTitle: String
|
||||
get() = name ?: "Episode $episodeNumber"
|
||||
|
||||
val stillUrl: String?
|
||||
get() = stillPath?.let { "https://image.tmdb.org/t/p/w300$it" }
|
||||
}
|
||||
|
||||
data class Season(
|
||||
val id: Int,
|
||||
val seasonNumber: Int,
|
||||
val name: String? = null,
|
||||
val episodeCount: Int = 0,
|
||||
val episodes: List<Episode> = emptyList()
|
||||
) {
|
||||
val displayName: String
|
||||
get() = name ?: "Season $seasonNumber"
|
||||
}
|
||||
|
||||
data class CastMember(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val character: String? = null,
|
||||
val profilePath: String? = null
|
||||
) {
|
||||
val profileUrl: String?
|
||||
get() = profilePath?.let { "https://image.tmdb.org/t/p/w185$it" }
|
||||
}
|
||||
|
||||
data class VideoClip(
|
||||
val id: String,
|
||||
val key: String,
|
||||
val name: String,
|
||||
val site: String,
|
||||
val type: String
|
||||
) {
|
||||
val thumbnailUrl: String
|
||||
get() = "https://img.youtube.com/vi/$key/mqdefault.jpg"
|
||||
}
|
||||
|
||||
data class MediaSegment(
|
||||
val type: String,
|
||||
val label: String,
|
||||
val start: Double,
|
||||
val end: Double?
|
||||
)
|
||||
|
||||
data class VylaSource(
|
||||
val key: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
data class StreamUrl(
|
||||
val url: String,
|
||||
val quality: String = "Auto",
|
||||
val type: String = "hls",
|
||||
val server: String? = null
|
||||
)
|
||||
|
||||
data class ResolvedStream(
|
||||
val provider: String,
|
||||
val label: String,
|
||||
val headers: Map<String, String>,
|
||||
val primaryUrl: String,
|
||||
val allUrls: List<StreamUrl>
|
||||
)
|
||||
|
||||
data class SubtitleTrack(
|
||||
val file: String,
|
||||
val label: String,
|
||||
val source: String? = "mirror"
|
||||
)
|
||||
305
android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt
Normal file
305
android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt
Normal file
@@ -0,0 +1,305 @@
|
||||
package tv.vyla.app.proxy
|
||||
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.regex.Pattern
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) {
|
||||
|
||||
companion object {
|
||||
const val PORT = 38417
|
||||
const val UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
|
||||
private val STRIP = Pattern.compile("tiktokcdn\\.com|ibyteimg\\.com|seg\\.html|enproxy|letsgocdn\\d+\\.shop", Pattern.CASE_INSENSITIVE)
|
||||
private val SEGSTRIP = Pattern.compile("seg\\.html|enproxy|tiktokcdn|ibyteimg", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
val CLIENT: OkHttpClient = OkHttpClient.Builder()
|
||||
.dispatcher(Dispatcher().apply {
|
||||
maxRequests = 128
|
||||
maxRequestsPerHost = 32
|
||||
})
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
private var instance: LocalProxy? = null
|
||||
|
||||
@Synchronized
|
||||
fun startServer() {
|
||||
if (instance == null) {
|
||||
try {
|
||||
val server = LocalProxy(PORT)
|
||||
server.start(5000, true)
|
||||
instance = server
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopServer() {
|
||||
instance?.stop()
|
||||
instance = null
|
||||
}
|
||||
|
||||
fun buildProxyUrl(rawUrl: String, headers: Map<String, String>? = null, strip: Boolean = false): String {
|
||||
val encodedUrl = URLEncoder.encode(rawUrl, "UTF-8")
|
||||
val sb = java.lang.StringBuilder("http://127.0.0.1:$PORT/api/proxy?url=$encodedUrl")
|
||||
if (!headers.isNullOrEmpty()) {
|
||||
val json = JSONObject(headers).toString()
|
||||
sb.append("&headers=").append(URLEncoder.encode(json, "UTF-8"))
|
||||
}
|
||||
if (strip) {
|
||||
sb.append("&strip=1")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private class Code(private val code: Int) : Response.IStatus {
|
||||
override fun getRequestStatus(): Int = code
|
||||
override fun getDescription(): String = "$code Status"
|
||||
}
|
||||
|
||||
private class PoolRunner : AsyncRunner {
|
||||
private val pool = Executors.newCachedThreadPool()
|
||||
private val running = Collections.synchronizedList(ArrayList<ClientHandler>())
|
||||
|
||||
override fun closeAll() {
|
||||
synchronized(running) { ArrayList(running).forEach { it.close() } }
|
||||
}
|
||||
|
||||
override fun closed(clientHandler: ClientHandler) {
|
||||
running.remove(clientHandler)
|
||||
}
|
||||
|
||||
override fun exec(clientHandler: ClientHandler) {
|
||||
running.add(clientHandler)
|
||||
pool.execute(clientHandler)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setAsyncRunner(PoolRunner())
|
||||
}
|
||||
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
val r: Response = when {
|
||||
session.method == Method.OPTIONS -> newFixedLengthResponse(Code(204), "text/plain", "")
|
||||
session.uri != "/api/proxy" -> text(404, "Not found")
|
||||
else -> proxy(session)
|
||||
}
|
||||
r.addHeader("Access-Control-Allow-Origin", "*")
|
||||
r.addHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
r.addHeader("Access-Control-Allow-Headers", "*")
|
||||
r.addHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
return r
|
||||
}
|
||||
|
||||
private fun text(code: Int, msg: String?): Response {
|
||||
return newFixedLengthResponse(Code(code), "text/plain; charset=utf-8", msg ?: "")
|
||||
}
|
||||
|
||||
private fun enc(s: String): String {
|
||||
return try {
|
||||
URLEncoder.encode(s, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
private fun origin(u: String): String {
|
||||
val h = u.toHttpUrlOrNull() ?: return ""
|
||||
val portStr = if (h.port == okhttp3.HttpUrl.defaultPort(h.scheme)) "" else ":${h.port}"
|
||||
return "${h.scheme}://${h.host}$portStr"
|
||||
}
|
||||
|
||||
private fun abs(u: String, base: String, origin: String): String {
|
||||
return when {
|
||||
u.startsWith("http") -> u
|
||||
u.startsWith("/") -> origin + u
|
||||
else -> base + u
|
||||
}
|
||||
}
|
||||
|
||||
private fun nap(ms: Long): Boolean {
|
||||
return try {
|
||||
Thread.sleep(ms)
|
||||
true
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun proxy(session: IHTTPSession): Response {
|
||||
val params = session.parms
|
||||
val target = params["url"] ?: return text(400, "Missing url parameter")
|
||||
if (target.isEmpty()) return text(400, "Missing url parameter")
|
||||
|
||||
var ch = JSONObject()
|
||||
try {
|
||||
val hd = params["headers"]
|
||||
if (!hd.isNullOrEmpty()) ch = JSONObject(hd)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
ch.remove("Host")
|
||||
ch.remove("host")
|
||||
|
||||
val strip = "1" == params["strip"] || "1" == params["tt"] || STRIP.matcher(target).find()
|
||||
|
||||
val rb = Request.Builder()
|
||||
try {
|
||||
rb.url(target)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return text(400, "Bad url")
|
||||
}
|
||||
|
||||
rb.header("User-Agent", UA)
|
||||
.header("Accept", "*/*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
val it = ch.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
if (k.equals("accept-encoding", ignoreCase = true)) continue
|
||||
try {
|
||||
rb.header(k, ch.optString(k))
|
||||
} catch (_: IllegalArgumentException) {}
|
||||
}
|
||||
|
||||
val range = session.headers["range"]
|
||||
if (range != null) rb.header("Range", range)
|
||||
if (session.method == Method.HEAD) rb.head()
|
||||
|
||||
val req = rb.build()
|
||||
var up: okhttp3.Response? = null
|
||||
var last: IOException? = null
|
||||
|
||||
for (i in 0 until 3) {
|
||||
up?.close()
|
||||
up = null
|
||||
try {
|
||||
val call = CLIENT.newCall(req)
|
||||
val resp = call.execute()
|
||||
val c = resp.code
|
||||
up = resp
|
||||
if (resp.isSuccessful || c == 404) break
|
||||
if (c == 403) {
|
||||
if (i == 2 || !nap(1000)) break
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
last = e
|
||||
if (i == 2 || !nap(500L * (i + 1))) break
|
||||
}
|
||||
}
|
||||
|
||||
if (up == null) return text(502, last?.message ?: "Bad Gateway")
|
||||
|
||||
val upf = up
|
||||
val code = upf.code
|
||||
if (!upf.isSuccessful && code != 206) {
|
||||
upf.close()
|
||||
return text(code, "Upstream error: $code")
|
||||
}
|
||||
|
||||
return try {
|
||||
val ctRaw = upf.header("Content-Type", "") ?: ""
|
||||
val ct = ctRaw.lowercase()
|
||||
val m3u8 = target.contains(".m3u8") || ct.contains("mpegurl") || ct.contains("m3u8")
|
||||
val body = upf.body
|
||||
|
||||
if (target.contains(".srt") || ct.contains("subrip")) {
|
||||
val raw = body?.string() ?: ""
|
||||
val vtt = "WEBVTT\n\n" + raw.replace("\r\n".toRegex(), "\n")
|
||||
.replace("\r".toRegex(), "\n")
|
||||
.replace("(\\d{2}:\\d{2}:\\d{2}),(\\d{3})".toRegex(), "$1.$2")
|
||||
upf.close()
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/vtt; charset=utf-8", vtt)
|
||||
}
|
||||
|
||||
if (m3u8) {
|
||||
val txt = body?.string() ?: ""
|
||||
upf.close()
|
||||
val base = target.substring(0, target.lastIndexOf('/') + 1)
|
||||
val org = origin(target)
|
||||
val hp = if (ch.length() > 0) "&headers=" + enc(ch.toString()) else ""
|
||||
val lines = txt.split("\n".toRegex()).dropLastWhile { false }.toTypedArray()
|
||||
val sb = java.lang.StringBuilder()
|
||||
|
||||
for (i in lines.indices) {
|
||||
val raw = lines[i]
|
||||
val t = raw.trim()
|
||||
var out = raw
|
||||
if (t.startsWith("#EXT-X-KEY") || t.startsWith("#EXT-X-MAP") || t.startsWith("#EXT-X-MEDIA")) {
|
||||
val u = t.indexOf("URI=\"")
|
||||
if (u != -1) {
|
||||
val end = t.indexOf('"', u + 5)
|
||||
if (end != -1) {
|
||||
val uri = t.substring(u + 5, end)
|
||||
out = t.substring(0, u + 5) + "/api/proxy?url=" + enc(abs(uri, base, org)) + hp + "\"" + t.substring(end + 1)
|
||||
}
|
||||
}
|
||||
} else if (t.isNotEmpty() && !t.startsWith("#")) {
|
||||
val a = abs(t, base, org)
|
||||
val sp = if (strip || SEGSTRIP.matcher(a).find()) "&strip=1" else ""
|
||||
out = "/api/proxy?url=" + enc(a) + hp + sp
|
||||
}
|
||||
sb.append(out)
|
||||
if (i < lines.size - 1) sb.append('\n')
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/vnd.apple.mpegurl", sb.toString())
|
||||
}
|
||||
|
||||
if (strip) {
|
||||
val b = body?.bytes() ?: ByteArray(0)
|
||||
upf.close()
|
||||
val finalBytes = if (b.size > 120 && (b[0] == 0x89.toByte() || b[0] == 0xFF.toByte() || b[0] == 0x00.toByte())) {
|
||||
b.copyOfRange(120, b.size)
|
||||
} else b
|
||||
return newFixedLengthResponse(Response.Status.OK, "video/MP2T", ByteArrayInputStream(finalBytes), finalBytes.size.toLong())
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
upf.close()
|
||||
return text(code, "")
|
||||
}
|
||||
|
||||
val n = body.contentLength()
|
||||
val mime = if (ctRaw.isEmpty()) "application/octet-stream" else ctRaw
|
||||
val inputStream: InputStream = object : FilterInputStream(body.byteStream()) {
|
||||
override fun close() {
|
||||
try {
|
||||
super.close()
|
||||
} finally {
|
||||
upf.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val resp = if (n >= 0) newFixedLengthResponse(Code(code), mime, inputStream, n) else newChunkedResponse(Code(code), mime, inputStream)
|
||||
val cr = upf.header("Content-Range")
|
||||
val ar = upf.header("Accept-Ranges")
|
||||
if (cr != null) resp.addHeader("Content-Range", cr)
|
||||
if (ar != null) resp.addHeader("Accept-Ranges", ar)
|
||||
resp
|
||||
} catch (e: IOException) {
|
||||
upf.close()
|
||||
text(502, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
505
android/app/src/main/java/tv/vyla/app/source/VylaResolver.kt
Normal file
505
android/app/src/main/java/tv/vyla/app/source/VylaResolver.kt
Normal file
@@ -0,0 +1,505 @@
|
||||
package tv.vyla.app.source
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.ResolvedStream
|
||||
import tv.vyla.app.model.StreamUrl
|
||||
import tv.vyla.app.model.SubtitleTrack
|
||||
import tv.vyla.app.model.VylaSource
|
||||
import tv.vyla.app.proxy.LocalProxy
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class VylaResolver private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TMDB_KEY = "3f6703f3e2a0b2b40fe4fbb3e1546244"
|
||||
|
||||
@Volatile
|
||||
private var instance: VylaResolver? = null
|
||||
|
||||
fun getInstance(context: Context): VylaResolver {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: VylaResolver(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var webView: WebView? = null
|
||||
private val isReadyDeferred = CompletableDeferred<List<VylaSource>>()
|
||||
|
||||
private val pendingStreams = ConcurrentHashMap<String, CompletableDeferred<ResolvedStream?>>()
|
||||
private val pendingSubtitles = ConcurrentHashMap<String, CompletableDeferred<List<SubtitleTrack>>>()
|
||||
private val callCounter = AtomicInteger(0)
|
||||
|
||||
private var cachedSources: List<VylaSource> = emptyList()
|
||||
|
||||
init {
|
||||
mainHandler.post { initWebView() }
|
||||
}
|
||||
|
||||
private fun initWebView() {
|
||||
val wv = WebView(context)
|
||||
wv.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
}
|
||||
|
||||
wv.addJavascriptInterface(Bridge(), "AndroidBridge")
|
||||
|
||||
val bootstrapHtml = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script>
|
||||
window.pendingFetches = {};
|
||||
|
||||
window.fetch = function(url, init) {
|
||||
init = init || {};
|
||||
const reqId = 'f_' + Math.random().toString(36).slice(2) + Date.now();
|
||||
const isReq = typeof url !== 'string';
|
||||
const finalUrl = (isReq ? url.url : url) || '';
|
||||
const method = (init.method || (isReq ? url.method : 'GET') || 'GET').toUpperCase();
|
||||
const headers = {};
|
||||
if (init.headers) {
|
||||
if (typeof init.headers.forEach === 'function') {
|
||||
init.headers.forEach((v, k) => headers[k] = v);
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers.forEach(([k, v]) => headers[k] = v);
|
||||
} else {
|
||||
Object.assign(headers, init.headers);
|
||||
}
|
||||
}
|
||||
const body = init.body != null ? String(init.body) : '';
|
||||
const timeout = init.timeout || 30000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
window.pendingFetches[reqId] = { resolve, reject };
|
||||
AndroidBridge.nativeFetch(reqId, finalUrl, method, JSON.stringify(headers), body, timeout);
|
||||
});
|
||||
};
|
||||
|
||||
window.__onFetchSuccess = function(reqId, status, headersJson, base64Body) {
|
||||
const p = window.pendingFetches[reqId];
|
||||
if (!p) return;
|
||||
delete window.pendingFetches[reqId];
|
||||
|
||||
const h = JSON.parse(headersJson || '{}');
|
||||
const binary = atob(base64Body || '');
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const text = new TextDecoder('utf-8').decode(bytes);
|
||||
|
||||
p.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status: status,
|
||||
statusText: '',
|
||||
headers: {
|
||||
get: k => h[String(k).toLowerCase()] || null,
|
||||
has: k => String(k).toLowerCase() in h
|
||||
},
|
||||
text: async () => text,
|
||||
json: async () => JSON.parse(text),
|
||||
arrayBuffer: async () => bytes.buffer
|
||||
});
|
||||
};
|
||||
|
||||
window.__onFetchError = function(reqId, err) {
|
||||
const p = window.pendingFetches[reqId];
|
||||
if (!p) return;
|
||||
delete window.pendingFetches[reqId];
|
||||
p.reject(new Error(err || 'Fetch failed'));
|
||||
};
|
||||
|
||||
window.__initVyla = function(apiKey) {
|
||||
if (window.VylaLocal) {
|
||||
window.VylaLocal.init(apiKey);
|
||||
const sources = window.VylaLocal.sources().map(s => ({ key: s.key, label: s.label }));
|
||||
AndroidBridge.onInitialized(JSON.stringify(sources));
|
||||
}
|
||||
};
|
||||
|
||||
window.__stream = async function(callId, key, id, s, e) {
|
||||
try {
|
||||
const stream = await window.VylaLocal.stream(key, String(id), s || undefined, e || undefined);
|
||||
AndroidBridge.onStreamSuccess(callId, key, JSON.stringify(stream || null));
|
||||
} catch (err) {
|
||||
AndroidBridge.onStreamError(callId, String(err && err.message ? err.message : err));
|
||||
}
|
||||
};
|
||||
|
||||
window.__subtitles = async function(callId, id, s, e) {
|
||||
try {
|
||||
const subs = await window.VylaLocal.subtitles(String(id), s || undefined, e || undefined);
|
||||
AndroidBridge.onSubtitlesSuccess(callId, JSON.stringify(subs || []));
|
||||
} catch (err) {
|
||||
AndroidBridge.onSubtitlesSuccess(callId, '[]');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="file:///android_asset/vyla-local.js"></script>
|
||||
</head>
|
||||
<body onload="__initVyla('$TMDB_KEY')">
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
|
||||
wv.loadDataWithBaseURL("https://localhost", bootstrapHtml, "text/html", "UTF-8", null)
|
||||
webView = wv
|
||||
}
|
||||
|
||||
private inner class Bridge {
|
||||
@JavascriptInterface
|
||||
fun nativeFetch(reqId: String, url: String, method: String, headersJson: String, body: String, timeoutMs: Int) {
|
||||
scope.launch {
|
||||
try {
|
||||
val rb = Request.Builder().url(url)
|
||||
var ctype: String? = null
|
||||
val headersObj = JSONObject(headersJson)
|
||||
val it = headersObj.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
if (k.equals("accept-encoding", ignoreCase = true)) continue
|
||||
val v = headersObj.optString(k, null) ?: continue
|
||||
rb.header(k, v)
|
||||
if (k.equals("content-type", ignoreCase = true)) ctype = v
|
||||
}
|
||||
if (!headersObj.has("user-agent") && !headersObj.has("User-Agent")) {
|
||||
rb.header("User-Agent", LocalProxy.UA)
|
||||
}
|
||||
|
||||
if (method != "GET" && method != "HEAD") {
|
||||
val mediaType = (ctype ?: "text/plain; charset=utf-8").toMediaTypeOrNull()
|
||||
rb.method(method, body.toRequestBody(mediaType))
|
||||
} else {
|
||||
rb.method(method, null)
|
||||
}
|
||||
|
||||
val customClient = LocalProxy.CLIENT.newBuilder()
|
||||
.callTimeout(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
customClient.newCall(rb.build()).execute().use { resp ->
|
||||
val code = resp.code
|
||||
val bytes = resp.body?.bytes() ?: ByteArray(0)
|
||||
val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
val outHeaders = JSONObject()
|
||||
for (name in resp.headers.names()) {
|
||||
outHeaders.put(name.lowercase(), resp.headers(name).joinToString(", "))
|
||||
}
|
||||
|
||||
val safeHeaders = JSONObject.quote(outHeaders.toString())
|
||||
runOnJs("window.__onFetchSuccess('$reqId', $code, $safeHeaders, '$b64')")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errMsg = JSONObject.quote(e.message ?: "Network error")
|
||||
runOnJs("window.__onFetchError('$reqId', $errMsg)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onInitialized(sourcesJson: String) {
|
||||
val list = mutableListOf<VylaSource>()
|
||||
try {
|
||||
val arr = JSONArray(sourcesJson)
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
list.add(VylaSource(obj.getString("key"), obj.getString("label")))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
cachedSources = list
|
||||
isReadyDeferred.complete(list)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onStreamSuccess(callId: String, providerKey: String, streamJson: String) {
|
||||
val deferred = pendingStreams.remove(callId) ?: return
|
||||
try {
|
||||
if (streamJson.isEmpty() || streamJson == "null") {
|
||||
deferred.complete(null)
|
||||
return
|
||||
}
|
||||
val obj = JSONObject(streamJson)
|
||||
val resolved = parseStreamObject(obj, providerKey)
|
||||
deferred.complete(resolved)
|
||||
} catch (e: Exception) {
|
||||
deferred.complete(null)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onStreamError(callId: String, error: String) {
|
||||
val deferred = pendingStreams.remove(callId)
|
||||
deferred?.complete(null)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onSubtitlesSuccess(callId: String, subsJson: String) {
|
||||
val deferred = pendingSubtitles.remove(callId) ?: return
|
||||
try {
|
||||
val arr = JSONArray(subsJson)
|
||||
val list = mutableListOf<SubtitleTrack>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
list.add(
|
||||
SubtitleTrack(
|
||||
file = obj.getString("file"),
|
||||
label = obj.getString("label"),
|
||||
source = obj.optString("source", "mirror")
|
||||
)
|
||||
)
|
||||
}
|
||||
deferred.complete(list)
|
||||
} catch (e: Exception) {
|
||||
deferred.complete(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStreamObject(obj: JSONObject, providerKey: String = "Vyla"): ResolvedStream? {
|
||||
val streamData = if (obj.has("stream")) obj.getJSONObject("stream") else obj
|
||||
val headers = mutableMapOf<String, String>()
|
||||
val hdObj = streamData.optJSONObject("headers")
|
||||
if (hdObj != null) {
|
||||
val it = hdObj.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
headers[k] = hdObj.getString(k)
|
||||
}
|
||||
}
|
||||
|
||||
val allUrls = mutableListOf<StreamUrl>()
|
||||
val arr = streamData.optJSONArray("allUrls")
|
||||
if (arr != null && arr.length() > 0) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val uObj = arr.getJSONObject(i)
|
||||
val u = uObj.optString("url")
|
||||
if (u.isNotEmpty()) {
|
||||
allUrls.add(
|
||||
StreamUrl(
|
||||
url = u,
|
||||
quality = uObj.optString("quality", "Auto"),
|
||||
type = uObj.optString("type", if (u.contains(".m3u8")) "hls" else "mp4"),
|
||||
server = uObj.optString("server", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val u = streamData.optString("url")
|
||||
if (u.isNotEmpty()) {
|
||||
allUrls.add(
|
||||
StreamUrl(
|
||||
url = u,
|
||||
quality = streamData.optString("quality", "Auto"),
|
||||
type = if (u.contains(".m3u8")) "hls" else "mp4"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (allUrls.isEmpty()) return null
|
||||
|
||||
val rawProvider = if (obj.has("provider") && obj.getString("provider").isNotEmpty() && obj.getString("provider") != "Vyla") {
|
||||
obj.getString("provider")
|
||||
} else {
|
||||
providerKey
|
||||
}
|
||||
val label = cachedSources.find { it.key.equals(rawProvider, ignoreCase = true) }?.label ?: rawProvider
|
||||
|
||||
return ResolvedStream(
|
||||
provider = rawProvider,
|
||||
label = label,
|
||||
headers = headers,
|
||||
primaryUrl = allUrls[0].url,
|
||||
allUrls = allUrls
|
||||
)
|
||||
}
|
||||
|
||||
private fun runOnJs(js: String) {
|
||||
mainHandler.post {
|
||||
webView?.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSources(): List<VylaSource> {
|
||||
return if (cachedSources.isNotEmpty()) cachedSources else isReadyDeferred.await()
|
||||
}
|
||||
|
||||
suspend fun resolveStream(
|
||||
provider: String,
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null
|
||||
): ResolvedStream? = withContext(Dispatchers.IO) {
|
||||
getSources()
|
||||
val callId = "s_${callCounter.incrementAndGet()}"
|
||||
val deferred = CompletableDeferred<ResolvedStream?>()
|
||||
pendingStreams[callId] = deferred
|
||||
|
||||
val sArg = season?.toString() ?: "null"
|
||||
val eArg = episode?.toString() ?: "null"
|
||||
|
||||
runOnJs("window.__stream('$callId', '$provider', $tmdbId, $sArg, $eArg)")
|
||||
|
||||
try {
|
||||
withTimeout(30000) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingStreams.remove(callId)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSubtitles(
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null
|
||||
): List<SubtitleTrack> = withContext(Dispatchers.IO) {
|
||||
getSources()
|
||||
val callId = "sub_${callCounter.incrementAndGet()}"
|
||||
val deferred = CompletableDeferred<List<SubtitleTrack>>()
|
||||
pendingSubtitles[callId] = deferred
|
||||
|
||||
val sArg = season?.toString() ?: "null"
|
||||
val eArg = episode?.toString() ?: "null"
|
||||
|
||||
runOnJs("window.__subtitles('$callId', $tmdbId, $sArg, $eArg)")
|
||||
|
||||
try {
|
||||
withTimeout(15000) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingSubtitles.remove(callId)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isStreamPlayable(url: String, headers: Map<String, String> = emptyMap(), depth: Int = 0): Boolean = withContext(Dispatchers.IO) {
|
||||
if (depth > 4) return@withContext false
|
||||
try {
|
||||
val rb = Request.Builder().url(url)
|
||||
.header("User-Agent", LocalProxy.UA)
|
||||
.header("Accept", "*/*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Range", "bytes=0-1024")
|
||||
|
||||
headers.forEach { (k, v) ->
|
||||
if (!k.equals("accept-encoding", ignoreCase = true)) {
|
||||
rb.header(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
val client = LocalProxy.CLIENT.newBuilder()
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
client.newCall(rb.build()).execute().use { resp ->
|
||||
val code = resp.code
|
||||
if (code != 200 && code != 206 && code != 304) return@withContext false
|
||||
|
||||
val ct = (resp.header("Content-Type") ?: "").lowercase()
|
||||
val isM3U8 = url.contains(".m3u8") || ct.contains("mpegurl") || ct.contains("m3u8")
|
||||
if (!isM3U8) return@withContext true
|
||||
|
||||
val text = resp.body?.string() ?: return@withContext false
|
||||
val lines = text.split("\n".toRegex()).map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
if (lines.isEmpty()) return@withContext false
|
||||
|
||||
val firstLine = lines[0]
|
||||
val origin = run {
|
||||
val h = url.toHttpUrlOrNull()
|
||||
if (h == null) return@run ""
|
||||
val p = if (h.port == okhttp3.HttpUrl.defaultPort(h.scheme)) "" else ":${h.port}"
|
||||
"${h.scheme}://${h.host}$p"
|
||||
}
|
||||
val base = url.substring(0, url.lastIndexOf('/') + 1)
|
||||
val target = when {
|
||||
firstLine.startsWith("http") -> firstLine
|
||||
firstLine.startsWith("/") -> origin + firstLine
|
||||
else -> base + firstLine
|
||||
}
|
||||
|
||||
if (target.contains(".m3u8")) {
|
||||
return@withContext isStreamPlayable(target, headers, depth + 1)
|
||||
}
|
||||
|
||||
val segRb = Request.Builder().url(target)
|
||||
.header("User-Agent", LocalProxy.UA)
|
||||
.header("Accept", "*/*")
|
||||
.header("Range", "bytes=0-1024")
|
||||
headers.forEach { (k, v) -> rb.header(k, v) }
|
||||
|
||||
client.newCall(segRb.build()).execute().use { segResp ->
|
||||
segResp.code in listOf(200, 206, 304)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun startConcurrentDiscovery(
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null,
|
||||
onFirstPlayable: (ResolvedStream) -> Unit,
|
||||
onStreamResolved: (ResolvedStream) -> Unit
|
||||
): Job {
|
||||
return scope.launch {
|
||||
val sources = getSources()
|
||||
var firstPlayableFound = false
|
||||
|
||||
sources.map { src ->
|
||||
launch {
|
||||
try {
|
||||
val stream = resolveStream(src.key, tmdbId, season, episode)
|
||||
if (stream != null && stream.allUrls.isNotEmpty()) {
|
||||
val playableUrls = stream.allUrls.filter { isStreamPlayable(it.url, stream.headers) }
|
||||
if (playableUrls.isNotEmpty()) {
|
||||
val verifiedStream = stream.copy(
|
||||
primaryUrl = playableUrls[0].url,
|
||||
allUrls = playableUrls
|
||||
)
|
||||
onStreamResolved(verifiedStream)
|
||||
synchronized(this@VylaResolver) {
|
||||
if (!firstPlayableFound) {
|
||||
firstPlayableFound = true
|
||||
onFirstPlayable(verifiedStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
|
||||
val HeroFadeStops: Array<Pair<Float, Float>> = arrayOf(
|
||||
0f to 1f,
|
||||
0.38f to 1f,
|
||||
0.50f to 0.92f,
|
||||
0.62f to 0.70f,
|
||||
0.74f to 0.42f,
|
||||
0.86f to 0.16f,
|
||||
1f to 0f
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AmbientBackdrop(url: String?, modifier: Modifier = Modifier, durationMs: Int = 600) {
|
||||
val context = LocalContext.current
|
||||
Crossfade(
|
||||
targetState = url,
|
||||
modifier = modifier,
|
||||
animationSpec = tween(durationMs),
|
||||
label = "ambient_backdrop"
|
||||
) { target ->
|
||||
if (!target.isNullOrEmpty()) {
|
||||
val request = remember(target) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(target)
|
||||
.size(48, 27)
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
}
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
filterQuality = FilterQuality.Medium,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.verticalFadeMask(vararg stops: Pair<Float, Float>): Modifier =
|
||||
this
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawWithCache {
|
||||
val brush = Brush.verticalGradient(
|
||||
*Array(stops.size) { stops[it].first to Color.Black.copy(alpha = stops[it].second) }
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = brush, blendMode = BlendMode.DstIn)
|
||||
}
|
||||
}
|
||||
103
android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt
Normal file
103
android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.theme.VylaCardBackground
|
||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
@Composable
|
||||
fun MediaCard(
|
||||
item: MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
width: Int = 140,
|
||||
onSelect: (MediaItem) -> Unit,
|
||||
onFocused: ((MediaItem) -> Unit)? = null
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.07f else 1.0f,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "card_scale"
|
||||
)
|
||||
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(width.dp)
|
||||
.aspectRatio(2f / 3f)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.clip(shape)
|
||||
.background(VylaCardBackground)
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, Color.White, shape)
|
||||
} else Modifier
|
||||
)
|
||||
.onFocusChanged { state ->
|
||||
isFocused = state.isFocused
|
||||
if (state.isFocused) {
|
||||
onFocused?.invoke(item)
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onSelect(item)
|
||||
}
|
||||
) {
|
||||
val poster = item.posterUrl
|
||||
if (!poster.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = poster,
|
||||
contentDescription = item.displayTitle,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.displayTitle,
|
||||
color = VylaTextPrimary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object SearchIcons {
|
||||
val Space: ImageVector by lazy {
|
||||
build("Space", "M4 10v6h16v-6")
|
||||
}
|
||||
|
||||
val Search: ImageVector by lazy {
|
||||
build("Search", "M11 4a7 7 0 1 0 0 14a7 7 0 0 0 0-14z", "M16 16l5 5")
|
||||
}
|
||||
|
||||
val Backspace: ImageVector by lazy {
|
||||
build(
|
||||
"Backspace",
|
||||
"M21 5H8l-6 7 6 7h13a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z",
|
||||
"M12 9.5l5 5",
|
||||
"M17 9.5l-5 5"
|
||||
)
|
||||
}
|
||||
|
||||
private fun build(name: String, vararg paths: String): ImageVector {
|
||||
val builder = ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
)
|
||||
paths.forEach { d ->
|
||||
builder.addPath(
|
||||
pathData = addPathNodes(d),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
262
android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt
Normal file
262
android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt
Normal file
@@ -0,0 +1,262 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import tv.vyla.app.R
|
||||
import tv.vyla.app.ui.theme.VylaTextMuted
|
||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
|
||||
object ContentFocusBridge {
|
||||
var restore: (() -> Boolean)? = null
|
||||
}
|
||||
|
||||
enum class NavDestination(val key: String, val title: String, val icon: ImageVector) {
|
||||
SEARCH("search", "Search", Icons.Default.Search),
|
||||
DISCOVERY("discover", "Discovery", Icons.Default.Explore),
|
||||
MOVIES("movie", "Movies", Icons.Default.Movie),
|
||||
TV_SHOWS("tv", "TV Shows", Icons.Default.Tv),
|
||||
ANIME("anime", "Anime", Icons.Default.AutoAwesome),
|
||||
HISTORY("history", "History", Icons.Default.History),
|
||||
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
|
||||
FAVORITES("fav", "Favorites", Icons.Default.Favorite)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun Sidebar(
|
||||
currentDestination: NavDestination,
|
||||
hasHistory: Boolean,
|
||||
hasWatchLater: Boolean,
|
||||
hasFavorites: Boolean,
|
||||
onNavigate: (NavDestination) -> Unit,
|
||||
onRequestExitSidebar: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
|
||||
val isSidebarFocused = focusedDestination != null
|
||||
val destinationFocusRequesters = remember { mutableMapOf<NavDestination, FocusRequester>() }
|
||||
|
||||
val animatedWidth by animateDpAsState(
|
||||
targetValue = if (isSidebarFocused) 220.dp else 58.dp,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "sidebar_width"
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(60)
|
||||
try {
|
||||
destinationFocusRequesters[currentDestination]?.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(animatedWidth)
|
||||
.zIndex(10f)
|
||||
.focusProperties {
|
||||
enter = { destinationFocusRequesters[currentDestination] ?: FocusRequester.Default }
|
||||
}
|
||||
.focusGroup()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = if (isSidebarFocused) {
|
||||
listOf(Color(0xF207070A), Color(0xCC07070A), Color.Transparent)
|
||||
} else {
|
||||
listOf(Color(0xD907070A), Color.Transparent)
|
||||
}
|
||||
)
|
||||
)
|
||||
.padding(vertical = 20.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 14.dp, top = 2.dp)
|
||||
.height(36.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(R.drawable.icon),
|
||||
contentDescription = "Vyla Logo",
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isSidebarFocused,
|
||||
enter = fadeIn(tween(160)),
|
||||
exit = fadeOut(tween(100))
|
||||
) {
|
||||
Text(
|
||||
text = "Vyla",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-0.5).sp,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
NavDestination.values().forEach { destination ->
|
||||
val shouldShow = when (destination) {
|
||||
NavDestination.HISTORY -> hasHistory || currentDestination == NavDestination.HISTORY
|
||||
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
|
||||
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
|
||||
else -> true
|
||||
}
|
||||
|
||||
if (shouldShow) {
|
||||
val requester = destinationFocusRequesters.getOrPut(destination) { FocusRequester() }
|
||||
SidebarItem(
|
||||
destination = destination,
|
||||
isSelected = currentDestination == destination,
|
||||
isExpanded = isSidebarFocused,
|
||||
focusRequester = requester,
|
||||
onFocusGained = { focusedDestination = destination },
|
||||
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
|
||||
onSelect = {
|
||||
focusedDestination = null
|
||||
onNavigate(destination)
|
||||
},
|
||||
onRightPress = {
|
||||
focusedDestination = null
|
||||
if (ContentFocusBridge.restore?.invoke() != true) onRequestExitSidebar()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarItem(
|
||||
destination: NavDestination,
|
||||
isSelected: Boolean,
|
||||
isExpanded: Boolean,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onFocusGained: () -> Unit,
|
||||
onFocusLost: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
onRightPress: () -> Unit
|
||||
) {
|
||||
var isItemFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val background = when {
|
||||
isItemFocused -> Color.White
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val contentColor = when {
|
||||
isItemFocused -> Color.Black
|
||||
isSelected -> Color.White
|
||||
else -> Color(0x99FFFFFF)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(background)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged { state ->
|
||||
isItemFocused = state.isFocused
|
||||
if (state.isFocused) onFocusGained() else onFocusLost()
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight) {
|
||||
onRightPress()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onSelect()
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(42.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.title,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = fadeIn(tween(160)),
|
||||
exit = fadeOut(tween(100))
|
||||
) {
|
||||
Text(
|
||||
text = destination.title,
|
||||
color = contentColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 2.dp, end = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
187
android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt
Normal file
187
android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt
Normal file
@@ -0,0 +1,187 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.PixelCopy
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.roundToInt
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
|
||||
private const val GRID_W = 32
|
||||
private const val GRID_H = 18
|
||||
|
||||
class VideoFrameColors(
|
||||
private val pixels: IntArray,
|
||||
private val width: Int,
|
||||
private val height: Int
|
||||
) {
|
||||
fun colorAt(fx: Float, fy: Float): Color {
|
||||
val cx = (fx.coerceIn(0f, 1f) * (width - 1)).roundToInt()
|
||||
val cy = (fy.coerceIn(0f, 1f) * (height - 1)).roundToInt()
|
||||
var r = 0
|
||||
var g = 0
|
||||
var b = 0
|
||||
var n = 0
|
||||
for (dy in -1..1) {
|
||||
for (dx in -1..1) {
|
||||
val x = (cx + dx).coerceIn(0, width - 1)
|
||||
val y = (cy + dy).coerceIn(0, height - 1)
|
||||
val p = pixels[y * width + x]
|
||||
r += (p shr 16) and 0xFF
|
||||
g += (p shr 8) and 0xFF
|
||||
b += p and 0xFF
|
||||
n++
|
||||
}
|
||||
}
|
||||
return Color(r / n, g / n, b / n)
|
||||
}
|
||||
}
|
||||
|
||||
object VideoTintState {
|
||||
var frame: VideoFrameColors? by mutableStateOf(null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoTintSampler(active: Boolean, viewProvider: () -> View?) {
|
||||
val provider by rememberUpdatedState(viewProvider)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { VideoTintState.frame = null }
|
||||
}
|
||||
LaunchedEffect(active) {
|
||||
if (!active) return@LaunchedEffect
|
||||
val bitmap = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
|
||||
val pixels = IntArray(GRID_W * GRID_H)
|
||||
while (true) {
|
||||
val view = provider()
|
||||
if (view != null && captureFrame(view, bitmap)) {
|
||||
bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
|
||||
VideoTintState.frame = VideoFrameColors(pixels.copyOf(), GRID_W, GRID_H)
|
||||
}
|
||||
delay(700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun captureFrame(view: View, bitmap: Bitmap): Boolean {
|
||||
return when (view) {
|
||||
is SurfaceView -> {
|
||||
if (Build.VERSION.SDK_INT < 24 || !view.holder.surface.isValid || view.width == 0 || view.height == 0) {
|
||||
false
|
||||
} else {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
PixelCopy.request(
|
||||
view,
|
||||
bitmap,
|
||||
{ result -> if (cont.isActive) cont.resume(result == PixelCopy.SUCCESS) },
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
if (cont.isActive) cont.resume(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is TextureView -> view.isAvailable && view.getBitmap(bitmap) != null
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun tinted(sample: Color, selected: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(sample.toArgb(), hsv)
|
||||
hsv[1] = (hsv[1] * 1.6f).coerceAtMost(1f)
|
||||
hsv[2] = if (selected) {
|
||||
(hsv[2] * 0.9f).coerceIn(0.3f, 0.85f)
|
||||
} else {
|
||||
(hsv[2] * 0.5f).coerceIn(0.05f, 0.45f)
|
||||
}
|
||||
return Color(android.graphics.Color.HSVToColor(hsv)).copy(alpha = if (selected) 0.9f else 0.8f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.videoTint(isFocused: Boolean, isSelected: Boolean = false): Modifier {
|
||||
val frame = VideoTintState.frame
|
||||
val rootView = LocalView.current.rootView
|
||||
var center by remember { mutableStateOf(Offset.Unspecified) }
|
||||
val target = when {
|
||||
isFocused -> Color(0xF2FFFFFF)
|
||||
frame == null || center == Offset.Unspecified || rootView.width == 0 || rootView.height == 0 ->
|
||||
if (isSelected) Color(0xCC1A1A1A) else Color(0x99020202)
|
||||
else -> tinted(
|
||||
frame.colorAt(center.x / rootView.width, center.y / rootView.height),
|
||||
isSelected
|
||||
)
|
||||
}
|
||||
val animated by animateColorAsState(target, tween(350), label = "video_tint")
|
||||
return this
|
||||
.onGloballyPositioned { center = it.boundsInWindow().center }
|
||||
.drawBehind { drawRect(animated) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackdropTintSampler(url: String?) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { VideoTintState.frame = null }
|
||||
}
|
||||
LaunchedEffect(url) {
|
||||
if (url.isNullOrEmpty()) {
|
||||
VideoTintState.frame = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.size(48, 27)
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
val result = context.imageLoader.execute(request)
|
||||
val source = ((result as? SuccessResult)?.drawable as? BitmapDrawable)?.bitmap
|
||||
?: return@LaunchedEffect
|
||||
val out = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
|
||||
val targetRatio = GRID_W.toFloat() / GRID_H
|
||||
val sourceRatio = source.width.toFloat() / source.height
|
||||
val srcRect = if (sourceRatio > targetRatio) {
|
||||
val w = (source.height * targetRatio).toInt()
|
||||
Rect((source.width - w) / 2, 0, (source.width + w) / 2, source.height)
|
||||
} else {
|
||||
val h = (source.width / targetRatio).toInt()
|
||||
Rect(0, (source.height - h) / 2, source.width, (source.height + h) / 2)
|
||||
}
|
||||
Canvas(out).drawBitmap(source, srcRect, Rect(0, 0, GRID_W, GRID_H), null)
|
||||
val pixels = IntArray(GRID_W * GRID_H)
|
||||
out.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
|
||||
VideoTintState.frame = VideoFrameColors(pixels, GRID_W, GRID_H)
|
||||
}
|
||||
}
|
||||
545
android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt
Normal file
545
android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt
Normal file
@@ -0,0 +1,545 @@
|
||||
package tv.vyla.app.ui.screens
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import tv.vyla.app.api.TmdbApi
|
||||
import tv.vyla.app.model.Genre
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.components.MediaCard
|
||||
import tv.vyla.app.ui.components.NavDestination
|
||||
import tv.vyla.app.ui.theme.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import tv.vyla.app.ui.components.ContentFocusBridge
|
||||
import tv.vyla.app.ui.components.BackdropTintSampler
|
||||
import tv.vyla.app.ui.components.videoTint
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||
import tv.vyla.app.ui.components.HeroFadeStops
|
||||
import tv.vyla.app.ui.components.verticalFadeMask
|
||||
|
||||
data class GenreRow(
|
||||
|
||||
val title: String,
|
||||
|
||||
val mediaType: String,
|
||||
|
||||
val genreId: Int,
|
||||
|
||||
val items: List<MediaItem>
|
||||
)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun BrowseScreen(
|
||||
mode: NavDestination,
|
||||
onOpenDetail: (MediaItem) -> Unit,
|
||||
onPlay: (MediaItem) -> Unit,
|
||||
onRequestSidebar: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var heroItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var heroLogoUrl by remember { mutableStateOf<String?>(null) }
|
||||
var heroJob by remember { mutableStateOf<Job?>(null) }
|
||||
val rows = remember { mutableStateListOf<GenreRow>() }
|
||||
var isLoadingMore by remember { mutableStateOf(false) }
|
||||
var rowIdx by remember { mutableIntStateOf(0) }
|
||||
val movieGenres = remember { mutableStateListOf<Genre>() }
|
||||
val tvGenres = remember { mutableStateListOf<Genre>() }
|
||||
val playButtonFocusRequester = remember { FocusRequester() }
|
||||
val currentMode by rememberUpdatedState(mode)
|
||||
var loadGeneration by remember { mutableIntStateOf(0) }
|
||||
val listState = rememberLazyListState()
|
||||
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
|
||||
var lastFocusedKey by remember { mutableStateOf<String?>(null) }
|
||||
var ambientUrl by remember { mutableStateOf<String?>(null) }
|
||||
var lastRowIndex by remember { mutableIntStateOf(-1) }
|
||||
fun updateHero(item: MediaItem) {
|
||||
heroJob?.cancel()
|
||||
if (heroItem?.id == item.id && heroItem?.mediaType == item.mediaType) return
|
||||
heroJob = coroutineScope.launch {
|
||||
delay(200)
|
||||
heroItem = item
|
||||
ambientUrl = item.ambientBackdropUrl
|
||||
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
|
||||
}
|
||||
}
|
||||
fun pinAmbientToHero() {
|
||||
heroItem?.let { ambientUrl = it.ambientBackdropUrl }
|
||||
}
|
||||
|
||||
suspend fun loadMoreRows(count: Int) {
|
||||
if (isLoadingMore || rows.size >= 48) return
|
||||
isLoadingMore = true
|
||||
val generation = loadGeneration
|
||||
val activeMode = currentMode
|
||||
try {
|
||||
val isMovieMode = activeMode == NavDestination.MOVIES
|
||||
val isTvMode = activeMode == NavDestination.TV_SHOWS
|
||||
val isAnimeMode = activeMode == NavDestination.ANIME
|
||||
val isDiscover = activeMode == NavDestination.DISCOVERY
|
||||
var added = 0
|
||||
var attempts = 0
|
||||
while (added < count && attempts < count * 4) {
|
||||
attempts++
|
||||
val curI = rowIdx++
|
||||
val isMovie = when {
|
||||
isMovieMode -> true
|
||||
isTvMode -> false
|
||||
isAnimeMode -> false
|
||||
isDiscover -> curI % 2 == 0
|
||||
else -> true
|
||||
}
|
||||
val list = if (isMovie) movieGenres else tvGenres
|
||||
if (list.isEmpty()) continue
|
||||
val genreIdx = if (isDiscover) curI / 2 else curI
|
||||
val genre = list[genreIdx % list.size]
|
||||
val page = 1 + (genreIdx / list.size) % 15
|
||||
val mt = if (isMovie) "movie" else "tv"
|
||||
val items = if (isAnimeMode) {
|
||||
TmdbApi.getAnimeDiscover(genre.id, page)
|
||||
} else {
|
||||
TmdbApi.getDiscover(mt, genre.id, page)
|
||||
}
|
||||
if (generation != loadGeneration) return
|
||||
if (items.isNotEmpty()) {
|
||||
val title = when {
|
||||
isAnimeMode -> "${genre.name} Anime"
|
||||
else -> "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
|
||||
}
|
||||
rows.add(GenreRow(title, mt, genre.id, items))
|
||||
added++
|
||||
if (heroItem == null) {
|
||||
val first = rows[0].items.firstOrNull()
|
||||
if (first != null) {
|
||||
heroItem = first
|
||||
ambientUrl = first.ambientBackdropUrl
|
||||
coroutineScope.launch {
|
||||
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (generation == loadGeneration) isLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreContentFocus(): Boolean {
|
||||
val cardRequester = lastFocusedKey?.let { cardRequesters[it] }
|
||||
if (cardRequester != null) {
|
||||
try {
|
||||
cardRequester.requestFocus()
|
||||
return true
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return try {
|
||||
playButtonFocusRequester.requestFocus()
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val restore: () -> Boolean = { restoreContentFocus() }
|
||||
ContentFocusBridge.restore = restore
|
||||
onDispose {
|
||||
if (ContentFocusBridge.restore === restore) ContentFocusBridge.restore = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mode) {
|
||||
loadGeneration++
|
||||
isLoadingMore = false
|
||||
lastFocusedKey = null
|
||||
cardRequesters.clear()
|
||||
rows.clear()
|
||||
rowIdx = 0
|
||||
heroItem = null
|
||||
ambientUrl = null
|
||||
lastRowIndex = -1
|
||||
heroLogoUrl = null
|
||||
movieGenres.clear()
|
||||
tvGenres.clear()
|
||||
listState.scrollToItem(0)
|
||||
movieGenres.addAll(TmdbApi.getGenreList("movie"))
|
||||
tvGenres.addAll(TmdbApi.getGenreList("tv"))
|
||||
loadMoreRows(5)
|
||||
withTimeoutOrNull(10000) { snapshotFlow { heroItem }.first { it != null } }
|
||||
delay(150)
|
||||
var tries = 0
|
||||
var focused = false
|
||||
while (!focused && tries < 10 && lastFocusedKey == null) {
|
||||
tries++
|
||||
try {
|
||||
playButtonFocusRequester.requestFocus()
|
||||
focused = true
|
||||
} catch (_: Exception) {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackdropTintSampler(heroItem?.ambientBackdropUrl)
|
||||
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||
AmbientBackdrop(
|
||||
url = ambientUrl,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
durationMs = 700
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color(0x0D000000),
|
||||
0.55f to Color(0x8C000000),
|
||||
1.0f to Color(0xF207070A)
|
||||
)
|
||||
)
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 60.dp)
|
||||
) {
|
||||
item {
|
||||
HeroSection(
|
||||
item = heroItem,
|
||||
logoUrl = heroLogoUrl,
|
||||
playButtonFocusRequester = playButtonFocusRequester,
|
||||
onFocusedTop = {
|
||||
lastFocusedKey = null
|
||||
lastRowIndex = -1
|
||||
pinAmbientToHero()
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
onPlay = {
|
||||
coroutineScope.launch { listState.scrollToItem(0) }
|
||||
heroItem?.let(onPlay)
|
||||
},
|
||||
onInfo = {
|
||||
coroutineScope.launch { listState.scrollToItem(0) }
|
||||
heroItem?.let(onOpenDetail)
|
||||
}
|
||||
)
|
||||
}
|
||||
itemsIndexed(rows, key = { i, row -> "$i:${row.title}" }) { rowIndex, row ->
|
||||
val rowState = rememberLazyListState()
|
||||
Column(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||
Text(
|
||||
text = row.title,
|
||||
color = VylaTextPrimary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 78.dp, bottom = 12.dp)
|
||||
)
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
contentPadding = PaddingValues(horizontal = 78.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
itemsIndexed(row.items, key = { _, it -> it.id }) { index, item ->
|
||||
val isFirstInRow = index == 0
|
||||
val cardKey = "$rowIndex:${item.id}"
|
||||
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
|
||||
MediaCard(
|
||||
item = item,
|
||||
width = 136,
|
||||
onSelect = { onOpenDetail(item) },
|
||||
onFocused = {
|
||||
lastFocusedKey = cardKey
|
||||
updateHero(item)
|
||||
coroutineScope.launch {
|
||||
val viewportWidth = rowState.layoutInfo.viewportEndOffset - rowState.layoutInfo.viewportStartOffset
|
||||
rowState.animateScrollToItem(
|
||||
index,
|
||||
-(viewportWidth / 2 - 68)
|
||||
)
|
||||
}
|
||||
if (lastRowIndex != rowIndex) {
|
||||
lastRowIndex = rowIndex
|
||||
coroutineScope.launch {
|
||||
val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
|
||||
listState.animateScrollToItem(
|
||||
rowIndex + 1,
|
||||
-(viewportHeight / 2 - 100)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft && isFirstInRow) {
|
||||
onRequestSidebar()
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
LaunchedEffect(rows.size) {
|
||||
if (rows.isNotEmpty()) {
|
||||
loadMoreRows(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun HeroSection(
|
||||
item: MediaItem?,
|
||||
logoUrl: String?,
|
||||
playButtonFocusRequester: FocusRequester,
|
||||
onFocusedTop: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onInfo: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(460.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalFadeMask(*HeroFadeStops)
|
||||
) {
|
||||
val backdropUrl = item?.backdropUrl
|
||||
if (!backdropUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = backdropUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
0.0f to Color(0xCC000000),
|
||||
0.40f to Color(0x59000000),
|
||||
0.70f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 78.dp, bottom = 28.dp)
|
||||
.widthIn(max = 640.dp)
|
||||
) {
|
||||
if (item != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(110.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
if (!logoUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = item.displayTitle,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.height(110.dp)
|
||||
.widthIn(max = 440.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = item.displayTitle,
|
||||
color = Color.White,
|
||||
fontSize = 38.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-1).sp,
|
||||
lineHeight = 42.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
item.voteAverage?.let { rate ->
|
||||
Text(
|
||||
text = "★ ${String.format("%.1f", rate)}",
|
||||
color = Color(0xFFFFD700),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
if (item.year.isNotEmpty()) {
|
||||
Text(
|
||||
text = item.year,
|
||||
color = Color(0xFFE3E3EE),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0x33FFFFFF))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (item.mediaType == "tv") "Series" else "Movie",
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
val overview = item.overview
|
||||
if (!overview.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = overview,
|
||||
color = Color(0xFFD4D4E2),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
HeroPillButton(
|
||||
text = "Play",
|
||||
icon = Icons.Default.PlayArrow,
|
||||
isPrimary = true,
|
||||
focusRequester = playButtonFocusRequester,
|
||||
onFocused = onFocusedTop,
|
||||
onClick = onPlay
|
||||
)
|
||||
HeroPillButton(
|
||||
text = "More Info",
|
||||
icon = Icons.Default.Info,
|
||||
isPrimary = false,
|
||||
onFocused = onFocusedTop,
|
||||
onClick = onInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun HeroPillButton(
|
||||
text: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
isPrimary: Boolean,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onFocused: (() -> Unit)? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.05f else 1.0f,
|
||||
label = "pill_scale"
|
||||
)
|
||||
val primaryBackground = if (isFocused) Color.White else Color(0xD9FFFFFF)
|
||||
val contentColor = when {
|
||||
isPrimary -> Color.Black
|
||||
isFocused -> Color.Black
|
||||
else -> Color.White
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.then(
|
||||
if (isPrimary) Modifier.background(primaryBackground) else Modifier.videoTint(isFocused)
|
||||
)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
onFocused?.invoke()
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
color = contentColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
1137
android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt
Normal file
1137
android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
package tv.vyla.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.components.MediaCard
|
||||
import tv.vyla.app.ui.theme.VylaBackground
|
||||
import tv.vyla.app.ui.theme.VylaTextMuted
|
||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
title: String,
|
||||
items: List<MediaItem>,
|
||||
onOpenDetail: (MediaItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
emptyMessage: String = "Nothing here yet. Open a title, press the ⋮ button and add it."
|
||||
) {
|
||||
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
|
||||
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||
AmbientBackdrop(
|
||||
url = ambientUrl,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
durationMs = 700
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color(0x0D000000),
|
||||
0.55f to Color(0x8C000000),
|
||||
1.0f to Color(0xF207070A)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 78.dp, end = 48.dp, top = 36.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = VylaTextPrimary,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "${items.size} title${if (items.size == 1) "" else "s"}",
|
||||
color = VylaTextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 100.dp),
|
||||
contentAlignment = androidx.compose.ui.Alignment.TopStart
|
||||
) {
|
||||
Text(
|
||||
text = emptyMessage,
|
||||
color = VylaTextMuted,
|
||||
fontSize = 17.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
contentPadding = PaddingValues(bottom = 60.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(items) { item ->
|
||||
MediaCard(
|
||||
item = item,
|
||||
width = 140,
|
||||
onSelect = { onOpenDetail(item) },
|
||||
onFocused = { ambientUrl = it.ambientBackdropUrl }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1676
android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt
Normal file
1676
android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
635
android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt
Normal file
635
android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt
Normal file
@@ -0,0 +1,635 @@
|
||||
package tv.vyla.app.ui.screens
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import tv.vyla.app.api.TmdbApi
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.theme.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import tv.vyla.app.ui.components.SearchIcons
|
||||
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
@Composable
|
||||
|
||||
fun SearchScreen(
|
||||
onOpenDetail: (MediaItem) -> Unit,
|
||||
onRequestSidebar: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var isNumbersMode by remember { mutableStateOf(false) }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var focusedItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var searchJob by remember { mutableStateOf<Job?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
val trendingMovies = remember { mutableStateListOf<MediaItem>() }
|
||||
val trendingTv = remember { mutableStateListOf<MediaItem>() }
|
||||
val searchResults = remember { mutableStateListOf<MediaItem>() }
|
||||
val letterAFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
trendingMovies.clear()
|
||||
trendingTv.clear()
|
||||
trendingMovies.addAll(TmdbApi.getTrending("movie"))
|
||||
trendingTv.addAll(TmdbApi.getTrending("tv"))
|
||||
delay(120)
|
||||
try {
|
||||
letterAFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
fun performSearch(q: String) {
|
||||
searchJob?.cancel()
|
||||
searchJob = coroutineScope.launch {
|
||||
delay(280)
|
||||
if (q.isBlank()) {
|
||||
searchResults.clear()
|
||||
} else {
|
||||
val res = TmdbApi.searchMulti(q)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(res.filter { !it.posterUrl.isNullOrEmpty() })
|
||||
}
|
||||
}
|
||||
}
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "cursor_blink")
|
||||
val cursorAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "cursor_alpha"
|
||||
)
|
||||
val letters = listOf("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z")
|
||||
val numbersAndSymbols = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "-", "'", "&", ":")
|
||||
val searchMovies = searchResults.filter { it.mediaType == "movie" }
|
||||
val searchTv = searchResults.filter { it.mediaType == "tv" }
|
||||
val suggestionNames = remember(query, searchResults.size, trendingMovies.size, trendingTv.size) {
|
||||
val pool = if (query.isNotBlank()) searchResults else (trendingMovies + trendingTv)
|
||||
pool.map { it.displayTitle }.distinct().filter { it.isNotBlank() && it != query }.take(9)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF07070A))
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
val code = event.nativeKeyEvent.keyCode
|
||||
if (code == android.view.KeyEvent.KEYCODE_DEL) {
|
||||
if (query.isNotEmpty()) {
|
||||
query = query.dropLast(1)
|
||||
performSearch(query)
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
val char = event.nativeKeyEvent.unicodeChar.toChar()
|
||||
if (char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char == ' ') {
|
||||
query += char
|
||||
performSearch(query)
|
||||
return@onKeyEvent true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
) {
|
||||
AmbientBackdrop(
|
||||
url = focusedItem?.ambientBackdropUrl,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color(0x33000000),
|
||||
1.0f to Color(0x99000000)
|
||||
)
|
||||
)
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 78.dp, end = 40.dp, top = 26.dp, bottom = 60.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SearchIcons.Search,
|
||||
contentDescription = null,
|
||||
tint = Color(0xD9FFFFFF),
|
||||
modifier = Modifier.size(38.dp)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (query.isEmpty()) "Search" else query,
|
||||
color = if (query.isEmpty()) Color(0x80FFFFFF) else Color.White,
|
||||
fontSize = 38.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = (-0.5).sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.width(3.dp)
|
||||
.height(30.dp)
|
||||
.drawBehind { drawRect(Color.White, alpha = cursorAlpha) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
val keyboardState = rememberLazyListState()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 4.dp)
|
||||
) {
|
||||
SearchKeyButton(
|
||||
label = if (isNumbersMode) "ABC" else "123",
|
||||
isWord = true,
|
||||
onLeftPress = onRequestSidebar,
|
||||
onClick = {
|
||||
isNumbersMode = !isNumbersMode
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onFocus = {
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
SearchKeyButton(
|
||||
label = "",
|
||||
isWord = true,
|
||||
icon = SearchIcons.Space,
|
||||
description = "Space",
|
||||
onClick = {
|
||||
if (query.isNotEmpty() && !query.endsWith(" ")) {
|
||||
query += " "
|
||||
performSearch(query)
|
||||
}
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onFocus = {
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
LazyRow(
|
||||
state = keyboardState,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
|
||||
items(activeKeys.size) { idx ->
|
||||
val keyChar = activeKeys[idx]
|
||||
val isFirstLetter = !isNumbersMode && idx == 0
|
||||
SearchKeyButton(
|
||||
label = keyChar,
|
||||
isWord = false,
|
||||
focusRequester = if (isFirstLetter) letterAFocusRequester else null,
|
||||
onClick = {
|
||||
query += keyChar
|
||||
performSearch(query)
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onFocus = {
|
||||
coroutineScope.launch {
|
||||
val viewportWidth = keyboardState.layoutInfo.viewportEndOffset - keyboardState.layoutInfo.viewportStartOffset
|
||||
keyboardState.animateScrollToItem(
|
||||
idx,
|
||||
-(viewportWidth / 2 - 20)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
SearchKeyButton(
|
||||
label = "",
|
||||
isWord = false,
|
||||
icon = SearchIcons.Backspace,
|
||||
description = "Backspace",
|
||||
onClick = {
|
||||
if (query.isNotEmpty()) {
|
||||
query = query.dropLast(1)
|
||||
performSearch(query)
|
||||
}
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (query.isNotEmpty() || suggestionNames.isNotEmpty()) {
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (query.isNotEmpty()) {
|
||||
item {
|
||||
SearchChip(
|
||||
text = "\"$query\"",
|
||||
showSearchIcon = true,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
items(suggestionNames) { name ->
|
||||
SearchChip(
|
||||
text = name,
|
||||
showSearchIcon = false,
|
||||
onClick = {
|
||||
query = name
|
||||
performSearch(query)
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onFocus = {
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
val tabs = if (query.isNotBlank()) {
|
||||
listOf("Top Results", "Movies", "TV Shows")
|
||||
} else {
|
||||
listOf("Top Results")
|
||||
}
|
||||
|
||||
LaunchedEffect(query.isBlank()) {
|
||||
if (query.isBlank() && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 18.dp)) {
|
||||
HorizontalDivider(color = Color(0x38FFFFFF), thickness = 1.dp)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (tabs.size > 1) Arrangement.SpaceAround else Arrangement.Center
|
||||
) {
|
||||
tabs.forEachIndexed { idx, title ->
|
||||
SearchTabButton(
|
||||
title = title,
|
||||
isSelected = selectedTab == idx,
|
||||
onClick = {
|
||||
selectedTab = idx
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onFocus = {
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val sections: List<Pair<String, List<MediaItem>>> = when (selectedTab) {
|
||||
1 -> listOf("Movies" to searchMovies)
|
||||
2 -> listOf("TV Shows" to searchTv)
|
||||
else -> {
|
||||
if (query.isNotEmpty()) {
|
||||
listOf(
|
||||
"Top Matches" to searchResults.take(10),
|
||||
"Movies" to searchMovies,
|
||||
"TV Shows" to searchTv
|
||||
).filter { it.second.isNotEmpty() }
|
||||
} else {
|
||||
listOf(
|
||||
"Trending Movies" to trendingMovies,
|
||||
"Trending TV Shows" to trendingTv
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sections.isEmpty() || sections.all { it.second.isEmpty() }) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (query.isNotEmpty()) "No results for \"$query\"" else "No results found",
|
||||
color = Color(0x88FFFFFF),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(sections) { (secTitle, itemsList) ->
|
||||
if (itemsList.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
text = secTitle,
|
||||
color = Color(0xCCFFFFFF),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(itemsList) { item ->
|
||||
SearchMediaCard(
|
||||
item = item,
|
||||
onSelect = { onOpenDetail(item) },
|
||||
onFocused = { focusedItem = item }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchKeyButton(
|
||||
label: String,
|
||||
isWord: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
description: String? = null,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onLeftPress: (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
onFocus: () -> Unit = {}
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.22f else 1.0f, label = "key_scale")
|
||||
val bg = when {
|
||||
isFocused -> Color.White
|
||||
isWord -> Color(0x38FFFFFF)
|
||||
else -> Color(0x1AFFFFFF)
|
||||
}
|
||||
val contentColor = if (isFocused) Color.Black else Color(0xE0FFFFFF)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.height(if (isWord) 34.dp else 40.dp)
|
||||
.then(
|
||||
if (isWord) Modifier.padding(horizontal = 2.dp)
|
||||
else Modifier.width(36.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bg)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocus()
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && onLeftPress != null) {
|
||||
onLeftPress()
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
.padding(horizontal = if (isWord) 12.dp else 0.dp)
|
||||
.zIndex(if (isFocused) 5f else 1f)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = description,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(if (isWord) 22.dp else 20.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
fontSize = if (isWord) 11.sp else 18.sp,
|
||||
fontWeight = if (isWord) FontWeight.Bold else FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchChip(
|
||||
text: String,
|
||||
showSearchIcon: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onFocus: () -> Unit = {}
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "chip_scale")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isFocused) Color.White else Color(0x3DFFFFFF))
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocus()
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
if (showSearchIcon) {
|
||||
Icon(
|
||||
imageVector = SearchIcons.Search,
|
||||
contentDescription = null,
|
||||
tint = if (isFocused) Color.Black else Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
color = if (isFocused) Color.Black else Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchTabButton(
|
||||
title: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onFocus: () -> Unit = {}
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (isFocused) Color(0x33FFFFFF) else Color.Transparent)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocus()
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
.padding(horizontal = 26.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (isSelected || isFocused) Color.White else Color(0xA6FFFFFF),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchMediaCard(
|
||||
item: MediaItem,
|
||||
onSelect: () -> Unit,
|
||||
onFocused: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "card_scale")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.offset(y = if (isFocused) (-3).dp else 0.dp)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
onFocused()
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onSelect() }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFF1B1B24))
|
||||
.then(
|
||||
if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(14.dp))
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
val posterUrl = item.posterUrl
|
||||
if (!posterUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = item.displayTitle,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.displayTitle,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
) {
|
||||
item.voteAverage?.let { v ->
|
||||
Text(
|
||||
text = "★ ${String.format("%.1f", v)}",
|
||||
color = Color(0x9EFFFFFF),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(text = "•", color = Color(0x50FFFFFF), fontSize = 12.sp)
|
||||
}
|
||||
if (item.year.isNotEmpty()) {
|
||||
Text(
|
||||
text = item.year,
|
||||
color = Color(0x9EFFFFFF),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(text = "•", color = Color(0x50FFFFFF), fontSize = 12.sp)
|
||||
}
|
||||
Text(
|
||||
text = if (item.mediaType == "tv") "Show" else "Movie",
|
||||
color = Color(0x9EFFFFFF),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
77
android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt
Normal file
77
android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
package tv.vyla.app.ui.theme
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
val VylaBackground = Color(0xFF07070A)
|
||||
val VylaCardBackground = Color(0xFF1B1B24)
|
||||
val VylaTextPrimary = Color(0xFFF2F2F5)
|
||||
val VylaTextSecondary = Color(0xFFD4D4E2)
|
||||
val VylaTextMuted = Color(0x99FFFFFF)
|
||||
val VylaPillBackground = Color(0x26FFFFFF)
|
||||
val VylaPillBackgroundFocused = Color(0x4DFFFFFF)
|
||||
val VylaYellow = Color(0xFFFFD700)
|
||||
val VylaCyan = Color(0xFF00FFFF)
|
||||
|
||||
private val VylaTvColorScheme = darkColorScheme(
|
||||
background = VylaBackground,
|
||||
surface = VylaCardBackground,
|
||||
primary = Color.White,
|
||||
onPrimary = Color.Black,
|
||||
onBackground = VylaTextPrimary,
|
||||
onSurface = VylaTextPrimary
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VylaTvTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = VylaTvColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.tvFocusable(
|
||||
isFocused: Boolean,
|
||||
scaleAmount: Float = 1.06f,
|
||||
shape: Shape = RoundedCornerShape(14.dp),
|
||||
focusedBorderWidth: Dp = 2.dp,
|
||||
focusedBorderColor: Color = Color.White
|
||||
): Modifier {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) scaleAmount else 1.0f,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "tv_scale"
|
||||
)
|
||||
|
||||
return this
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier
|
||||
.shadow(16.dp, shape = shape, spotColor = Color(0x66FFFFFF))
|
||||
.border(BorderStroke(focusedBorderWidth, focusedBorderColor), shape)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-v24/icon.png
Normal file
BIN
android/app/src/main/res/drawable-v24/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/drawable/icon.png
Normal file
BIN
android/app/src/main/res/drawable/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -1,22 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">#07070A</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
<item name="android:windowBackground">#07070A</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
@@ -8,10 +6,8 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
0
android/gradlew
vendored
Normal file → Executable file
0
android/gradlew
vendored
Normal file → Executable file
@@ -1,5 +1 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
include ':app'
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"appId": "tv.vyla.app",
|
||||
"appName": "Vyla",
|
||||
"webDir": "www",
|
||||
"server": {
|
||||
"androidScheme": "https",
|
||||
"cleartext": true
|
||||
}
|
||||
}
|
||||
327
css/base.css
327
css/base.css
@@ -1,327 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--pad: clamp(56px, 6vw, 132px);
|
||||
--cw: clamp(120px, 9.4vw, 200px);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
background: #07070a;
|
||||
color: #f2f2f5;
|
||||
font-family: "Inter", "SF Pro Display", "Segoe UI", Roboto, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
overscroll-behavior: none;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.card,
|
||||
.sc,
|
||||
.pe,
|
||||
.tr,
|
||||
.ep {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none
|
||||
}
|
||||
|
||||
.f {
|
||||
outline: none;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.xf {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
opacity: 0;
|
||||
transition: opacity .7s ease
|
||||
}
|
||||
|
||||
.xf.in {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
#bg {
|
||||
position: fixed;
|
||||
inset: -40px;
|
||||
background: #07070a;
|
||||
filter: blur(42px) brightness(.55) saturate(1)
|
||||
}
|
||||
|
||||
#dim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, .05) 0%, rgba(0, 0, 0, .55) 55%, #000 100%)
|
||||
}
|
||||
|
||||
#dim2 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
opacity: 0;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
#main {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 60vh
|
||||
}
|
||||
|
||||
#hb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 50%, transparent 100%);
|
||||
mask-image: linear-gradient(180deg, #000 50%, transparent 100%)
|
||||
}
|
||||
|
||||
#hb::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, .8) 0%, rgba(0, 0, 0, .35) 40%, transparent 70%)
|
||||
}
|
||||
|
||||
.hc {
|
||||
position: absolute;
|
||||
left: var(--pad);
|
||||
bottom: 5vh;
|
||||
width: min(48vw, 680px);
|
||||
z-index: 2
|
||||
}
|
||||
|
||||
#hlogo {
|
||||
height: 16vh;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 2vh
|
||||
}
|
||||
|
||||
#hlogo img {
|
||||
max-width: min(34vw, 440px);
|
||||
max-height: 16vh;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 18px rgba(0, 0, 0, .6))
|
||||
}
|
||||
|
||||
#hlogo h1 {
|
||||
font-size: clamp(28px, 3.6vw, 64px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.02em;
|
||||
line-height: 1.02;
|
||||
text-shadow: 0 4px 24px rgba(0, 0, 0, .6)
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
font-size: clamp(13px, 1.05vw, 19px);
|
||||
color: #e3e3ee;
|
||||
margin-bottom: 1.4vh;
|
||||
font-weight: 500
|
||||
}
|
||||
|
||||
.meta .b {
|
||||
background: rgba(255, 255, 255, .18);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: .85em;
|
||||
font-weight: 600
|
||||
}
|
||||
|
||||
.ov1 {
|
||||
font-size: clamp(13px, 1.1vw, 20px);
|
||||
line-height: 1.5;
|
||||
color: #d4d4e2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2.4vh;
|
||||
max-width: 640px
|
||||
}
|
||||
|
||||
.hb {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.pb,
|
||||
.ib {
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: clamp(14px, 1.1vw, 20px);
|
||||
border-radius: 999px;
|
||||
height: clamp(44px, 3.6vw, 64px);
|
||||
padding: 0 clamp(28px, 2.6vw, 48px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: transform .2s, background .2s
|
||||
}
|
||||
|
||||
.pb {
|
||||
background: #fff;
|
||||
color: #000
|
||||
}
|
||||
|
||||
.ib {
|
||||
background: rgba(255, 255, 255, .16);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 0 clamp(20px, 2vw, 36px)
|
||||
}
|
||||
|
||||
.pb.on,
|
||||
.ib.on {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.ib.on {
|
||||
background: rgba(255, 255, 255, .3)
|
||||
}
|
||||
|
||||
.sec {
|
||||
padding-top: 10px
|
||||
}
|
||||
|
||||
.sec h2 {
|
||||
padding: 0 var(--pad);
|
||||
font-size: clamp(16px, 1.5vw, 26px);
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, .6)
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 22px var(--pad) 26px;
|
||||
position: relative;
|
||||
scrollbar-width: none;
|
||||
min-height: calc(var(--cw)*1.5 + 48px)
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 0 0 var(--cw);
|
||||
height: calc(var(--cw)*1.5);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #1b1b24;
|
||||
position: relative;
|
||||
transform: translateZ(0);
|
||||
transition: transform .38s cubic-bezier(.22, .9, .3, 1);
|
||||
will-change: transform
|
||||
}
|
||||
|
||||
.card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
filter: brightness(.86);
|
||||
transition: filter .38s
|
||||
}
|
||||
|
||||
.card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity .25s;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
.card.on {
|
||||
transform: scale(1.06) translateY(-4px);
|
||||
z-index: 2
|
||||
}
|
||||
|
||||
.card.on img {
|
||||
filter: none
|
||||
}
|
||||
|
||||
.card.on::after {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
.kbm,
|
||||
.kbm * {
|
||||
cursor: none !important
|
||||
}
|
||||
|
||||
#sentinel {
|
||||
height: 40vh
|
||||
}
|
||||
|
||||
.none {
|
||||
color: rgba(255, 255, 255, .6);
|
||||
padding: 5vh 0;
|
||||
font-size: 18px
|
||||
}
|
||||
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 7vh;
|
||||
transform: translateX(-50%);
|
||||
z-index: 200;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 12px 22px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
animation: tin .25s both, tout .3s 1.9s forwards
|
||||
}
|
||||
|
||||
@keyframes tin {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 14px)
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tout {
|
||||
to {
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
447
css/detail.css
447
css/detail.css
@@ -1,447 +0,0 @@
|
||||
#ov {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: #000;
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(1.04);
|
||||
transition: opacity .35s, transform .35s;
|
||||
scrollbar-width: none
|
||||
}
|
||||
|
||||
#ov.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none
|
||||
}
|
||||
|
||||
.dbl {
|
||||
position: fixed;
|
||||
inset: -60px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(60px) brightness(.5) saturate(1)
|
||||
}
|
||||
|
||||
.dw {
|
||||
position: relative;
|
||||
min-height: 100vh
|
||||
}
|
||||
|
||||
.dimg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 82vh;
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 45%, transparent 100%);
|
||||
mask-image: linear-gradient(180deg, #000 45%, transparent 100%)
|
||||
}
|
||||
|
||||
.dc {
|
||||
position: relative;
|
||||
padding: 40vh 0 8vh
|
||||
}
|
||||
|
||||
.dc>.dh {
|
||||
padding: 0 6vw
|
||||
}
|
||||
|
||||
.dh #dlogo {
|
||||
height: 20vh;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 2vh
|
||||
}
|
||||
|
||||
.dh #dlogo img {
|
||||
max-width: min(40vw, 540px);
|
||||
max-height: 20vh;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 18px rgba(0, 0, 0, .6))
|
||||
}
|
||||
|
||||
#dback {
|
||||
position: fixed;
|
||||
top: 3.5vh;
|
||||
left: 3.5vw;
|
||||
z-index: 12;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
#dback.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.dh h1 {
|
||||
font-size: clamp(30px, 4.4vw, 76px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.02em;
|
||||
line-height: 1
|
||||
}
|
||||
|
||||
.dh .meta {
|
||||
margin-top: 1vh
|
||||
}
|
||||
|
||||
.dh .hb {
|
||||
margin: 2vh 0
|
||||
}
|
||||
|
||||
.circ {
|
||||
width: clamp(44px, 3.6vw, 64px);
|
||||
height: clamp(44px, 3.6vw, 64px);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.circ.on {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.otx {
|
||||
max-width: min(70vw, 980px);
|
||||
font-size: clamp(13px, 1.1vw, 20px);
|
||||
line-height: 1.55;
|
||||
color: #d9d9e4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.otx.x {
|
||||
-webkit-line-clamp: unset;
|
||||
line-clamp: unset;
|
||||
display: block
|
||||
}
|
||||
|
||||
.more {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
background: rgba(255, 255, 255, .18);
|
||||
border-radius: 999px;
|
||||
padding: 5px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.more.on {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.ds {
|
||||
margin-top: 3vh
|
||||
}
|
||||
|
||||
.ds h3 {
|
||||
padding: 0 6vw;
|
||||
font-size: clamp(17px, 1.5vw, 26px);
|
||||
font-weight: 700
|
||||
}
|
||||
|
||||
.ds .row {
|
||||
padding-left: 6vw;
|
||||
padding-right: 6vw;
|
||||
min-height: 0
|
||||
}
|
||||
|
||||
.tr {
|
||||
flex: 0 0 clamp(250px, 21vw, 400px);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #101016;
|
||||
padding: 0 0 18px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.tr .tb {
|
||||
position: absolute;
|
||||
inset: -40px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(34px) brightness(.62) saturate(1.35);
|
||||
transform: scale(1.2);
|
||||
z-index: -1
|
||||
}
|
||||
|
||||
.tr .th {
|
||||
aspect-ratio: 16/9;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
margin: 0 0 -26px;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 50%, rgba(0, 0, 0, .55) 74%, transparent 100%);
|
||||
mask-image: linear-gradient(180deg, #000 50%, rgba(0, 0, 0, .55) 74%, transparent 100%)
|
||||
}
|
||||
|
||||
.tr b,
|
||||
.tr small {
|
||||
display: block;
|
||||
padding: 0 18px;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.tr b {
|
||||
font-size: clamp(14px, 1.1vw, 19px);
|
||||
font-weight: 600
|
||||
}
|
||||
|
||||
.tr small {
|
||||
color: #d0d0da;
|
||||
margin-top: 5px
|
||||
}
|
||||
|
||||
.tr.on {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.cs {
|
||||
flex: 0 0 clamp(96px, 7.6vw, 150px);
|
||||
text-align: center;
|
||||
transition: transform .22s
|
||||
}
|
||||
|
||||
.cs .pf {
|
||||
width: clamp(90px, 7vw, 130px);
|
||||
height: clamp(90px, 7vw, 130px);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 10px;
|
||||
background: #2a2a3c center/cover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cs b {
|
||||
display: block;
|
||||
font-size: clamp(12px, .95vw, 16px)
|
||||
}
|
||||
|
||||
.cs small {
|
||||
display: block;
|
||||
color: #aaa;
|
||||
font-size: clamp(11px, .8vw, 14px);
|
||||
margin-top: 2px
|
||||
}
|
||||
|
||||
.cs.on {
|
||||
transform: scale(1.07)
|
||||
}
|
||||
|
||||
.circ {
|
||||
position: relative
|
||||
}
|
||||
|
||||
.circ svg {
|
||||
width: 46%;
|
||||
height: 46%
|
||||
}
|
||||
|
||||
.circ.sv {
|
||||
background: #fff;
|
||||
color: #000
|
||||
}
|
||||
|
||||
#dfv.sv svg {
|
||||
fill: currentColor
|
||||
}
|
||||
|
||||
.circ[data-l].on::after {
|
||||
content: attr(data-l);
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
z-index: 5;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
.mx {
|
||||
display: none;
|
||||
gap: 14px;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.mx.open {
|
||||
display: flex
|
||||
}
|
||||
|
||||
.mx.open .circ {
|
||||
animation: pop .32s cubic-bezier(.22, .9, .3, 1) backwards
|
||||
}
|
||||
|
||||
.mx.open .circ+.circ {
|
||||
animation-delay: .06s
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(.4) translateX(-24px)
|
||||
}
|
||||
}
|
||||
|
||||
.eps {
|
||||
padding: 0 6vw
|
||||
}
|
||||
|
||||
.eh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px
|
||||
}
|
||||
|
||||
.eps .eh h3 {
|
||||
padding: 0
|
||||
}
|
||||
|
||||
.dd {
|
||||
position: relative;
|
||||
z-index: 6
|
||||
}
|
||||
|
||||
.ddb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .16);
|
||||
backdrop-filter: blur(12px);
|
||||
font-weight: 600;
|
||||
font-size: clamp(13px, 1vw, 17px);
|
||||
transition: background .2s, color .2s, transform .25s
|
||||
}
|
||||
|
||||
.ddb svg {
|
||||
width: 16px;
|
||||
height: 16px
|
||||
}
|
||||
|
||||
.ddb.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.04)
|
||||
}
|
||||
|
||||
.ddl {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 240px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border-radius: 16px;
|
||||
background: rgba(22, 22, 30, .97);
|
||||
scrollbar-width: none
|
||||
}
|
||||
|
||||
.ddl.open {
|
||||
display: block
|
||||
}
|
||||
|
||||
.ddi {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.ddi small {
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.ddi.on {
|
||||
background: #fff;
|
||||
color: #000
|
||||
}
|
||||
|
||||
.ddi.on small {
|
||||
color: rgba(0, 0, 0, .55)
|
||||
}
|
||||
|
||||
.el {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(210px, 17vw, 320px), 1fr));
|
||||
gap: 28px 20px;
|
||||
padding: 8px 6px 14px;
|
||||
margin: 0 -6px
|
||||
}
|
||||
|
||||
.el .none {
|
||||
grid-column: 1/-1
|
||||
}
|
||||
|
||||
.ep {
|
||||
min-width: 0
|
||||
}
|
||||
|
||||
.ep .et {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.ep .et img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block
|
||||
}
|
||||
|
||||
.ep b {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: clamp(13px, 1vw, 16px);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis
|
||||
}
|
||||
|
||||
.ep small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: clamp(12px, .9vw, 14px);
|
||||
color: rgba(255, 255, 255, .5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis
|
||||
}
|
||||
|
||||
.ep.on .et {
|
||||
transform: scale(1.03)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
#lib {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .7));
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .35s;
|
||||
padding: 9vh var(--pad) 10vh;
|
||||
scrollbar-width: none
|
||||
}
|
||||
|
||||
#lib.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto
|
||||
}
|
||||
|
||||
.lib #main {
|
||||
visibility: hidden
|
||||
}
|
||||
|
||||
.lib #bg {
|
||||
filter: blur(70px) brightness(.5) saturate(1.5)
|
||||
}
|
||||
|
||||
.lib #dim {
|
||||
opacity: .35
|
||||
}
|
||||
|
||||
.lib #dim2 {
|
||||
opacity: 0 !important
|
||||
}
|
||||
|
||||
#lib h1 {
|
||||
font-size: clamp(28px, 3.4vw, 56px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.02em;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 18px
|
||||
}
|
||||
|
||||
#lib h1 small {
|
||||
font-size: clamp(13px, 1.1vw, 19px);
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, .6);
|
||||
letter-spacing: 0
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--cw), 1fr));
|
||||
gap: 22px;
|
||||
padding: 30px 10px
|
||||
}
|
||||
|
||||
.grid .card {
|
||||
height: auto;
|
||||
aspect-ratio: 2/3
|
||||
}
|
||||
|
||||
.grid .none {
|
||||
grid-column: 1/-1
|
||||
}
|
||||
705
css/player.css
705
css/player.css
@@ -1,705 +0,0 @@
|
||||
#pl {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: #000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .35s;
|
||||
}
|
||||
|
||||
#pl.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#pl:not(.ui) {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
#pv {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#pl.cover #pv {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
video::cue {
|
||||
font: 600 clamp(16px, 1.8vw, 30px) Inter, sans-serif;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sub-active::cue {
|
||||
color: var(--sub-color, #ffffff) !important;
|
||||
background-color: var(--sub-bg, rgba(0, 0, 0, 0.75)) !important;
|
||||
font-size: var(--sub-size, 22px) !important;
|
||||
text-shadow: var(--sub-shadow, 0 2px 4px rgba(0, 0, 0, 0.8)) !important;
|
||||
}
|
||||
|
||||
.sub-ctrl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sub-ctrl-row span {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.sub-btn-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sub-btn-group button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sub-btn-group button.active {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.src-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.src-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.src-header.on,
|
||||
.src-header:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.src-expand-btn {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: inherit;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.src-sublist {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: 14px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.src-sublist.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.src-subitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.src-subitem.on,
|
||||
.src-subitem:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#pl .top,
|
||||
#pl .bot,
|
||||
#pmid {
|
||||
opacity: 0;
|
||||
transition: opacity .3s, transform .25s, background .2s;
|
||||
}
|
||||
|
||||
#pl.ui .top,
|
||||
#pl.ui .bot,
|
||||
#pl.ui #pmid {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#pl .f {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#pl:not(.ui) .f {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#pl .top,
|
||||
#pl .bot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#pl .top {
|
||||
top: 0;
|
||||
padding: 2.6vh 2.4vw 14vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, .65), transparent);
|
||||
}
|
||||
|
||||
#pl .bot {
|
||||
bottom: 0;
|
||||
padding: 10vh 2.4vw 2.6vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, .7), transparent);
|
||||
}
|
||||
|
||||
.pic {
|
||||
width: clamp(44px, 3.3vw, 56px);
|
||||
height: clamp(44px, 3.3vw, 56px);
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: background .2s, color .2s, transform .2s;
|
||||
}
|
||||
|
||||
.pic svg {
|
||||
width: 52%;
|
||||
height: 52%;
|
||||
}
|
||||
|
||||
.pic.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.av {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin: 3px 12px 0 6px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #2a2a34;
|
||||
}
|
||||
|
||||
.pt {
|
||||
height: 46px;
|
||||
margin-top: 3px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pt b {
|
||||
font-size: clamp(15px, 1.2vw, 19px);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 36vw;
|
||||
}
|
||||
|
||||
.pt small {
|
||||
font-size: clamp(12px, .9vw, 14px);
|
||||
color: rgba(255, 255, 255, .7);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pr {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#pmid {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
margin: -56px 0 0 -56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#pmid svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
filter: drop-shadow(0 2px 14px rgba(0, 0, 0, .45));
|
||||
}
|
||||
|
||||
#pmid.on {
|
||||
background: rgba(255, 255, 255, .18);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.bt {
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 44px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#pdur {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sk {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sk::before,
|
||||
.sk u,
|
||||
.sk i {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
height: 4px;
|
||||
margin-top: -2px;
|
||||
border-radius: 2px;
|
||||
transition: height .15s, margin .15s;
|
||||
}
|
||||
|
||||
.sk::before {
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, .2);
|
||||
}
|
||||
|
||||
.sk u {
|
||||
width: 0;
|
||||
background: rgba(255, 255, 255, .38);
|
||||
}
|
||||
|
||||
.sk i {
|
||||
width: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sk b {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform .15s;
|
||||
}
|
||||
|
||||
.sk.on::before,
|
||||
.sk.on u,
|
||||
.sk.on i {
|
||||
height: 6px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.sk.on b {
|
||||
transform: scale(1.35);
|
||||
}
|
||||
|
||||
#pm {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(2.6vh + 66px);
|
||||
right: 2.4vw;
|
||||
width: 320px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
border-radius: 18px;
|
||||
background: rgba(0, 0, 0, .94);
|
||||
backdrop-filter: blur(24px);
|
||||
z-index: 5;
|
||||
pointer-events: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#pm.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#pm h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
#pm p {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, .4);
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
|
||||
.pi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.pi svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pi.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.vrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 6px 8px;
|
||||
}
|
||||
|
||||
.vrow span {
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, .7);
|
||||
}
|
||||
|
||||
.vs {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vs::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 4px;
|
||||
margin-top: -2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, .2);
|
||||
}
|
||||
|
||||
.vs i {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
height: 4px;
|
||||
margin-top: -2px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skip {
|
||||
display: none;
|
||||
height: 48px;
|
||||
padding: 0 26px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .18);
|
||||
backdrop-filter: blur(16px);
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
transition: transform .25s, background .2s, color .2s;
|
||||
}
|
||||
|
||||
.skip.v {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.skip.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
#pact {
|
||||
position: absolute;
|
||||
right: 2.4vw;
|
||||
bottom: 16vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skip svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#pnext {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#pnext.on {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#pl.eps .bot,
|
||||
#pl.eps #pmid,
|
||||
#pl.eps #pact {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#pep {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 9vh 0 3.4vh;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, .97) 60%, rgba(0, 0, 0, .8) 82%, transparent);
|
||||
transform: translateY(40px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: transform .45s cubic-bezier(.22, .9, .3, 1), opacity .3s, visibility 0s .45s;
|
||||
}
|
||||
|
||||
#pl.eps #pep {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.peh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 0 2.4vw;
|
||||
}
|
||||
|
||||
.peh h3 {
|
||||
font-size: clamp(18px, 1.6vw, 26px);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.peh h3 small {
|
||||
font-size: .62em;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, .55);
|
||||
max-width: 22vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#pep #pseas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#pep .chip {
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 14px;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
}
|
||||
|
||||
#pep .chip.act {
|
||||
background: rgba(255, 255, 255, .3);
|
||||
}
|
||||
|
||||
#pep .chip.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#pep .pcar {
|
||||
padding: 18px 2.4vw 8px;
|
||||
min-height: 0;
|
||||
gap: 18px;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 5vw), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 calc(100% - 5vw), transparent);
|
||||
}
|
||||
|
||||
.pe {
|
||||
flex: 0 0 clamp(230px, 19vw, 330px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pe .et {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.pe .et img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pe .et>i {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.pe .pn {
|
||||
display: block;
|
||||
margin-top: 11px;
|
||||
font-size: clamp(13px, 1vw, 16px);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pe small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
}
|
||||
|
||||
.pe.on .et {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.pe .pg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, .22);
|
||||
}
|
||||
|
||||
.pe .pg u,
|
||||
.eq u {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pe .pg u {
|
||||
height: 100%;
|
||||
width: var(--p, 0%);
|
||||
}
|
||||
|
||||
.eq {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 14px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: flex-end;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.eq u {
|
||||
width: 3px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
animation: eq 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.eq u:nth-child(2) {
|
||||
animation-delay: .2s;
|
||||
}
|
||||
|
||||
.eq u:nth-child(3) {
|
||||
animation-delay: .4s;
|
||||
}
|
||||
|
||||
@keyframes eq {
|
||||
50% {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
#pbg {
|
||||
position: absolute;
|
||||
inset: -60px;
|
||||
width: calc(100% + 120px);
|
||||
height: calc(100% + 120px);
|
||||
object-fit: cover;
|
||||
filter: blur(54px) brightness(0.42) saturate(1.45);
|
||||
transform: scale(1.08);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
#pl.cover #pbg {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
273
css/search.css
273
css/search.css
@@ -1,273 +0,0 @@
|
||||
.srch #main {
|
||||
visibility: hidden
|
||||
}
|
||||
|
||||
.srch #bg {
|
||||
filter: blur(70px) brightness(.5) saturate(1.5)
|
||||
}
|
||||
|
||||
.srch #dim {
|
||||
opacity: .35
|
||||
}
|
||||
|
||||
.srch #dim2 {
|
||||
opacity: 0 !important
|
||||
}
|
||||
|
||||
#sr {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .6));
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .35s;
|
||||
padding: 6vh var(--pad) 8vh;
|
||||
scrollbar-width: none
|
||||
}
|
||||
|
||||
#sr.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto
|
||||
}
|
||||
|
||||
.stop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: clamp(46px, 4.6vw, 76px)
|
||||
}
|
||||
|
||||
.stop svg {
|
||||
width: clamp(30px, 2.8vw, 46px);
|
||||
height: clamp(30px, 2.8vw, 46px);
|
||||
color: rgba(255, 255, 255, .85);
|
||||
flex: 0 0 auto
|
||||
}
|
||||
|
||||
#stext {
|
||||
font-size: clamp(28px, 3vw, 52px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -.02em;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70vw
|
||||
}
|
||||
|
||||
#stext.q {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
#stext.q::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: .8em;
|
||||
background: #fff;
|
||||
margin-left: 5px;
|
||||
vertical-align: -.08em;
|
||||
animation: bl 1s steps(1) infinite
|
||||
}
|
||||
|
||||
@keyframes bl {
|
||||
50% {
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
|
||||
#kb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: clamp(2px, .35vw, 8px);
|
||||
margin: 1.4vh 0 0 4px;
|
||||
width: min(100%, 1180px)
|
||||
}
|
||||
|
||||
.k {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 46px;
|
||||
height: clamp(34px, 3vw, 52px);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(16px, 1.6vw, 26px);
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, .88);
|
||||
transition: background .2s, color .2s, transform .22s cubic-bezier(.22, .9, .3, 1)
|
||||
}
|
||||
|
||||
.k.w {
|
||||
flex: 0 0 auto;
|
||||
max-width: none;
|
||||
font-size: clamp(9px, .8vw, 13px);
|
||||
font-weight: 700;
|
||||
padding: 0 clamp(8px, .8vw, 14px);
|
||||
background: rgba(255, 255, 255, .22);
|
||||
letter-spacing: .04em;
|
||||
height: clamp(26px, 2.3vw, 40px)
|
||||
}
|
||||
|
||||
.k.d svg {
|
||||
width: 1.1em;
|
||||
height: 1.1em
|
||||
}
|
||||
|
||||
.k.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.22);
|
||||
}
|
||||
|
||||
.row.ch {
|
||||
padding: 16px 6px 18px;
|
||||
min-height: 0;
|
||||
margin: 0 -6px;
|
||||
gap: 14px
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
height: clamp(36px, 3vw, 52px);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .24);
|
||||
color: #fff;
|
||||
font-size: clamp(14px, 1.3vw, 22px);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background .2s, color .2s, transform .22s cubic-bezier(.22, .9, .3, 1)
|
||||
}
|
||||
|
||||
.chip svg {
|
||||
width: 1.1em;
|
||||
height: 1.1em
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
transform: scale(1.06)
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border-top: 1px solid rgba(255, 255, 255, .22);
|
||||
margin-top: 1vh;
|
||||
padding-top: 1.6vh
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 24px;
|
||||
border-radius: 999px;
|
||||
font-size: clamp(13px, 1.15vw, 19px);
|
||||
color: rgba(255, 255, 255, .65);
|
||||
position: relative;
|
||||
transition: background .2s, color .2s
|
||||
}
|
||||
|
||||
.tab.act {
|
||||
color: #fff;
|
||||
font-weight: 600
|
||||
}
|
||||
|
||||
.tab.act::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
border-radius: 2px
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
background: rgba(255, 255, 255, .2);
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.ss {
|
||||
margin-top: 2.6vh
|
||||
}
|
||||
|
||||
.ss h3 {
|
||||
font-size: clamp(14px, 1.2vw, 20px);
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, .75)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.sc {
|
||||
flex: 0 0 var(--cw);
|
||||
width: var(--cw);
|
||||
max-width: var(--cw);
|
||||
min-width: 0;
|
||||
transition: transform .32s cubic-bezier(.22, .9, .3, 1);
|
||||
}
|
||||
|
||||
.sc .po {
|
||||
width: 100%;
|
||||
height: calc(var(--cw) * 1.5);
|
||||
aspect-ratio: 2/3;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #1b1b24;
|
||||
}
|
||||
|
||||
.sc .po img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
filter: brightness(.86);
|
||||
transition: filter .3s
|
||||
}
|
||||
|
||||
.sc b {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: clamp(13px, 1vw, 17px);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis
|
||||
}
|
||||
|
||||
.sc small {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 3px;
|
||||
color: rgba(255, 255, 255, .62);
|
||||
font-size: clamp(11px, .85vw, 14px);
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.sc small span+span::before {
|
||||
content: "\2022";
|
||||
margin-right: 8px;
|
||||
opacity: .5
|
||||
}
|
||||
|
||||
.sc.on {
|
||||
transform: scale(1.05) translateY(-3px)
|
||||
}
|
||||
|
||||
.sc.on img {
|
||||
filter: none
|
||||
}
|
||||
114
css/sidebar.css
114
css/sidebar.css
@@ -1,114 +0,0 @@
|
||||
#side {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
z-index: 30;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
#side::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, .92) 0%, rgba(0, 0, 0, .7) 55%, transparent 100%);
|
||||
opacity: 0;
|
||||
transition: opacity .35s
|
||||
}
|
||||
|
||||
#side.open::before {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
#side nav {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: auto
|
||||
}
|
||||
|
||||
.ni {
|
||||
margin-left: 16px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 16px;
|
||||
color: #fff;
|
||||
opacity: .45;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: width .32s cubic-bezier(.2, .8, .2, 1), background .25s, color .25s, opacity .25s
|
||||
}
|
||||
|
||||
.ni svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 24px
|
||||
}
|
||||
|
||||
.ni span {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity .25s
|
||||
}
|
||||
|
||||
.ni.act {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
#side.open .ni {
|
||||
width: 260px;
|
||||
opacity: .85
|
||||
}
|
||||
|
||||
#side.open .ni span {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
#side.open .ni.on {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
top: 5vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 44px;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.brand svg,
|
||||
.brand img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
object-fit: contain
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.03em;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
transition: opacity .3s, transform .3s
|
||||
}
|
||||
|
||||
#side.open .brand span {
|
||||
opacity: 1;
|
||||
transform: none
|
||||
}
|
||||
213
css/skeleton.css
vendored
213
css/skeleton.css
vendored
@@ -1,213 +0,0 @@
|
||||
.sb,
|
||||
.card:not(.lded),
|
||||
.sc .po:not(.lded),
|
||||
.ep .et:not(.lded),
|
||||
.cs.sl .pf,
|
||||
.trk,
|
||||
.chip.sl {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #15151c
|
||||
}
|
||||
|
||||
.sb::before,
|
||||
.card:not(.lded)::before,
|
||||
.sc .po:not(.lded)::before,
|
||||
.ep .et:not(.lded)::before,
|
||||
.cs.sl .pf::before,
|
||||
.trk::before,
|
||||
.chip.sl::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .075) 50%, transparent 75%);
|
||||
transform: translateX(-100%);
|
||||
animation: shim 1.5s ease-in-out infinite
|
||||
}
|
||||
|
||||
@keyframes shim {
|
||||
to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
}
|
||||
|
||||
.card img,
|
||||
.sc .po img,
|
||||
.ep .et img {
|
||||
opacity: 0;
|
||||
transition: opacity .45s ease, filter .38s
|
||||
}
|
||||
|
||||
.card img.ld,
|
||||
.sc .po img.ld,
|
||||
.ep .et img.ld {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
.sb {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
height: 14px
|
||||
}
|
||||
|
||||
.sec>.sb.h2 {
|
||||
width: 220px;
|
||||
height: 22px;
|
||||
margin: 0 var(--pad) 4px
|
||||
}
|
||||
|
||||
.ds>.sb.h3 {
|
||||
width: 180px;
|
||||
height: 22px;
|
||||
margin: 0 6vw 6px
|
||||
}
|
||||
|
||||
.ss>.sb.h3s {
|
||||
width: 160px;
|
||||
height: 18px
|
||||
}
|
||||
|
||||
.sc.sl .sb {
|
||||
width: 80%;
|
||||
height: 13px;
|
||||
margin-top: 10px
|
||||
}
|
||||
|
||||
.sc.sl .sb.s {
|
||||
width: 50%;
|
||||
height: 11px;
|
||||
margin-top: 6px
|
||||
}
|
||||
|
||||
.chip.sl {
|
||||
height: clamp(36px, 3vw, 52px)
|
||||
}
|
||||
|
||||
.trk {
|
||||
flex: 0 0 clamp(250px, 21vw, 400px);
|
||||
aspect-ratio: 16/10;
|
||||
border-radius: 20px
|
||||
}
|
||||
|
||||
.cs.sl {
|
||||
flex: 0 0 clamp(96px, 7.6vw, 150px)
|
||||
}
|
||||
|
||||
.hskel {
|
||||
display: none
|
||||
}
|
||||
|
||||
.hc.sl .hskel {
|
||||
display: block
|
||||
}
|
||||
|
||||
.hc.sl>:not(.hskel) {
|
||||
display: none
|
||||
}
|
||||
|
||||
.hskel .t,
|
||||
.dsk .t {
|
||||
width: min(32vw, 400px);
|
||||
height: 10vh;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 3vh
|
||||
}
|
||||
|
||||
.hskel .m,
|
||||
.dsk .m {
|
||||
width: min(22vw, 300px);
|
||||
height: 18px;
|
||||
margin-bottom: 2vh
|
||||
}
|
||||
|
||||
.hskel .l,
|
||||
.dsk .l {
|
||||
width: min(44vw, 620px);
|
||||
height: 15px;
|
||||
margin-bottom: 10px
|
||||
}
|
||||
|
||||
.hskel .l.x,
|
||||
.dsk .l.x {
|
||||
width: min(26vw, 360px)
|
||||
}
|
||||
|
||||
.hskel .hb,
|
||||
.dsk .hb {
|
||||
margin: 3vh 0
|
||||
}
|
||||
|
||||
.hskel .b,
|
||||
.dsk .b {
|
||||
width: clamp(130px, 11vw, 190px);
|
||||
height: clamp(44px, 3.6vw, 64px);
|
||||
border-radius: 999px
|
||||
}
|
||||
|
||||
.hskel .b.c,
|
||||
.dsk .c {
|
||||
width: clamp(44px, 3.6vw, 64px);
|
||||
border-radius: 50%
|
||||
}
|
||||
|
||||
.dw {
|
||||
animation: fin .4s ease
|
||||
}
|
||||
|
||||
@keyframes fin {
|
||||
from {
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
|
||||
#pspin {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: -32px 0 0 -32px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, .2);
|
||||
border-top-color: #fff;
|
||||
animation: spin .8s linear infinite;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s
|
||||
}
|
||||
|
||||
#pl.buf #pspin {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
#pl.buf #pmid {
|
||||
opacity: 0 !important
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
}
|
||||
|
||||
.pe .et:not(.lded)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .075) 50%, transparent 75%);
|
||||
transform: translateX(-100%);
|
||||
animation: shim 1.5s ease-in-out infinite
|
||||
}
|
||||
|
||||
.pe .et:not(.lded) {
|
||||
background: #15151c
|
||||
}
|
||||
|
||||
.pe .et img {
|
||||
opacity: 0;
|
||||
transition: opacity .45s ease
|
||||
}
|
||||
|
||||
.pe .et img.ld {
|
||||
opacity: 1
|
||||
}
|
||||
94
index.html
94
index.html
@@ -1,94 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Vyla</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/sidebar.css">
|
||||
<link rel="stylesheet" href="css/detail.css">
|
||||
<link rel="stylesheet" href="css/search.css">
|
||||
<link rel="stylesheet" href="css/library.css">
|
||||
<link rel="stylesheet" href="css/player.css">
|
||||
<link rel="stylesheet" href="css/skeleton.css">
|
||||
|
||||
<script src="js/hls.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="bg"></div>
|
||||
<div id="dim"></div>
|
||||
<div id="dim2"></div>
|
||||
<div id="main">
|
||||
<div class="hero">
|
||||
<div id="hb"></div>
|
||||
<div class="hc sl">
|
||||
<div class="hskel"><i class="sb t"></i><i class="sb m"></i><i class="sb l"></i><i class="sb l"></i><i
|
||||
class="sb l x"></i>
|
||||
<div class="hb"><i class="sb b"></i><i class="sb b c"></i></div>
|
||||
</div>
|
||||
<div id="hlogo"></div>
|
||||
<div class="meta" id="hmeta"></div>
|
||||
<p class="ov1" id="hov"></p>
|
||||
<div class="hb"><button class="pb f" id="hplay">▶ Play</button><button class="ib f"
|
||||
id="hinfo">ⓘ More Info</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rows"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
<div id="sr">
|
||||
<div class="stop"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg><span id="stext">Search</span></div>
|
||||
<div id="kb"></div>
|
||||
<div class="row ch" id="chips"></div>
|
||||
<div class="tabs" id="tabs"></div>
|
||||
<div id="sres"></div>
|
||||
</div>
|
||||
<div id="lib">
|
||||
<h1 id="lt"></h1>
|
||||
<div class="grid" id="lgrid"></div>
|
||||
</div>
|
||||
<aside id="side">
|
||||
<div class="brand"><img id="mark" alt="Vyla"><span>Vyla</span></div>
|
||||
<nav id="nav"></nav>
|
||||
</aside>
|
||||
<div id="ov"></div>
|
||||
<div id="pl">
|
||||
<video id="pbg" playsinline preload="auto" muted></video>
|
||||
<video id="pv" playsinline preload="auto"></video>
|
||||
<div class="top" id="ptop"></div>
|
||||
<div class="f" id="pmid"></div>
|
||||
<div id="pact">
|
||||
<div class="skip f" id="pskip">Skip Intro</div>
|
||||
<div class="skip f" id="pnext"></div>
|
||||
</div>
|
||||
<div class="bot" id="pbot"></div>
|
||||
<div id="pep"></div>
|
||||
<div id="pspin"></div>
|
||||
<div id="pm"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/vyla-local.js"></script>
|
||||
<script src="js/native.js"></script>
|
||||
<script src="js/core.js"></script>
|
||||
<script src="js/skeleton.js"></script>
|
||||
<script src="js/focus.js"></script>
|
||||
<script src="js/library.js"></script>
|
||||
<script src="js/sidebar.js"></script>
|
||||
<script src="js/browse.js"></script>
|
||||
<script src="js/search.js"></script>
|
||||
<script src="js/detail.js"></script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/input.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
57
js/browse.js
57
js/browse.js
@@ -1,57 +0,0 @@
|
||||
function hero(it) { clearTimeout(ht); ht = setTimeout(() => applyHero(it), 130) }
|
||||
async function applyHero(it) {
|
||||
if (heroIt === it) return; heroIt = it; $('.hc').classList.remove('sl');
|
||||
const bp = it.backdrop_path || it.poster_path;
|
||||
xf(bgb, I + 'w780' + bp); xf(hb, I + 'w1280' + bp);
|
||||
const t = it.title || it.name;
|
||||
$('#hlogo').innerHTML = `<h1>${t}</h1>`;
|
||||
$('#hmeta').innerHTML = `<span>★ ${(it.vote_average || 0).toFixed(1)}</span><span>${yr(it)}</span><span class="b">${it._t === 'movie' ? 'Movie' : 'Series'}</span><span>${gn(it)}</span>`;
|
||||
$('#hov').textContent = it.overview || '';
|
||||
const l = await logo(it);
|
||||
if (heroIt === it && l) $('#hlogo').innerHTML = `<img src="${l}" alt="${t.replace(/"/g, '')}">`
|
||||
}
|
||||
|
||||
function card(it, h = true) {
|
||||
const c = document.createElement('div'); c.className = 'card f'; c.tabIndex = -1;
|
||||
c.innerHTML = `<img loading="lazy" src="${I}w342${it.poster_path}" alt="">`;
|
||||
if (h) c._it = it; c._a = () => openDetail(it); return c
|
||||
}
|
||||
|
||||
function mkRow(i) {
|
||||
const mv = mode === "movie" || (mode === "discover" && i % 2 === 0), k = mode === "discover" ? i >> 1 : i, list = mv ? MG : TG, g = list[k % list.length], pg = 1 + Math.floor(k / list.length) % 20;
|
||||
const sec = document.createElement('section'); sec.className = 'sec';
|
||||
sec.innerHTML = `<h2>${g.name} ${mv ? 'Movies' : 'TV Shows'}</h2><div class="row">${SK.cards(14)}</div>`;
|
||||
rowsEl.appendChild(sec);
|
||||
return api(`/discover/${mv ? 'movie' : 'tv'}`, `&with_genres=${g.id}&sort_by=popularity.desc&vote_count.gte=100&include_adult=false&page=${pg}`).then(d => {
|
||||
const rs = (d.results || []).filter(x => x.poster_path);
|
||||
if (!rs.length) { sec.remove(); return }
|
||||
sec.lastChild.innerHTML = ''; rs.forEach(x => { x._t = mv ? 'movie' : 'tv'; sec.lastChild.appendChild(card(x)) })
|
||||
})
|
||||
}
|
||||
|
||||
async function more(n) {
|
||||
if (busy) return; busy = true; const g = gen;
|
||||
const ps = []; for (let j = 0; j < n; j++)ps.push(mkRow(idx++));
|
||||
await Promise.all(ps); if (g === gen) busy = false
|
||||
}
|
||||
|
||||
async function go(k) {
|
||||
if (k === 'search') { closeLib(true); openSearch(); return }
|
||||
closeSearch(true);
|
||||
if (k === 'wl' || k === 'fav') { openLib(k); return }
|
||||
closeLib(true);
|
||||
if (k === mode && rowsEl.children.length) { setAct(k); setFocus(document.querySelector('#rows .card')); return }
|
||||
mode = k; setAct(k); gen++; busy = false; idx = 0; heroIt = null; lastMain = null; rowsEl.innerHTML = ''; main.scrollTop = 0; $('.hc').classList.add('sl');
|
||||
await more(6);
|
||||
const first = document.querySelector('#rows .card');
|
||||
if (first) { applyHero(first._it); setFocus(first) }
|
||||
}
|
||||
|
||||
main.addEventListener('scroll', () => {
|
||||
const h = innerHeight;
|
||||
$('#dim2').style.opacity = Math.min(main.scrollTop / (h * .6), 1) * .55;
|
||||
if (main.scrollTop + h * 2.5 > main.scrollHeight) more(4)
|
||||
});
|
||||
|
||||
$('#hplay')._a = () => openPlayer(heroIt);
|
||||
$('#hinfo')._a = () => heroIt && openDetail(heroIt);
|
||||
37
js/core.js
37
js/core.js
@@ -1,37 +0,0 @@
|
||||
const API_BASE = window.API_BASE || localStorage.getItem('vyla.api') || '';
|
||||
|
||||
const K = '3f6703f3e2a0b2b40fe4fbb3e1546244', A = 'https://api.themoviedb.org/3', I = 'https://image.tmdb.org/t/p/';
|
||||
const $ = s => document.querySelector(s);
|
||||
const main = $('#main'), rowsEl = $('#rows'), side = $('#side'), ov = $('#ov'), bgb = $('#bg'), hb = $('#hb'), sr = $('#sr'), sres = $('#sres'), chips = $('#chips'), tabs = $('#tabs'), kb = $('#kb'), pl = $('#pl'), vd = $('#pv');
|
||||
const api = async (p, q = '') => { try { const r = await fetch(`${A}${p}?api_key=${K}&language=en-US${q}`); return r.ok ? await r.json() : {} } catch (e) { return {} } };
|
||||
const ic = p => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
|
||||
const P_WL = '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', P_FV = '<path d="M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 22l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8z"/>';
|
||||
const LN = { wl: 'Watch Later', fav: 'Favorites' };
|
||||
const NAVEL = {}; let navAct = null, mode = '', gen = 0, sgen = 0, st;
|
||||
let fast = false, G = {}, MG = [], TG = [], cur = null, lastMain = null, heroIt = null, idx = 0, busy = false, ht;
|
||||
const LC = {};
|
||||
|
||||
function xf(box, url, cls = 'xf') {
|
||||
if (box._u === url) return; box._u = url;
|
||||
const im = new Image();
|
||||
im.onload = () => { if (box._u !== url) return; const d = document.createElement('div'); d.className = cls; d.style.backgroundImage = `url(${url})`; box.appendChild(d); requestAnimationFrame(() => requestAnimationFrame(() => d.classList.add('in'))); setTimeout(() => { while (box.children.length > 2) box.firstChild.remove() }, 800) };
|
||||
im.onerror = () => { if (box._u === url) box._u = null };
|
||||
im.src = url
|
||||
}
|
||||
|
||||
async function logo(it) {
|
||||
const k = it._t + it.id; if (k in LC) return LC[k];
|
||||
const d = await api(`/${it._t}/${it.id}/images`, '&include_image_language=en,null');
|
||||
const l = (d.logos || []).find(x => x.iso_639_1 === 'en') || (d.logos || [])[0];
|
||||
return LC[k] = l ? I + 'w500' + l.file_path : null
|
||||
}
|
||||
|
||||
const yr = it => ((it.release_date || it.first_air_date || '') + '').slice(0, 4);
|
||||
const gn = it => (it.genre_ids || []).map(i => G[i]).filter(Boolean).slice(0, 2).join(', ');
|
||||
|
||||
const esc = t => String(t).replace(/</g, '<');
|
||||
const mkf = (cls, html, fn) => { const e = document.createElement('div'); e.className = cls + ' f'; e.tabIndex = -1; e.innerHTML = html; e._a = fn; return e };
|
||||
const LOGO = 'assets/favicon.ico', PH = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="11" fill="#2a2a34"/><circle cx="14" cy="14" r="3" fill="#5b5b6b"/><path d="M7 31l9-10 6 6 4-4 7 8z" fill="#5b5b6b"/></svg>');
|
||||
function toast(m) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = m; (document.fullscreenElement || document.body).appendChild(t); setTimeout(() => t.remove(), 2300) }
|
||||
const imgDone = e => { const t = e.target; if (t.tagName === 'IMG') { t.classList.add('ld'); t.parentElement && t.parentElement.classList.add('lded') } };
|
||||
document.addEventListener('load', imgDone, true); document.addEventListener('error', imgDone, true);
|
||||
66
js/detail.js
66
js/detail.js
@@ -1,66 +0,0 @@
|
||||
let dgen = 0;
|
||||
async function openDetail(it) {
|
||||
const t = it._t;
|
||||
if (!ov.classList.contains('show')) ov._prev = cur;
|
||||
ov.classList.add('show'); side.classList.remove('open'); const my = ++dgen; ov.innerHTML = `<div class="pic f" id="dback">${ic('<path d="M19 12H5M12 19l-7-7 7-7"/>')}</div>` + SK.detail(it); $('#dback')._a = closeDetail; ov.scrollTop = 0;
|
||||
const d = await api(`/${t}/${it.id}`, `&append_to_response=videos,credits,recommendations,images,${t === 'movie' ? 'release_dates' : 'content_ratings'}&include_image_language=en,null`);
|
||||
if (my !== dgen || !ov.classList.contains('show')) return;
|
||||
if (!d.id) { closeDetail(); return }
|
||||
const title = d.title || d.name, year = ((d.release_date || d.first_air_date) || '').slice(0, 4);
|
||||
const cert = t === 'movie' ? (((d.release_dates || {}).results || []).find(r => r.iso_3166_1 === 'US') || { release_dates: [] }).release_dates.map(x => x.certification).find(Boolean) : (((d.content_ratings || {}).results || []).find(r => r.iso_3166_1 === 'US') || {}).rating;
|
||||
const rt = t === 'movie' ? (d.runtime ? `${Math.floor(d.runtime / 60)}h ${d.runtime % 60}m` : '') : (d.number_of_seasons ? `${d.number_of_seasons} Season${d.number_of_seasons > 1 ? 's' : ''}` : '');
|
||||
const lg = ((d.images || {}).logos || []).find(x => x.iso_639_1 === 'en') || ((d.images || {}).logos || [])[0];
|
||||
const bp = d.backdrop_path || d.poster_path;
|
||||
ov.innerHTML = `<div class="pic f" id="dback">${ic('<path d="M19 12H5M12 19l-7-7 7-7"/>')}</div><div class="dbl" style="background-image:url(${I}w780${bp})"></div><div class="dw"><div class="dimg" style="background-image:url(${I}w1280${bp})"></div><div class="dc"><div class="dh"><div id="dlogo">${lg ? `<img src="${I}w500${lg.file_path}" alt="">` : `<h1>${title}</h1>`}</div><div class="meta"><span>${(d.vote_average || 0).toFixed(1)}</span><span>${year}</span>${cert ? `<span class="b">${cert}</span>` : ''}<span>${rt}</span><span>${(d.genres || []).slice(0, 3).map(g => g.name).join(', ')}</span></div><div class="hb"><button class="pb f" id="dplay">Play</button><div class="circ f" id="dmenu">⋮</div><div class="mx" id="dmx"><div class="circ f" id="dwl"></div><div class="circ f" id="dfv"></div></div></div><p class="otx" id="otx">${d.overview || ''}</p><span class="more f" id="dmore">MORE</span></div><div id="dsecs"></div></div></div>`;
|
||||
$('#dback')._a = closeDetail; const secs = $('#dsecs');
|
||||
const rowOf = (name, kids) => { if (!kids.length) return; const s = document.createElement('section'); s.className = 'ds'; s.innerHTML = `<h3>${name}</h3><div class="row"></div>`; s.lastChild.append(...kids); secs.appendChild(s) };
|
||||
const el = (c, h) => { const e = document.createElement('div'); e.className = c + ' f'; e.tabIndex = -1; e.innerHTML = h; e._a = () => { }; return e };
|
||||
if (t === 'tv') {
|
||||
const ss = (d.seasons || []).filter(x => x.season_number > 0 && x.episode_count);
|
||||
if (ss.length) {
|
||||
const sec = document.createElement('section'); sec.className = 'ds eps';
|
||||
sec.innerHTML = '<div class="eh"><h3>Episodes</h3><div class="dd"><div class="ddb f" id="ddb"></div><div class="ddl" id="ddl"></div></div></div><div class="el" id="el"></div>';
|
||||
secs.appendChild(sec);
|
||||
const ddb = $('#ddb'), ddl = $('#ddl'), list = $('#el'); let cs = null;
|
||||
const nm = x => x.name || 'Season ' + x.season_number;
|
||||
const loadEps = async x => {
|
||||
cs = x; ddb.innerHTML = `<span>${esc(nm(x))}</span>` + ic('<path d="m6 9 6 6 6-6"/>');
|
||||
[...ddl.children].forEach((c, i) => c.classList.toggle('cur', ss[i] === x));
|
||||
list.innerHTML = SK.eps(10);
|
||||
const r = await api(`/tv/${it.id}/season/${x.season_number}`);
|
||||
if (cs !== x || !list.isConnected) return;
|
||||
list.innerHTML = '';
|
||||
(r.episodes || []).forEach((p, ix) => {
|
||||
const n = p.episode_number, img = p.still_path ? I + 'w300' + p.still_path : (it.backdrop_path ? I + 'w300' + it.backdrop_path : PH);
|
||||
const e = el('ep', `<div class="et"><img loading="lazy" src="${img}" alt=""></div><b>${esc(p.name || 'Episode ' + n)}</b><small>${['Episode ' + n, p.runtime ? p.runtime + 'm' : ''].filter(Boolean).join(' \u2022 ')}</small>`);
|
||||
e.title = p.overview || '';
|
||||
e._a = () => openPlayer(it, `S${x.season_number} E${n} \u2022 ${p.name || ''}`, { it, seasons: ss, s: x.season_number, eps: r.episodes, i: ix, cache: { [x.season_number]: r.episodes } }); list.appendChild(e)
|
||||
});
|
||||
const f0 = list.firstChild; if (f0 && f0._a && $('#dplay')) $('#dplay')._a = () => f0._a();
|
||||
if (!list.children.length) list.innerHTML = '<p class="none">No episodes listed.</p>'
|
||||
};
|
||||
ss.forEach(x => ddl.appendChild(mkf('ddi', `<span>${esc(nm(x))}</span><small>${x.episode_count} eps</small>`, () => { closeDD(); loadEps(x) })));
|
||||
ddb._a = () => { if (!closeDD()) { ddl.classList.add('open'); setFocus(ddl.children[Math.max(0, ss.indexOf(cs))]) } };
|
||||
loadEps(ss[0])
|
||||
}
|
||||
}
|
||||
rowOf('Trailers', ((d.videos || {}).results || []).filter(v => v.site === 'YouTube' && /Trailer|Teaser|Clip/.test(v.type)).sort((a, b) => (b.type === 'Trailer') - (a.type === 'Trailer')).slice(0, 8).map(v => el('tr', `<div class="tb" style="background-image:url(https://img.youtube.com/vi/${v.key}/mqdefault.jpg)"></div><div class="th" style="background-image:url(https://img.youtube.com/vi/${v.key}/mqdefault.jpg)"></div><b>${v.name}</b><small>▶ ${v.type}</small>`)));
|
||||
rowOf('Related', ((d.recommendations || {}).results || []).filter(x => x.poster_path).map(x => { x._t = t; return card(x, false) }));
|
||||
rowOf('Cast', ((d.credits || {}).cast || []).slice(0, 20).map(p => el('cs', `<div class="pf" style="${p.profile_path ? `background-image:url(${I}w185${p.profile_path})` : ''}">${p.profile_path ? '' : p.name[0]}</div><b>${p.name}</b><small>${p.character || ''}</small>`)));
|
||||
$('#dplay')._a = () => openPlayer(it); const sync = () => [['dwl', 'wl', P_WL], ['dfv', 'fav', P_FV]].forEach(([id, k, p]) => { const e = $('#' + id), v = has(k, it); e.classList.toggle('sv', v); e.dataset.l = (v ? 'Remove from ' : 'Add to ') + LN[k]; e.innerHTML = ic(p) });
|
||||
sync();
|
||||
$('#dmenu')._a = () => { const o = $('#dmx').classList.toggle('open'); $('#dmenu').classList.toggle('sv', o) };
|
||||
$('#dwl')._a = () => { toggleLib('wl', it); sync() }; $('#dfv')._a = () => { toggleLib('fav', it); sync() };
|
||||
$('#dmore')._a = () => { const o = $('#otx'), m = $('#dmore'); o.classList.toggle('x'); m.textContent = o.classList.contains('x') ? 'LESS' : 'MORE' };
|
||||
ov.scrollTop = 0;
|
||||
setFocus($('#dplay'))
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
ov.classList.remove('show');
|
||||
const p = ov._prev;
|
||||
setTimeout(() => { if (!ov.classList.contains('show')) ov.innerHTML = '' }, 400);
|
||||
if (p && document.body.contains(p)) setFocus(p); else if (lib.classList.contains('show')) setFocus($('#lgrid .card') || navAct)
|
||||
}
|
||||
|
||||
function closeDD() { const l = $('#ddl'); if (l && l.classList.contains('open')) { l.classList.remove('open'); setFocus($('#ddb')); return true } return false }
|
||||
60
js/focus.js
60
js/focus.js
@@ -1,60 +0,0 @@
|
||||
function setFocus(el) {
|
||||
if (!el) return;
|
||||
const od = $('#ddl.open'); if (od && !el.closest('.dd')) od.classList.remove('open');
|
||||
cur && cur.classList.remove('on'); cur = el; el.classList.add('on');
|
||||
const inSide = !!el.closest('#side'), inOv = !!el.closest('#ov');
|
||||
side.classList.toggle('open', inSide && !ov.classList.contains('show'));
|
||||
if (!inSide && !inOv && !el.closest('#pl')) lastMain = el;
|
||||
const row = el.closest('.row');
|
||||
if (row) row.scrollTo({ left: Math.max(0, el.offsetLeft - parseInt(getComputedStyle(row).paddingLeft)), behavior: fast ? 'auto' : 'smooth' });
|
||||
|
||||
const menu = el.closest('#pm');
|
||||
if (menu) {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: fast ? 'auto' : 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const scr = el.closest('#ov,#sr,#lib');
|
||||
if (scr && el.closest('#kb,#chips,#tabs')) scr.scrollTo({ top: 0, behavior: fast ? 'auto' : 'smooth' });
|
||||
else if (scr) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.bottom > innerHeight - 50) scr.scrollBy({ top: r.bottom - innerHeight + 110, behavior: fast ? 'auto' : 'smooth' });
|
||||
else if (r.top < 50) scr.scrollBy({ top: r.top - 110, behavior: fast ? 'auto' : 'smooth' });
|
||||
}
|
||||
else if (!inSide && !el.closest('#pl')) {
|
||||
const sec = el.closest('.sec');
|
||||
main.scrollTo({ top: sec ? Math.max(0, sec.offsetTop - innerHeight * .52) : 0, behavior: fast ? 'auto' : 'smooth' });
|
||||
}
|
||||
if (el._it) hero(el._it); if (el._bg) sbgf(el._bg);
|
||||
}
|
||||
|
||||
function nav(d) {
|
||||
const sc = pl.classList.contains('show') ? (pl.classList.contains('eps') ? '#pep .f' : '#pl .f') : ov.classList.contains('show') ? '#ov .f' : sr.classList.contains('show') ? '#sr .f,#side .f' : lib.classList.contains('show') ? '#lib .f,#side .f' : '#side .f,#main .f';
|
||||
const c = cur.getBoundingClientRect(), cx = c.left + c.width / 2, cy = c.top + c.height / 2;
|
||||
const inSide = !!cur.closest('#side');
|
||||
if (inSide && d === 'r' && lastMain && getComputedStyle(lastMain).visibility !== 'hidden') { setFocus(lastMain); return }
|
||||
if ((d === 'l' || d === 'r') && !inSide) {
|
||||
const g = cur.closest('.row,#kb,#tabs');
|
||||
if (g) {
|
||||
const L = [...g.children].filter(e => e.classList.contains('f') && e.getBoundingClientRect().width), i = L.indexOf(cur), n = L[i + (d === 'r' ? 1 : -1)];
|
||||
if (i >= 0) { if (n) setFocus(n); else if (d === 'l' && !cur.closest('#ov,#pl')) setFocus(navAct); return }
|
||||
}
|
||||
}
|
||||
let best = null, bs = 1e9;
|
||||
document.querySelectorAll(sc).forEach(e => {
|
||||
if (e === cur) return;
|
||||
const r = e.getBoundingClientRect(); if (!r.width) return;
|
||||
const x = r.left + r.width / 2, y = r.top + r.height / 2, dx = x - cx, dy = y - cy;
|
||||
let p, s;
|
||||
if (d === 'r') { if (dx < 8) return; p = dx; s = Math.abs(dy) * 2.5 }
|
||||
else if (d === 'l') { if (dx > -8) return; p = -dx; s = Math.abs(dy) * 2.5 }
|
||||
else if (d === 'd') { if (dy < 8) return; p = dy; s = Math.abs(dx) * 2 }
|
||||
else { if (dy > -8) return; p = -dy; s = Math.abs(dx) * 2 }
|
||||
if (p + s < bs) { bs = p + s; best = e }
|
||||
});
|
||||
if (!best) return;
|
||||
if (best.closest('#side') && !inSide) best = navAct;
|
||||
setFocus(best)
|
||||
}
|
||||
|
||||
function act(el) { el && el._a && el._a() }
|
||||
112
js/input.js
112
js/input.js
@@ -1,112 +0,0 @@
|
||||
if (window.tizen && window.tizen.tvinputdevice) {
|
||||
try {
|
||||
['MediaPlay', 'MediaPause', 'MediaPlayPause', 'MediaStop', 'MediaFastForward', 'MediaRewind', '10009']
|
||||
.forEach(k => tizen.tvinputdevice.registerKey(k));
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
const KM = {
|
||||
37: 'l', 21: 'l',
|
||||
38: 'u', 19: 'u',
|
||||
39: 'r', 22: 'r',
|
||||
40: 'd', 20: 'd',
|
||||
13: 'e', 23: 'e', 66: 'e',
|
||||
27: 'b', 8: 'b', 4: 'b', 10009: 'b', 461: 'b'
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
fast = e.repeat;
|
||||
document.body.classList.add('kbm');
|
||||
|
||||
if (e.keyCode === 85 || e.keyCode === 179 || e.keyCode === 10252 || e.keyCode === 415) {
|
||||
e.preventDefault();
|
||||
tgl();
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === 126 || e.keyCode === 250) {
|
||||
e.preventDefault();
|
||||
vd.play();
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === 127 || e.keyCode === 413) {
|
||||
e.preventDefault();
|
||||
vd.pause();
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === 89 || e.keyCode === 227 || e.keyCode === 412) {
|
||||
e.preventDefault();
|
||||
vd.currentTime = Math.max(0, vd.currentTime - 10);
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === 90 || e.keyCode === 228 || e.keyCode === 417) {
|
||||
e.preventDefault();
|
||||
vd.currentTime = Math.min(vd.duration || Infinity, vd.currentTime + 10);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pl.classList.contains('show')) {
|
||||
showCtl();
|
||||
if (e.key === ' ' || e.keyCode === 32) { e.preventDefault(); tgl(); return }
|
||||
if (cur === $('#pseek') && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 21 || e.keyCode === 22)) {
|
||||
e.preventDefault();
|
||||
vd.currentTime += (e.keyCode === 39 || e.keyCode === 22) ? 10 : -10;
|
||||
return;
|
||||
}
|
||||
if (cur && cur.id === 'vsl' && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 21 || e.keyCode === 22)) {
|
||||
e.preventDefault();
|
||||
vd.muted = false;
|
||||
vd.volume = Math.min(1, Math.max(0, vd.volume + ((e.keyCode === 39 || e.keyCode === 22) ? .1 : -.1)));
|
||||
cur._v();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sr.classList.contains('show') && !ov.classList.contains('show') && !pl.classList.contains('show')) {
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setQ(sqv + e.key); return }
|
||||
if (e.keyCode === 8) { e.preventDefault(); setQ(sqv.slice(0, -1)); return }
|
||||
}
|
||||
|
||||
const k = KM[e.keyCode];
|
||||
if (!k) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (!cur) {
|
||||
const fallback = document.querySelector('.f');
|
||||
if (fallback) setFocus(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (k === 'e') act(cur);
|
||||
else if (k === 'b') {
|
||||
if (pl.classList.contains('show')) {
|
||||
pmK ? closeMenu(1) : pl.classList.contains('eps') ? closeEps(1) : closePlayer();
|
||||
} else if (ov.classList.contains('show')) {
|
||||
closeDD() || closeDetail();
|
||||
} else if (lib.classList.contains('show')) {
|
||||
closeLib();
|
||||
} else if (sr.classList.contains('show')) {
|
||||
closeSearch();
|
||||
}
|
||||
} else {
|
||||
nav(k);
|
||||
}
|
||||
});
|
||||
|
||||
let mx = -1, my = -1;
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (Math.abs(e.clientX - mx) + Math.abs(e.clientY - my) < 4) return;
|
||||
mx = e.clientX; my = e.clientY; document.body.classList.remove('kbm');
|
||||
if (pl.classList.contains('show')) { showCtl(); const g = e.target.closest('.f'); if (g && g !== cur && pl.contains(g)) setFocus(g); return }
|
||||
const f = e.target.closest('.f');
|
||||
if (f && f !== cur && (!ov.classList.contains('show') || ov.contains(f))) setFocus(f)
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
const f = e.target.closest('.f'); if (f) act(f)
|
||||
});
|
||||
ov.addEventListener('wheel', () => { }, { passive: true });
|
||||
|
||||
window.vylaBack = () => {
|
||||
const open = [pl, ov, lib, sr].some(e => e.classList.contains('show'));
|
||||
if (open) document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 4, key: 'GoBack', bubbles: true, cancelable: true }));
|
||||
return open;
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
let LIB = { wl: [], fav: [] }, libK = null; const lib = $('#lib');
|
||||
try { Object.assign(LIB, JSON.parse(localStorage.getItem('vyla.lib') || '{}')) } catch (e) { }
|
||||
const saveLib = () => { try { localStorage.setItem('vyla.lib', JSON.stringify(LIB)) } catch (e) { } };
|
||||
const slim = it => ({ id: it.id, _t: it._t || it.media_type, title: it.title, name: it.name, poster_path: it.poster_path, backdrop_path: it.backdrop_path, release_date: it.release_date, first_air_date: it.first_air_date, vote_average: it.vote_average, overview: it.overview });
|
||||
const has = (k, it) => LIB[k].some(x => x.id === it.id && x._t === it._t);
|
||||
function toggleLib(k, it) {
|
||||
const i = LIB[k].findIndex(x => x.id === it.id && x._t === it._t), had = i >= 0;
|
||||
if (had) LIB[k].splice(i, 1); else LIB[k].unshift(slim(it));
|
||||
saveLib(); syncNav(); toast((had ? 'Removed from ' : 'Added to ') + LN[k]);
|
||||
if (lib.classList.contains('show')) drawLib()
|
||||
}
|
||||
function drawLib() {
|
||||
const k = libK, a = LIB[k], g = $('#lgrid');
|
||||
$('#lt').innerHTML = `${LN[k]}<small>${a.length} title${a.length === 1 ? '' : 's'}</small>`;
|
||||
g.innerHTML = '';
|
||||
if (!a.length) { g.innerHTML = '<p class="none">Nothing here yet. Open a title, press the \u22EE button and add it.</p>'; return }
|
||||
a.forEach(x => { const c = card(x, false); c._bg = x; g.appendChild(c) })
|
||||
}
|
||||
function openLib(k) {
|
||||
libK = k; setAct(k); document.body.classList.add('lib'); lib.classList.add('show'); side.classList.remove('open'); lib.scrollTop = 0;
|
||||
drawLib(); syncNav(); setFocus($('#lgrid .card') || NAVEL[k])
|
||||
}
|
||||
function closeLib(silent) {
|
||||
if (!lib.classList.contains('show')) return;
|
||||
lib.classList.remove('show'); document.body.classList.remove('lib'); libK = null; heroIt = null; bgb._u = null; syncNav();
|
||||
if (!silent) { setAct(mode); setFocus($('#rows .card')) }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
(async () => {
|
||||
rowsEl.innerHTML = SK.rows(4);
|
||||
const [m, t] = await Promise.all([api('/genre/movie/list'), api('/genre/tv/list')]);
|
||||
MG = m.genres || []; TG = t.genres || [];
|
||||
[...MG, ...TG].forEach(g => G[g.id] = g.name);
|
||||
if (!MG.length || !TG.length) { rowsEl.innerHTML = '<p style="padding:40vh var(--pad)">Could not reach TMDB.</p>'; return }
|
||||
await go('discover');
|
||||
})();
|
||||
112
js/native.js
112
js/native.js
@@ -1,112 +0,0 @@
|
||||
(() => {
|
||||
const cap = window.Capacitor || {};
|
||||
const N = (cap.Plugins && cap.Plugins.VylaNative) || cap.registerPlugin('VylaNative');
|
||||
window.API_BASE = 'http://127.0.0.1:38417';
|
||||
|
||||
const webFetch = window.fetch.bind(window);
|
||||
const dec = new TextDecoder('utf-8');
|
||||
const bytesOf = s => { const b = atob(s), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u };
|
||||
const abortErr = () => new DOMException('The operation was aborted', 'AbortError');
|
||||
|
||||
const wrap = r => {
|
||||
const bytes = bytesOf(r.body || ''), h = r.headers || {};
|
||||
const text = async () => dec.decode(bytes);
|
||||
return {
|
||||
ok: r.status >= 200 && r.status < 300,
|
||||
status: r.status,
|
||||
statusText: '',
|
||||
url: r.url,
|
||||
headers: { get: k => { const v = h[String(k).toLowerCase()]; return v === undefined ? null : v }, has: k => String(k).toLowerCase() in h },
|
||||
body: { getReader: () => { let d = false; return { read: async () => d ? { done: true, value: undefined } : (d = true, { done: false, value: bytes }) } }, cancel: async () => { } },
|
||||
text,
|
||||
json: async () => JSON.parse(await text()),
|
||||
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||
};
|
||||
};
|
||||
|
||||
window.fetch = (input, init = {}) => {
|
||||
const isReq = typeof input !== 'string';
|
||||
const url = (isReq ? (input && input.url) : input) || '';
|
||||
if (!/^https?:\/\//i.test(url) || url.startsWith(window.API_BASE) || /^https:\/\/(api|image)\.themoviedb\.org|^https:\/\/image\.tmdb\.org/i.test(url)) return webFetch(input, init);
|
||||
const hd = {};
|
||||
new Headers(init.headers || (isReq ? input.headers : undefined)).forEach((v, k) => { hd[k] = v });
|
||||
const sig = init.signal;
|
||||
if (sig && sig.aborted) return Promise.reject(abortErr());
|
||||
const call = N.fetch({
|
||||
url,
|
||||
method: String(init.method || (isReq ? input.method : 'GET')).toUpperCase(),
|
||||
headers: hd,
|
||||
body: init.body == null ? undefined : String(init.body),
|
||||
timeout: init.timeout || 120000,
|
||||
redirect: init.redirect || 'follow',
|
||||
maxBytes: init.maxBytes || 0
|
||||
}).then(wrap);
|
||||
if (!sig) return call;
|
||||
return Promise.race([call, new Promise((_, rej) => sig.addEventListener('abort', () => rej(abortErr()), { once: true }))]);
|
||||
};
|
||||
|
||||
let booted = false;
|
||||
const boot = () => { if (!booted) { VylaLocal.init(K); booted = true } };
|
||||
const opt = v => v === undefined || v === null || v === '' || v === 'null' || v === 'undefined' ? undefined : String(v);
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||
const OKS = [200, 206, 304];
|
||||
const SEG = { recap: 'Skip Recap', intro: 'Skip Intro', preview: 'Skip Preview', credits: 'Skip Credits' };
|
||||
|
||||
const probeGet = (u, h, max) => fetch(u, { headers: { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...h, Range: 'bytes=0-1024' }, timeout: 6000, maxBytes: max });
|
||||
|
||||
const playable = async (url, h, depth = 0) => {
|
||||
try {
|
||||
const r = await probeGet(url, h, 1048576);
|
||||
if (!OKS.includes(r.status)) return false;
|
||||
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||
if (!(url.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8'))) return true;
|
||||
if (depth > 4) return false;
|
||||
const line = (await r.text()).split('\n').map(x => x.trim()).find(x => x && x[0] !== '#');
|
||||
if (!line) return false;
|
||||
const base = url.slice(0, url.lastIndexOf('/') + 1), origin = new URL(url).origin;
|
||||
const t = line.startsWith('http') ? line : line.startsWith('/') ? origin + line : base + line;
|
||||
if (t.includes('m3u8')) return playable(t, h, depth + 1);
|
||||
return OKS.includes((await probeGet(t, h, 1024)).status);
|
||||
} catch (_) { return false }
|
||||
};
|
||||
|
||||
window.Vyla = {
|
||||
sources() {
|
||||
boot();
|
||||
return VylaLocal.sources().map(c => ({ key: c.key, label: c.label }));
|
||||
},
|
||||
async stream(provider, tmdbId, season, episode) {
|
||||
boot();
|
||||
try {
|
||||
const d = await VylaLocal.stream(String(provider), String(tmdbId), opt(season), opt(episode));
|
||||
if (!d) return { success: false };
|
||||
const h = d.headers || {};
|
||||
if (Array.isArray(d.allUrls) && d.allUrls.length) {
|
||||
const ok = await Promise.all(d.allUrls.map(async u => (u && u.url && await playable(u.url, h)) ? u : null));
|
||||
d.allUrls = ok.filter(Boolean);
|
||||
if (d.allUrls.length) { if (!d.url || !d.allUrls.some(u => u.url === d.url)) d.url = d.allUrls[0].url }
|
||||
else d.url = null;
|
||||
} else if (d.url && !(await playable(d.url, h))) d.url = null;
|
||||
return d.url ? { success: true, provider, stream: d } : { success: false };
|
||||
} catch (_) { return { success: false } }
|
||||
},
|
||||
async subtitles(id, season, episode) {
|
||||
boot();
|
||||
try { return (await VylaLocal.subtitles(String(id), opt(season), opt(episode))) || [] } catch (_) { return [] }
|
||||
},
|
||||
async timestamps(id, season, episode, durationSec) {
|
||||
let u = `https://api.theintrodb.org/v3/media?tmdb_id=${id}`;
|
||||
if (season) u += `&season=${season}`;
|
||||
if (episode) u += `&episode=${episode}`;
|
||||
if (durationSec) u += `&duration_ms=${Math.round(durationSec * 1000)}`;
|
||||
try {
|
||||
const r = await fetch(u, { timeout: 8000 });
|
||||
if (!r.ok) return [];
|
||||
const d = await r.json(), out = [];
|
||||
Object.keys(SEG).forEach(t => (Array.isArray(d[t]) ? d[t] : []).forEach(x => out.push({ type: t, label: SEG[t], start: x.start_ms != null ? x.start_ms / 1000 : 0, end: x.end_ms != null ? x.end_ms / 1000 : null })));
|
||||
return out;
|
||||
} catch (_) { return [] }
|
||||
}
|
||||
};
|
||||
})();
|
||||
794
js/player.js
794
js/player.js
@@ -1,794 +0,0 @@
|
||||
let currentHls = null;
|
||||
let currentBgHls = null;
|
||||
let currentSources = [];
|
||||
let resolvedStreams = [];
|
||||
let selectedSourceKey = null;
|
||||
let selectedStreamUrl = null;
|
||||
let expandedSources = new Set();
|
||||
let currentQualityLevels = [];
|
||||
let selectedQualityIndex = -1;
|
||||
let activeSubtitles = [];
|
||||
let mediaSegments = [];
|
||||
let activeSegment = null;
|
||||
let plPrev = null;
|
||||
let plT = null;
|
||||
let plItem = null;
|
||||
let pmK = null;
|
||||
let plCtx = null;
|
||||
let viewS = null;
|
||||
let drag = 0;
|
||||
let discoverySession = 0;
|
||||
let userHasManuallySelectedSource = false;
|
||||
|
||||
let subSettings = {
|
||||
size: 24,
|
||||
color: '#ffffff',
|
||||
bg: 'rgba(0,0,0,0.75)',
|
||||
offset: 0
|
||||
};
|
||||
|
||||
const pi = (p, f) => `<svg viewBox="0 0 24 24" fill="${f ? 'currentColor' : 'none'}" stroke="${f ? 'none' : 'currentColor'}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
|
||||
const SPK = '<path d="M4 9v6h4l5 4V5L8 9z"/>';
|
||||
const PI = {
|
||||
x: pi('<path d="M5 5l14 14M19 5 5 19"/>'),
|
||||
play: pi('<path d="M7 4.5v15L19.5 12z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>', 1),
|
||||
pause: pi('<rect x="6" y="4.5" width="4" height="15" rx="1.2"/><rect x="14" y="4.5" width="4" height="15" rx="1.2"/>', 1),
|
||||
cc: pi('<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10a2 2 0 1 0 0 4M17 10a2 2 0 1 0 0 4"/>'),
|
||||
vol: pi(SPK + '<path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a9 9 0 0 1 0 12"/>'),
|
||||
mute: pi(SPK + '<path d="m17 9.5 4 5m0-5-4 5"/>'),
|
||||
qlt: pi('<path d="M4 6h16M4 12h16M4 18h7"/>'),
|
||||
set: pi('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
|
||||
more: pi('<path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.4"/>'),
|
||||
fs: pi('<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>'),
|
||||
fx: pi('<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>'),
|
||||
eps: pi('<rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="3" y="14" width="7" height="6" rx="1.5"/><rect x="14" y="14" width="7" height="6" rx="1.5"/>'),
|
||||
chk: pi('<path d="m5 12.5 4.5 4.5L19 7"/>')
|
||||
};
|
||||
|
||||
$('#ptop').innerHTML = `<div class="pic f" id="pback">${PI.x}</div><img class="av" id="pav" alt=""><div class="pt"><b id="pt"></b><small id="ps"></small></div><div class="pr"><div class="pic f" id="pqlt" title="Quality">${PI.qlt}</div><div class="pic f" id="pcc">${PI.cc}</div><div class="pic f" id="pvol">${PI.vol}</div><div class="pic f" id="pset">${PI.set}</div><div class="pic f" id="pmore">${PI.more}</div></div>`;
|
||||
$('#pbot').innerHTML = `<span class="bt" id="pcur">0:00</span><div class="sk f" id="pseek"><u id="pbuf"></u><i id="pfill"></i><b id="phd"></b></div><span class="bt" id="pdur">0:00</span><div class="pic f" id="peps">${PI.eps}</div><div class="pic f" id="pfs">${PI.fs}</div>`;
|
||||
$('#pmid').innerHTML = PI.play;
|
||||
|
||||
const pm = $('#pm');
|
||||
const sk = $('#pseek');
|
||||
const pbg = $('#pbg');
|
||||
|
||||
const fmt = t => {
|
||||
t = Math.max(0, Math.floor(t || 0));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const c = t % 60;
|
||||
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(c).padStart(2, '0');
|
||||
};
|
||||
|
||||
const tgl = () => vd.paused ? vd.play() : vd.pause();
|
||||
const pico = () => {
|
||||
$('#pmid').innerHTML = vd.paused ? PI.play : PI.pause;
|
||||
};
|
||||
|
||||
function showCtl() {
|
||||
pl.classList.add('ui');
|
||||
clearTimeout(plT);
|
||||
plT = setTimeout(() => {
|
||||
if (!vd.paused && !pmK && !pl.classList.contains('eps')) {
|
||||
pl.classList.remove('ui');
|
||||
}
|
||||
}, 3800);
|
||||
}
|
||||
|
||||
function closeMenu(rf) {
|
||||
if (!pmK) return;
|
||||
const t = {
|
||||
qlt: '#pqlt',
|
||||
cc: '#pcc',
|
||||
vol: '#pvol',
|
||||
set: '#pset',
|
||||
more: '#pmore'
|
||||
}[pmK];
|
||||
pmK = null;
|
||||
pm.classList.remove('open');
|
||||
pm.innerHTML = '';
|
||||
if (rf) setFocus($(t));
|
||||
}
|
||||
|
||||
function updateSubStyles() {
|
||||
vd.classList.add('sub-active');
|
||||
vd.style.setProperty('--sub-size', `${subSettings.size}px`);
|
||||
vd.style.setProperty('--sub-color', subSettings.color);
|
||||
vd.style.setProperty('--sub-bg', subSettings.bg);
|
||||
Array.from(vd.textTracks).forEach(track => {
|
||||
if (track.cues) {
|
||||
for (let i = 0; i < track.cues.length; i++) {
|
||||
const cue = track.cues[i];
|
||||
if (cue && !cue._initialOffsetApplied) {
|
||||
cue.startTime += subSettings.offset;
|
||||
cue.endTime += subSettings.offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySubOffset(delta) {
|
||||
subSettings.offset += delta;
|
||||
Array.from(vd.textTracks).forEach(track => {
|
||||
if (track.cues) {
|
||||
for (let i = 0; i < track.cues.length; i++) {
|
||||
const cue = track.cues[i];
|
||||
if (cue) {
|
||||
cue.startTime += delta;
|
||||
cue.endTime += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildProxyUrl(rawUrl, headersObj = {}) {
|
||||
return `${API_BASE}/api/proxy?url=${encodeURIComponent(rawUrl)}&headers=${encodeURIComponent(JSON.stringify(headersObj))}`;
|
||||
}
|
||||
|
||||
async function fetchSources() {
|
||||
currentSources = Vyla.sources();
|
||||
}
|
||||
|
||||
async function startConcurrentDiscovery(it, s = null, e = null) {
|
||||
const session = ++discoverySession;
|
||||
pl.classList.add('buf');
|
||||
|
||||
resolvedStreams = [];
|
||||
selectedStreamUrl = null;
|
||||
let foundFirstPlayable = false;
|
||||
|
||||
if (!currentSources.length) {
|
||||
await fetchSources();
|
||||
}
|
||||
|
||||
if (session !== discoverySession) return;
|
||||
|
||||
const sourcePromises = currentSources.map(async (src) => {
|
||||
try {
|
||||
const data = await Vyla.stream(src.key, it.id, s, e);
|
||||
if (session !== discoverySession) return;
|
||||
|
||||
if (data.success && data.stream) {
|
||||
const streamData = data.stream;
|
||||
const headers = streamData.headers || {};
|
||||
let playable = [];
|
||||
|
||||
if (Array.isArray(streamData.allUrls) && streamData.allUrls.length > 0) {
|
||||
playable = streamData.allUrls;
|
||||
} else if (streamData.url) {
|
||||
playable = [{ url: streamData.url, quality: streamData.quality || 'Auto', isM3U8: streamData.isM3U8 }];
|
||||
}
|
||||
|
||||
if (playable.length > 0) {
|
||||
const resolvedEntry = {
|
||||
provider: src.key,
|
||||
label: src.label || src.key,
|
||||
headers,
|
||||
primaryUrl: playable[0].url,
|
||||
allUrls: playable
|
||||
};
|
||||
|
||||
resolvedStreams.push(resolvedEntry);
|
||||
|
||||
if (pmK === 'set') {
|
||||
renderSourcesMenu();
|
||||
}
|
||||
|
||||
if (!foundFirstPlayable && !userHasManuallySelectedSource) {
|
||||
foundFirstPlayable = true;
|
||||
selectedSourceKey = src.key;
|
||||
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
|
||||
} else if (userHasManuallySelectedSource && selectedSourceKey === src.key && !selectedStreamUrl) {
|
||||
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) { }
|
||||
});
|
||||
|
||||
Promise.allSettled(sourcePromises).then(() => {
|
||||
if (session !== discoverySession) return;
|
||||
if (!resolvedStreams.length) {
|
||||
pl.classList.remove('buf');
|
||||
toast('No playable sources available.');
|
||||
}
|
||||
});
|
||||
|
||||
loadSubtitles(it.id, s, e);
|
||||
loadTimestamps(it.id, s, e);
|
||||
}
|
||||
|
||||
function playSpecificStream(streamUrl, headers = {}) {
|
||||
pl.classList.add('buf');
|
||||
selectedStreamUrl = streamUrl;
|
||||
|
||||
if (currentHls) {
|
||||
currentHls.destroy();
|
||||
currentHls = null;
|
||||
}
|
||||
if (currentBgHls) {
|
||||
currentBgHls.destroy();
|
||||
currentBgHls = null;
|
||||
}
|
||||
|
||||
const finalUrl = buildProxyUrl(streamUrl, headers);
|
||||
|
||||
if (streamUrl.includes('.m3u8')) {
|
||||
if (Hls.isSupported()) {
|
||||
currentHls = new Hls({
|
||||
xhrSetup: function (xhr) {
|
||||
xhr.withCredentials = false;
|
||||
}
|
||||
});
|
||||
currentHls.loadSource(finalUrl);
|
||||
currentHls.attachMedia(vd);
|
||||
currentHls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
currentQualityLevels = data.levels || [];
|
||||
vd.play().catch(() => { });
|
||||
});
|
||||
currentHls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
|
||||
selectedQualityIndex = data.level;
|
||||
});
|
||||
|
||||
if (pbg) {
|
||||
currentBgHls = new Hls({
|
||||
xhrSetup: function (xhr) {
|
||||
xhr.withCredentials = false;
|
||||
}
|
||||
});
|
||||
currentBgHls.loadSource(finalUrl);
|
||||
currentBgHls.attachMedia(pbg);
|
||||
currentBgHls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
pbg.muted = true;
|
||||
if (!vd.paused) pbg.play().catch(() => { });
|
||||
});
|
||||
}
|
||||
} else if (vd.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
vd.src = finalUrl;
|
||||
vd.play().catch(() => { });
|
||||
if (pbg) {
|
||||
pbg.src = finalUrl;
|
||||
pbg.muted = true;
|
||||
pbg.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentQualityLevels = [];
|
||||
vd.src = finalUrl;
|
||||
vd.play().catch(() => { });
|
||||
if (pbg) {
|
||||
pbg.src = finalUrl;
|
||||
pbg.muted = true;
|
||||
pbg.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPlaybackQuality(levelIndex) {
|
||||
selectedQualityIndex = levelIndex;
|
||||
if (currentHls) {
|
||||
currentHls.currentLevel = levelIndex;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubtitles(id, s, e) {
|
||||
Array.from(vd.querySelectorAll('track')).forEach(t => t.remove());
|
||||
activeSubtitles = [];
|
||||
|
||||
try {
|
||||
activeSubtitles = await Vyla.subtitles(id, s, e);
|
||||
|
||||
activeSubtitles.forEach((sub, idx) => {
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
track.label = `${sub.label} (${sub.source || 'mirror'})`;
|
||||
track.srclang = sub.label.slice(0, 2).toLowerCase();
|
||||
track.src = buildProxyUrl(sub.file);
|
||||
track.default = idx === 0;
|
||||
vd.appendChild(track);
|
||||
track.track.mode = 'disabled';
|
||||
});
|
||||
updateSubStyles();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
async function loadTimestamps(tmdbId, season = null, episode = null, durationSec = null) {
|
||||
mediaSegments = [];
|
||||
activeSegment = null;
|
||||
$('#pskip').classList.remove('v');
|
||||
try {
|
||||
let q = `${API_BASE}/api/timestamps?tmdb_id=${tmdbId}`;
|
||||
if (season) q += `&season=${season}`;
|
||||
if (episode) q += `&episode=${episode}`;
|
||||
if (durationSec) q += `&duration_ms=${Math.round(durationSec * 1000)}`;
|
||||
|
||||
const res = await fetch(q);
|
||||
const data = await res.json();
|
||||
mediaSegments = data.segments || [];
|
||||
} catch (_) {
|
||||
mediaSegments = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderSourcesMenu() {
|
||||
let container = $('#pm-src-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!resolvedStreams.length) {
|
||||
container.innerHTML = '<div style="padding:10px 14px;color:rgba(255,255,255,0.4);font-size:13px;">Probing providers...</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedStreams.forEach(s => {
|
||||
const grp = document.createElement('div');
|
||||
grp.className = 'src-group';
|
||||
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'src-header f' + (s.provider === selectedSourceKey ? ' on' : '');
|
||||
|
||||
const hasMulti = s.allUrls && s.allUrls.length > 1;
|
||||
const countLabel = hasMulti ? ` (${s.allUrls.length})` : '';
|
||||
const expandIcon = hasMulti ? `<span class="src-expand-btn">${expandedSources.has(s.provider) ? '▲' : '▼'}</span>` : '';
|
||||
|
||||
hdr.innerHTML = `<span>${s.label}${countLabel}</span><div>${expandIcon}${s.provider === selectedSourceKey ? PI.chk : ''}</div>`;
|
||||
hdr._a = () => {
|
||||
userHasManuallySelectedSource = true;
|
||||
if (hasMulti && s.provider === selectedSourceKey) {
|
||||
if (expandedSources.has(s.provider)) expandedSources.delete(s.provider);
|
||||
else expandedSources.add(s.provider);
|
||||
renderSourcesMenu();
|
||||
} else {
|
||||
selectedSourceKey = s.provider;
|
||||
playSpecificStream(s.primaryUrl, s.headers);
|
||||
renderSourcesMenu();
|
||||
}
|
||||
};
|
||||
grp.appendChild(hdr);
|
||||
|
||||
if (hasMulti) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'src-sublist' + (expandedSources.has(s.provider) ? ' open' : '');
|
||||
|
||||
s.allUrls.forEach((u, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'src-subitem f' + (u.url === selectedStreamUrl ? ' on' : '');
|
||||
item.innerHTML = `<span>${u.quality || 'Stream ' + (i + 1)}</span>${u.url === selectedStreamUrl ? PI.chk : ''}`;
|
||||
item._a = () => {
|
||||
userHasManuallySelectedSource = true;
|
||||
selectedSourceKey = s.provider;
|
||||
playSpecificStream(u.url, s.headers);
|
||||
renderSourcesMenu();
|
||||
};
|
||||
sub.appendChild(item);
|
||||
});
|
||||
grp.appendChild(sub);
|
||||
}
|
||||
|
||||
container.appendChild(grp);
|
||||
});
|
||||
}
|
||||
|
||||
function openMenu(k) {
|
||||
if (pmK === k) {
|
||||
closeMenu(1);
|
||||
return;
|
||||
}
|
||||
closeEps();
|
||||
pm.innerHTML = '';
|
||||
pmK = k;
|
||||
pm.classList.add('open');
|
||||
let first = null;
|
||||
|
||||
const h = t => {
|
||||
const e = document.createElement('h4');
|
||||
e.textContent = t;
|
||||
pm.appendChild(e);
|
||||
};
|
||||
|
||||
const r = (l, on, fn) => {
|
||||
const e = mkf('pi', `<span>${l}</span>${on ? PI.chk : ''}`, () => {
|
||||
fn();
|
||||
closeMenu(1);
|
||||
});
|
||||
pm.appendChild(e);
|
||||
if (on && !first) first = e;
|
||||
return e;
|
||||
};
|
||||
|
||||
if (k === 'qlt') {
|
||||
h('Stream Quality');
|
||||
if (currentHls && currentQualityLevels.length > 0) {
|
||||
r('Auto', selectedQualityIndex === -1, () => setPlaybackQuality(-1));
|
||||
currentQualityLevels.forEach((lvl, idx) => {
|
||||
const label = lvl.height ? `${lvl.height}p` : `${Math.round(lvl.bitrate / 1000)} kbps`;
|
||||
r(label, selectedQualityIndex === idx, () => setPlaybackQuality(idx));
|
||||
});
|
||||
} else {
|
||||
const activeProv = resolvedStreams.find(s => s.provider === selectedSourceKey);
|
||||
if (activeProv && activeProv.allUrls.length > 1) {
|
||||
activeProv.allUrls.forEach((u, idx) => {
|
||||
const label = u.quality || `Stream ${idx + 1}`;
|
||||
r(label, u.url === selectedStreamUrl, () => {
|
||||
userHasManuallySelectedSource = true;
|
||||
playSpecificStream(u.url, activeProv.headers);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
r('Auto', true, () => { });
|
||||
}
|
||||
}
|
||||
} else if (k === 'cc') {
|
||||
h('Subtitles');
|
||||
const T = [...vd.textTracks];
|
||||
r('Off', !T.some(t => t.mode === 'showing'), () => {
|
||||
T.forEach(t => t.mode = 'disabled');
|
||||
});
|
||||
T.forEach(t => r(t.label, t.mode === 'showing', () => {
|
||||
T.forEach(u => u.mode = u === t ? 'showing' : 'disabled');
|
||||
updateSubStyles();
|
||||
}));
|
||||
|
||||
h('Subtitle Size');
|
||||
[18, 22, 26, 32].forEach(sz => {
|
||||
r(`${sz}px`, subSettings.size === sz, () => {
|
||||
subSettings.size = sz;
|
||||
updateSubStyles();
|
||||
});
|
||||
});
|
||||
|
||||
h('Subtitle Color');
|
||||
[['White', '#ffffff'], ['Yellow', '#ffd700'], ['Cyan', '#00ffff']].forEach(([col, code]) => {
|
||||
r(col, subSettings.color === code, () => {
|
||||
subSettings.color = code;
|
||||
updateSubStyles();
|
||||
});
|
||||
});
|
||||
|
||||
h('Subtitle Sync');
|
||||
const syncRow = document.createElement('div');
|
||||
syncRow.className = 'sub-ctrl-row';
|
||||
syncRow.innerHTML = `<span>Timing Offset</span><div class="sub-btn-group"><button id="sub-min">-0.5s</button><button id="sub-plus">+0.5s</button></div>`;
|
||||
pm.appendChild(syncRow);
|
||||
$('#sub-min').onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
applySubOffset(-0.5);
|
||||
};
|
||||
$('#sub-plus').onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
applySubOffset(0.5);
|
||||
};
|
||||
} else if (k === 'vol') {
|
||||
h('Volume');
|
||||
const w = document.createElement('div');
|
||||
w.className = 'vrow';
|
||||
w.innerHTML = '<div class="pic f" id="vm"></div><div class="vs f" id="vsl"><i></i></div><span id="vpc"></span>';
|
||||
pm.appendChild(w);
|
||||
const vm = $('#vm');
|
||||
const vs = $('#vsl');
|
||||
const vu = () => {
|
||||
const v = vd.muted ? 0 : vd.volume;
|
||||
vs.firstChild.style.width = v * 100 + '%';
|
||||
$('#vpc').textContent = Math.round(v * 100);
|
||||
vm.innerHTML = $('#pvol').innerHTML = v ? PI.vol : PI.mute;
|
||||
};
|
||||
vm._a = () => {
|
||||
vd.muted = !vd.muted;
|
||||
vu();
|
||||
};
|
||||
vs._a = () => { };
|
||||
vs._v = vu;
|
||||
vs.addEventListener('click', e => {
|
||||
const b = vs.getBoundingClientRect();
|
||||
vd.muted = false;
|
||||
vd.volume = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width));
|
||||
vu();
|
||||
});
|
||||
vu();
|
||||
first = vs;
|
||||
} else if (k === 'set') {
|
||||
h('Available Sources');
|
||||
const srcContainer = document.createElement('div');
|
||||
srcContainer.id = 'pm-src-list';
|
||||
pm.appendChild(srcContainer);
|
||||
renderSourcesMenu();
|
||||
|
||||
h('Playback Speed');
|
||||
[.5, 1, 1.25, 1.5, 2].forEach(v => r(v === 1 ? 'Normal' : v + '\u00d7', vd.playbackRate === v, () => vd.playbackRate = v));
|
||||
|
||||
h('Aspect Ratio');
|
||||
r('Fit Screen', !pl.classList.contains('cover'), () => {
|
||||
pl.classList.remove('cover');
|
||||
if (pbg) {
|
||||
pbg.currentTime = vd.currentTime;
|
||||
if (!vd.paused) pbg.play().catch(() => { });
|
||||
}
|
||||
});
|
||||
r('Fill Screen', pl.classList.contains('cover'), () => {
|
||||
pl.classList.add('cover');
|
||||
if (pbg) pbg.pause();
|
||||
});
|
||||
} else {
|
||||
h('Library');
|
||||
r('Watch Later', has('wl', plItem), () => toggleLib('wl', plItem));
|
||||
r('Favorites', has('fav', plItem), () => toggleLib('fav', plItem));
|
||||
}
|
||||
|
||||
showCtl();
|
||||
setFocus(first || pm.querySelector('.f'));
|
||||
}
|
||||
|
||||
async function openPlayer(it, ep, ctx) {
|
||||
if (!it) return;
|
||||
plPrev = cur;
|
||||
plItem = it;
|
||||
plCtx = ctx || null;
|
||||
userHasManuallySelectedSource = false;
|
||||
$('#peps').style.display = plCtx ? '' : 'none';
|
||||
pl.classList.remove('eps');
|
||||
$('#pnext').classList.remove('v');
|
||||
$('#pt').textContent = it.title || it.name || '';
|
||||
$('#ps').textContent = ep || [yr(it), it._t === 'tv' ? 'Series' : 'Movie'].filter(Boolean).join(' \u2022 ');
|
||||
$('#pav').src = it.poster_path ? I + 'w185' + it.poster_path : PH;
|
||||
vd.poster = it.backdrop_path ? I + 'w1280' + it.backdrop_path : '';
|
||||
|
||||
pl.classList.add('show');
|
||||
showCtl();
|
||||
pico();
|
||||
setFocus($('#pmid'));
|
||||
|
||||
if (plCtx) {
|
||||
const epData = plCtx.eps[plCtx.i];
|
||||
startConcurrentDiscovery(it, plCtx.s, epData.episode_number);
|
||||
} else {
|
||||
startConcurrentDiscovery(it);
|
||||
}
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
closeMenu();
|
||||
closeEps();
|
||||
if (currentHls) {
|
||||
currentHls.destroy();
|
||||
currentHls = null;
|
||||
}
|
||||
if (currentBgHls) {
|
||||
currentBgHls.destroy();
|
||||
currentBgHls = null;
|
||||
}
|
||||
plCtx = null;
|
||||
vd.pause();
|
||||
vd.removeAttribute('src');
|
||||
vd.load();
|
||||
if (pbg) {
|
||||
pbg.pause();
|
||||
pbg.removeAttribute('src');
|
||||
pbg.load();
|
||||
}
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
pl.classList.remove('show', 'ui');
|
||||
if (plPrev && document.body.contains(plPrev)) setFocus(plPrev);
|
||||
}
|
||||
|
||||
$('#pback')._a = closePlayer;
|
||||
$('#pmid')._a = tgl;
|
||||
|
||||
$('#pskip')._a = () => {
|
||||
if (activeSegment) {
|
||||
const target = activeSegment.end != null ? activeSegment.end + 0.5 : (vd.duration || vd.currentTime + 30);
|
||||
vd.currentTime = target;
|
||||
$('#pskip').classList.remove('v');
|
||||
activeSegment = null;
|
||||
} else {
|
||||
vd.currentTime += 85;
|
||||
}
|
||||
};
|
||||
|
||||
$('#pqlt')._a = () => openMenu('qlt');
|
||||
$('#pcc')._a = () => openMenu('cc');
|
||||
$('#pvol')._a = () => openMenu('vol');
|
||||
$('#pset')._a = () => openMenu('set');
|
||||
$('#pmore')._a = () => openMenu('more');
|
||||
$('#pfs')._a = () => document.fullscreenElement ? document.exitFullscreen() : pl.requestFullscreen && pl.requestFullscreen();
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
$('#pfs').innerHTML = document.fullscreenElement ? PI.fx : PI.fs;
|
||||
});
|
||||
|
||||
const sp = e => {
|
||||
const b = sk.getBoundingClientRect();
|
||||
vd.currentTime = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width)) * (vd.duration || 0);
|
||||
};
|
||||
|
||||
sk.addEventListener('pointerdown', e => {
|
||||
drag = 1;
|
||||
sk._m = 1;
|
||||
sk.setPointerCapture(e.pointerId);
|
||||
sp(e);
|
||||
});
|
||||
|
||||
sk.addEventListener('pointermove', e => {
|
||||
if (drag) sp(e);
|
||||
});
|
||||
|
||||
sk.addEventListener('pointerup', () => drag = 0);
|
||||
|
||||
sk._a = () => {
|
||||
if (sk._m) {
|
||||
sk._m = 0;
|
||||
return;
|
||||
}
|
||||
tgl();
|
||||
};
|
||||
|
||||
pl.querySelectorAll('.f').forEach(e => {
|
||||
if (!e._a) e._a = () => { };
|
||||
});
|
||||
|
||||
vd.addEventListener('click', () => {
|
||||
if (pmK) closeMenu();
|
||||
else if (pl.classList.contains('eps')) closeEps();
|
||||
else tgl();
|
||||
showCtl();
|
||||
});
|
||||
|
||||
vd.addEventListener('play', () => {
|
||||
if (!pl.classList.contains('cover') && pbg) {
|
||||
pbg.muted = true;
|
||||
pbg.currentTime = vd.currentTime;
|
||||
pbg.play().catch(() => { });
|
||||
}
|
||||
});
|
||||
|
||||
vd.addEventListener('pause', () => {
|
||||
if (pbg) pbg.pause();
|
||||
});
|
||||
|
||||
vd.addEventListener('seeking', () => {
|
||||
if (pbg) pbg.currentTime = vd.currentTime;
|
||||
});
|
||||
|
||||
vd.addEventListener('seeked', () => {
|
||||
if (pbg) pbg.currentTime = vd.currentTime;
|
||||
});
|
||||
|
||||
vd.addEventListener('ratechange', () => {
|
||||
if (pbg) pbg.playbackRate = vd.playbackRate;
|
||||
});
|
||||
|
||||
vd.addEventListener('durationchange', () => {
|
||||
$('#pdur').textContent = fmt(vd.duration);
|
||||
if (vd.duration && plItem) {
|
||||
const c = plCtx;
|
||||
loadTimestamps(plItem.id, c ? c.s : null, c ? c.eps[c.i].episode_number : null, vd.duration);
|
||||
}
|
||||
});
|
||||
|
||||
vd.addEventListener('timeupdate', () => {
|
||||
const d = vd.duration || 0;
|
||||
const t = vd.currentTime;
|
||||
const p = d ? (t / d) * 100 : 0;
|
||||
|
||||
$('#pfill').style.width = p + '%';
|
||||
$('#phd').style.left = p + '%';
|
||||
$('#pcur').textContent = fmt(t);
|
||||
|
||||
if (d && vd.buffered.length) {
|
||||
$('#pbuf').style.width = (vd.buffered.end(vd.buffered.length - 1) / d * 100) + '%';
|
||||
}
|
||||
|
||||
if (!pl.classList.contains('cover') && pbg && Math.abs(pbg.currentTime - t) > 0.35) {
|
||||
pbg.currentTime = t;
|
||||
}
|
||||
|
||||
const currentSeg = mediaSegments.find(seg => {
|
||||
const afterStart = t >= seg.start;
|
||||
const beforeEnd = seg.end == null ? (d ? t < d - 5 : true) : t < seg.end;
|
||||
return afterStart && beforeEnd;
|
||||
});
|
||||
|
||||
if (currentSeg) {
|
||||
activeSegment = currentSeg;
|
||||
$('#pskip').textContent = currentSeg.label;
|
||||
$('#pskip').classList.add('v');
|
||||
} else {
|
||||
activeSegment = null;
|
||||
$('#pskip').classList.remove('v');
|
||||
}
|
||||
});
|
||||
|
||||
['play', 'pause', 'ended'].forEach(n => vd.addEventListener(n, pico));
|
||||
['loadstart', 'waiting', 'seeking'].forEach(n => vd.addEventListener(n, () => pl.classList.add('buf')));
|
||||
['playing', 'canplay', 'seeked', 'error'].forEach(n => vd.addEventListener(n, () => pl.classList.remove('buf')));
|
||||
|
||||
const hasNext = () => !!plCtx && (plCtx.i + 1 < plCtx.eps.length || plCtx.seasons.some(x => x.season_number > plCtx.s));
|
||||
const getSeason = async s => plCtx.cache[s] || (plCtx.cache[s] = (await api(`/tv/${plCtx.it.id}/season/${s}`)).episodes || []);
|
||||
|
||||
async function playEp(s, i) {
|
||||
const c = plCtx;
|
||||
if (!c) return;
|
||||
const eps = await getSeason(s);
|
||||
const p = eps[i];
|
||||
if (!p || plCtx !== c) return;
|
||||
c.s = s;
|
||||
c.i = i;
|
||||
c.eps = eps;
|
||||
$('#ps').textContent = `S${s} E${p.episode_number} \u2022 ${p.name || ''}`;
|
||||
$('#pnext').classList.remove('v');
|
||||
closeEps();
|
||||
showCtl();
|
||||
setFocus($('#pmid'));
|
||||
userHasManuallySelectedSource = false;
|
||||
startConcurrentDiscovery(c.it, s, p.episode_number);
|
||||
}
|
||||
|
||||
function nextEp() {
|
||||
const c = plCtx;
|
||||
if (!hasNext()) return;
|
||||
if (c.i + 1 < c.eps.length) {
|
||||
playEp(c.s, c.i + 1);
|
||||
} else {
|
||||
playEp(c.seasons.find(x => x.season_number > c.s).season_number, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function closeEps(rf) {
|
||||
if (!pl.classList.contains('eps')) return;
|
||||
pl.classList.remove('eps');
|
||||
if (rf) setFocus($('#peps'));
|
||||
}
|
||||
|
||||
function openEps() {
|
||||
const c = plCtx;
|
||||
if (!c) return;
|
||||
closeMenu();
|
||||
$('#peT').innerHTML = `Episodes<small>${esc(c.it.title || c.it.name || '')}</small>`;
|
||||
const sr = $('#pseas');
|
||||
sr.innerHTML = '';
|
||||
c.seasons.forEach(x => {
|
||||
sr.appendChild(mkf('chip', esc(x.name || 'Season ' + x.season_number), () => showSeason(x.season_number)));
|
||||
});
|
||||
pl.classList.add('eps');
|
||||
showCtl();
|
||||
showSeason(c.s, true);
|
||||
}
|
||||
|
||||
async function showSeason(sn, focus) {
|
||||
const c = plCtx;
|
||||
const car = $('#pcar');
|
||||
if (!c) return;
|
||||
viewS = sn;
|
||||
[...$('#pseas').children].forEach((e, i) => e.classList.toggle('act', c.seasons[i].season_number === sn));
|
||||
car.innerHTML = SK.pep(8);
|
||||
const eps = await getSeason(sn);
|
||||
if (viewS !== sn || plCtx !== c) return;
|
||||
car.innerHTML = '';
|
||||
eps.forEach((p, i) => {
|
||||
const n = p.episode_number;
|
||||
const cur = c.s === sn && c.i === i;
|
||||
const img = p.still_path ? I + 'w300' + p.still_path : (c.it.backdrop_path ? I + 'w300' + c.it.backdrop_path : PH);
|
||||
car.appendChild(mkf('pe' + (cur ? ' cur' : ''), `<div class="et"><img loading="lazy" src="${img}" alt=""><i>${n}</i>${cur ? '<span class="eq"><u></u><u></u><u></u></span><span class="pg"><u></u></span>' : ''}</div><span class="pn">${esc(p.name || 'Episode ' + n)}</span><small>${p.runtime ? p.runtime + 'm' : 'Episode ' + n}</small>`, () => playEp(sn, i)));
|
||||
});
|
||||
const t = car.querySelector('.cur') || car.firstChild;
|
||||
if (focus && t) setFocus(t);
|
||||
}
|
||||
|
||||
$('#pep').innerHTML = `<div class="peh"><h3 id="peT"></h3><div class="row" id="pseas"></div><div class="pic f" id="pepx">${PI.x}</div></div><div class="row pcar" id="pcar"></div>`;
|
||||
$('#pnext').innerHTML = 'Next Episode' + pi('<path d="M6 5.5v13l9-6.5z"/><path d="M18 5.5v13"/>');
|
||||
$('#pnext')._a = nextEp;
|
||||
$('#pepx')._a = () => closeEps(1);
|
||||
$('#peps')._a = () => pl.classList.contains('eps') ? closeEps(1) : openEps();
|
||||
|
||||
vd.addEventListener('timeupdate', () => {
|
||||
const d = vd.duration || 0;
|
||||
const r = d - vd.currentTime;
|
||||
$('#pnext').classList.toggle('v', !!d && hasNext() && r <= 300 && r > 0);
|
||||
$('#pep').style.setProperty('--p', d ? (vd.currentTime / d) * 100 + '%' : '0%');
|
||||
});
|
||||
|
||||
vd.addEventListener('ended', () => {
|
||||
if (hasNext()) nextEp();
|
||||
});
|
||||
70
js/search.js
70
js/search.js
@@ -1,70 +0,0 @@
|
||||
let sqv = '', bgT, stab = 0, SD = { q: '', all: [], mv: [], tv: [] };
|
||||
const SVS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
|
||||
const sdraw = () => { const t = $('#stext'); t.textContent = sqv || 'Search'; t.classList.toggle('q', !!sqv) };
|
||||
function setQ(v) { sqv = v; sdraw(); clearTimeout(st); st = setTimeout(() => fillSearch(sqv.trim()), 300) }
|
||||
function sbgf(it) { clearTimeout(bgT); bgT = setTimeout(() => xf(bgb, I + 'w780' + (it.backdrop_path || it.poster_path)), 120) }
|
||||
async function fillSearch(q) {
|
||||
const my = ++sgen; skelSearch();
|
||||
let all, mv, tv;
|
||||
const ok = x => x.poster_path && (x.media_type === 'movie' || x.media_type === 'tv');
|
||||
if (q) {
|
||||
const d = await api('/search/multi', `&query=${encodeURIComponent(q)}&include_adult=false`);
|
||||
if (my !== sgen) return;
|
||||
all = (d.results || []).filter(ok); mv = all.filter(x => x.media_type === 'movie'); tv = all.filter(x => x.media_type === 'tv')
|
||||
}
|
||||
else {
|
||||
const [a, b] = await Promise.all([api('/trending/movie/week'), api('/trending/tv/week')]);
|
||||
if (my !== sgen) return;
|
||||
mv = (a.results || []).filter(x => x.poster_path).map(x => ({ ...x, media_type: 'movie' }));
|
||||
tv = (b.results || []).filter(x => x.poster_path).map(x => ({ ...x, media_type: 'tv' })); all = []
|
||||
}
|
||||
SD = { q, all, mv, tv }; drawSearch()
|
||||
}
|
||||
function tile(x) {
|
||||
x._t = x.media_type;
|
||||
const e = mkf('sc', `<div class="po"><img src="${I}w342${x.poster_path}" alt=""></div><b>${esc(x.title || x.name || '')}</b><small><span>★ ${(x.vote_average || 0).toFixed(1)}</span><span>${yr(x)}</span><span>${x.media_type === 'tv' ? 'Show' : 'Movie'}</span></small>`, () => openDetail(x));
|
||||
e._bg = x; return e
|
||||
}
|
||||
function drawSearch() {
|
||||
const { q, all, mv, tv } = SD;
|
||||
chips.innerHTML = '';
|
||||
const names = [...new Set((q ? all : [...mv, ...tv]).map(x => x.title || x.name))].slice(0, 9);
|
||||
if (q) chips.appendChild(mkf('chip', SVS + `"${esc(q)}"`, () => { }));
|
||||
names.forEach(n => chips.appendChild(mkf('chip', esc(n), () => setQ(n))));
|
||||
sres.innerHTML = '';
|
||||
const L = stab === 1 ? [['Movies', mv]] : stab === 2 ? [['TV Shows', tv]] : q ? [['Top Matches', all.slice(0, 10)], ['Movies', mv], ['TV Shows', tv]] : [['Trending Movies', mv], ['Trending TV Shows', tv]];
|
||||
let n = 0;
|
||||
L.forEach(([h, a]) => { if (!a.length) return; n++; const sec = document.createElement('section'); sec.className = 'ss'; sec.innerHTML = `<h3>${h}</h3><div class="row ch"></div>`; sec.lastChild.append(...a.map(tile)); sres.appendChild(sec) });
|
||||
if (!n) sres.innerHTML = `<p class="none">No results${q ? ` for \u201c${esc(q)}\u201d` : ''}</p>`;
|
||||
[...tabs.children].forEach((t, i) => t.classList.toggle('act', i === stab));
|
||||
if (cur && !document.body.contains(cur)) setFocus(chips.children[1] || chips.firstChild || kb.children[2])
|
||||
}
|
||||
['Top Results', 'Movies', 'TV Shows'].forEach((t, i) => tabs.appendChild(mkf('tab', t, () => { stab = i; drawSearch() })));
|
||||
const KL = 'abcdefghijklmnopqrstuvwxyz'.split(''), KN = "1234567890.-'&:".split('');
|
||||
let num = false;
|
||||
function buildKb() {
|
||||
kb.innerHTML = '';
|
||||
const mk = (label, cls, fn) => kb.appendChild(mkf('k ' + cls, label, () => { fn(); sr.scrollTo({ top: 0, behavior: 'smooth' }) }));
|
||||
mk(num ? 'ABC' : '123', 'w', () => { num = !num; buildKb(); setFocus(kb.firstChild) });
|
||||
mk('SPACE', 'w', () => { if (sqv && !sqv.endsWith(' ')) setQ(sqv + ' ') });
|
||||
(num ? KN : KL).forEach(c => mk(c, '', () => setQ(sqv + c)));
|
||||
mk('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5h11a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H9l-6-7z"/><path d="m12 9 5 5m0-5-5 5"/></svg>', 'd', () => setQ(sqv.slice(0, -1)))
|
||||
}
|
||||
buildKb();
|
||||
function openSearch() {
|
||||
setAct('search'); document.body.classList.add('srch'); sr.classList.add('show'); side.classList.remove('open'); sr.scrollTop = 0;
|
||||
if (!sres.children.length) fillSearch('');
|
||||
setFocus(kb.children[2])
|
||||
}
|
||||
function closeSearch(silent) {
|
||||
if (!sr.classList.contains('show')) return;
|
||||
sr.classList.remove('show'); document.body.classList.remove('srch');
|
||||
heroIt = null; bgb._u = null;
|
||||
if (!silent) { setAct(mode); setFocus(document.querySelector('#rows .card')) }
|
||||
}
|
||||
|
||||
|
||||
function skelSearch() {
|
||||
chips.innerHTML = SK.chips(7);
|
||||
sres.innerHTML = [0, 1].map(() => `<section class="ss"><i class="sb h3s"></i><div class="row ch">${SK.tiles(9)}</div></section>`).join('')
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
const NAV = [
|
||||
['search', 'Search', ic('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>')],
|
||||
['discover', 'Discovery', ic('<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z"/>')],
|
||||
['movie', 'Movies', ic('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18M17 3v18M3 8h4M3 12h4M3 16h4M17 8h4M17 12h4M17 16h4"/>')],
|
||||
['tv', 'TV Shows', ic('<rect x="3" y="7" width="18" height="13" rx="2"/><path d="m8 3 4 4 4-4"/>')],
|
||||
['wl', 'Watch Later', ic(P_WL)],
|
||||
['fav', 'Favorites', ic(P_FV)]
|
||||
];
|
||||
|
||||
NAV.forEach(([k, n, s]) => { const e = document.createElement('div'); e.className = 'ni f'; e.tabIndex = -1; e.innerHTML = s + `<span>${n}</span>`; e._a = () => go(k); NAVEL[k] = e; $('#nav').appendChild(e) });
|
||||
syncNav();
|
||||
function setAct(k) { Object.entries(NAVEL).forEach(([n, e]) => e.classList.toggle('act', n === k)); navAct = NAVEL[k] }
|
||||
const mark = $('#mark'); mark.onerror = () => { mark.onerror = null; mark.src = PH }; mark.src = LOGO;
|
||||
|
||||
|
||||
function syncNav() { ['wl', 'fav'].forEach(k => { if (NAVEL[k]) NAVEL[k].style.display = (LIB[k].length || libK === k) ? '' : 'none' }) }
|
||||
@@ -1,13 +0,0 @@
|
||||
const SK = {
|
||||
cards: n => '<div class="card sl"></div>'.repeat(n),
|
||||
tiles: n => '<div class="sc sl"><div class="po"></div><i class="sb"></i><i class="sb s"></i></div>'.repeat(n),
|
||||
chips: n => Array.from({ length: n }, (_, i) => `<div class="chip sl" style="width:${90 + (i * 37) % 80}px"></div>`).join(''),
|
||||
pep: n => '<div class="pe sl"><div class="et"></div><i class="sb" style="width:70%;height:14px;margin-top:11px"></i><i class="sb" style="width:35%;height:11px;margin-top:6px"></i></div>'.repeat(n),
|
||||
eps: n => '<div class="ep sl"><div class="et"></div><i class="sb" style="width:70%;height:14px;margin-top:12px"></i><i class="sb" style="width:40%;height:11px;margin-top:6px"></i></div>'.repeat(n),
|
||||
rows: n => Array.from({ length: n }, () => '<section class="sec"><i class="sb h2"></i><div class="row">' + SK.cards(14) + '</div></section>').join(''),
|
||||
detail: it => {
|
||||
const bp = it.backdrop_path || it.poster_path || '';
|
||||
return `<div class="dbl" style="background-image:url(${I}w780${bp})"></div><div class="dw"><div class="dimg" style="background-image:url(${I}w1280${bp})"></div><div class="dc"><div class="dh dsk"><i class="sb t"></i><i class="sb m"></i><div class="hb"><i class="sb b"></i><i class="sb c"></i></div><i class="sb l"></i><i class="sb l"></i><i class="sb l x"></i></div>` +
|
||||
`<div class="ds"><i class="sb h3"></i><div class="row">${'<div class="trk"></div>'.repeat(4)}</div></div><div class="ds"><i class="sb h3"></i><div class="row">${SK.cards(9)}</div></div></div></div>`
|
||||
}
|
||||
};
|
||||
1837
package-lock.json
generated
1837
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user