25 Commits
v1.0.2 ... main

Author SHA1 Message Date
97c45f14c2 Update SDK version 2026-10-28 10:21:44 -04:00
3d59612d6e Implemented automatic fallback for sources. 2026-10-28 10:20:00 -04:00
bd7d443f21 fixed next episode prompt displaying way too early; update sdk 2026-10-25 22:15:13 -04:00
Devin Bot
8cd9ec3671 updated gitignore 2026-10-24 20:55:11 -07:00
Devin Bot
d6e7ded9ac 1.0.8 2026-10-24 20:53:20 -07:00
Devin Bot
dd2445c86b updated gitignore 2026-10-24 20:52:04 -07:00
Devin Bot
7f68419c2a updated vyla sdk 2026-10-24 20:51:16 -07:00
2028219f74 1.0.7 2026-10-24 23:33:01 -04:00
bb1c084c4c Fixed player UI dismissal bug 2026-10-24 23:31:46 -04:00
1c4ee48141 1.0.6 2026-10-05 20:42:23 -07:00
09ddbe6bae 1.0.6 2026-10-05 20:40:56 -07:00
494d8e6835 fixed watch history 2026-10-05 20:38:56 -07:00
1c3ffacccd 1.0.5 2026-10-05 15:50:17 -07:00
44a6e3cef9 1.0.5 2026-10-05 15:47:25 -07:00
9c0c5bf17d tweaked ui 2026-10-05 15:47:10 -07:00
01e532a6a5 v1.0.4 2026-10-05 13:22:52 -07:00
58c7889b8a 1.0.4 2026-10-05 13:21:35 -07:00
102c3bdc84 great UI update 2026-10-05 13:21:01 -07:00
112d405e75 v1.0.3 2026-10-05 11:11:30 -07:00
c3358fee44 fixed outdated capacitor 2026-10-05 11:08:26 -07:00
818b03cfd3 1.0.3 2026-10-05 11:04:39 -07:00
09b03508ed updated md 2026-10-05 11:03:59 -07:00
30d21ef39b fixed Ui controls on player 2026-10-05 11:01:14 -07:00
a0621f725a rewrote the entire app using jetpack compose, and exoplayer/media3 for playback 2026-10-05 11:00:55 -07:00
ecbf5953d6 v1.0.2 2026-10-04 22:04:57 -07:00
129 changed files with 7057 additions and 14408 deletions

View File

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

View File

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

View File

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

View File

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

@@ -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[]>;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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))] : [];
}

View File

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

View File

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

View File

@@ -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'];
}

View File

@@ -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))] : [];
}

View File

@@ -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))] : [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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))] : [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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()
}
}
}

View File

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

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

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

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

View 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"
)

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

View 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) {}
}
}
}
}
}

View File

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

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

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

View File

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

View 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'

View File

@@ -1,9 +0,0 @@
{
"appId": "tv.vyla.app",
"appName": "Vyla",
"webDir": "www",
"server": {
"androidScheme": "https",
"cleartext": true
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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">&#9654; Play</button><button class="ib f"
id="hinfo">&#9432; 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>

View File

@@ -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>&#9733; ${(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);

View File

@@ -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, '&lt;');
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);

View File

@@ -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">&#8942;</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>&#9654; ${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 }

View File

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

View File

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

View File

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

View File

@@ -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');
})();

View File

@@ -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 [] }
}
};
})();

View File

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

View File

@@ -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>&#9733; ${(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('')
}

View File

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

View File

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

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