mwask commited on
Commit
3e8e34c
·
verified ·
1 Parent(s): 5aacb8e

Upload 45 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .git
4
+ .env
5
+ .env.local
6
+ *.log
7
+ Dockerfile
8
+ docker-compose.yml
9
+ README.md
.env ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Copy this file to ".env" and fill it in: cp .env.example .env
3
+ # Only TMDB_API_KEY and APP_SECRET are required. Everything else is optional.
4
+ # ─────────────────────────────────────────────────────────────────────────────
5
+
6
+ # REQUIRED — your own TMDB (The Movie Database) API key.
7
+ # Get one free at https://www.themoviedb.org/settings/api (v3 auth "API Key")
8
+ TMDB_API_KEY=1c9a2f152e729aecfec1c47b46aa9607
9
+
10
+
11
+ # REQUIRED — a long random secret used to sign the internal stream-proxy URLs
12
+ # so your server can't be abused as an open proxy. Generate one with:
13
+ # openssl rand -hex 32
14
+ APP_SECRET=
15
+
16
+ # Port the server listens on (default 3000).
17
+ PORT=3000
18
+
19
+ # ── Branding (safe to expose to the browser) ────────────────────────────────
20
+ # Visible player / brand name (window title + metadata).
21
+ PLAYER_NAME=Vidsuper
22
+ # Short tagline shown alongside the name in the browser tab.
23
+ PLAYER_TAGLINE=Video streaming player
24
+ # Default accent colour (hex, with or without #). Override per-embed with ?color=
25
+ ACCENT_COLOR=ef4444
26
+ # Logo path served from /public. To rebrand, replace public/logo.svg (keep the
27
+ # name) OR drop a new file in public/ and point this at it, e.g. /brand.png
28
+ LOGO_SRC=/logo.svg
29
+
30
+ # ── Source (server) display names — optional rename per source ───────────────
31
+ # The player shows these in its "Server" menu. Uppercase the source id.
32
+ # SOURCE_ONEROOM_NAME=Server 1
33
+ # SOURCE_INSERTUNIT_NAME=Server 2
34
+ # SOURCE_FLIXHQ_NAME=Server 3
35
+ # SOURCE_CASTLE_NAME=Server 4
36
+ # SOURCE_CINESU_NAME=Server 5
37
+ # SOURCE_VIDNEST_NAME=Server 6
38
+ # SOURCE_VIDROCK_NAME=Server 7
39
+ # SOURCE_VIXSRC_NAME=Server 8
40
+
41
+ # ── Subtitles — set to "false" to disable a provider (default: enabled) ──────
42
+ SUBTITLES_OPENSUBTITLES=true
43
+ SUBTITLES_VDRK=true
44
+
45
+ # ── Skip intro/credits ───────────────────────────────────────────────────────
46
+ # Skip-intro works out of the box via theintrodb.org (no key needed). This is
47
+ # only here in case you have a key you want forwarded; leave it blank otherwise.
48
+ INTRODB_API_KEY=
.gitignore CHANGED
@@ -1,72 +1,24 @@
1
- ```
2
- # Dependencies
3
- node_modules/
4
- .venv/
5
- venv/
6
- __pycache__/
7
- .mypy_cache/
8
- .pytest_cache/
9
- target/
10
- .gradle/
11
 
12
- # Build artifacts
13
- dist/
14
- build/
15
- *.pyc
16
- *.class
17
- *.o
18
- *.exe
19
- *.dll
20
- *.so
21
- *.a
22
- *.obj
23
- *.out
24
 
25
- # Environment files
 
 
 
26
  .env
27
  .env.local
28
- *.env.*
29
 
30
- # Editor/IDE files
31
- .vscode/
32
- .idea/
33
- *.swp
34
- *.swo
35
- *.tmp
36
-
37
- # System files
38
  .DS_Store
39
- Thumbs.db
40
-
41
- # Logs
42
  *.log
43
-
44
- # Coverage
45
- coverage/
46
- htmlcov/
47
- .coverage
48
-
49
- # Compressed files
50
- *.zip
51
- *.gz
52
- *.tar
53
- *.tgz
54
- *.bz2
55
- *.xz
56
- *.7z
57
- *.rar
58
- *.zst
59
- *.lz4
60
- *.lzh
61
- *.cab
62
- *.arj
63
- *.rpm
64
- *.deb
65
- *.Z
66
- *.lz
67
- *.lzo
68
- *.tar.gz
69
- *.tar.bz2
70
- *.tar.xz
71
- *.tar.zst
72
- ```
 
1
+ # dependencies
2
+ /node_modules
3
+ /.pnp
4
+ .pnp.js
 
 
 
 
 
 
5
 
6
+ # next.js
7
+ /.next/
8
+ /out/
9
+ next-env.d.ts
 
 
 
 
 
 
 
 
10
 
11
+ # production
12
+ /build
13
+
14
+ # env — never commit real secrets
15
  .env
16
  .env.local
17
+ .env*.local
18
 
19
+ # misc
 
 
 
 
 
 
 
20
  .DS_Store
 
 
 
21
  *.log
22
+ npm-debug.log*
23
+ .vscode/
24
+ .idea/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,17 +1,26 @@
1
- FROM python:3.11-slim
2
-
3
  WORKDIR /app
 
 
4
 
5
- # Install dependencies
6
- COPY requirements.txt .
7
- RUN pip install --no-cache-dir -r requirements.txt
8
-
9
- # Copy application code
10
- COPY api.py .
11
-
12
- # Expose the default FastAPI port
13
- EXPOSE 8000
14
 
15
- # Run the application with uvicorn
16
- # Koyeb sets PORT environment variable, so we use it with a default of 8000
17
- CMD ["sh", "-c", "uvicorn api:app --host 0.0.0.0 --port ${PORT:-8000}"]
 
 
 
 
 
 
 
 
 
 
1
+ # ---- deps ----
2
+ FROM node:20-alpine AS deps
3
  WORKDIR /app
4
+ COPY package.json package-lock.json* ./
5
+ RUN npm ci 2>/dev/null || npm install
6
 
7
+ # ---- build ----
8
+ FROM node:20-alpine AS builder
9
+ WORKDIR /app
10
+ COPY --from=deps /app/node_modules ./node_modules
11
+ COPY . .
12
+ # Branding + keys are read at RUNTIME (not baked in), so no build args needed.
13
+ RUN npm run build
 
 
14
 
15
+ # ---- run ----
16
+ FROM node:20-alpine AS runner
17
+ WORKDIR /app
18
+ ENV NODE_ENV=production
19
+ RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
20
+ COPY --from=builder /app/public ./public
21
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
22
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
23
+ USER nextjs
24
+ EXPOSE 3000
25
+ ENV PORT=3000 HOSTNAME=0.0.0.0
26
+ CMD ["node", "server.js"]
app/api/cinesu/route.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ mintStreamToken,
4
+ verify,
5
+ StreamClaim,
6
+ } from "@/lib/security/tokens";
7
+ import {
8
+ cineSuVariants,
9
+ buildVariantM3u8,
10
+ stripPngWrapper,
11
+ } from "@/lib/scraper/cinesu";
12
+
13
+ export const runtime = "nodejs";
14
+ export const dynamic = "force-dynamic";
15
+
16
+ const UA =
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
18
+
19
+ // Two modes:
20
+ // ?seg=<token> → de-wrap a PNG-polyglot segment into raw MPEG-TS
21
+ // ?m&t&s&e&v=<variant> → build the variant's M3U8 (segments routed back here)
22
+ export async function GET(req: NextRequest) {
23
+ const { searchParams } = new URL(req.url);
24
+ const origin = new URL(req.url).origin;
25
+
26
+ // ---- segment mode: fetch the PNG-wrapped .ts and strip the wrapper ----
27
+ const segToken = searchParams.get("seg");
28
+ if (segToken) {
29
+ const claim = verify<StreamClaim>(segToken);
30
+ if (!claim || claim.k !== "stream") {
31
+ return new NextResponse("forbidden", { status: 403 });
32
+ }
33
+ try {
34
+ const r = await fetch(claim.url, {
35
+ headers: { "User-Agent": UA, Referer: "https://cine.su/" },
36
+ cache: "no-store",
37
+ });
38
+ if (!r.ok) return new NextResponse("not found", { status: 404 });
39
+ const ts = stripPngWrapper(Buffer.from(await r.arrayBuffer()));
40
+ return new NextResponse(new Uint8Array(ts), {
41
+ headers: {
42
+ "Content-Type": "video/mp2t",
43
+ "Cache-Control": "public, max-age=86400",
44
+ "Access-Control-Allow-Origin": "*",
45
+ },
46
+ });
47
+ } catch {
48
+ return new NextResponse("bad gateway", { status: 502 });
49
+ }
50
+ }
51
+
52
+ // ---- playlist mode ----
53
+ const isTv = searchParams.get("m") === "tv";
54
+ const t = parseInt(searchParams.get("t") || "0", 10);
55
+ const s = isTv ? parseInt(searchParams.get("s") || "0", 10) : 0;
56
+ const e = isTv ? parseInt(searchParams.get("e") || "0", 10) : 0;
57
+ const v = parseInt(searchParams.get("v") || "0", 10);
58
+ if (!t) return new NextResponse("bad request", { status: 400 });
59
+
60
+ const variants = await cineSuVariants(t, s, e).catch(() => []);
61
+ const variant = variants[v];
62
+ if (!variant) return new NextResponse("not found", { status: 404 });
63
+
64
+ const proxySeg = (url: string) =>
65
+ `${origin}/api/cinesu?seg=${encodeURIComponent(mintStreamToken(url))}`;
66
+ const m3u8 = buildVariantM3u8(variant, proxySeg);
67
+
68
+ return new NextResponse(m3u8, {
69
+ headers: {
70
+ "Content-Type": "application/vnd.apple.mpegurl",
71
+ "Cache-Control": "no-store",
72
+ "Access-Control-Allow-Origin": "*",
73
+ },
74
+ });
75
+ }
app/api/dseg/[...slug]/route.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { verify, DashBaseClaim } from "@/lib/security/tokens";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ const UA =
8
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
9
+
10
+ // DASH segment proxy. The .mpd's <BaseURL> is rewritten to
11
+ // /api/dseg/<base-token>/ so dash.js appends the segment path here; we
12
+ // reconstruct the real URL (real CDN host + referer) and stream the bytes.
13
+ export async function GET(
14
+ req: NextRequest,
15
+ { params }: { params: Promise<{ slug: string[] }> }
16
+ ) {
17
+ const { slug } = await params;
18
+ const [token, ...rest] = slug;
19
+ const claim = verify<DashBaseClaim>(token);
20
+ if (!claim || claim.k !== "dbase") {
21
+ return new NextResponse("forbidden", { status: 403 });
22
+ }
23
+
24
+ const search = new URL(req.url).search; // preserves segment query/tokens
25
+ const target = claim.url + rest.join("/") + search;
26
+
27
+ const headers: Record<string, string> = { "User-Agent": UA };
28
+ if (claim.ref) {
29
+ headers["Referer"] = claim.ref;
30
+ headers["Origin"] = new URL(claim.ref).origin;
31
+ }
32
+ const range = req.headers.get("range");
33
+ if (range) headers["Range"] = range;
34
+
35
+ let up: Response;
36
+ try {
37
+ up = await fetch(target, { headers, redirect: "follow", cache: "no-store" });
38
+ } catch {
39
+ return new NextResponse("bad gateway", { status: 502 });
40
+ }
41
+
42
+ const out = new Headers();
43
+ for (const h of [
44
+ "content-type",
45
+ "content-length",
46
+ "accept-ranges",
47
+ "content-range",
48
+ ]) {
49
+ const v = up.headers.get(h);
50
+ if (v) out.set(h, v);
51
+ }
52
+ out.set("Cache-Control", "no-store");
53
+ return new NextResponse(up.body, { status: up.status, headers: out });
54
+ }
app/api/introdb/route.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ // Skip intro / credits timestamps via theintrodb.org. No API key required —
7
+ // works out of the box. An optional INTRODB_API_KEY is forwarded if you set
8
+ // one, but it isn't needed. The player only requests this when a title is
9
+ // played with ?skip_intro=true.
10
+ const INTRODB_KEY = process.env.INTRODB_API_KEY || "";
11
+
12
+ export async function GET(req: NextRequest) {
13
+ const { searchParams } = new URL(req.url);
14
+ const id = searchParams.get("id") || "";
15
+ const season = searchParams.get("season") || "";
16
+ const episode = searchParams.get("episode") || "";
17
+ const durationMs = searchParams.get("duration_ms") || "";
18
+ if (!id) return NextResponse.json({ error: "bad request" }, { status: 400 });
19
+
20
+ const qs = new URLSearchParams({ tmdb_id: id });
21
+ if (season) qs.set("season", season);
22
+ if (episode) qs.set("episode", episode);
23
+ if (durationMs) qs.set("duration_ms", durationMs);
24
+
25
+ try {
26
+ const headers: Record<string, string> = {};
27
+ if (INTRODB_KEY) {
28
+ headers["Authorization"] = INTRODB_KEY;
29
+ headers["X-API-Key"] = INTRODB_KEY;
30
+ }
31
+ const res = await fetch(`https://api.theintrodb.org/v3/media?${qs.toString()}`, {
32
+ headers,
33
+ cache: "no-store",
34
+ });
35
+ if (!res.ok) return NextResponse.json({ intro: [], recap: [], credits: [] });
36
+ const data = await res.json();
37
+ return NextResponse.json(
38
+ { intro: data.intro || [], recap: data.recap || [], credits: data.credits || [] },
39
+ { headers: { "Cache-Control": "no-store" } }
40
+ );
41
+ } catch {
42
+ return NextResponse.json({ intro: [], recap: [], credits: [] });
43
+ }
44
+ }
app/api/sources/list/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { sourceList } from "@/lib/scraper";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ // Active source metadata (id/name/label) so the player builds its server list
8
+ // from whatever's registered in lib/scraper.
9
+ export async function GET() {
10
+ return NextResponse.json(
11
+ { sources: sourceList() },
12
+ { headers: { "Cache-Control": "no-store" } }
13
+ );
14
+ }
app/api/sources/route.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getSource } from "@/lib/scraper";
3
+ import type { SourceContext } from "@/lib/scraper/types";
4
+ import { mintStreamToken } from "@/lib/security/tokens";
5
+ import { fetchOpenSubtitles } from "@/lib/subtitles/opensubtitles";
6
+ import { fetchVdrkSubs } from "@/lib/subtitles/vdrk";
7
+
8
+ // Subtitle sources are toggled via env (all enabled by default). Set
9
+ // SUBTITLES_OPENSUBTITLES / SUBTITLES_VDRK to "false" (or "0"/"off"/"no").
10
+ function subEnabled(name: string): boolean {
11
+ const v = (process.env[name] || "").toLowerCase().trim();
12
+ return v !== "false" && v !== "0" && v !== "off" && v !== "no";
13
+ }
14
+
15
+ export const runtime = "nodejs";
16
+ export const dynamic = "force-dynamic";
17
+
18
+ // Resolves playable sources for a title. Upstream stream + subtitle URLs are
19
+ // wrapped in signed /api/stream + /api/sub proxy links so every byte flows
20
+ // through this server (never a third-party CDN or worker), and the real URLs
21
+ // stay server-side.
22
+ export async function GET(req: NextRequest) {
23
+ const { searchParams } = new URL(req.url);
24
+ const type = searchParams.get("type") || "";
25
+ const id = searchParams.get("id") || "";
26
+ const season = parseInt(searchParams.get("season") || "0", 10) || 0;
27
+ const episode = parseInt(searchParams.get("episode") || "0", 10) || 0;
28
+ const server = searchParams.get("server") || "";
29
+ const tmdbId = parseInt(id, 10);
30
+ if (!tmdbId) return NextResponse.json({ error: "bad id" }, { status: 400 });
31
+
32
+ const origin = new URL(req.url).origin;
33
+ const proxied = (url: string, ref?: string) =>
34
+ `${origin}/api/stream?t=${encodeURIComponent(mintStreamToken(url, ref))}`;
35
+ const proxiedSub = (url: string, ref?: string) =>
36
+ `${origin}/api/sub?t=${encodeURIComponent(mintStreamToken(url, ref))}`;
37
+
38
+ // Resolve the requested source from the registry (drop a file in lib/scraper
39
+ // to add a new one). Falls back to the first registered source.
40
+ const source = getSource(server);
41
+ if (!source) {
42
+ return NextResponse.json({ error: "bad server" }, { status: 400 });
43
+ }
44
+
45
+ const isEnglish = (lang?: string) =>
46
+ (lang || "").toLowerCase().startsWith("en");
47
+ const openSubtitlesOn = subEnabled("SUBTITLES_OPENSUBTITLES");
48
+ const vdrkOn = subEnabled("SUBTITLES_VDRK");
49
+ const subType = type === "tv" ? "tv" : "movie";
50
+ const [osRaw, vdrkRaw] = await Promise.all([
51
+ openSubtitlesOn
52
+ ? fetchOpenSubtitles(tmdbId, season, episode).catch(() => [])
53
+ : [],
54
+ vdrkOn ? fetchVdrkSubs(subType, tmdbId, season, episode).catch(() => []) : [],
55
+ ]);
56
+ // English first, then the rest — tracks proxied for same-origin WebVTT.
57
+ const openSubs = osRaw
58
+ .sort((a, b) => Number(isEnglish(b.language)) - Number(isEnglish(a.language)))
59
+ .map((s) => ({
60
+ url: proxiedSub(s.url),
61
+ display: s.display,
62
+ language: s.language,
63
+ source: "OpenSubtitles",
64
+ }));
65
+ const vdrkSubs = vdrkRaw
66
+ .sort((a, b) => Number(isEnglish(b.language)) - Number(isEnglish(a.language)))
67
+ .map((s) => ({
68
+ url: proxiedSub(s.file),
69
+ display: s.label,
70
+ language: s.language,
71
+ source: "vdrk",
72
+ }));
73
+
74
+ const ctx: SourceContext = {
75
+ tmdbId,
76
+ season,
77
+ episode,
78
+ origin,
79
+ proxyStream: proxied,
80
+ proxySub: proxiedSub,
81
+ };
82
+
83
+ const result = await source.fetch(ctx).catch(() => null);
84
+ if (!result || !result.streams.length) {
85
+ return NextResponse.json({ error: "no sources" }, { status: 404 });
86
+ }
87
+
88
+ return NextResponse.json(
89
+ {
90
+ sources: result.streams,
91
+ provider: source.name,
92
+ subtitles: [...openSubs, ...vdrkSubs, ...(result.subtitles || [])],
93
+ },
94
+ { headers: { "Cache-Control": "no-store" } }
95
+ );
96
+ }
app/api/stream/route.ts ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ verify,
4
+ mintStreamToken,
5
+ mintDashBase,
6
+ StreamClaim,
7
+ } from "@/lib/security/tokens";
8
+
9
+ export const runtime = "nodejs";
10
+ export const dynamic = "force-dynamic";
11
+
12
+ const UA =
13
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
14
+
15
+ // Rewrite every URI in an HLS manifest so child playlists, keys and segments
16
+ // also flow back through this proxy (the real hosts are never exposed).
17
+ function rewriteManifest(
18
+ text: string,
19
+ baseUrl: string,
20
+ origin: string,
21
+ ref?: string
22
+ ): string {
23
+ const base = new URL(baseUrl);
24
+ const wrap = (u: string) => {
25
+ let abs: string;
26
+ try {
27
+ abs = new URL(u, base).toString();
28
+ } catch {
29
+ return u;
30
+ }
31
+ return `${origin}/api/stream?t=${encodeURIComponent(
32
+ mintStreamToken(abs, ref)
33
+ )}`;
34
+ };
35
+ return text
36
+ .split("\n")
37
+ .map((line) => {
38
+ const t = line.trim();
39
+ if (!t) return line;
40
+ if (t.startsWith("#")) {
41
+ // rewrite URI="..." attributes (EXT-X-KEY / MAP / MEDIA / I-FRAME)
42
+ return line.replace(/URI="([^"]+)"/g, (_m, u) => `URI="${wrap(u)}"`);
43
+ }
44
+ return wrap(t); // bare segment / sub-playlist line
45
+ })
46
+ .join("\n");
47
+ }
48
+
49
+ // Rewrite a DASH .mpd so its <BaseURL> points at the segment proxy. The real
50
+ // host is BaseURL[0]; "x-bc.interkh.com" placeholder bases are dropped.
51
+ function rewriteMpd(
52
+ xml: string,
53
+ mpdUrl: string,
54
+ origin: string,
55
+ ref?: string
56
+ ): string {
57
+ const bases = [...xml.matchAll(/<BaseURL>([^<]+)<\/BaseURL>/g)].map((m) =>
58
+ m[1].trim()
59
+ );
60
+ let real = bases.find((u) => !u.includes("x-bc")) || bases[0];
61
+ if (!real) {
62
+ // no explicit BaseURL — derive from the manifest directory
63
+ real = mpdUrl.split("?")[0].replace(/[^/]*$/, "");
64
+ } else {
65
+ real = new URL(real, mpdUrl).toString();
66
+ }
67
+ if (!real.endsWith("/")) real += "/";
68
+
69
+ const proxied = `${origin}/api/dseg/${mintDashBase(real, ref)}/`;
70
+
71
+ let first = true;
72
+ let out = xml.replace(/<BaseURL>[^<]*<\/BaseURL>/g, () =>
73
+ first ? ((first = false), `<BaseURL>${proxied}</BaseURL>`) : ""
74
+ );
75
+ // if the manifest had no BaseURL at all, inject one after <MPD ...>
76
+ if (first) {
77
+ out = out.replace(/(<MPD\b[^>]*>)/, `$1<BaseURL>${proxied}</BaseURL>`);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ export async function GET(req: NextRequest) {
83
+ const { searchParams, origin } = new URL(req.url);
84
+ const claim = verify<StreamClaim>(searchParams.get("t"));
85
+ if (!claim || claim.k !== "stream") {
86
+ return new NextResponse("forbidden", { status: 403 });
87
+ }
88
+
89
+ const target = claim.url;
90
+ const upstreamHeaders: Record<string, string> = { "User-Agent": UA };
91
+ if (claim.ref) {
92
+ upstreamHeaders["Referer"] = claim.ref;
93
+ upstreamHeaders["Origin"] = new URL(claim.ref).origin;
94
+ }
95
+ const range = req.headers.get("range");
96
+ if (range) upstreamHeaders["Range"] = range;
97
+
98
+ let upstream: Response;
99
+ try {
100
+ upstream = await fetch(target, {
101
+ headers: upstreamHeaders,
102
+ redirect: "follow",
103
+ cache: "no-store", // never serve a stale manifest (segment tokens rotate)
104
+ });
105
+ } catch {
106
+ return new NextResponse("bad gateway", { status: 502 });
107
+ }
108
+
109
+ const ct = (upstream.headers.get("content-type") || "").toLowerCase();
110
+ const path0 = target.split("?")[0].toLowerCase();
111
+ const isManifest = path0.endsWith(".m3u8") || ct.includes("mpegurl");
112
+ const isMpd = path0.endsWith(".mpd") || ct.includes("dash+xml");
113
+
114
+ if (isMpd) {
115
+ const xml = await upstream.text();
116
+ const finalUrl = (upstream as any).url || target;
117
+ const body = rewriteMpd(xml, finalUrl, origin, claim.ref);
118
+ return new NextResponse(body, {
119
+ status: upstream.status,
120
+ headers: {
121
+ "Content-Type": "application/dash+xml",
122
+ "Cache-Control": "no-store",
123
+ },
124
+ });
125
+ }
126
+
127
+ if (isManifest) {
128
+ const text = await upstream.text();
129
+ // upstream.url reflects any redirects, giving the correct base for relatives
130
+ const finalUrl = (upstream as any).url || target;
131
+ const body = rewriteManifest(text, finalUrl, origin, claim.ref);
132
+ return new NextResponse(body, {
133
+ status: upstream.status,
134
+ headers: {
135
+ "Content-Type": "application/vnd.apple.mpegurl",
136
+ "Cache-Control": "no-store",
137
+ },
138
+ });
139
+ }
140
+
141
+ // Binary passthrough for segments / mp4 (preserve range/seek headers)
142
+ const headers = new Headers();
143
+ for (const h of [
144
+ "content-type",
145
+ "content-length",
146
+ "accept-ranges",
147
+ "content-range",
148
+ "cache-control",
149
+ ]) {
150
+ const v = upstream.headers.get(h);
151
+ if (v) headers.set(h, v);
152
+ }
153
+ if (!headers.has("cache-control")) headers.set("Cache-Control", "no-store");
154
+
155
+ return new NextResponse(upstream.body, {
156
+ status: upstream.status,
157
+ headers,
158
+ });
159
+ }
app/api/sub/route.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import zlib from "zlib";
3
+ import { verify, StreamClaim } from "@/lib/security/tokens";
4
+
5
+ export const runtime = "nodejs";
6
+ export const dynamic = "force-dynamic";
7
+
8
+ const UA =
9
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
10
+
11
+ // In-memory cache of converted WebVTT, keyed by upstream URL. Lets a track that
12
+ // fetched successfully stay available without re-hitting rate-limited upstreams
13
+ // (notably OpenSubtitles' throttled free download endpoint).
14
+ const SUB_CACHE = new Map<string, { vtt: string; ts: number }>();
15
+ const SUB_CACHE_TTL = 6 * 60 * 60 * 1000; // 6h
16
+ const SUB_CACHE_MAX = 300;
17
+
18
+ function cacheGet(url: string): string | null {
19
+ const hit = SUB_CACHE.get(url);
20
+ if (!hit) return null;
21
+ if (Date.now() - hit.ts > SUB_CACHE_TTL) {
22
+ SUB_CACHE.delete(url);
23
+ return null;
24
+ }
25
+ return hit.vtt;
26
+ }
27
+
28
+ function cacheSet(url: string, vtt: string) {
29
+ if (SUB_CACHE.size >= SUB_CACHE_MAX) {
30
+ const oldest = SUB_CACHE.keys().next().value;
31
+ if (oldest) SUB_CACHE.delete(oldest);
32
+ }
33
+ SUB_CACHE.set(url, { vtt, ts: Date.now() });
34
+ }
35
+
36
+ function vttResponse(out: string) {
37
+ return new NextResponse(out, {
38
+ headers: {
39
+ "Content-Type": "text/vtt; charset=utf-8",
40
+ "Cache-Control": "public, max-age=3600",
41
+ "Access-Control-Allow-Origin": "*",
42
+ },
43
+ });
44
+ }
45
+
46
+ // Minimal SRT → WebVTT conversion (comma decimals → dot, add header).
47
+ function srtToVtt(input: string): string {
48
+ const body = input
49
+ .replace(/\r+/g, "")
50
+ .replace(/^/, "")
51
+ .replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
52
+ return "WEBVTT\n\n" + body;
53
+ }
54
+
55
+ // Proxies a signed subtitle URL and always serves WebVTT, same-origin.
56
+ export async function GET(req: NextRequest) {
57
+ const { searchParams } = new URL(req.url);
58
+ const claim = verify<StreamClaim>(searchParams.get("t"));
59
+ if (!claim || claim.k !== "stream") {
60
+ return new NextResponse("forbidden", { status: 403 });
61
+ }
62
+
63
+ // Serve from cache when we've already fetched + converted this track.
64
+ const cached = cacheGet(claim.url);
65
+ if (cached) return vttResponse(cached);
66
+
67
+ const headers: Record<string, string> = { "User-Agent": UA };
68
+ if (claim.ref) headers["Referer"] = claim.ref;
69
+
70
+ const target = claim.url;
71
+
72
+ // Fail fast instead of hanging on slow/rate-limited upstreams (OpenSubtitles
73
+ // can stall for 10s+ when throttled).
74
+ const controller = new AbortController();
75
+ const timer = setTimeout(() => controller.abort(), 8000);
76
+
77
+ let text: string;
78
+ try {
79
+ const res = await fetch(target, {
80
+ headers,
81
+ cache: "no-store",
82
+ redirect: "follow",
83
+ signal: controller.signal,
84
+ });
85
+ if (!res.ok) return new NextResponse("not found", { status: 404 });
86
+ let buf = Buffer.from(await res.arrayBuffer());
87
+ // OpenSubtitles (and some others) serve a gzip-compressed body without a
88
+ // Content-Encoding header, so fetch doesn't auto-decompress. Detect the
89
+ // gzip magic bytes (1f 8b) and inflate ourselves.
90
+ if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
91
+ try {
92
+ buf = zlib.gunzipSync(buf);
93
+ } catch {}
94
+ }
95
+ text = buf.toString("utf8");
96
+ } catch {
97
+ return new NextResponse("bad gateway", { status: 502 });
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+
102
+ // If we got an HTML error page (e.g. download-limit notice) it's not a
103
+ // subtitle — fail so the player can fall back to another track.
104
+ if (/^\s*<(?:!doctype|html)/i.test(text)) {
105
+ return new NextResponse("not a subtitle", { status: 502 });
106
+ }
107
+
108
+ const isVtt = text.trimStart().startsWith("WEBVTT");
109
+ let out = isVtt ? text : srtToVtt(text);
110
+
111
+ // Rebrand any source watermark/credit lines onto the serving domain.
112
+ const host = req.headers.get("host");
113
+ if (host) out = out.replace(/hoofoot\.ru/gi, host);
114
+
115
+ cacheSet(claim.url, out);
116
+ return vttResponse(out);
117
+ }
app/api/tmdb/route.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ search,
4
+ getById,
5
+ getPopular,
6
+ getTvSeasons,
7
+ getSeasonEpisodes,
8
+ } from "@/lib/tmdb";
9
+
10
+ // Lightweight proxy so the player page can search / look up titles without
11
+ // shipping the API key to the browser.
12
+ export async function GET(req: NextRequest) {
13
+ const { searchParams } = new URL(req.url);
14
+ const action = searchParams.get("action");
15
+ const type = (searchParams.get("type") || "movie") as "movie" | "tv";
16
+
17
+ try {
18
+ if (action === "popular") {
19
+ const data = await getPopular(type);
20
+ return NextResponse.json({ results: data.slice(0, 12) });
21
+ }
22
+ if (action === "search") {
23
+ const q = searchParams.get("q") || "";
24
+ const data = await search(type, q);
25
+ return NextResponse.json({ results: data.slice(0, 8) });
26
+ }
27
+ if (action === "lookup") {
28
+ const id = searchParams.get("id") || "";
29
+ const data = await getById(type, id);
30
+ return NextResponse.json({ result: data });
31
+ }
32
+ if (action === "seasons") {
33
+ const id = searchParams.get("id") || "";
34
+ const data = await getTvSeasons(id);
35
+ return NextResponse.json({ seasons: data });
36
+ }
37
+ if (action === "episodes") {
38
+ const id = searchParams.get("id") || "";
39
+ const season = parseInt(searchParams.get("season") || "1", 10) || 1;
40
+ const data = await getSeasonEpisodes(id, season);
41
+ return NextResponse.json({ episodes: data });
42
+ }
43
+ return NextResponse.json({ error: "unknown action" }, { status: 400 });
44
+ } catch (e) {
45
+ return NextResponse.json({ error: "tmdb error" }, { status: 502 });
46
+ }
47
+ }
app/globals.css ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --bg: #050208;
7
+ --brand: #f59ff1;
8
+ --brand-2: #c084fc;
9
+ --brand-3: #a855f7;
10
+ }
11
+
12
+ * {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ html,
17
+ body {
18
+ padding: 0;
19
+ margin: 0;
20
+ background-color: var(--bg);
21
+ color: #fff;
22
+ min-height: 100%;
23
+ -webkit-font-smoothing: antialiased;
24
+ -moz-osx-font-smoothing: grayscale;
25
+ }
26
+
27
+ body {
28
+ font-family: var(--font-montserrat), var(--font-inter), ui-sans-serif, system-ui, sans-serif;
29
+ position: relative;
30
+ overflow-x: hidden;
31
+ }
32
+
33
+ /* Custom scrollbar */
34
+ ::-webkit-scrollbar {
35
+ width: 10px;
36
+ height: 10px;
37
+ }
38
+ ::-webkit-scrollbar-track {
39
+ background: transparent;
40
+ }
41
+ ::-webkit-scrollbar-thumb {
42
+ background: rgba(139, 92, 246, 0.35);
43
+ border-radius: 999px;
44
+ border: 2px solid transparent;
45
+ background-clip: padding-box;
46
+ }
47
+ ::-webkit-scrollbar-thumb:hover {
48
+ background: rgba(139, 92, 246, 0.6);
49
+ background-clip: padding-box;
50
+ }
51
+
52
+ .text-gradient-purple {
53
+ background-image: linear-gradient(to right, #c084fc, #d8b4fe, #e9d5ff);
54
+ -webkit-background-clip: text;
55
+ background-clip: text;
56
+ color: transparent;
57
+ }
58
+
59
+ /* Matches the logo "V" colour (#f59ff1), lightening toward the right */
60
+ .text-gradient-logo {
61
+ background-image: linear-gradient(110deg, #f59ff1 0%, #f4a9f0 38%, #e9c7ff 100%);
62
+ -webkit-background-clip: text;
63
+ background-clip: text;
64
+ color: transparent;
65
+ }
66
+
67
+ /* ───────────────────────────────────────────────────────────────────────
68
+ Scrolling movie-poster wall — cinematic page background. Columns drift up
69
+ and down at varied speeds behind a dark, brand-tinted veil for legibility.
70
+ ─────────────────────────────────────────────────────────────────────── */
71
+ .movie-wall {
72
+ position: fixed;
73
+ inset: 0;
74
+ z-index: -1;
75
+ overflow: hidden;
76
+ background: var(--bg);
77
+ }
78
+ .movie-wall-track {
79
+ display: flex;
80
+ gap: 14px;
81
+ height: 100%;
82
+ padding: 14px;
83
+ /* size columns to their content (not stretched) so translateY(-50%) maps to
84
+ exactly one duplicated set — a seamless loop. */
85
+ align-items: flex-start;
86
+ opacity: 0.2;
87
+ }
88
+ .movie-wall-col {
89
+ flex: 0 0 clamp(120px, 13vw, 190px);
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 14px;
93
+ will-change: transform;
94
+ /* longhand props so a missing duration in a shorthand can't reset it to 0s */
95
+ animation-name: wall-up;
96
+ animation-duration: var(--dur, 60s);
97
+ animation-timing-function: linear;
98
+ animation-iteration-count: infinite;
99
+ }
100
+ .movie-wall-col.is-down {
101
+ animation-name: wall-down;
102
+ }
103
+ .movie-wall-col img {
104
+ width: 100%;
105
+ aspect-ratio: 2 / 3;
106
+ object-fit: cover;
107
+ border-radius: 12px;
108
+ display: block;
109
+ }
110
+ @keyframes wall-up {
111
+ from { transform: translateY(0); }
112
+ to { transform: translateY(-50%); }
113
+ }
114
+ @keyframes wall-down {
115
+ from { transform: translateY(-50%); }
116
+ to { transform: translateY(0); }
117
+ }
118
+ /* Dark, brand-tinted wash so foreground text stays readable over the wall. */
119
+ .movie-wall-veil {
120
+ position: absolute;
121
+ inset: 0;
122
+ background:
123
+ radial-gradient(ellipse 60% 55% at 50% 42%, rgba(5, 2, 8, 0.55), rgba(5, 2, 8, 0.93) 78%),
124
+ radial-gradient(circle at 12% 18%, rgba(168, 85, 247, 0.22), transparent 42%),
125
+ radial-gradient(circle at 88% 80%, rgba(245, 159, 241, 0.16), transparent 46%),
126
+ linear-gradient(to bottom, rgba(5, 2, 8, 0.86), rgba(5, 2, 8, 0.55) 32%, rgba(5, 2, 8, 0.92));
127
+ }
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .movie-wall-col { animation: none !important; }
130
+ }
131
+
132
+ /* Glass card used across the marketing pages */
133
+ .glass {
134
+ border: 1px solid rgba(255, 255, 255, 0.08);
135
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.015));
136
+ border-radius: 20px;
137
+ backdrop-filter: blur(10px);
138
+ box-shadow:
139
+ 0 1px 0 0 rgba(255, 255, 255, 0.05) inset,
140
+ 0 24px 60px -28px rgba(0, 0, 0, 0.8);
141
+ }
142
+
143
+ /* Glass frame with corner brackets */
144
+ .frame {
145
+ position: relative;
146
+ border-radius: 24px;
147
+ border: 1px solid rgba(255, 255, 255, 0.08);
148
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
149
+ box-shadow:
150
+ 0 0 0 1px rgba(139, 92, 246, 0.06),
151
+ 0 30px 80px -20px rgba(0, 0, 0, 0.7),
152
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.04);
153
+ backdrop-filter: blur(2px);
154
+ }
155
+ .frame::before,
156
+ .frame::after {
157
+ content: "";
158
+ position: absolute;
159
+ width: 28px;
160
+ height: 28px;
161
+ border-color: rgba(216, 180, 254, 0.55);
162
+ pointer-events: none;
163
+ }
164
+ .frame-corner {
165
+ position: absolute;
166
+ width: 26px;
167
+ height: 26px;
168
+ pointer-events: none;
169
+ }
170
+ .frame-corner::before,
171
+ .frame-corner::after {
172
+ content: "";
173
+ position: absolute;
174
+ background: rgba(216, 180, 254, 0.5);
175
+ }
176
+ .frame-corner::before {
177
+ width: 100%;
178
+ height: 1.5px;
179
+ }
180
+ .frame-corner::after {
181
+ width: 1.5px;
182
+ height: 100%;
183
+ }
184
+ .frame-corner.tl {
185
+ top: 18px;
186
+ left: 18px;
187
+ }
188
+ .frame-corner.tr {
189
+ top: 18px;
190
+ right: 18px;
191
+ }
192
+ .frame-corner.tr::before {
193
+ right: 0;
194
+ }
195
+ .frame-corner.tr::after {
196
+ right: 0;
197
+ }
198
+ .frame-corner.bl {
199
+ bottom: 18px;
200
+ left: 18px;
201
+ }
202
+ .frame-corner.bl::before {
203
+ bottom: 0;
204
+ }
205
+ .frame-corner.br {
206
+ bottom: 18px;
207
+ right: 18px;
208
+ }
209
+ .frame-corner.br::before {
210
+ bottom: 0;
211
+ right: 0;
212
+ }
213
+ .frame-corner.br::after {
214
+ bottom: 0;
215
+ right: 0;
216
+ }
217
+
218
+ .no-scrollbar::-webkit-scrollbar {
219
+ display: none;
220
+ }
221
+ .no-scrollbar {
222
+ -ms-overflow-style: none;
223
+ scrollbar-width: none;
224
+ }
app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter, Montserrat } from "next/font/google";
3
+ import "./globals.css";
4
+ import { PLAYER_NAME, PLAYER_TAGLINE, LOGO_SRC } from "@/lib/config";
5
+
6
+ const inter = Inter({
7
+ subsets: ["latin"],
8
+ variable: "--font-inter",
9
+ display: "swap",
10
+ });
11
+
12
+ const montserrat = Montserrat({
13
+ subsets: ["latin"],
14
+ variable: "--font-montserrat",
15
+ display: "swap",
16
+ });
17
+
18
+ export const metadata: Metadata = {
19
+ title: `${PLAYER_NAME} — ${PLAYER_TAGLINE}`,
20
+ description: PLAYER_TAGLINE,
21
+ icons: { icon: LOGO_SRC, shortcut: LOGO_SRC, apple: LOGO_SRC },
22
+ };
23
+
24
+ export default function RootLayout({
25
+ children,
26
+ }: {
27
+ children: React.ReactNode;
28
+ }) {
29
+ return (
30
+ <html lang="en" className={`${inter.variable} ${montserrat.variable}`}>
31
+ <body>{children}</body>
32
+ </html>
33
+ );
34
+ }
app/movie/[id]/page.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import PlayerScreen from "@/components/player/PlayerScreen";
3
+ import { getById } from "@/lib/tmdb";
4
+ import { PLAYER_NAME } from "@/lib/config";
5
+
6
+ export const revalidate = 3600;
7
+
8
+ export async function generateMetadata({
9
+ params,
10
+ }: {
11
+ params: Promise<{ id: string }>;
12
+ }): Promise<Metadata> {
13
+ const { id } = await params;
14
+ const item = await getById("movie", id).catch(() => null);
15
+ return { title: item?.title ? `${item.title} — ${PLAYER_NAME}` : PLAYER_NAME };
16
+ }
17
+
18
+ // /movie/{tmdbId}
19
+ export default async function MoviePlayerPage({
20
+ params,
21
+ searchParams,
22
+ }: {
23
+ params: Promise<{ id: string }>;
24
+ searchParams: Promise<Record<string, string | undefined>>;
25
+ }) {
26
+ const { id } = await params;
27
+ const sp = await searchParams;
28
+ return (
29
+ <PlayerScreen
30
+ type="movie"
31
+ id={id}
32
+ color={sp.color}
33
+ src={sp.src}
34
+ server={sp.server}
35
+ progress={sp.progress}
36
+ overlay={sp.overlay}
37
+ skipIntro={sp.skip_intro}
38
+ />
39
+ );
40
+ }
app/not-found.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function NotFound() {
2
+ return (
3
+ <div
4
+ style={{
5
+ position: "fixed",
6
+ inset: 0,
7
+ zIndex: 9999,
8
+ background: "#000",
9
+ color: "#fff",
10
+ }}
11
+ >
12
+ 404 not found
13
+ </div>
14
+ );
15
+ }
app/tv/[id]/[season]/[episode]/page.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import PlayerScreen from "@/components/player/PlayerScreen";
3
+ import { getById } from "@/lib/tmdb";
4
+ import { PLAYER_NAME } from "@/lib/config";
5
+
6
+ export const revalidate = 3600;
7
+
8
+ export async function generateMetadata({
9
+ params,
10
+ }: {
11
+ params: Promise<{ id: string; season: string; episode: string }>;
12
+ }): Promise<Metadata> {
13
+ const { id, season, episode } = await params;
14
+ const item = await getById("tv", id).catch(() => null);
15
+ if (!item?.title) return { title: PLAYER_NAME };
16
+ return { title: `${item.title} · S${season} E${episode} — ${PLAYER_NAME}` };
17
+ }
18
+
19
+ // /tv/{tmdbId}/{season}/{episode}
20
+ export default async function TvPlayerPage({
21
+ params,
22
+ searchParams,
23
+ }: {
24
+ params: Promise<{ id: string; season: string; episode: string }>;
25
+ searchParams: Promise<Record<string, string | undefined>>;
26
+ }) {
27
+ const { id, season, episode } = await params;
28
+ const sp = await searchParams;
29
+ return (
30
+ <PlayerScreen
31
+ type="tv"
32
+ id={id}
33
+ season={season}
34
+ episode={episode}
35
+ color={sp.color}
36
+ src={sp.src}
37
+ server={sp.server}
38
+ progress={sp.progress}
39
+ nextEpisode={sp.nextEpisode}
40
+ episodeSelector={sp.episodeSelector}
41
+ autoplayNextEpisode={sp.autoplayNextEpisode}
42
+ overlay={sp.overlay}
43
+ skipIntro={sp.skip_intro}
44
+ />
45
+ );
46
+ }
components/player/PlayerScreen.tsx ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import VideoPlayer from "@/components/player/VideoPlayer";
2
+ import { getById, getLogo, getSeasonEpisodes, img } from "@/lib/tmdb";
3
+ import { DEFAULT_ACCENT } from "@/lib/config";
4
+
5
+ /**
6
+ * Full-screen player screen — /{type}/{id}. Renders TMDB metadata and plays the
7
+ * resolved stream (HLS / DASH / MP4). Supports `?src=` to play an arbitrary
8
+ * URL and `?color=` for the accent hex.
9
+ */
10
+ export default async function PlayerScreen({
11
+ type,
12
+ id,
13
+ season,
14
+ episode,
15
+ color,
16
+ src,
17
+ server,
18
+ progress,
19
+ nextEpisode,
20
+ episodeSelector,
21
+ autoplayNextEpisode,
22
+ overlay,
23
+ skipIntro,
24
+ }: {
25
+ type: "movie" | "tv";
26
+ id: string;
27
+ season?: string;
28
+ episode?: string;
29
+ color?: string;
30
+ src?: string;
31
+ server?: string;
32
+ progress?: string;
33
+ nextEpisode?: string;
34
+ episodeSelector?: string;
35
+ autoplayNextEpisode?: string;
36
+ overlay?: string;
37
+ skipIntro?: string;
38
+ }) {
39
+ const truthy = (v?: string) => v === "true" || v === "1" || v === "yes";
40
+ const item = await getById(type === "tv" ? "tv" : "movie", id).catch(
41
+ () => null
42
+ );
43
+
44
+ const accent = (color || DEFAULT_ACCENT).replace("#", "");
45
+
46
+ const title = item?.title || `${type === "tv" ? "Show" : "Movie"} #${id}`;
47
+ const subtitle =
48
+ type === "tv" && season && episode ? `S${season} · E${episode}` : item?.year;
49
+
50
+ // Extra metadata for the idle overlay.
51
+ const logo = item ? await getLogo(type, id).catch(() => null) : null;
52
+ let runtime: number | undefined = item?.runtime ?? undefined;
53
+ let episodeTitle: string | undefined;
54
+ let overview = item?.overview;
55
+ if (type === "tv" && season && episode) {
56
+ const eps = await getSeasonEpisodes(id, parseInt(season, 10)).catch(() => []);
57
+ const ep = eps.find((e) => e.episode === parseInt(episode, 10));
58
+ if (ep) {
59
+ episodeTitle = ep.name;
60
+ runtime = ep.runtime ?? runtime;
61
+ if (ep.overview) overview = ep.overview;
62
+ }
63
+ }
64
+
65
+ return (
66
+ <div className="fixed inset-0 z-[100] bg-black">
67
+ <VideoPlayer
68
+ src={src}
69
+ initialServer={server}
70
+ season={season}
71
+ episode={episode}
72
+ title={title}
73
+ year={subtitle}
74
+ overview={overview}
75
+ rating={item?.rating}
76
+ logo={logo}
77
+ runtime={runtime}
78
+ episodeTitle={episodeTitle}
79
+ poster={
80
+ img.backdrop(item?.backdrop ?? null, "original") ||
81
+ img.poster(item?.poster ?? null, "original")
82
+ }
83
+ color={accent}
84
+ type={type}
85
+ contentId={id}
86
+ startTime={progress ? parseInt(progress, 10) || 0 : 0}
87
+ nextEpisode={truthy(nextEpisode)}
88
+ episodeSelector={truthy(episodeSelector)}
89
+ autoplayNext={truthy(autoplayNextEpisode)}
90
+ overlay={truthy(overlay)}
91
+ skipIntro={truthy(skipIntro)}
92
+ />
93
+ </div>
94
+ );
95
+ }
components/player/VideoPlayer.tsx ADDED
@@ -0,0 +1,2598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { detectStreamKind } from "@/lib/constants";
11
+ import { flagUrl } from "@/lib/subtitles/flags";
12
+ import {
13
+ PlayIcon,
14
+ PauseIcon,
15
+ Rewind10Icon,
16
+ Forward10Icon,
17
+ VolumeHighIcon,
18
+ VolumeMutedIcon,
19
+ SubtitlesIcon,
20
+ SettingsIcon,
21
+ CloudIcon,
22
+ FullscreenIcon,
23
+ FullscreenExitIcon,
24
+ HeartIcon,
25
+ CheckIcon,
26
+ UploadIcon,
27
+ SlidersIcon,
28
+ ChevronRightIcon,
29
+ CloseIcon,
30
+ NextEpisodeIcon,
31
+ ListIcon,
32
+ WarnIcon,
33
+ StarIcon,
34
+ } from "./icons";
35
+
36
+ export interface PlayerProps {
37
+ /** Direct stream URL (demo mode). Omit to resolve via the protected API. */
38
+ src?: string;
39
+ title?: string;
40
+ year?: string;
41
+ poster?: string | null;
42
+ /** hex without the leading # — matches the vidsuper ?color= param */
43
+ color?: string;
44
+ type?: "movie" | "tv";
45
+ contentId?: string | number;
46
+ autoPlay?: boolean;
47
+ season?: string | number;
48
+ episode?: string | number;
49
+ /** Provider to resolve first (defaults to "oneroom"). */
50
+ initialServer?: string;
51
+ overview?: string;
52
+ /** Start playback at this many seconds (?progress=). */
53
+ startTime?: number;
54
+ /** Show the next-episode button (?nextEpisode=). */
55
+ nextEpisode?: boolean;
56
+ /** Enable the season/episode picker (?episodeSelector=). */
57
+ episodeSelector?: boolean;
58
+ /** Auto-advance to the next episode on finish (?autoplayNextEpisode=). */
59
+ autoplayNext?: boolean;
60
+ /** Netflix-style overlay after 5s paused (?overlay=). */
61
+ overlay?: boolean;
62
+ /** Show a Skip Intro button over intro/recap segments (?skip_intro=). */
63
+ skipIntro?: boolean;
64
+ /** TMDB rating (0–10) shown on the idle overlay. */
65
+ rating?: number;
66
+ /** Title logo image (transparent PNG) shown on the idle overlay. */
67
+ logo?: string | null;
68
+ /** Runtime in minutes, shown on the idle overlay. */
69
+ runtime?: number;
70
+ /** Episode title (TV), shown on the idle overlay. */
71
+ episodeTitle?: string;
72
+ }
73
+
74
+ type Tab = "Quality" | "Subs" | "Servers" | "Speed";
75
+ type Panel = Tab | "CaptionStyle" | "Episodes" | "SubSources";
76
+
77
+ // ---- Subtitle appearance (persisted) --------------------------------------
78
+ interface SubStyle {
79
+ size: number; // px
80
+ color: string;
81
+ opacity: number; // text opacity 0..1
82
+ bg: string; // background color, "none" to disable
83
+ bgOpacity: number; // 0..1
84
+ font: string; // css font-family stack
85
+ bold: boolean;
86
+ edge: "none" | "shadow" | "outline" | "glow";
87
+ position: number; // distance from bottom, % of player height (when controls hidden)
88
+ }
89
+
90
+ const MONTSERRAT_STACK =
91
+ 'var(--font-montserrat), Montserrat, system-ui, "Segoe UI", sans-serif';
92
+
93
+ const DEFAULT_SUB_STYLE: SubStyle = {
94
+ size: 28,
95
+ color: "#ffffff",
96
+ opacity: 1,
97
+ bg: "#000000",
98
+ bgOpacity: 0, // no background by default
99
+ font: MONTSERRAT_STACK,
100
+ bold: false,
101
+ edge: "shadow",
102
+ position: 8,
103
+ };
104
+
105
+ const SUB_FONTS: { label: string; value: string }[] = [
106
+ { label: "Montserrat", value: MONTSERRAT_STACK },
107
+ { label: "Sans", value: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
108
+ { label: "Serif", value: 'Georgia, "Times New Roman", serif' },
109
+ { label: "Mono", value: '"SF Mono", "Roboto Mono", Menlo, Consolas, monospace' },
110
+ { label: "Rounded", value: '"Trebuchet MS", "Segoe UI", system-ui, sans-serif' },
111
+ ];
112
+
113
+ const SUB_COLORS = ["#ffffff", "#ffe600", "#00e5ff", "#7CFC00", "#ff6b6b", "#ff8a3d", "#c77dff"];
114
+
115
+ // Build the CSS for the rendered caption text from a SubStyle.
116
+ function captionTextStyle(s: SubStyle): React.CSSProperties {
117
+ const edge: Record<SubStyle["edge"], string> = {
118
+ none: "none",
119
+ shadow: "0 2px 4px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.7)",
120
+ outline:
121
+ "-1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000, 0 0 3px #000",
122
+ glow: "0 0 8px rgba(0,0,0,1), 0 0 14px var(--accent)",
123
+ };
124
+ return {
125
+ fontSize: `${s.size}px`,
126
+ lineHeight: 1.3,
127
+ color: s.color,
128
+ opacity: s.opacity,
129
+ fontFamily: s.font,
130
+ fontWeight: s.bold ? 800 : 500,
131
+ textShadow: edge[s.edge],
132
+ backgroundColor:
133
+ s.bg === "none" ? "transparent" : hexWithAlpha(s.bg, s.bgOpacity),
134
+ padding: s.bg === "none" ? 0 : "0.1em 0.4em",
135
+ borderRadius: 6,
136
+ whiteSpace: "pre-line",
137
+ boxDecorationBreak: "clone",
138
+ WebkitBoxDecorationBreak: "clone",
139
+ } as React.CSSProperties;
140
+ }
141
+
142
+ function hexWithAlpha(hex: string, alpha: number): string {
143
+ const h = hex.replace("#", "");
144
+ const r = parseInt(h.slice(0, 2), 16) || 0;
145
+ const g = parseInt(h.slice(2, 4), 16) || 0;
146
+ const b = parseInt(h.slice(4, 6), 16) || 0;
147
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
148
+ }
149
+
150
+ // Source providers are discovered at runtime from /api/sources/list, which is
151
+ // driven by the lib/scraper registry — so adding a scraper file automatically
152
+ // adds a server here.
153
+ type Provider = {
154
+ id: string;
155
+ name: string;
156
+ label: string;
157
+ children?: { id: string; name: string }[];
158
+ };
159
+
160
+ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
161
+
162
+ // Quality labels are usually a height ("1080") — show "1080p". Anything else
163
+ // (a sub-source name like "MovieBox") is shown as-is.
164
+ function qualityLabel(label: string): string {
165
+ return /^\d{3,4}$/.test(label) ? `${label}p` : label;
166
+ }
167
+ // Display name for a (possibly "parent:child") server id, using the loaded list.
168
+ function serverName(id: string, providers: Provider[]): string {
169
+ const [pid, cid] = id.split(":");
170
+ const p = providers.find((x) => x.id === pid);
171
+ if (!p) return id;
172
+ if (!cid) return p.name;
173
+ const c = p.children?.find((x) => x.id === cid);
174
+ return c ? `${p.name} · ${c.name}` : p.name;
175
+ }
176
+
177
+ function fmt(t: number) {
178
+ if (!isFinite(t) || t < 0) t = 0;
179
+ const h = Math.floor(t / 3600);
180
+ const m = Math.floor((t % 3600) / 60);
181
+ const s = Math.floor(t % 60);
182
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
183
+ return `${h > 0 ? h + ":" : ""}${mm}:${String(s).padStart(2, "0")}`;
184
+ }
185
+
186
+ // Minutes -> "1h 23m" / "23m" for the idle overlay meta line.
187
+ function fmtRuntime(min?: number | null) {
188
+ if (!min || min <= 0) return "";
189
+ const h = Math.floor(min / 60);
190
+ const m = min % 60;
191
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
192
+ }
193
+
194
+ function qualityBadge(height: number) {
195
+ if (height >= 2160) return "4K";
196
+ if (height >= 1080) return "Full HD";
197
+ if (height >= 720) return "HD";
198
+ return "";
199
+ }
200
+
201
+ // "en" + "English" -> "English - English", matching the player's subtitle list
202
+ function subLabel(lang: string | undefined, name: string | undefined) {
203
+ let langName = "";
204
+ if (lang) {
205
+ try {
206
+ langName =
207
+ new Intl.DisplayNames(["en"], { type: "language" }).of(lang) || lang;
208
+ } catch {
209
+ langName = lang;
210
+ }
211
+ }
212
+ const clean = (name || "").trim();
213
+ if (langName && clean && langName.toLowerCase() !== clean.toLowerCase())
214
+ return `${langName} - ${clean}`;
215
+ return clean || langName || "Subtitle";
216
+ }
217
+
218
+ export default function VideoPlayer({
219
+ src,
220
+ title,
221
+ year,
222
+ poster,
223
+ color = "ef4444",
224
+ type = "movie",
225
+ contentId,
226
+ autoPlay = false,
227
+ season,
228
+ episode,
229
+ initialServer,
230
+ overview,
231
+ startTime = 0,
232
+ nextEpisode = false,
233
+ episodeSelector = false,
234
+ autoplayNext = false,
235
+ overlay = false,
236
+ skipIntro = false,
237
+ rating,
238
+ logo,
239
+ runtime,
240
+ episodeTitle,
241
+ }: PlayerProps) {
242
+ const accent = `#${color.replace("#", "")}`;
243
+
244
+ const wrapRef = useRef<HTMLDivElement>(null);
245
+ const videoRef = useRef<HTMLVideoElement>(null);
246
+ const hlsRef = useRef<any>(null);
247
+ const dashRef = useRef<any>(null);
248
+ const engineRef = useRef<"hls" | "dash" | "native">("native");
249
+ const fileInputRef = useRef<HTMLInputElement>(null);
250
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
251
+
252
+ const [started, setStarted] = useState(autoPlay);
253
+ const [playing, setPlaying] = useState(false);
254
+ const [buffering, setBuffering] = useState(false);
255
+ const [current, setCurrent] = useState(0);
256
+ const [duration, setDuration] = useState(0);
257
+ const [buffered, setBuffered] = useState(0);
258
+ const [volume, setVolume] = useState(1);
259
+ const [muted, setMuted] = useState(false);
260
+ const [fullscreen, setFullscreen] = useState(false);
261
+ const [controlsVisible, setControlsVisible] = useState(true);
262
+ const [panel, setPanel] = useState<Panel | null>(null);
263
+ const [hoverTime, setHoverTime] = useState<{ x: number; t: number } | null>(
264
+ null
265
+ );
266
+ const [skipFlash, setSkipFlash] = useState<"fwd" | "back" | null>(null);
267
+
268
+ // episodes / next-episode (TV)
269
+ const isSeries = type === "tv";
270
+ const curSeason = parseInt(String(season ?? "0"), 10) || 0;
271
+ const curEpisode = parseInt(String(episode ?? "0"), 10) || 0;
272
+ const [seasonsInfo, setSeasonsInfo] = useState<
273
+ { season: number; episodeCount: number; name: string }[]
274
+ >([]);
275
+ const [pickerSeason, setPickerSeason] = useState(curSeason || 1);
276
+ const [pickerEpisodes, setPickerEpisodes] = useState<
277
+ { episode: number; name: string; still: string | null; overview: string }[]
278
+ >([]);
279
+ // Netflix-style idle overlay
280
+ const [showOverlay, setShowOverlay] = useState(false);
281
+ const overlayTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
282
+ const didInitialSeekRef = useRef(false);
283
+ // Always points at the latest armOverlay (poke is defined before it).
284
+ const armOverlayRef = useRef<() => void>(() => {});
285
+
286
+ // skip intro / recap (?skip_intro=)
287
+ type SkipSeg = { start: number; end: number; label: string };
288
+ const [skipSegments, setSkipSegments] = useState<SkipSeg[]>([]);
289
+ const [activeSkip, setActiveSkip] = useState<SkipSeg | null>(null);
290
+
291
+ // source switching: show a loading curtain + remember which servers failed.
292
+ const [switching, setSwitching] = useState(false);
293
+ const [failedServers, setFailedServers] = useState<string[]>([]);
294
+
295
+ // selections
296
+ const [levels, setLevels] = useState<{ height: number; index: number }[]>([]);
297
+ const [currentLevel, setCurrentLevel] = useState(-1); // -1 = auto
298
+ const [speed, setSpeed] = useState(1);
299
+ const [serverId, setServerId] = useState(initialServer || "oneroom");
300
+ const [audioTracks, setAudioTracks] = useState<
301
+ { id: number; name: string }[]
302
+ >([]);
303
+ const [currentAudio, setCurrentAudio] = useState(0);
304
+ // Source providers, discovered from the lib/scraper registry at runtime.
305
+ const [providers, setProviders] = useState<Provider[]>([]);
306
+ useEffect(() => {
307
+ let cancelled = false;
308
+ fetch("/api/sources/list")
309
+ .then((r) => r.json())
310
+ .then((d) => {
311
+ if (!cancelled && Array.isArray(d.sources)) setProviders(d.sources);
312
+ })
313
+ .catch(() => {});
314
+ return () => {
315
+ cancelled = true;
316
+ };
317
+ }, []);
318
+ // Which parent source's sub-sources are shown (in the SubSources sub-panel).
319
+ const [subSourceParent, setSubSourceParent] = useState<string | null>(null);
320
+ // Favorited servers (persisted) — these take priority when resolving sources.
321
+ const [favorites, setFavorites] = useState<string[]>([]);
322
+ useEffect(() => {
323
+ try {
324
+ const raw = localStorage.getItem("vc-fav-servers");
325
+ if (raw) setFavorites(JSON.parse(raw));
326
+ } catch {}
327
+ }, []);
328
+ const [subsOn, setSubsOn] = useState(false);
329
+ const [loadError, setLoadError] = useState<string | null>(null);
330
+ const [subTracks, setSubTracks] = useState<
331
+ { id: number; label: string; builtIn: boolean }[]
332
+ >([]);
333
+ const [activeSub, setActiveSub] = useState(-1); // -1 = off
334
+
335
+ // Custom subtitle rendering: we keep the active track in "hidden" mode (so the
336
+ // browser parses cues but doesn't draw them) and render them ourselves into a
337
+ // floating overlay that sits above the controls and is fully styleable.
338
+ const [cueText, setCueText] = useState("");
339
+ const [subStyle, setSubStyle] = useState<SubStyle>(DEFAULT_SUB_STYLE);
340
+ const cueTrackRef = useRef<TextTrack | null>(null);
341
+ // Subtitle delay (seconds): positive = subs appear later, negative = earlier.
342
+ const [subDelay, setSubDelay] = useState(0);
343
+ const subDelayRef = useRef(0);
344
+ useEffect(() => {
345
+ subDelayRef.current = subDelay;
346
+ }, [subDelay]);
347
+
348
+ // Load persisted caption style once on mount.
349
+ useEffect(() => {
350
+ try {
351
+ const raw = localStorage.getItem("vc-sub-style");
352
+ if (raw) setSubStyle({ ...DEFAULT_SUB_STYLE, ...JSON.parse(raw) });
353
+ } catch {}
354
+ }, []);
355
+ const updateSubStyle = (patch: Partial<SubStyle>) =>
356
+ setSubStyle((prev) => {
357
+ const next = { ...prev, ...patch };
358
+ try {
359
+ localStorage.setItem("vc-sub-style", JSON.stringify(next));
360
+ } catch {}
361
+ return next;
362
+ });
363
+
364
+ // source resolution
365
+ const [resolvedSrc, setResolvedSrc] = useState<string | null>(null);
366
+ const [resolvedKind, setResolvedKind] = useState<
367
+ "hls" | "dash" | "native" | null
368
+ >(null);
369
+ const [resolving, setResolving] = useState(false);
370
+ const activeSrc = src || resolvedSrc;
371
+
372
+ // all sources from the API (for multi-quality switching) + Wyzie subtitles
373
+ type ApiSource = { file: string; label: string; type: string };
374
+ type ApiSub = {
375
+ url: string;
376
+ display: string;
377
+ language: string;
378
+ hi?: boolean;
379
+ source?: string;
380
+ /** Flag image URL supplied by the source (Wyzie); else derived. */
381
+ flag?: string;
382
+ };
383
+ const [resolvedSources, setResolvedSources] = useState<ApiSource[]>([]);
384
+ const [qualityIndex, setQualityIndex] = useState(0);
385
+ const [apiSubs, setApiSubs] = useState<ApiSub[]>([]);
386
+ const [activeApiSub, setActiveApiSub] = useState(-1);
387
+ // Once the user picks/clears a subtitle, stop auto-loading the default one.
388
+ const userPickedSubRef = useRef(false);
389
+ const pendingSeekRef = useRef<number | null>(null);
390
+ const wasPlayingRef = useRef(false);
391
+ // Set when the user has asked to play — the source effect starts playback as
392
+ // soon as the stream is ready (regardless of autoPlay).
393
+ const wantPlayRef = useRef(false);
394
+ // Servers already attempted for this title, so failover doesn't loop forever.
395
+ const triedServersRef = useRef<Set<string>>(new Set());
396
+ // Always points at the latest failover handler (the source effect captures a
397
+ // stale closure otherwise).
398
+ const failoverRef = useRef<() => void>(() => {});
399
+ // Resolution order: favorited servers first (in provider order), then the
400
+ // rest. Drives both the initial pick and the failover sequence. Falls back to
401
+ // the initial server until the provider list has loaded.
402
+ const serverOrder = useMemo(() => {
403
+ const ids = providers.map((p) => p.id);
404
+ if (!ids.length) return initialServer ? [initialServer] : [];
405
+ const favs = ids.filter((id) => favorites.includes(id));
406
+ const rest = ids.filter((id) => !favorites.includes(id));
407
+ return [...favs, ...rest];
408
+ }, [providers, favorites, initialServer]);
409
+
410
+ /* ---------------- source loading (HLS / DASH / MP4) ---------------- */
411
+ useEffect(() => {
412
+ const video = videoRef.current;
413
+ if (!video || !started || !activeSrc) return;
414
+ const src = activeSrc;
415
+ let destroyed = false;
416
+ const kind = resolvedKind || detectStreamKind(src);
417
+ engineRef.current = kind;
418
+ setLoadError(null);
419
+
420
+ // Begin playback once ready if autoPlay is set or the user pressed play.
421
+ const playIfWanted = () => {
422
+ if (autoPlay || wantPlayRef.current) {
423
+ wantPlayRef.current = false;
424
+ video!.play().catch(() => {});
425
+ }
426
+ };
427
+
428
+ const dedupeByHeight = (
429
+ arr: { height: number; index: number }[]
430
+ ) => {
431
+ const byHeight = new Map<number, number>();
432
+ arr.forEach(({ height, index }) => {
433
+ if (height && !byHeight.has(height)) byHeight.set(height, index);
434
+ });
435
+ return Array.from(byHeight, ([height, index]) => ({ height, index })).sort(
436
+ (a, b) => b.height - a.height
437
+ );
438
+ };
439
+
440
+ async function setupHls() {
441
+ // Prefer hls.js when supported — it gives us quality, ABR and subtitle
442
+ // track control. Only fall back to native HLS (Safari / iOS) otherwise.
443
+ // NOTE: some Chrome builds report canPlayType("…mpegurl") === "maybe",
444
+ // so we must check Hls.isSupported() first rather than trusting that.
445
+ const Hls = (await import("hls.js")).default;
446
+ if (destroyed) return;
447
+ if (!Hls.isSupported()) {
448
+ if (video!.canPlayType("application/vnd.apple.mpegurl")) {
449
+ video!.src = src;
450
+ video!.addEventListener("loadedmetadata", readNativeTextTracks);
451
+ video!.textTracks.addEventListener?.("addtrack", readNativeTextTracks);
452
+ video!.addEventListener("canplay", playIfWanted, { once: true });
453
+ video!.addEventListener("error", () => failoverRef.current());
454
+ return;
455
+ }
456
+ setLoadError("HLS is not supported in this browser.");
457
+ return;
458
+ }
459
+ const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
460
+ hlsRef.current = hls;
461
+ hls.loadSource(src);
462
+ hls.attachMedia(video!);
463
+ const readSubs = (tracks: any[]) => {
464
+ setSubTracks(
465
+ (tracks || []).map((t: any, id: number) => ({
466
+ id,
467
+ label: subLabel(t.lang || t.language, t.name),
468
+ builtIn: true,
469
+ }))
470
+ );
471
+ };
472
+ hls.on(Hls.Events.MANIFEST_PARSED, (_e: any, data: any) => {
473
+ setLevels(
474
+ dedupeByHeight(hls.levels.map((l, index) => ({ height: l.height, index })))
475
+ );
476
+ // The parsed manifest carries the full alternate-track lists.
477
+ readSubs(data?.subtitleTracks || hls.subtitleTracks);
478
+ playIfWanted();
479
+ });
480
+ hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) =>
481
+ setCurrentLevel(hls.autoLevelEnabled ? -1 : d.level)
482
+ );
483
+ hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e: any, data: any) =>
484
+ readSubs(data?.subtitleTracks || hls.subtitleTracks)
485
+ );
486
+ const readAudio = () => {
487
+ const tracks = (hls.audioTracks || []).map((t: any, id: number) => ({
488
+ id,
489
+ name: t.name || t.lang || `Audio ${id + 1}`,
490
+ }));
491
+ setAudioTracks(tracks.length > 1 ? tracks : []);
492
+ setCurrentAudio(hls.audioTrack ?? 0);
493
+ };
494
+ hls.on(Hls.Events.MANIFEST_PARSED, readAudio);
495
+ hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, readAudio);
496
+ hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, (_e: any, d: any) =>
497
+ setCurrentAudio(d.id)
498
+ );
499
+ // hls.js renders its own subtitle track; keep its display off by default
500
+ hls.subtitleDisplay = false;
501
+ let netRetries = 0;
502
+ hls.on(Hls.Events.ERROR, (_e, data) => {
503
+ if (data.fatal) {
504
+ if (data.type === "networkError" && netRetries++ < 2) hls.startLoad();
505
+ else if (data.type === "mediaError") hls.recoverMediaError();
506
+ // Unrecoverable — give up on this server and fail over to the next.
507
+ else failoverRef.current();
508
+ }
509
+ });
510
+ }
511
+
512
+ async function setupDash() {
513
+ const dashjs = await import("dashjs");
514
+ if (destroyed) return;
515
+ const player = dashjs.MediaPlayer().create();
516
+ dashRef.current = player;
517
+ player.initialize(video!, src, autoPlay || wantPlayRef.current);
518
+ if (wantPlayRef.current) wantPlayRef.current = false;
519
+ player.on("streamInitialized", () => {
520
+ try {
521
+ const list = player.getBitrateInfoListFor("video") || [];
522
+ setLevels(
523
+ dedupeByHeight(
524
+ list.map((b: any) => ({ height: b.height, index: b.qualityIndex }))
525
+ )
526
+ );
527
+ } catch {}
528
+ // dash.js exposes subtitle tracks on the media element
529
+ setTimeout(() => {
530
+ const tt = video!.textTracks;
531
+ const out: { id: number; label: string; builtIn: boolean }[] = [];
532
+ for (let i = 0; i < tt.length; i++) {
533
+ const t = tt[i];
534
+ if (t.kind === "subtitles" || t.kind === "captions") {
535
+ out.push({ id: i, label: subLabel(t.language, t.label), builtIn: true });
536
+ t.mode = "disabled";
537
+ }
538
+ }
539
+ setSubTracks(out);
540
+ // multi-audio (dub) tracks
541
+ try {
542
+ const at = player.getTracksFor("audio") || [];
543
+ const audio = at.map((t: any, i: number) => ({
544
+ id: typeof t.index === "number" ? t.index : i,
545
+ name:
546
+ (t.labels && t.labels[0]?.text) ||
547
+ t.lang ||
548
+ `Audio ${i + 1}`,
549
+ }));
550
+ setAudioTracks(audio.length > 1 ? audio : []);
551
+ } catch {}
552
+ }, 300);
553
+ });
554
+ player.on("error", () => failoverRef.current());
555
+ }
556
+
557
+ function readNativeTextTracks() {
558
+ const tt = video!.textTracks;
559
+ const list: { id: number; label: string; builtIn: boolean }[] = [];
560
+ for (let i = 0; i < tt.length; i++) {
561
+ const t = tt[i];
562
+ if (t.kind === "subtitles" || t.kind === "captions") {
563
+ list.push({
564
+ id: i,
565
+ label: subLabel(t.language, t.label),
566
+ builtIn: true,
567
+ });
568
+ t.mode = "disabled";
569
+ }
570
+ }
571
+ setSubTracks(list);
572
+ }
573
+
574
+ function setupNative() {
575
+ // Progressive MP4 / WebM / etc.
576
+ video!.src = src;
577
+ const onErr = () => failoverRef.current();
578
+ video!.addEventListener("error", onErr);
579
+ video!.textTracks.addEventListener?.("addtrack", readNativeTextTracks);
580
+ video!.addEventListener("loadedmetadata", readNativeTextTracks);
581
+ video!.addEventListener("canplay", playIfWanted, { once: true });
582
+ }
583
+
584
+ (async () => {
585
+ try {
586
+ if (kind === "hls") await setupHls();
587
+ else if (kind === "dash") await setupDash();
588
+ else setupNative();
589
+ } catch (e) {
590
+ if (!destroyed) setLoadError("Failed to load the player.");
591
+ }
592
+ })();
593
+
594
+ return () => {
595
+ destroyed = true;
596
+ try {
597
+ hlsRef.current?.destroy?.();
598
+ } catch {}
599
+ try {
600
+ dashRef.current?.reset?.();
601
+ } catch {}
602
+ hlsRef.current = null;
603
+ dashRef.current = null;
604
+ };
605
+ }, [activeSrc, resolvedKind, started, autoPlay]);
606
+
607
+ /* ---------------- episodes / next-episode ---------------- */
608
+ // Navigate the iframe to another episode, preserving every query param so the
609
+ // host's feature flags (color, nextEpisode, etc.) carry over.
610
+ const goToEpisode = useCallback(
611
+ (s: number, e: number) => {
612
+ if (typeof window === "undefined" || contentId == null) return;
613
+ const qs = window.location.search;
614
+ window.location.href = `/tv/${contentId}/${s}/${e}${qs}`;
615
+ },
616
+ [contentId]
617
+ );
618
+
619
+ // Compute the next episode (rolls into the next season for TV when known).
620
+ const nextEpisodeTarget = useMemo((): { s: number; e: number } | null => {
621
+ if (!isSeries || !curEpisode) return null;
622
+ const info = seasonsInfo.find((x) => x.season === curSeason);
623
+ if (info && curEpisode >= info.episodeCount) {
624
+ const hasNextSeason = seasonsInfo.some((x) => x.season === curSeason + 1);
625
+ if (hasNextSeason) return { s: curSeason + 1, e: 1 };
626
+ return null; // series finished
627
+ }
628
+ return { s: curSeason, e: curEpisode + 1 };
629
+ }, [isSeries, type, curSeason, curEpisode, seasonsInfo]);
630
+
631
+ // Fetch the TV season list when episode features are on.
632
+ useEffect(() => {
633
+ if (type !== "tv" || contentId == null) return;
634
+ if (!nextEpisode && !episodeSelector && !autoplayNext) return;
635
+ let cancelled = false;
636
+ fetch(`/api/tmdb?action=seasons&type=tv&id=${contentId}`)
637
+ .then((r) => r.json())
638
+ .then((d) => {
639
+ if (!cancelled) setSeasonsInfo(d.seasons || []);
640
+ })
641
+ .catch(() => {});
642
+ return () => {
643
+ cancelled = true;
644
+ };
645
+ }, [type, contentId, nextEpisode, episodeSelector, autoplayNext]);
646
+
647
+ // Lazy-load the episode list for the picker's selected season.
648
+ useEffect(() => {
649
+ if (panel !== "Episodes" || type !== "tv" || contentId == null) return;
650
+ let cancelled = false;
651
+ setPickerEpisodes([]);
652
+ fetch(`/api/tmdb?action=episodes&type=tv&id=${contentId}&season=${pickerSeason}`)
653
+ .then((r) => r.json())
654
+ .then((d) => {
655
+ if (!cancelled) setPickerEpisodes(d.episodes || []);
656
+ })
657
+ .catch(() => {});
658
+ return () => {
659
+ cancelled = true;
660
+ };
661
+ }, [panel, pickerSeason, type, contentId]);
662
+
663
+ /* ---------------- parent-window progress events ---------------- */
664
+ // Posts playback events to the embedding page (documented postMessage API).
665
+ const postEvent = useCallback(
666
+ (eventType: string) => {
667
+ if (typeof window === "undefined" || window.parent === window) return;
668
+ const v = videoRef.current;
669
+ if (!v) return;
670
+ try {
671
+ window.parent.postMessage(
672
+ JSON.stringify({
673
+ type: eventType,
674
+ id: contentId,
675
+ mediaType: type,
676
+ progress: Math.floor(v.currentTime || 0),
677
+ duration: Math.floor(v.duration || 0),
678
+ timestamp: Date.now(),
679
+ season: curSeason || undefined,
680
+ episode: curEpisode || undefined,
681
+ }),
682
+ "*"
683
+ );
684
+ } catch {}
685
+ },
686
+ [contentId, type, curSeason, curEpisode]
687
+ );
688
+
689
+ /* ---------------- idle overlay (?overlay=) ---------------- */
690
+ // Show the overlay instantly whenever playback is paused; hide it otherwise.
691
+ const armOverlay = useCallback(() => {
692
+ if (!overlay) return;
693
+ clearTimeout(overlayTimer.current);
694
+ const v = videoRef.current;
695
+ setShowOverlay(!!v && v.paused && started);
696
+ }, [overlay, started]);
697
+ armOverlayRef.current = armOverlay;
698
+
699
+ /* ---------------- skip intro / recap (?skip_intro=) ---------------- */
700
+ useEffect(() => {
701
+ if (!skipIntro || contentId == null || !duration) return;
702
+ let cancelled = false;
703
+ const qs = new URLSearchParams({
704
+ id: String(contentId),
705
+ duration_ms: String(Math.floor(duration * 1000)),
706
+ });
707
+ if (isSeries) {
708
+ if (curSeason) qs.set("season", String(curSeason));
709
+ if (curEpisode) qs.set("episode", String(curEpisode));
710
+ }
711
+ fetch(`/api/introdb?${qs.toString()}`)
712
+ .then((r) => r.json())
713
+ .then((d) => {
714
+ if (cancelled) return;
715
+ const segs: SkipSeg[] = [];
716
+ const add = (arr: any[], label: string) =>
717
+ (arr || []).forEach((x) =>
718
+ segs.push({
719
+ start: (x.start_ms ?? 0) / 1000,
720
+ // Credits often have a null end (runs to the finish) — fall back
721
+ // to the video duration so the segment is valid.
722
+ end: x.end_ms == null ? duration : x.end_ms / 1000,
723
+ label,
724
+ })
725
+ );
726
+ add(d.intro, "Skip Intro");
727
+ add(d.recap, "Skip Recap");
728
+ add(d.credits, "Skip Credits");
729
+ setSkipSegments(segs.filter((s) => s.end > s.start));
730
+ })
731
+ .catch(() => {});
732
+ return () => {
733
+ cancelled = true;
734
+ };
735
+ }, [skipIntro, contentId, duration, isSeries, curSeason, curEpisode]);
736
+
737
+ // Which skip segment (if any) the playhead is currently inside.
738
+ useEffect(() => {
739
+ if (!skipSegments.length) {
740
+ setActiveSkip(null);
741
+ return;
742
+ }
743
+ const seg = skipSegments.find(
744
+ (s) => current >= s.start && current < s.end - 0.3
745
+ );
746
+ setActiveSkip(seg || null);
747
+ }, [current, skipSegments]);
748
+
749
+ /* ---------------- video events ---------------- */
750
+ useEffect(() => {
751
+ const v = videoRef.current;
752
+ if (!v) return;
753
+ const onTime = () => {
754
+ setCurrent(v.currentTime);
755
+ // hls.js can disable foreign text tracks during setup; keep the bound
756
+ // caption track in "hidden" mode and mirror its cues into our overlay.
757
+ const tr = cueTrackRef.current;
758
+ if (tr) {
759
+ if (tr.mode === "disabled") tr.mode = "hidden";
760
+ renderCues();
761
+ }
762
+ };
763
+ const onDur = () => setDuration(v.duration || 0);
764
+ const onMeta = () => {
765
+ setDuration(v.duration || 0);
766
+ // ?progress= — start at the given second (once, on first load).
767
+ if (
768
+ !didInitialSeekRef.current &&
769
+ startTime > 0 &&
770
+ isFinite(v.duration) &&
771
+ pendingSeekRef.current == null
772
+ ) {
773
+ try {
774
+ v.currentTime = Math.min(startTime, v.duration - 1);
775
+ } catch {}
776
+ didInitialSeekRef.current = true;
777
+ }
778
+ // restore position after a quality switch
779
+ if (pendingSeekRef.current != null) {
780
+ try {
781
+ v.currentTime = pendingSeekRef.current;
782
+ } catch {}
783
+ pendingSeekRef.current = null;
784
+ if (wasPlayingRef.current) v.play().catch(() => {});
785
+ }
786
+ };
787
+ const onPlay = () => setPlaying(true);
788
+ const onPause = () => setPlaying(false);
789
+ const onWaiting = () => setBuffering(true);
790
+ const onPlaying = () => setBuffering(false);
791
+ const onProgress = () => {
792
+ if (v.buffered.length) setBuffered(v.buffered.end(v.buffered.length - 1));
793
+ };
794
+ const onVol = () => {
795
+ setVolume(v.volume);
796
+ setMuted(v.muted);
797
+ };
798
+ v.addEventListener("timeupdate", onTime);
799
+ v.addEventListener("durationchange", onDur);
800
+ v.addEventListener("loadedmetadata", onMeta);
801
+ v.addEventListener("play", onPlay);
802
+ v.addEventListener("pause", onPause);
803
+ v.addEventListener("waiting", onWaiting);
804
+ v.addEventListener("playing", onPlaying);
805
+ v.addEventListener("progress", onProgress);
806
+ v.addEventListener("volumechange", onVol);
807
+ return () => {
808
+ v.removeEventListener("timeupdate", onTime);
809
+ v.removeEventListener("durationchange", onDur);
810
+ v.removeEventListener("loadedmetadata", onMeta);
811
+ v.removeEventListener("play", onPlay);
812
+ v.removeEventListener("pause", onPause);
813
+ v.removeEventListener("waiting", onWaiting);
814
+ v.removeEventListener("playing", onPlaying);
815
+ v.removeEventListener("progress", onProgress);
816
+ v.removeEventListener("volumechange", onVol);
817
+ };
818
+ }, [started]);
819
+
820
+ /* playback events: parent postMessage + idle overlay + autoplay-next */
821
+ useEffect(() => {
822
+ const v = videoRef.current;
823
+ if (!v || !started) return;
824
+ let lastPost = 0;
825
+ const onTimePost = () => {
826
+ const now = Date.now();
827
+ if (now - lastPost > 2000) {
828
+ lastPost = now;
829
+ postEvent("timeupdate");
830
+ }
831
+ };
832
+ const onPlay = () => {
833
+ postEvent("play");
834
+ setShowOverlay(false);
835
+ clearTimeout(overlayTimer.current);
836
+ };
837
+ const onPause = () => {
838
+ postEvent("pause");
839
+ armOverlay();
840
+ };
841
+ const onEnded = () => {
842
+ postEvent("ended");
843
+ if (autoplayNext && nextEpisodeTarget) {
844
+ goToEpisode(nextEpisodeTarget.s, nextEpisodeTarget.e);
845
+ }
846
+ };
847
+ v.addEventListener("timeupdate", onTimePost);
848
+ v.addEventListener("play", onPlay);
849
+ v.addEventListener("pause", onPause);
850
+ v.addEventListener("ended", onEnded);
851
+ return () => {
852
+ v.removeEventListener("timeupdate", onTimePost);
853
+ v.removeEventListener("play", onPlay);
854
+ v.removeEventListener("pause", onPause);
855
+ v.removeEventListener("ended", onEnded);
856
+ clearTimeout(overlayTimer.current);
857
+ };
858
+ }, [started, postEvent, armOverlay, autoplayNext, nextEpisodeTarget, goToEpisode]);
859
+
860
+ /* ---------------- fullscreen ---------------- */
861
+ useEffect(() => {
862
+ const onFs = () => setFullscreen(!!document.fullscreenElement);
863
+ document.addEventListener("fullscreenchange", onFs);
864
+ return () => document.removeEventListener("fullscreenchange", onFs);
865
+ }, []);
866
+
867
+ const toggleFullscreen = useCallback(() => {
868
+ if (document.fullscreenElement) document.exitFullscreen();
869
+ else wrapRef.current?.requestFullscreen?.().catch(() => {});
870
+ }, []);
871
+
872
+ /* ---------------- controls ---------------- */
873
+ // Single attempt against one server. Returns true only if it yields a
874
+ // playable source. Records the server so failover can skip it next time.
875
+ const attemptResolve = useCallback(
876
+ async (sv: string): Promise<boolean> => {
877
+ if (contentId == null) return false;
878
+ triedServersRef.current.add(sv);
879
+ setResolving(true);
880
+ try {
881
+ const qs = new URLSearchParams({ type, id: String(contentId), server: sv });
882
+ if (season) qs.set("season", String(season));
883
+ if (episode) qs.set("episode", String(episode));
884
+
885
+ const res = await fetch(`/api/sources?${qs.toString()}`);
886
+ if (!res.ok) throw new Error(String(res.status));
887
+ const json = await res.json();
888
+ const list: ApiSource[] = json.sources || [];
889
+ const best = list[0];
890
+ if (!best?.file) throw new Error("no sources");
891
+ setAudioTracks([]);
892
+ setCurrentAudio(0);
893
+ setResolvedSources(list);
894
+ setQualityIndex(0);
895
+ setApiSubs(json.subtitles || []);
896
+ setResolvedSrc(best.file);
897
+ setResolvedKind(best.type === "mp4" ? "native" : (best.type as any));
898
+ setFailedServers((f) => f.filter((x) => x !== sv)); // recovered
899
+ return true;
900
+ } catch {
901
+ setFailedServers((f) => (f.includes(sv) ? f : [...f, sv]));
902
+ return false;
903
+ } finally {
904
+ setResolving(false);
905
+ }
906
+ },
907
+ [contentId, type, season, episode]
908
+ );
909
+
910
+ // Resolve `sv`, and if it fails, auto-advance through the remaining servers
911
+ // until one works or all are exhausted.
912
+ const resolveSources = useCallback(
913
+ async (sv: string = serverId, force = false): Promise<boolean> => {
914
+ if (resolvedSrc && !force) return true;
915
+ if (contentId == null) return false;
916
+ setLoadError(null);
917
+ let cur = sv;
918
+ for (;;) {
919
+ if (await attemptResolve(cur)) {
920
+ setServerId(cur);
921
+ return true;
922
+ }
923
+ const next = serverOrder.find((s) => !triedServersRef.current.has(s));
924
+ if (!next) {
925
+ setLoadError("Couldn't find a playable source on any server.");
926
+ return false;
927
+ }
928
+ cur = next;
929
+ }
930
+ },
931
+ [serverId, resolvedSrc, contentId, attemptResolve, serverOrder]
932
+ );
933
+
934
+ // Called when a resolved stream fails to actually play — drop it and fail
935
+ // over to the next untried server, preserving the user's intent to play.
936
+ const handlePlaybackFailure = useCallback(() => {
937
+ // The current stream failed to play — flag this server in the menu.
938
+ setFailedServers((f) => (f.includes(serverId) ? f : [...f, serverId]));
939
+ const next = serverOrder.find((s) => !triedServersRef.current.has(s));
940
+ if (!next) {
941
+ setLoadError("This stream could not be played.");
942
+ return;
943
+ }
944
+ const v = videoRef.current;
945
+ if (v) {
946
+ pendingSeekRef.current = v.currentTime;
947
+ wasPlayingRef.current = !v.paused;
948
+ try {
949
+ v.pause();
950
+ } catch {}
951
+ }
952
+ wantPlayRef.current = true;
953
+ setSwitching(true);
954
+ setServerId(next);
955
+ setResolvedSrc(null);
956
+ void resolveSources(next, true).finally(() => setSwitching(false));
957
+ }, [resolveSources, serverOrder, serverId]);
958
+ failoverRef.current = handlePlaybackFailure;
959
+
960
+ // Switch source provider — pause, show a loading curtain, then re-resolve and
961
+ // keep the playback position.
962
+ const switchServer = (id: string) => {
963
+ if (id === serverId) {
964
+ setPanel(null);
965
+ return;
966
+ }
967
+ const v = videoRef.current;
968
+ if (v) {
969
+ pendingSeekRef.current = v.currentTime;
970
+ wasPlayingRef.current = !v.paused;
971
+ wantPlayRef.current = !v.paused;
972
+ try {
973
+ v.pause();
974
+ } catch {}
975
+ }
976
+ setServerId(id);
977
+ setPanel(null);
978
+ setSwitching(true);
979
+ // Manual switch starts a fresh failover cycle from the chosen server.
980
+ triedServersRef.current = new Set();
981
+ void resolveSources(id, true).finally(() => setSwitching(false));
982
+ };
983
+
984
+ // Switch the embedded audio track (multi-dub streams).
985
+ const switchAudio = (id: number) => {
986
+ if (engineRef.current === "hls" && hlsRef.current) {
987
+ hlsRef.current.audioTrack = id;
988
+ } else if (engineRef.current === "dash" && dashRef.current) {
989
+ try {
990
+ const tracks = dashRef.current.getTracksFor("audio") || [];
991
+ const t = tracks.find((x: any) => x.index === id) || tracks[id];
992
+ if (t) dashRef.current.setCurrentTrack(t);
993
+ } catch {}
994
+ }
995
+ setCurrentAudio(id);
996
+ };
997
+
998
+ const start = useCallback(async () => {
999
+ if (started) return;
1000
+ // User pressed play — begin playback as soon as the source is ready.
1001
+ wantPlayRef.current = true;
1002
+ if (src || resolvedSrc) {
1003
+ setStarted(true);
1004
+ return;
1005
+ }
1006
+ // Start from the highest-priority server (favorited first).
1007
+ if (await resolveSources(serverOrder[0])) setStarted(true);
1008
+ else wantPlayRef.current = false;
1009
+ }, [started, src, resolvedSrc, resolveSources, serverOrder]);
1010
+
1011
+ const togglePlay = useCallback(() => {
1012
+ const v = videoRef.current;
1013
+ if (!started) {
1014
+ void start();
1015
+ return;
1016
+ }
1017
+ if (!v) return;
1018
+ if (v.paused) v.play().catch(() => {});
1019
+ else v.pause();
1020
+ }, [started, start]);
1021
+
1022
+ const skip = useCallback((sec: number) => {
1023
+ const v = videoRef.current;
1024
+ if (!v) return;
1025
+ v.currentTime = Math.min(
1026
+ Math.max(0, v.currentTime + sec),
1027
+ v.duration || Infinity
1028
+ );
1029
+ setSkipFlash(sec > 0 ? "fwd" : "back");
1030
+ setTimeout(() => setSkipFlash(null), 450);
1031
+ }, []);
1032
+
1033
+ const toggleMute = useCallback(() => {
1034
+ const v = videoRef.current;
1035
+ if (!v) return;
1036
+ v.muted = !v.muted;
1037
+ }, []);
1038
+
1039
+ const onSeek = useCallback(
1040
+ (clientX: number, el: HTMLElement) => {
1041
+ const v = videoRef.current;
1042
+ if (!v || !duration) return;
1043
+ const rect = el.getBoundingClientRect();
1044
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
1045
+ v.currentTime = ratio * duration;
1046
+ },
1047
+ [duration]
1048
+ );
1049
+
1050
+ /* auto-hide controls */
1051
+ const poke = useCallback(() => {
1052
+ setControlsVisible(true);
1053
+ clearTimeout(hideTimer.current);
1054
+ if (playing && !panel) {
1055
+ hideTimer.current = setTimeout(() => setControlsVisible(false), 3000);
1056
+ }
1057
+ // Reset the idle-overlay countdown on any activity.
1058
+ armOverlayRef.current();
1059
+ }, [playing, panel]);
1060
+
1061
+ useEffect(() => {
1062
+ poke();
1063
+ }, [playing, panel, poke]);
1064
+
1065
+ /* keyboard */
1066
+ useEffect(() => {
1067
+ const onKey = (e: KeyboardEvent) => {
1068
+ if (!started) return;
1069
+ const tag = (e.target as HTMLElement)?.tagName;
1070
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
1071
+ switch (e.key) {
1072
+ case " ":
1073
+ case "k":
1074
+ e.preventDefault();
1075
+ togglePlay();
1076
+ break;
1077
+ case "ArrowLeft":
1078
+ skip(-10);
1079
+ break;
1080
+ case "ArrowRight":
1081
+ skip(10);
1082
+ break;
1083
+ case "ArrowUp": {
1084
+ const v = videoRef.current;
1085
+ if (v) v.volume = Math.min(1, v.volume + 0.1);
1086
+ break;
1087
+ }
1088
+ case "ArrowDown": {
1089
+ const v = videoRef.current;
1090
+ if (v) v.volume = Math.max(0, v.volume - 0.1);
1091
+ break;
1092
+ }
1093
+ case "f":
1094
+ toggleFullscreen();
1095
+ break;
1096
+ case "m":
1097
+ toggleMute();
1098
+ break;
1099
+ case "Escape":
1100
+ setPanel(null);
1101
+ break;
1102
+ }
1103
+ poke();
1104
+ };
1105
+ window.addEventListener("keydown", onKey);
1106
+ return () => window.removeEventListener("keydown", onKey);
1107
+ }, [started, togglePlay, skip, toggleFullscreen, toggleMute, poke]);
1108
+
1109
+ const applyLevel = (idx: number) => {
1110
+ if (engineRef.current === "hls" && hlsRef.current) {
1111
+ hlsRef.current.currentLevel = idx; // -1 = auto
1112
+ } else if (engineRef.current === "dash" && dashRef.current) {
1113
+ const player = dashRef.current;
1114
+ if (idx === -1) {
1115
+ player.updateSettings({
1116
+ streaming: { abr: { autoSwitchBitrate: { video: true } } },
1117
+ });
1118
+ } else {
1119
+ player.updateSettings({
1120
+ streaming: { abr: { autoSwitchBitrate: { video: false } } },
1121
+ });
1122
+ player.setQualityFor("video", idx);
1123
+ }
1124
+ }
1125
+ setCurrentLevel(idx);
1126
+ };
1127
+
1128
+ const applySpeed = (s: number) => {
1129
+ if (videoRef.current) videoRef.current.playbackRate = s;
1130
+ setSpeed(s);
1131
+ };
1132
+
1133
+ // Swap to a different quality file, preserving playback position + state.
1134
+ const switchQuality = (i: number) => {
1135
+ const s = resolvedSources[i];
1136
+ if (!s) return;
1137
+ const v = videoRef.current;
1138
+ if (v) {
1139
+ pendingSeekRef.current = v.currentTime;
1140
+ wasPlayingRef.current = !v.paused;
1141
+ }
1142
+ setQualityIndex(i);
1143
+ setResolvedKind(s.type === "mp4" ? "native" : (s.type as any));
1144
+ setResolvedSrc(s.file); // triggers the source-loading effect
1145
+ setPanel(null);
1146
+ };
1147
+
1148
+ // ---- custom cue rendering ----
1149
+ // Read the active cues off the bound track into our overlay. Stable across
1150
+ // renders (reads the ref), so it can be added/removed as an event listener.
1151
+ const renderCues = useCallback(() => {
1152
+ const tr = cueTrackRef.current;
1153
+ if (!tr) {
1154
+ setCueText("");
1155
+ return;
1156
+ }
1157
+ const delay = subDelayRef.current;
1158
+ // With no delay, the browser's activeCues are authoritative (cheap). With a
1159
+ // delay we scan all cues against (currentTime − delay) ourselves.
1160
+ let active: (TextTrackCue | VTTCue)[] = [];
1161
+ if (delay !== 0 && tr.cues) {
1162
+ const t = (videoRef.current?.currentTime || 0) - delay;
1163
+ for (let i = 0; i < tr.cues.length; i++) {
1164
+ const c = tr.cues[i];
1165
+ if (c.startTime <= t && t <= c.endTime) active.push(c);
1166
+ }
1167
+ } else if (tr.activeCues) {
1168
+ active = Array.from(tr.activeCues);
1169
+ }
1170
+ if (active.length === 0) {
1171
+ setCueText("");
1172
+ return;
1173
+ }
1174
+ let txt = "";
1175
+ for (let i = 0; i < active.length; i++) {
1176
+ const c = active[i] as VTTCue;
1177
+ txt += (i ? "\n" : "") + (c.text || "");
1178
+ }
1179
+ // strip simple VTT/SRT tags; keep the text + line breaks.
1180
+ setCueText(txt.replace(/<[^>]+>/g, ""));
1181
+ }, []);
1182
+
1183
+ // Bind a TextTrack for custom rendering: keep it "hidden" (parsed, not drawn
1184
+ // natively) and mirror its cues into our overlay.
1185
+ const bindTrack = useCallback(
1186
+ (track: TextTrack | null) => {
1187
+ if (cueTrackRef.current && cueTrackRef.current !== track) {
1188
+ cueTrackRef.current.removeEventListener("cuechange", renderCues);
1189
+ }
1190
+ cueTrackRef.current = track;
1191
+ if (track) {
1192
+ track.mode = "hidden";
1193
+ track.removeEventListener("cuechange", renderCues);
1194
+ track.addEventListener("cuechange", renderCues);
1195
+ }
1196
+ renderCues();
1197
+ },
1198
+ [renderCues]
1199
+ );
1200
+
1201
+ // Safety net: while subtitles are on, keep the bound track in "hidden" mode
1202
+ // (some engines flip it back to "disabled") and mirror its cues into our
1203
+ // overlay on a short interval. This guarantees captions render even when
1204
+ // paused or when a `cuechange`/`timeupdate` event is missed.
1205
+ useEffect(() => {
1206
+ if (!subsOn) return;
1207
+ const sync = () => {
1208
+ const tr = cueTrackRef.current;
1209
+ if (tr && tr.mode === "disabled") tr.mode = "hidden";
1210
+ renderCues();
1211
+ };
1212
+ sync();
1213
+ const interval = setInterval(sync, 300);
1214
+ return () => clearInterval(interval);
1215
+ }, [subsOn, activeApiSub, activeSub, subDelay, renderCues]);
1216
+
1217
+ const subsOff = () => {
1218
+ const v = videoRef.current;
1219
+ if (engineRef.current === "hls" && hlsRef.current) {
1220
+ hlsRef.current.subtitleDisplay = false;
1221
+ hlsRef.current.subtitleTrack = -1;
1222
+ }
1223
+ if (v) {
1224
+ for (let i = 0; i < v.textTracks.length; i++) v.textTracks[i].mode = "disabled";
1225
+ v.querySelectorAll("track[data-api]").forEach((t) => t.remove());
1226
+ }
1227
+ bindTrack(null);
1228
+ setActiveSub(-1);
1229
+ setActiveApiSub(-1);
1230
+ setSubsOn(false);
1231
+ userPickedSubRef.current = true;
1232
+ };
1233
+
1234
+ // Built-in track (HLS/DASH/native manifest subtitle).
1235
+ const selectSub = (id: number) => {
1236
+ const v = videoRef.current;
1237
+ if (!v) return;
1238
+ v.querySelectorAll("track[data-api]").forEach((t) => t.remove());
1239
+ if (engineRef.current === "hls" && hlsRef.current) {
1240
+ // Let hls fetch the cues but render them ourselves.
1241
+ hlsRef.current.subtitleDisplay = false;
1242
+ hlsRef.current.subtitleTrack = id;
1243
+ // hls sets the matching text track to "hidden"; grab it once ready.
1244
+ setTimeout(() => {
1245
+ let chosen: TextTrack | null = null;
1246
+ for (let i = 0; i < v.textTracks.length; i++) {
1247
+ const t = v.textTracks[i];
1248
+ if (t.mode !== "disabled") chosen = t;
1249
+ else t.mode = "disabled";
1250
+ }
1251
+ bindTrack(chosen);
1252
+ }, 60);
1253
+ } else {
1254
+ const tt = v.textTracks[id] || null;
1255
+ for (let i = 0; i < v.textTracks.length; i++)
1256
+ v.textTracks[i].mode = "disabled";
1257
+ bindTrack(tt);
1258
+ }
1259
+ setActiveSub(id);
1260
+ setActiveApiSub(-1);
1261
+ setSubsOn(true);
1262
+ userPickedSubRef.current = true;
1263
+ };
1264
+
1265
+ // Create a same-origin WebVTT <track> for an external subtitle URL and render
1266
+ // it through the custom cue overlay.
1267
+ const loadSubTrack = (url: string, display: string, language: string) => {
1268
+ const v = videoRef.current;
1269
+ if (!v) return;
1270
+ if (engineRef.current === "hls" && hlsRef.current) {
1271
+ hlsRef.current.subtitleDisplay = false;
1272
+ hlsRef.current.subtitleTrack = -1;
1273
+ }
1274
+ for (let k = 0; k < v.textTracks.length; k++) v.textTracks[k].mode = "disabled";
1275
+ v.querySelectorAll("track[data-api]").forEach((t) => t.remove());
1276
+
1277
+ const track = document.createElement("track");
1278
+ track.kind = "subtitles";
1279
+ track.label = display;
1280
+ track.srclang = language || "en";
1281
+ track.src = url;
1282
+ track.setAttribute("data-api", "1");
1283
+ v.appendChild(track);
1284
+ // The TextTrack appears asynchronously; bind once cues start parsing.
1285
+ const trySelect = (attempt = 0) => {
1286
+ const tt = track.track;
1287
+ if (tt) bindTrack(tt);
1288
+ else if (attempt < 10) setTimeout(() => trySelect(attempt + 1), 60);
1289
+ };
1290
+ trySelect();
1291
+ setSubsOn(true);
1292
+ };
1293
+
1294
+ // Load a Wyzie subtitle and render it.
1295
+ const selectApiSub = (i: number, auto = false) => {
1296
+ const sub = apiSubs[i];
1297
+ if (!sub) return;
1298
+ loadSubTrack(sub.url, sub.display, sub.language);
1299
+ setActiveApiSub(i);
1300
+ setActiveSub(-1);
1301
+ if (!auto) userPickedSubRef.current = true;
1302
+ };
1303
+
1304
+ // Picks the best default Wyzie track: English, non-hearing-impaired first.
1305
+ const defaultSubIndex = (subs: ApiSub[]): number => {
1306
+ const en = (s: ApiSub) =>
1307
+ (s.language || "").toLowerCase().startsWith("en") ||
1308
+ /english/i.test(s.display || "");
1309
+ let idx = subs.findIndex((s) => en(s) && !s.hi);
1310
+ if (idx < 0) idx = subs.findIndex((s) => en(s));
1311
+ return idx;
1312
+ };
1313
+
1314
+ // Auto-load the default subtitle once sources resolve (unless the user has
1315
+ // already made a choice). Wyzie is the default source for every server.
1316
+ useEffect(() => {
1317
+ if (!started || userPickedSubRef.current) return;
1318
+ if (activeApiSub !== -1 || activeSub !== -1) return;
1319
+ if (!apiSubs.length) return;
1320
+ const idx = defaultSubIndex(apiSubs);
1321
+ if (idx >= 0) {
1322
+ // Wait a tick so the <video> element and current source are mounted.
1323
+ const t = setTimeout(() => selectApiSub(idx, true), 200);
1324
+ return () => clearTimeout(t);
1325
+ }
1326
+ }, [apiSubs, started]);
1327
+
1328
+ const onUploadSubs = (e: React.ChangeEvent<HTMLInputElement>) => {
1329
+ const file = e.target.files?.[0];
1330
+ const v = videoRef.current;
1331
+ if (!file || !v) return;
1332
+ const url = URL.createObjectURL(file);
1333
+ // remove existing custom tracks
1334
+ if (engineRef.current === "hls" && hlsRef.current) {
1335
+ hlsRef.current.subtitleDisplay = false;
1336
+ hlsRef.current.subtitleTrack = -1;
1337
+ }
1338
+ [...v.querySelectorAll("track")].forEach((t) => t.remove());
1339
+ for (let k = 0; k < v.textTracks.length; k++) v.textTracks[k].mode = "disabled";
1340
+ const track = document.createElement("track");
1341
+ track.kind = "subtitles";
1342
+ track.label = "Custom";
1343
+ track.srclang = "en";
1344
+ track.src = url;
1345
+ track.setAttribute("data-api", "1");
1346
+ v.appendChild(track);
1347
+ const trySelect = (attempt = 0) => {
1348
+ if (track.track) bindTrack(track.track);
1349
+ else if (attempt < 10) setTimeout(() => trySelect(attempt + 1), 60);
1350
+ };
1351
+ trySelect();
1352
+ setActiveApiSub(-1);
1353
+ setActiveSub(-1);
1354
+ setSubsOn(true);
1355
+ setPanel(null);
1356
+ userPickedSubRef.current = true;
1357
+ };
1358
+
1359
+ const pct = duration ? (current / duration) * 100 : 0;
1360
+ const bufPct = duration ? (buffered / duration) * 100 : 0;
1361
+
1362
+ const toggleFav = (name: string) =>
1363
+ setFavorites((f) => {
1364
+ const next = f.includes(name) ? f.filter((x) => x !== name) : [...f, name];
1365
+ try {
1366
+ localStorage.setItem("vc-fav-servers", JSON.stringify(next));
1367
+ } catch {}
1368
+ return next;
1369
+ });
1370
+
1371
+ /* CSS var for accent so child styles can reference it */
1372
+ const accentStyle = { ["--accent" as any]: accent } as React.CSSProperties;
1373
+
1374
+ return (
1375
+ <div
1376
+ ref={wrapRef}
1377
+ style={accentStyle}
1378
+ className="group relative h-full w-full select-none overflow-hidden bg-black"
1379
+ onMouseMove={poke}
1380
+ onMouseLeave={() => playing && !panel && setControlsVisible(false)}
1381
+ >
1382
+ {/* video */}
1383
+ <video
1384
+ ref={videoRef}
1385
+ className="absolute inset-0 h-full w-full bg-black"
1386
+ playsInline
1387
+ onClick={() => {
1388
+ if (panel) setPanel(null);
1389
+ else togglePlay();
1390
+ }}
1391
+ onDoubleClick={toggleFullscreen}
1392
+ poster={poster || undefined}
1393
+ />
1394
+
1395
+ {/* poster / start overlay */}
1396
+ {!started && (
1397
+ <button
1398
+ onClick={togglePlay}
1399
+ disabled={resolving}
1400
+ className="absolute inset-0 z-20 flex flex-col items-center justify-center"
1401
+ >
1402
+ {poster && (
1403
+ /* eslint-disable-next-line @next/next/no-img-element */
1404
+ <img
1405
+ src={poster}
1406
+ alt={title || ""}
1407
+ className="absolute inset-0 h-full w-full object-cover"
1408
+ />
1409
+ )}
1410
+ <span className="absolute inset-0 bg-black/40" />
1411
+ <span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-white text-black shadow-2xl transition-transform hover:scale-110">
1412
+ {resolving ? (
1413
+ <span
1414
+ className="h-7 w-7 animate-spin rounded-full border-[3px] border-black/20"
1415
+ style={{ borderTopColor: "#000" }}
1416
+ />
1417
+ ) : (
1418
+ <PlayIcon className="ml-1 h-7 w-7" />
1419
+ )}
1420
+ </span>
1421
+ {title && (
1422
+ <span className="relative mt-4 text-center">
1423
+ <span className="block text-lg font-bold text-white drop-shadow">
1424
+ {title}
1425
+ </span>
1426
+ {year && (
1427
+ <span className="block text-sm text-white/70">{year}</span>
1428
+ )}
1429
+ </span>
1430
+ )}
1431
+ </button>
1432
+ )}
1433
+
1434
+ {/* buffering spinner */}
1435
+ {started && buffering && !switching && (
1436
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
1437
+ <span
1438
+ className="h-12 w-12 animate-spin rounded-full border-[3px] border-white/25"
1439
+ style={{ borderTopColor: "#fff" }}
1440
+ />
1441
+ </div>
1442
+ )}
1443
+
1444
+ {/* source-switching curtain */}
1445
+ {started && switching && (
1446
+ <div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-4 bg-black/80 text-center">
1447
+ <span
1448
+ className="h-12 w-12 animate-spin rounded-full border-[3px] border-white/25"
1449
+ style={{ borderTopColor: accent }}
1450
+ />
1451
+ <div>
1452
+ <p className="text-sm font-semibold text-white/90">
1453
+ Loading new source…
1454
+ </p>
1455
+ <p className="mt-1 text-xs text-white/50">
1456
+ Switching to {serverName(serverId, providers)}
1457
+ </p>
1458
+ </div>
1459
+ </div>
1460
+ )}
1461
+
1462
+ {/* skip intro / recap button — z-40 so it sits above the controls bar */}
1463
+ {started && skipIntro && activeSkip && !switching && (
1464
+ <button
1465
+ onClick={(e) => {
1466
+ e.stopPropagation();
1467
+ const v = videoRef.current;
1468
+ const target = activeSkip;
1469
+ if (v && target) {
1470
+ const dest = Math.max(0, target.end);
1471
+ const seekTo = () => {
1472
+ try {
1473
+ v.currentTime = dest;
1474
+ } catch {}
1475
+ };
1476
+ seekTo();
1477
+ // HLS may clamp a forward seek until the manifest is seekable;
1478
+ // retry once on the next tick.
1479
+ if (Math.abs(v.currentTime - dest) > 1) {
1480
+ setTimeout(seekTo, 120);
1481
+ }
1482
+ v.play().catch(() => {});
1483
+ }
1484
+ setActiveSkip(null);
1485
+ }}
1486
+ className="absolute bottom-28 right-4 z-40 flex items-center gap-2 rounded-lg border border-white/20 bg-black/75 px-5 py-2.5 text-sm font-bold text-white shadow-xl backdrop-blur transition hover:bg-black/95 sm:bottom-32 sm:right-6"
1487
+ >
1488
+ {activeSkip.label}
1489
+ <ChevronRightIcon className="h-4 w-4" />
1490
+ </button>
1491
+ )}
1492
+
1493
+ {/* load error */}
1494
+ {started && loadError && (
1495
+ <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/70 px-6 text-center">
1496
+ <p className="text-sm text-white/80">{loadError}</p>
1497
+ <button
1498
+ onClick={() => {
1499
+ // Fresh failover cycle from the first server.
1500
+ triedServersRef.current = new Set();
1501
+ wantPlayRef.current = true;
1502
+ setLoadError(null);
1503
+ setResolvedSrc(null);
1504
+ void resolveSources(serverOrder[0], true);
1505
+ }}
1506
+ className="rounded-full px-4 py-2 text-sm font-semibold text-white"
1507
+ style={{ backgroundColor: accent }}
1508
+ >
1509
+ Retry
1510
+ </button>
1511
+ </div>
1512
+ )}
1513
+
1514
+ {/* skip flash */}
1515
+ {skipFlash && (
1516
+ <div
1517
+ className={`pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 ${
1518
+ skipFlash === "fwd" ? "right-[22%]" : "left-[22%]"
1519
+ } flex h-20 w-20 items-center justify-center rounded-full bg-white/10 text-white animate-fade-in-slow`}
1520
+ >
1521
+ {skipFlash === "fwd" ? (
1522
+ <Forward10Icon className="h-9 w-9" />
1523
+ ) : (
1524
+ <Rewind10Icon className="h-9 w-9" />
1525
+ )}
1526
+ </div>
1527
+ )}
1528
+
1529
+ {/* floating subtitles — rendered above the controls/progress bar */}
1530
+ {started && cueText && (
1531
+ <div
1532
+ className="pointer-events-none absolute inset-x-0 z-20 flex justify-center px-[6%] text-center transition-[bottom] duration-200"
1533
+ style={{
1534
+ // The position slider always applies; when the controls/panel are
1535
+ // visible we lift the subs by the control-bar height so they clear it.
1536
+ bottom:
1537
+ controlsVisible || panel
1538
+ ? `calc(${subStyle.position}% + 4.25rem)`
1539
+ : `${subStyle.position}%`,
1540
+ }}
1541
+ >
1542
+ <span style={captionTextStyle(subStyle)}>{cueText}</span>
1543
+ </div>
1544
+ )}
1545
+
1546
+ {/* Netflix-style idle overlay (?overlay=) — appears instantly on pause */}
1547
+ {started && showOverlay && overlay && !panel && (
1548
+ <div
1549
+ className="absolute inset-0 z-30 flex flex-col justify-end bg-gradient-to-r from-black/95 via-black/70 to-transparent px-8 pb-24 pt-8 sm:px-12 sm:pb-28"
1550
+ onClick={() => {
1551
+ setShowOverlay(false);
1552
+ videoRef.current?.play().catch(() => {});
1553
+ }}
1554
+ >
1555
+ <p className="text-xs font-semibold uppercase tracking-widest text-white/50">
1556
+ You're watching
1557
+ </p>
1558
+
1559
+ {/* title logo image, falling back to plain title text */}
1560
+ {logo ? (
1561
+ /* eslint-disable-next-line @next/next/no-img-element */
1562
+ <img
1563
+ src={logo}
1564
+ alt={title || ""}
1565
+ className="mt-2 max-h-24 w-auto max-w-[60%] object-contain object-left drop-shadow-lg"
1566
+ />
1567
+ ) : (
1568
+ title && (
1569
+ <h2 className="mt-1 max-w-xl text-3xl font-black text-white sm:text-4xl">
1570
+ {title}
1571
+ </h2>
1572
+ )
1573
+ )}
1574
+
1575
+ {/* meta line: golden star + rating · season/episode · runtime */}
1576
+ <div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-white/70">
1577
+ {rating != null && rating > 0 && (
1578
+ <span className="flex items-center gap-1">
1579
+ <StarIcon className="h-4 w-4" style={{ color: "#f5c518" }} />
1580
+ <span className="text-white/90">{rating.toFixed(1)}</span>
1581
+ </span>
1582
+ )}
1583
+ {isSeries && curSeason > 0 && curEpisode > 0 && (
1584
+ <>
1585
+ {rating != null && rating > 0 && <span className="text-white/30">·</span>}
1586
+ <span>
1587
+ Season {curSeason} · Episode {curEpisode}
1588
+ </span>
1589
+ </>
1590
+ )}
1591
+ {!isSeries && year && (
1592
+ <>
1593
+ {rating != null && rating > 0 && <span className="text-white/30">·</span>}
1594
+ <span>{year}</span>
1595
+ </>
1596
+ )}
1597
+ {fmtRuntime(runtime) && (
1598
+ <>
1599
+ <span className="text-white/30">·</span>
1600
+ <span>{fmtRuntime(runtime)}</span>
1601
+ </>
1602
+ )}
1603
+ </div>
1604
+
1605
+ {episodeTitle && (
1606
+ <h3 className="mt-2 max-w-xl text-xl font-bold text-white">
1607
+ {episodeTitle}
1608
+ </h3>
1609
+ )}
1610
+ {overview && (
1611
+ <p className="mt-2 line-clamp-3 max-w-xl text-sm leading-relaxed text-white/70">
1612
+ {overview}
1613
+ </p>
1614
+ )}
1615
+ </div>
1616
+ )}
1617
+
1618
+ {/* dim when panel open */}
1619
+ {panel && panel !== "CaptionStyle" && (
1620
+ <div
1621
+ className="absolute inset-0 z-20 bg-black/50"
1622
+ onClick={() => setPanel(null)}
1623
+ />
1624
+ )}
1625
+
1626
+ {/* ---------------- Controls ---------------- */}
1627
+ {started && (
1628
+ <div
1629
+ className={`absolute inset-x-0 bottom-0 z-30 bg-gradient-to-t from-black/90 via-black/40 to-transparent px-3 pb-3 pt-16 transition-opacity duration-300 sm:px-5 sm:pb-4 ${
1630
+ controlsVisible || panel ? "opacity-100" : "pointer-events-none opacity-0"
1631
+ }`}
1632
+ >
1633
+ {/* progress bar */}
1634
+ <div className="mb-2 flex items-center gap-3">
1635
+ <div
1636
+ className="group/seek relative h-4 flex-1 cursor-pointer"
1637
+ onClick={(e) => onSeek(e.clientX, e.currentTarget)}
1638
+ onMouseMove={(e) => {
1639
+ if (!duration) return;
1640
+ const rect = e.currentTarget.getBoundingClientRect();
1641
+ const ratio = Math.min(
1642
+ 1,
1643
+ Math.max(0, (e.clientX - rect.left) / rect.width)
1644
+ );
1645
+ setHoverTime({ x: e.clientX - rect.left, t: ratio * duration });
1646
+ }}
1647
+ onMouseLeave={() => setHoverTime(null)}
1648
+ >
1649
+ <div className="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded-full bg-white/25 transition-all group-hover/seek:h-1.5">
1650
+ <div
1651
+ className="absolute inset-y-0 left-0 rounded-full bg-white/30"
1652
+ style={{ width: `${bufPct}%` }}
1653
+ />
1654
+ <div
1655
+ className="absolute inset-y-0 left-0 rounded-full"
1656
+ style={{ width: `${pct}%`, backgroundColor: accent }}
1657
+ />
1658
+ <div
1659
+ className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 scale-0 rounded-full bg-white shadow transition-transform group-hover/seek:scale-100"
1660
+ style={{ left: `${pct}%` }}
1661
+ />
1662
+ </div>
1663
+ {hoverTime && (
1664
+ <div
1665
+ className="pointer-events-none absolute bottom-5 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 font-sans text-xs font-medium text-white"
1666
+ style={{ left: hoverTime.x }}
1667
+ >
1668
+ {fmt(hoverTime.t)}
1669
+ </div>
1670
+ )}
1671
+ </div>
1672
+ <span className="shrink-0 font-sans text-[13px] font-medium tabular-nums text-white/90 sm:text-sm">
1673
+ {fmt(current)} / {fmt(duration)}
1674
+ </span>
1675
+ </div>
1676
+
1677
+ {/* buttons row */}
1678
+ <div className="flex items-center justify-between">
1679
+ <div className="flex items-center gap-1 sm:gap-2">
1680
+ <CtrlBtn onClick={togglePlay} label={playing ? "Pause" : "Play"}>
1681
+ {playing ? (
1682
+ <PauseIcon className="h-6 w-6" />
1683
+ ) : (
1684
+ <PlayIcon className="h-6 w-6" />
1685
+ )}
1686
+ </CtrlBtn>
1687
+ <CtrlBtn onClick={() => skip(-10)} label="Rewind 10s">
1688
+ <Rewind10Icon className="h-7 w-7" />
1689
+ </CtrlBtn>
1690
+ <CtrlBtn onClick={() => skip(10)} label="Forward 10s">
1691
+ <Forward10Icon className="h-7 w-7" />
1692
+ </CtrlBtn>
1693
+ <div className="group/vol flex items-center">
1694
+ <CtrlBtn onClick={toggleMute} label="Mute">
1695
+ {muted || volume === 0 ? (
1696
+ <VolumeMutedIcon className="h-7 w-7" />
1697
+ ) : (
1698
+ <VolumeHighIcon className="h-7 w-7" />
1699
+ )}
1700
+ </CtrlBtn>
1701
+ <input
1702
+ type="range"
1703
+ min={0}
1704
+ max={1}
1705
+ step={0.05}
1706
+ value={muted ? 0 : volume}
1707
+ onChange={(e) => {
1708
+ const v = videoRef.current;
1709
+ if (!v) return;
1710
+ v.volume = Number(e.target.value);
1711
+ v.muted = Number(e.target.value) === 0;
1712
+ }}
1713
+ className="w-0 cursor-pointer opacity-0 transition-all duration-200 group-hover/vol:ml-1 group-hover/vol:w-16 group-hover/vol:opacity-100"
1714
+ style={{ accentColor: accent }}
1715
+ aria-label="Volume"
1716
+ />
1717
+ </div>
1718
+ </div>
1719
+
1720
+ <div className="flex items-center gap-1 sm:gap-2">
1721
+ {nextEpisode && nextEpisodeTarget && (
1722
+ <CtrlBtn
1723
+ onClick={() =>
1724
+ goToEpisode(nextEpisodeTarget.s, nextEpisodeTarget.e)
1725
+ }
1726
+ label="Next episode"
1727
+ >
1728
+ <NextEpisodeIcon className="h-6 w-6" />
1729
+ </CtrlBtn>
1730
+ )}
1731
+ {episodeSelector && type === "tv" && (
1732
+ <CtrlBtn
1733
+ onClick={() =>
1734
+ setPanel(panel === "Episodes" ? null : "Episodes")
1735
+ }
1736
+ label="Episodes"
1737
+ >
1738
+ <ListIcon className="h-7 w-7" />
1739
+ </CtrlBtn>
1740
+ )}
1741
+ <CtrlBtn
1742
+ onClick={() => setPanel(panel === "Subs" ? null : "Subs")}
1743
+ label="Subtitles"
1744
+ active={subsOn}
1745
+ >
1746
+ <SubtitlesIcon className="h-7 w-7" />
1747
+ </CtrlBtn>
1748
+ <CtrlBtn
1749
+ onClick={() => setPanel(panel === "Quality" ? null : "Quality")}
1750
+ label="Settings"
1751
+ >
1752
+ <SettingsIcon className="h-7 w-7" />
1753
+ </CtrlBtn>
1754
+ <CtrlBtn
1755
+ onClick={() => setPanel(panel === "Servers" ? null : "Servers")}
1756
+ label="Servers"
1757
+ >
1758
+ <CloudIcon className="h-7 w-7" />
1759
+ </CtrlBtn>
1760
+ <CtrlBtn onClick={toggleFullscreen} label="Fullscreen">
1761
+ {fullscreen ? (
1762
+ <FullscreenExitIcon className="h-6 w-6" />
1763
+ ) : (
1764
+ <FullscreenIcon className="h-6 w-6" />
1765
+ )}
1766
+ </CtrlBtn>
1767
+ </div>
1768
+ </div>
1769
+ </div>
1770
+ )}
1771
+
1772
+ {/* ---------------- Settings panel ---------------- */}
1773
+ {panel && (
1774
+ <div className="absolute bottom-[4.5rem] right-3 z-40 flex max-h-[calc(100%-6rem)] w-[min(92vw,360px)] flex-col sm:bottom-20 sm:right-5">
1775
+ {/* tabs */}
1776
+ <div className="mb-3 flex shrink-0 items-center justify-end gap-1">
1777
+ {panel === "CaptionStyle" ? (
1778
+ <button
1779
+ onClick={() => setPanel("Subs")}
1780
+ className="flex items-center gap-1.5 rounded-full bg-black/50 py-1 pl-2 pr-3 text-[13px] font-medium text-white/85 backdrop-blur transition hover:text-white"
1781
+ >
1782
+ <ChevronRightIcon className="h-4 w-4 rotate-180" />
1783
+ Caption style
1784
+ </button>
1785
+ ) : panel === "Episodes" ? (
1786
+ <span className="rounded-full bg-black/50 px-3 py-1.5 text-[13px] font-medium text-white/85 backdrop-blur">
1787
+ Episodes
1788
+ </span>
1789
+ ) : panel === "SubSources" ? (
1790
+ <button
1791
+ onClick={() => setPanel("Servers")}
1792
+ className="flex items-center gap-1.5 rounded-full bg-black/50 py-1 pl-2 pr-3 text-[13px] font-medium text-white/85 backdrop-blur transition hover:text-white"
1793
+ >
1794
+ <ChevronRightIcon className="h-4 w-4 rotate-180" />
1795
+ {providers.find((p) => p.id === subSourceParent)?.name || "Sources"}
1796
+ </button>
1797
+ ) : (
1798
+ <div className="flex items-center gap-0.5 rounded-full bg-black/50 p-1 backdrop-blur">
1799
+ {(["Quality", "Subs", "Servers", "Speed"] as Tab[]).map((t) => (
1800
+ <button
1801
+ key={t}
1802
+ onClick={() => setPanel(t)}
1803
+ className={`rounded-full px-2.5 py-1 text-[13px] font-medium transition ${
1804
+ panel === t
1805
+ ? "bg-white/15 text-white"
1806
+ : "text-white/60 hover:text-white"
1807
+ }`}
1808
+ >
1809
+ {t}
1810
+ </button>
1811
+ ))}
1812
+ </div>
1813
+ )}
1814
+ <button
1815
+ onClick={() => setPanel(null)}
1816
+ className="flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur transition hover:text-white"
1817
+ >
1818
+ <CloseIcon className="h-4 w-4" />
1819
+ </button>
1820
+ </div>
1821
+
1822
+ {/* content */}
1823
+ <div className="no-scrollbar min-h-0 flex-1 overflow-y-auto rounded-2xl border border-white/10 bg-[#0c0a14]/95 p-2 backdrop-blur-xl">
1824
+ {panel === "Quality" && (
1825
+ <Rows>
1826
+ {resolvedSources.length > 1 ? (
1827
+ // Multi-file qualities from the sources API
1828
+ resolvedSources.map((s, i) => {
1829
+ const h = parseInt(s.label, 10) || 0;
1830
+ return (
1831
+ <Row
1832
+ key={`${s.label}-${i}`}
1833
+ selected={qualityIndex === i}
1834
+ accent={accent}
1835
+ onClick={() => switchQuality(i)}
1836
+ left={qualityLabel(s.label)}
1837
+ right={
1838
+ qualityBadge(h) ? (
1839
+ <Badge accent={accent}>{qualityBadge(h)}</Badge>
1840
+ ) : null
1841
+ }
1842
+ />
1843
+ );
1844
+ })
1845
+ ) : (
1846
+ // Adaptive (single HLS) — use the stream's own levels
1847
+ <>
1848
+ <Row
1849
+ selected={currentLevel === -1}
1850
+ accent={accent}
1851
+ onClick={() => applyLevel(-1)}
1852
+ left="Auto"
1853
+ right={<Badge accent={accent}>Adaptive</Badge>}
1854
+ />
1855
+ {levels.map((l) => (
1856
+ <Row
1857
+ key={l.index}
1858
+ selected={currentLevel === l.index}
1859
+ accent={accent}
1860
+ onClick={() => applyLevel(l.index)}
1861
+ left={`${l.height}p`}
1862
+ right={
1863
+ qualityBadge(l.height) ? (
1864
+ <Badge accent={accent}>{qualityBadge(l.height)}</Badge>
1865
+ ) : null
1866
+ }
1867
+ />
1868
+ ))}
1869
+ {levels.length === 0 && (
1870
+ <p className="px-3 py-3 text-sm text-white/40">
1871
+ Loading qualities…
1872
+ </p>
1873
+ )}
1874
+ </>
1875
+ )}
1876
+ </Rows>
1877
+ )}
1878
+
1879
+ {panel === "Subs" && (
1880
+ <div className="flex flex-col">
1881
+ {/* subtitle menu header with the appearance/settings cog */}
1882
+ <div className="mb-1 flex items-center justify-between px-2 pt-1">
1883
+ <span className="text-xs font-semibold uppercase tracking-wide text-white/40">
1884
+ Subtitles
1885
+ </span>
1886
+ <button
1887
+ onClick={() => setPanel("CaptionStyle")}
1888
+ aria-label="Subtitle settings"
1889
+ title="Subtitle settings"
1890
+ className="flex h-7 w-7 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
1891
+ >
1892
+ <SettingsIcon className="h-4 w-4" />
1893
+ </button>
1894
+ </div>
1895
+ <Rows>
1896
+ <Row
1897
+ selected={!subsOn}
1898
+ accent={accent}
1899
+ onClick={subsOff}
1900
+ left="Off"
1901
+ right={
1902
+ <span className="text-xs text-white/45">No subtitles</span>
1903
+ }
1904
+ />
1905
+ <button
1906
+ onClick={() => fileInputRef.current?.click()}
1907
+ className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-white/85 transition hover:bg-white/5"
1908
+ >
1909
+ <UploadIcon className="h-5 w-5 text-white/70" />
1910
+ Upload subtitles
1911
+ </button>
1912
+ {apiSubs.map((s, i) => (
1913
+ <Row
1914
+ key={`api-${i}`}
1915
+ selected={subsOn && activeApiSub === i}
1916
+ accent={accent}
1917
+ onClick={() => selectApiSub(i)}
1918
+ left={
1919
+ <span className="flex items-center gap-2">
1920
+ <Flag src={s.flag} language={s.language} />
1921
+ {s.display}
1922
+ {s.hi && (
1923
+ <span className="rounded bg-white/10 px-1 text-[10px] font-semibold text-white/60">
1924
+ HI
1925
+ </span>
1926
+ )}
1927
+ </span>
1928
+ }
1929
+ right={
1930
+ <span className="text-xs capitalize text-white/40">
1931
+ {s.source || "OpenSubtitles"}
1932
+ </span>
1933
+ }
1934
+ />
1935
+ ))}
1936
+ {subTracks.map((t) => (
1937
+ <Row
1938
+ key={t.id}
1939
+ selected={subsOn && activeSub === t.id}
1940
+ accent={accent}
1941
+ onClick={() => selectSub(t.id)}
1942
+ left={t.label}
1943
+ right={
1944
+ t.builtIn ? (
1945
+ <Badge accent={accent}>Built-in</Badge>
1946
+ ) : null
1947
+ }
1948
+ />
1949
+ ))}
1950
+ </Rows>
1951
+ <input
1952
+ ref={fileInputRef}
1953
+ type="file"
1954
+ accept=".vtt,.srt"
1955
+ hidden
1956
+ onChange={onUploadSubs}
1957
+ />
1958
+ </div>
1959
+ )}
1960
+
1961
+ {panel === "Servers" && (
1962
+ <Rows>
1963
+ {providers.map((p) => {
1964
+ const hasKids = !!p.children?.length;
1965
+ // Parent is "active" when it (or one of its children) is the
1966
+ // current server.
1967
+ const active =
1968
+ serverId === p.id || serverId.startsWith(`${p.id}:`);
1969
+ const onClick = () => {
1970
+ if (hasKids) {
1971
+ setSubSourceParent(p.id);
1972
+ setPanel("SubSources");
1973
+ } else {
1974
+ switchServer(p.id);
1975
+ }
1976
+ };
1977
+ return (
1978
+ <div
1979
+ key={p.id}
1980
+ onClick={onClick}
1981
+ className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-3 transition ${
1982
+ active
1983
+ ? "border-transparent"
1984
+ : "border-transparent hover:bg-white/5"
1985
+ }`}
1986
+ style={
1987
+ active
1988
+ ? {
1989
+ backgroundColor: `${accent}33`,
1990
+ borderColor: `${accent}55`,
1991
+ }
1992
+ : undefined
1993
+ }
1994
+ >
1995
+ {/* English source — US flag next to every server. */}
1996
+ <Flag
1997
+ language="en-US"
1998
+ size={48}
1999
+ className="h-6 w-auto shrink-0 rounded-[3px]"
2000
+ />
2001
+ <span className="min-w-0 flex-1">
2002
+ <span
2003
+ className="block text-sm font-semibold"
2004
+ style={{ color: active ? accent : "#fff" }}
2005
+ >
2006
+ {p.name}
2007
+ </span>
2008
+ <span className="block text-xs text-white/45">
2009
+ {p.label}
2010
+ </span>
2011
+ </span>
2012
+ {failedServers.includes(p.id) && (
2013
+ <span title="This source failed">
2014
+ <WarnIcon className="h-4 w-4 text-red-500" />
2015
+ </span>
2016
+ )}
2017
+ {active && !hasKids && !failedServers.includes(p.id) && (
2018
+ <CheckIcon className="h-4 w-4" style={{ color: accent }} />
2019
+ )}
2020
+ <button
2021
+ onClick={(e) => {
2022
+ e.stopPropagation();
2023
+ toggleFav(p.id);
2024
+ }}
2025
+ className="text-white/50 transition hover:text-white"
2026
+ >
2027
+ <HeartIcon
2028
+ filled={favorites.includes(p.id)}
2029
+ className="h-4 w-4"
2030
+ style={
2031
+ favorites.includes(p.id)
2032
+ ? { color: accent }
2033
+ : undefined
2034
+ }
2035
+ />
2036
+ </button>
2037
+ {hasKids && (
2038
+ <ChevronRightIcon className="h-4 w-4 text-white/40" />
2039
+ )}
2040
+ </div>
2041
+ );
2042
+ })}
2043
+
2044
+ {audioTracks.length > 1 && (
2045
+ <>
2046
+ <p className="px-1 pb-1 pt-3 text-xs font-medium uppercase tracking-wide text-white/40">
2047
+ Audio
2048
+ </p>
2049
+ {audioTracks.map((a) => (
2050
+ <Row
2051
+ key={`audio-${a.id}`}
2052
+ selected={currentAudio === a.id}
2053
+ accent={accent}
2054
+ onClick={() => switchAudio(a.id)}
2055
+ left={a.name}
2056
+ />
2057
+ ))}
2058
+ </>
2059
+ )}
2060
+ </Rows>
2061
+ )}
2062
+
2063
+ {panel === "SubSources" &&
2064
+ (() => {
2065
+ const parent = providers.find((p) => p.id === subSourceParent);
2066
+ if (!parent) return null;
2067
+ const items = [
2068
+ { id: parent.id, name: "Auto · try all", auto: true },
2069
+ ...(parent.children || []).map((c) => ({
2070
+ id: `${parent.id}:${c.id}`,
2071
+ name: c.name,
2072
+ auto: false,
2073
+ })),
2074
+ ];
2075
+ return (
2076
+ <Rows>
2077
+ {items.map((c) => {
2078
+ const csel = serverId === c.id;
2079
+ const cfail = failedServers.includes(c.id);
2080
+ return (
2081
+ <Row
2082
+ key={c.id}
2083
+ selected={csel}
2084
+ accent={accent}
2085
+ onClick={() => switchServer(c.id)}
2086
+ left={c.name}
2087
+ right={
2088
+ cfail ? (
2089
+ <span title="This source failed">
2090
+ <WarnIcon className="h-4 w-4 text-red-500" />
2091
+ </span>
2092
+ ) : c.auto ? (
2093
+ <Badge accent={accent}>auto</Badge>
2094
+ ) : null
2095
+ }
2096
+ />
2097
+ );
2098
+ })}
2099
+ </Rows>
2100
+ );
2101
+ })()}
2102
+
2103
+ {panel === "Speed" && (
2104
+ <Rows>
2105
+ {SPEEDS.map((s) => (
2106
+ <Row
2107
+ key={s}
2108
+ selected={speed === s}
2109
+ accent={accent}
2110
+ onClick={() => applySpeed(s)}
2111
+ left={s === 1 ? "1x (Normal)" : `${s}x`}
2112
+ right={
2113
+ speed === s ? (
2114
+ <span
2115
+ className="h-2.5 w-2.5 rounded-full"
2116
+ style={{ backgroundColor: accent }}
2117
+ />
2118
+ ) : null
2119
+ }
2120
+ />
2121
+ ))}
2122
+ </Rows>
2123
+ )}
2124
+
2125
+ {panel === "Episodes" && (
2126
+ <div className="flex flex-col">
2127
+ {/* season selector */}
2128
+ {seasonsInfo.length > 1 && (
2129
+ <div className="no-scrollbar mb-2 flex gap-1.5 overflow-x-auto pb-1">
2130
+ {seasonsInfo.map((s) => (
2131
+ <button
2132
+ key={s.season}
2133
+ onClick={() => setPickerSeason(s.season)}
2134
+ className="shrink-0 rounded-lg border px-3 py-1.5 text-[13px] font-medium transition"
2135
+ style={
2136
+ pickerSeason === s.season
2137
+ ? {
2138
+ backgroundColor: `${accent}33`,
2139
+ borderColor: `${accent}77`,
2140
+ color: "#fff",
2141
+ }
2142
+ : {
2143
+ backgroundColor: "rgba(255,255,255,0.05)",
2144
+ borderColor: "transparent",
2145
+ color: "rgba(255,255,255,0.7)",
2146
+ }
2147
+ }
2148
+ >
2149
+ S{s.season}
2150
+ </button>
2151
+ ))}
2152
+ </div>
2153
+ )}
2154
+ <Rows>
2155
+ {pickerEpisodes.length === 0 && (
2156
+ <p className="px-3 py-3 text-sm text-white/40">
2157
+ Loading episodes…
2158
+ </p>
2159
+ )}
2160
+ {pickerEpisodes.map((ep) => {
2161
+ const isCurrent =
2162
+ pickerSeason === curSeason && ep.episode === curEpisode;
2163
+ return (
2164
+ <button
2165
+ key={ep.episode}
2166
+ onClick={() => goToEpisode(pickerSeason, ep.episode)}
2167
+ className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition ${
2168
+ isCurrent
2169
+ ? "border-transparent"
2170
+ : "border-transparent hover:bg-white/5"
2171
+ }`}
2172
+ style={
2173
+ isCurrent
2174
+ ? {
2175
+ backgroundColor: `${accent}33`,
2176
+ borderColor: `${accent}55`,
2177
+ }
2178
+ : undefined
2179
+ }
2180
+ >
2181
+ <span
2182
+ className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-xs font-bold"
2183
+ style={{
2184
+ backgroundColor: isCurrent
2185
+ ? accent
2186
+ : "rgba(255,255,255,0.08)",
2187
+ color: isCurrent ? "#fff" : "rgba(255,255,255,0.7)",
2188
+ }}
2189
+ >
2190
+ {ep.episode}
2191
+ </span>
2192
+ <span className="min-w-0 flex-1">
2193
+ <span
2194
+ className="block truncate text-sm font-medium"
2195
+ style={{ color: isCurrent ? accent : "#fff" }}
2196
+ >
2197
+ {ep.name}
2198
+ </span>
2199
+ </span>
2200
+ </button>
2201
+ );
2202
+ })}
2203
+ </Rows>
2204
+ </div>
2205
+ )}
2206
+
2207
+ {panel === "CaptionStyle" && (
2208
+ <div className="space-y-4 px-1 pb-1">
2209
+ {/* live preview */}
2210
+ <div className="relative flex h-24 items-end justify-center overflow-hidden rounded-xl border border-white/10 bg-[radial-gradient(circle_at_30%_20%,#3a3550,#0b0a12)]">
2211
+ <div
2212
+ className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center px-3 text-center"
2213
+ >
2214
+ <span style={captionTextStyle(subStyle)}>
2215
+ {cueText || "The quick brown fox"}
2216
+ </span>
2217
+ </div>
2218
+ </div>
2219
+
2220
+ <StyleControl label="Text size">
2221
+ <input
2222
+ type="range"
2223
+ min={16}
2224
+ max={64}
2225
+ step={1}
2226
+ value={subStyle.size}
2227
+ onChange={(e) =>
2228
+ updateSubStyle({ size: Number(e.target.value) })
2229
+ }
2230
+ className="w-full cursor-pointer"
2231
+ style={{ accentColor: accent }}
2232
+ />
2233
+ </StyleControl>
2234
+
2235
+ <StyleControl label="Text color">
2236
+ <div className="flex flex-wrap gap-2">
2237
+ {SUB_COLORS.map((c) => (
2238
+ <Swatch
2239
+ key={c}
2240
+ color={c}
2241
+ selected={subStyle.color.toLowerCase() === c.toLowerCase()}
2242
+ accent={accent}
2243
+ onClick={() => updateSubStyle({ color: c })}
2244
+ />
2245
+ ))}
2246
+ </div>
2247
+ </StyleControl>
2248
+
2249
+ <StyleControl label="Font">
2250
+ <div className="flex flex-wrap gap-1.5">
2251
+ {SUB_FONTS.map((f) => (
2252
+ <Chip
2253
+ key={f.label}
2254
+ selected={subStyle.font === f.value}
2255
+ accent={accent}
2256
+ onClick={() => updateSubStyle({ font: f.value })}
2257
+ >
2258
+ <span style={{ fontFamily: f.value }}>{f.label}</span>
2259
+ </Chip>
2260
+ ))}
2261
+ <Chip
2262
+ selected={subStyle.bold}
2263
+ accent={accent}
2264
+ onClick={() => updateSubStyle({ bold: !subStyle.bold })}
2265
+ >
2266
+ <span className="font-extrabold">Bold</span>
2267
+ </Chip>
2268
+ </div>
2269
+ </StyleControl>
2270
+
2271
+ <StyleControl label="Edge style">
2272
+ <div className="flex flex-wrap gap-1.5">
2273
+ {(
2274
+ [
2275
+ ["none", "None"],
2276
+ ["shadow", "Shadow"],
2277
+ ["outline", "Outline"],
2278
+ ["glow", "Glow"],
2279
+ ] as [SubStyle["edge"], string][]
2280
+ ).map(([val, lbl]) => (
2281
+ <Chip
2282
+ key={val}
2283
+ selected={subStyle.edge === val}
2284
+ accent={accent}
2285
+ onClick={() => updateSubStyle({ edge: val })}
2286
+ >
2287
+ {lbl}
2288
+ </Chip>
2289
+ ))}
2290
+ </div>
2291
+ </StyleControl>
2292
+
2293
+ <StyleControl label="Background">
2294
+ <div className="flex flex-wrap items-center gap-2">
2295
+ <Chip
2296
+ selected={subStyle.bg === "none"}
2297
+ accent={accent}
2298
+ onClick={() => updateSubStyle({ bg: "none" })}
2299
+ >
2300
+ Off
2301
+ </Chip>
2302
+ {["#000000", "#ffffff"].map((c) => (
2303
+ <Swatch
2304
+ key={c}
2305
+ color={c}
2306
+ selected={subStyle.bg.toLowerCase() === c}
2307
+ accent={accent}
2308
+ onClick={() => updateSubStyle({ bg: c })}
2309
+ />
2310
+ ))}
2311
+ </div>
2312
+ </StyleControl>
2313
+
2314
+ <StyleControl
2315
+ label={`Background opacity · ${Math.round(
2316
+ subStyle.bgOpacity * 100
2317
+ )}%`}
2318
+ >
2319
+ <input
2320
+ type="range"
2321
+ min={0}
2322
+ max={1}
2323
+ step={0.05}
2324
+ disabled={subStyle.bg === "none"}
2325
+ value={subStyle.bgOpacity}
2326
+ onChange={(e) =>
2327
+ updateSubStyle({ bgOpacity: Number(e.target.value) })
2328
+ }
2329
+ className="w-full cursor-pointer disabled:opacity-30"
2330
+ style={{ accentColor: accent }}
2331
+ aria-label="Background opacity"
2332
+ />
2333
+ </StyleControl>
2334
+
2335
+ <StyleControl label={`Vertical position · ${subStyle.position}%`}>
2336
+ <input
2337
+ type="range"
2338
+ min={2}
2339
+ max={40}
2340
+ step={1}
2341
+ value={subStyle.position}
2342
+ onChange={(e) =>
2343
+ updateSubStyle({ position: Number(e.target.value) })
2344
+ }
2345
+ className="w-full cursor-pointer"
2346
+ style={{ accentColor: accent }}
2347
+ />
2348
+ </StyleControl>
2349
+
2350
+ <StyleControl label="Subtitle delay">
2351
+ <div className="flex items-center gap-2">
2352
+ <button
2353
+ onClick={() =>
2354
+ setSubDelay((d) => Math.round((d - 0.1) * 10) / 10)
2355
+ }
2356
+ aria-label="Subtitles earlier"
2357
+ className="flex h-9 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-lg font-semibold text-white/80 transition hover:bg-white/10 hover:text-white"
2358
+ >
2359
+
2360
+ </button>
2361
+ <div className="relative flex-1">
2362
+ <input
2363
+ type="number"
2364
+ step={0.1}
2365
+ value={subDelay}
2366
+ onChange={(e) => {
2367
+ const n = parseFloat(e.target.value);
2368
+ setSubDelay(isNaN(n) ? 0 : Math.round(n * 10) / 10);
2369
+ }}
2370
+ className="w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-center text-sm tabular-nums text-white outline-none transition focus:border-white/30"
2371
+ aria-label="Subtitle delay in seconds"
2372
+ />
2373
+ <span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-white/40">
2374
+ s
2375
+ </span>
2376
+ </div>
2377
+ <button
2378
+ onClick={() =>
2379
+ setSubDelay((d) => Math.round((d + 0.1) * 10) / 10)
2380
+ }
2381
+ aria-label="Subtitles later"
2382
+ className="flex h-9 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-lg font-semibold text-white/80 transition hover:bg-white/10 hover:text-white"
2383
+ >
2384
+ +
2385
+ </button>
2386
+ </div>
2387
+ <p className="mt-1.5 text-[11px] text-white/35">
2388
+ {subDelay > 0
2389
+ ? "Subtitles appear later"
2390
+ : subDelay < 0
2391
+ ? "Subtitles appear earlier"
2392
+ : "In sync"}{" "}
2393
+ · ± steps by 0.1s
2394
+ </p>
2395
+ </StyleControl>
2396
+
2397
+ <button
2398
+ onClick={() => {
2399
+ updateSubStyle(DEFAULT_SUB_STYLE);
2400
+ setSubDelay(0);
2401
+ }}
2402
+ className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 text-sm font-medium text-white/70 transition hover:bg-white/10 hover:text-white"
2403
+ >
2404
+ Reset to default
2405
+ </button>
2406
+ </div>
2407
+ )}
2408
+ </div>
2409
+ </div>
2410
+ )}
2411
+ </div>
2412
+ );
2413
+ }
2414
+
2415
+ /* ---------------- small building blocks ---------------- */
2416
+
2417
+ // Country flag for a subtitle: prefers a flag URL supplied by the source
2418
+ // (Wyzie returns one per subtitle), otherwise derives one from the language
2419
+ // via flagsapi.com. Renders nothing if neither is available or it fails to load.
2420
+ function Flag({
2421
+ src,
2422
+ language,
2423
+ size = 24,
2424
+ className = "h-3.5 w-auto shrink-0 rounded-[2px]",
2425
+ }: {
2426
+ src?: string;
2427
+ language?: string;
2428
+ size?: 16 | 24 | 32 | 48 | 64;
2429
+ className?: string;
2430
+ }) {
2431
+ const url = src || flagUrl(language, size);
2432
+ if (!url) return null;
2433
+ return (
2434
+ /* eslint-disable-next-line @next/next/no-img-element */
2435
+ <img
2436
+ src={url}
2437
+ alt={language || ""}
2438
+ loading="lazy"
2439
+ className={className}
2440
+ onError={(e) => {
2441
+ (e.currentTarget as HTMLImageElement).style.display = "none";
2442
+ }}
2443
+ />
2444
+ );
2445
+ }
2446
+
2447
+ function CtrlBtn({
2448
+ children,
2449
+ onClick,
2450
+ label,
2451
+ active,
2452
+ }: {
2453
+ children: React.ReactNode;
2454
+ onClick: () => void;
2455
+ label: string;
2456
+ active?: boolean;
2457
+ }) {
2458
+ return (
2459
+ <button
2460
+ onClick={onClick}
2461
+ aria-label={label}
2462
+ title={label}
2463
+ className={`flex h-10 w-10 items-center justify-center rounded-full transition hover:bg-white/10 ${
2464
+ active ? "text-white" : "text-white/90 hover:text-white"
2465
+ }`}
2466
+ >
2467
+ {children}
2468
+ </button>
2469
+ );
2470
+ }
2471
+
2472
+ function Rows({ children }: { children: React.ReactNode }) {
2473
+ return <div className="space-y-1">{children}</div>;
2474
+ }
2475
+
2476
+ function Row({
2477
+ left,
2478
+ right,
2479
+ selected,
2480
+ accent,
2481
+ onClick,
2482
+ }: {
2483
+ left: React.ReactNode;
2484
+ right?: React.ReactNode;
2485
+ selected?: boolean;
2486
+ accent: string;
2487
+ onClick: () => void;
2488
+ }) {
2489
+ return (
2490
+ <button
2491
+ onClick={onClick}
2492
+ className={`flex w-full items-center justify-between rounded-xl border px-3 py-3 text-left transition ${
2493
+ selected ? "border-transparent" : "border-transparent hover:bg-white/5"
2494
+ }`}
2495
+ style={
2496
+ selected
2497
+ ? { backgroundColor: `${accent}33`, borderColor: `${accent}55` }
2498
+ : undefined
2499
+ }
2500
+ >
2501
+ <span
2502
+ className="text-sm font-medium"
2503
+ style={{ color: selected ? accent : "#fff" }}
2504
+ >
2505
+ {left}
2506
+ </span>
2507
+ {right}
2508
+ </button>
2509
+ );
2510
+ }
2511
+
2512
+ function Badge({
2513
+ children,
2514
+ accent,
2515
+ }: {
2516
+ children: React.ReactNode;
2517
+ accent: string;
2518
+ }) {
2519
+ return (
2520
+ <span
2521
+ className="rounded-md px-2 py-0.5 text-xs font-semibold"
2522
+ style={{ backgroundColor: `${accent}33`, color: accent }}
2523
+ >
2524
+ {children}
2525
+ </span>
2526
+ );
2527
+ }
2528
+
2529
+ function StyleControl({
2530
+ label,
2531
+ children,
2532
+ }: {
2533
+ label: string;
2534
+ children: React.ReactNode;
2535
+ }) {
2536
+ return (
2537
+ <div>
2538
+ <p className="mb-1.5 text-xs font-medium text-white/50">{label}</p>
2539
+ {children}
2540
+ </div>
2541
+ );
2542
+ }
2543
+
2544
+ function Swatch({
2545
+ color,
2546
+ selected,
2547
+ accent,
2548
+ onClick,
2549
+ }: {
2550
+ color: string;
2551
+ selected?: boolean;
2552
+ accent: string;
2553
+ onClick: () => void;
2554
+ }) {
2555
+ return (
2556
+ <button
2557
+ onClick={onClick}
2558
+ aria-label={color}
2559
+ className="h-7 w-7 rounded-full border transition hover:scale-110"
2560
+ style={{
2561
+ backgroundColor: color,
2562
+ borderColor: selected ? accent : "rgba(255,255,255,0.25)",
2563
+ boxShadow: selected ? `0 0 0 2px ${accent}` : undefined,
2564
+ }}
2565
+ />
2566
+ );
2567
+ }
2568
+
2569
+ function Chip({
2570
+ children,
2571
+ selected,
2572
+ accent,
2573
+ onClick,
2574
+ }: {
2575
+ children: React.ReactNode;
2576
+ selected?: boolean;
2577
+ accent: string;
2578
+ onClick: () => void;
2579
+ }) {
2580
+ return (
2581
+ <button
2582
+ onClick={onClick}
2583
+ className="rounded-lg border px-3 py-1.5 text-[13px] font-medium transition"
2584
+ style={
2585
+ selected
2586
+ ? { backgroundColor: `${accent}33`, borderColor: `${accent}77`, color: "#fff" }
2587
+ : {
2588
+ backgroundColor: "rgba(255,255,255,0.05)",
2589
+ borderColor: "transparent",
2590
+ color: "rgba(255,255,255,0.7)",
2591
+ }
2592
+ }
2593
+ >
2594
+ {children}
2595
+ </button>
2596
+ );
2597
+ }
2598
+
components/player/icons.tsx ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Player control icons — recreated to match the vidsuper player. */
2
+ import { SVGProps } from "react";
3
+
4
+ type P = SVGProps<SVGSVGElement>;
5
+
6
+ export const PlayIcon = (p: P) => (
7
+ <svg viewBox="0 0 384 512" fill="currentColor" {...p}>
8
+ <path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z" />
9
+ </svg>
10
+ );
11
+
12
+ export const PauseIcon = (p: P) => (
13
+ <svg viewBox="0 0 320 512" fill="currentColor" {...p}>
14
+ <path d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z" />
15
+ </svg>
16
+ );
17
+
18
+ /* Rewind 10s — p-stream skip-backward (curved arrow with baked-in "10") */
19
+ export const Rewind10Icon = (p: P) => (
20
+ <svg viewBox="0 0 25 24" fill="none" {...p}>
21
+ <path
22
+ d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16"
23
+ stroke="currentColor"
24
+ strokeWidth="2.5"
25
+ strokeLinecap="round"
26
+ strokeLinejoin="round"
27
+ />
28
+ <path
29
+ d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z"
30
+ fill="currentColor"
31
+ />
32
+ </svg>
33
+ );
34
+
35
+ /* Forward 10s — p-stream skip-forward (curved arrow with baked-in "10") */
36
+ export const Forward10Icon = (p: P) => (
37
+ <svg viewBox="0 0 26 24" fill="none" {...p}>
38
+ <path
39
+ d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9"
40
+ stroke="currentColor"
41
+ strokeWidth="2.5"
42
+ strokeLinecap="round"
43
+ strokeLinejoin="round"
44
+ />
45
+ <path
46
+ d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z"
47
+ fill="currentColor"
48
+ />
49
+ </svg>
50
+ );
51
+
52
+ /* Volume (high) — FontAwesome volume-high, p-stream's player volume icon */
53
+ export const VolumeHighIcon = (p: P) => (
54
+ <svg viewBox="0 0 640 512" {...p}>
55
+ <path
56
+ fill="currentColor"
57
+ d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"
58
+ />
59
+ </svg>
60
+ );
61
+
62
+ /* Volume muted — FontAwesome volume-xmark (pairs with volume-high) */
63
+ export const VolumeMutedIcon = (p: P) => (
64
+ <svg viewBox="0 0 576 512" {...p}>
65
+ <path
66
+ fill="currentColor"
67
+ d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"
68
+ />
69
+ </svg>
70
+ );
71
+
72
+ /* Subtitles / closed captions — p-stream captions icon */
73
+ export const SubtitlesIcon = (p: P) => (
74
+ <svg viewBox="0 0 25 20" {...p}>
75
+ <path
76
+ transform="translate(-3 -6)"
77
+ d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z"
78
+ fill="currentColor"
79
+ />
80
+ </svg>
81
+ );
82
+
83
+ /* Settings — p-stream gear icon */
84
+ export const SettingsIcon = (p: P) => (
85
+ <svg viewBox="0 0 48.4 48.4" fill="currentColor" {...p}>
86
+ <path d="M48.4,24.2c0-1.8-1.297-3.719-2.896-4.285s-3.149-1.952-3.6-3.045c-0.451-1.093-0.334-3.173,0.396-4.705 c0.729-1.532,0.287-3.807-0.986-5.08c-1.272-1.273-3.547-1.714-5.08-0.985c-1.532,0.729-3.609,0.848-4.699,0.397 s-2.477-2.003-3.045-3.602C27.921,1.296,26,0,24.2,0c-1.8,0-3.721,1.296-4.29,2.895c-0.569,1.599-1.955,3.151-3.045,3.602 c-1.09,0.451-3.168,0.332-4.7-0.397c-1.532-0.729-3.807-0.288-5.08,0.985c-1.273,1.273-1.714,3.547-0.985,5.08 c0.729,1.533,0.845,3.611,0.392,4.703c-0.453,1.092-1.998,2.481-3.597,3.047S0,22.4,0,24.2s1.296,3.721,2.895,4.29 c1.599,0.568,3.146,1.957,3.599,3.047c0.453,1.089,0.335,3.166-0.394,4.698s-0.288,3.807,0.985,5.08 c1.273,1.272,3.547,1.714,5.08,0.985c1.533-0.729,3.61-0.847,4.7-0.395c1.091,0.452,2.476,2.008,3.045,3.604 c0.569,1.596,2.49,2.891,4.29,2.891c1.8,0,3.721-1.295,4.29-2.891c0.568-1.596,1.953-3.15,3.043-3.604 c1.09-0.453,3.17-0.334,4.701,0.396c1.533,0.729,3.808,0.287,5.08-0.985c1.273-1.273,1.715-3.548,0.986-5.08 c-0.729-1.533-0.849-3.61-0.398-4.7c0.451-1.09,2.004-2.477,3.603-3.045C47.104,27.921,48.4,26,48.4,24.2z M24.2,33.08 c-4.91,0-8.88-3.97-8.88-8.87c0-4.91,3.97-8.88,8.88-8.88c4.899,0,8.87,3.97,8.87,8.88C33.07,29.11,29.1,33.08,24.2,33.08z" />
87
+ </svg>
88
+ );
89
+
90
+ /* Cloud — server selector (solid, FontAwesome cloud) */
91
+ export const CloudIcon = (p: P) => (
92
+ <svg viewBox="0 0 640 512" {...p}>
93
+ <path
94
+ fill="currentColor"
95
+ d="M0 336c0 79.5 64.5 144 144 144H512c70.7 0 128-57.3 128-128c0-61.9-44-113.6-102.4-125.4c4.1-10.7 6.4-22.4 6.4-34.6c0-53-43-96-96-96c-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32C167.6 32 96 103.6 96 192c0 2.7 .1 5.4 .2 8.1C40.2 219.8 0 273.2 0 336z"
96
+ />
97
+ </svg>
98
+ );
99
+
100
+ /* Fullscreen — FontAwesome expand (p-stream) */
101
+ export const FullscreenIcon = (p: P) => (
102
+ <svg viewBox="0 0 448 512" {...p}>
103
+ <path
104
+ fill="currentColor"
105
+ d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"
106
+ />
107
+ </svg>
108
+ );
109
+
110
+ /* Fullscreen exit — FontAwesome compress (p-stream) */
111
+ export const FullscreenExitIcon = (p: P) => (
112
+ <svg viewBox="0 0 448 512" {...p}>
113
+ <path
114
+ fill="currentColor"
115
+ d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"
116
+ />
117
+ </svg>
118
+ );
119
+
120
+ export const HeartIcon = ({ filled, ...p }: P & { filled?: boolean }) => (
121
+ <svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} {...p}>
122
+ <path
123
+ d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
124
+ stroke="currentColor"
125
+ strokeWidth="2"
126
+ strokeLinecap="round"
127
+ strokeLinejoin="round"
128
+ />
129
+ </svg>
130
+ );
131
+
132
+ /* Filled star — used for the rating badge on the idle overlay */
133
+ export const StarIcon = (p: P) => (
134
+ <svg viewBox="0 0 24 24" fill="currentColor" {...p}>
135
+ <path d="M12 2.5l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.77l-5.8 3.05 1.1-6.46-4.69-4.58 6.49-.94L12 2.5z" />
136
+ </svg>
137
+ );
138
+
139
+ export const CheckIcon = (p: P) => (
140
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
141
+ <path
142
+ d="M20 6L9 17l-5-5"
143
+ stroke="currentColor"
144
+ strokeWidth="2.5"
145
+ strokeLinecap="round"
146
+ strokeLinejoin="round"
147
+ />
148
+ </svg>
149
+ );
150
+
151
+ export const UploadIcon = (p: P) => (
152
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
153
+ <path
154
+ d="M12 16V4m0 0L7 9m5-5l5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"
155
+ stroke="currentColor"
156
+ strokeWidth="2"
157
+ strokeLinecap="round"
158
+ strokeLinejoin="round"
159
+ />
160
+ </svg>
161
+ );
162
+
163
+ export const SearchIcon = (p: P) => (
164
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
165
+ <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
166
+ <path d="m21 21-4.3-4.3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
167
+ </svg>
168
+ );
169
+
170
+ export const SlidersIcon = (p: P) => (
171
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
172
+ <path
173
+ d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"
174
+ stroke="currentColor"
175
+ strokeWidth="2"
176
+ strokeLinecap="round"
177
+ />
178
+ </svg>
179
+ );
180
+
181
+ export const ChevronRightIcon = (p: P) => (
182
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
183
+ <path
184
+ d="M9 6l6 6-6 6"
185
+ stroke="currentColor"
186
+ strokeWidth="2"
187
+ strokeLinecap="round"
188
+ strokeLinejoin="round"
189
+ />
190
+ </svg>
191
+ );
192
+
193
+ export const CloseIcon = (p: P) => (
194
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
195
+ <path
196
+ d="M18 6L6 18M6 6l12 12"
197
+ stroke="currentColor"
198
+ strokeWidth="2"
199
+ strokeLinecap="round"
200
+ />
201
+ </svg>
202
+ );
203
+
204
+ /* Skip to next episode — play with a trailing bar */
205
+ export const NextEpisodeIcon = (p: P) => (
206
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
207
+ <path d="M5 5l10 7-10 7V5z" fill="currentColor" />
208
+ <path
209
+ d="M19 5v14"
210
+ stroke="currentColor"
211
+ strokeWidth="2.2"
212
+ strokeLinecap="round"
213
+ />
214
+ </svg>
215
+ );
216
+
217
+ /* Episode list */
218
+ export const ListIcon = (p: P) => (
219
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
220
+ <path
221
+ d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"
222
+ stroke="currentColor"
223
+ strokeWidth="2"
224
+ strokeLinecap="round"
225
+ strokeLinejoin="round"
226
+ />
227
+ </svg>
228
+ );
229
+
230
+ /* Warning triangle — failed source */
231
+ export const WarnIcon = (p: P) => (
232
+ <svg viewBox="0 0 24 24" fill="none" {...p}>
233
+ <path
234
+ d="M12 3.2 1.6 21h20.8L12 3.2z"
235
+ stroke="currentColor"
236
+ strokeWidth="2"
237
+ strokeLinejoin="round"
238
+ />
239
+ <path
240
+ d="M12 9.5v5M12 17.6v.01"
241
+ stroke="currentColor"
242
+ strokeWidth="2"
243
+ strokeLinecap="round"
244
+ />
245
+ </svg>
246
+ );
docker-compose.yml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ vidsuper:
3
+ build: .
4
+ # Or pull a prebuilt image instead of building:
5
+ # image: ghcr.io/YOUR_USERNAME/vidsuper:latest
6
+ restart: unless-stopped
7
+ ports:
8
+ - "3000:3000"
9
+ env_file:
10
+ - .env
lib/config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Server-side branding config. Read at runtime from the environment, so
2
+ // changing these in .env and restarting is enough — no rebuild required.
3
+ // (These are threaded into the player via server components / props, never
4
+ // referenced from client code, so they don't need the NEXT_PUBLIC_ prefix.)
5
+
6
+ export const PLAYER_NAME = process.env.PLAYER_NAME || "Vidsuper";
7
+
8
+ export const PLAYER_TAGLINE =
9
+ process.env.PLAYER_TAGLINE || "Video streaming player";
10
+
11
+ /** Default accent colour (hex, no leading #). Overridable per-request via ?color= */
12
+ export const DEFAULT_ACCENT = (process.env.ACCENT_COLOR || "ef4444").replace(
13
+ /^#/,
14
+ ""
15
+ );
16
+
17
+ /** Logo path served from /public. Replace public/logo.svg to rebrand. */
18
+ export const LOGO_SRC = process.env.LOGO_SRC || "/logo.svg";
lib/constants.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export type StreamKind = "hls" | "dash" | "native";
2
+
3
+ /** Detect the streaming engine to use from a source URL. */
4
+ export function detectStreamKind(src: string): StreamKind {
5
+ const path = src.split("?")[0].toLowerCase();
6
+ if (path.endsWith(".m3u8")) return "hls";
7
+ if (path.endsWith(".mpd")) return "dash";
8
+ return "native"; // mp4, webm, ogg, mov, …
9
+ }
lib/scraper/castle.ts ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "crypto";
2
+
3
+ // Server-only Castle source resolver (api.hlowb.com). Resolves a TMDB title to
4
+ // a Castle movieId, walks seasons/episodes, and pulls per-language (v1) or
5
+ // shared (v2) HLS streams. All API payloads are AES-CBC encrypted with a key
6
+ // derived from a fetched security key + a fixed suffix (key doubles as IV).
7
+ //
8
+ // The resulting streams play directly (no proxying required).
9
+
10
+ const TMDB_BASE_URL = "https://api.themoviedb.org/3";
11
+ const CASTLE_BASE = "https://api.hlowb.com";
12
+ const PKG = "com.external.castle";
13
+ const CHANNEL = "IndiaA";
14
+ const CLIENT = "1";
15
+ const LANG = "en-US";
16
+ const SUFFIX = "T!BgJB";
17
+ const APK_SIGN_KEY = "ED0955EB04E67A1D9F3305B95454FED485261475";
18
+
19
+ const WORKING_HEADERS: Record<string, string> = {
20
+ "User-Agent": "okhttp/4.9.3",
21
+ Accept: "application/json",
22
+ "Accept-Language": "en-US,en;q=0.9",
23
+ Connection: "Keep-Alive",
24
+ Referer: CASTLE_BASE,
25
+ };
26
+
27
+ // Headers the stream host expects for direct playback.
28
+ export const CASTLE_PLAYBACK_HEADERS: Record<string, string> = {
29
+ "User-Agent":
30
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
31
+ Accept:
32
+ "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5",
33
+ "Accept-Language": "en-US,en;q=0.9",
34
+ "Accept-Encoding": "identity",
35
+ Connection: "keep-alive",
36
+ "Sec-Fetch-Dest": "video",
37
+ "Sec-Fetch-Mode": "no-cors",
38
+ "Sec-Fetch-Site": "cross-site",
39
+ DNT: "1",
40
+ };
41
+
42
+ export interface CastleStream {
43
+ name: string;
44
+ title: string;
45
+ url: string;
46
+ quality: string;
47
+ size: string;
48
+ provider: string;
49
+ }
50
+
51
+ interface TmdbInfo {
52
+ title: string;
53
+ year: number | null;
54
+ tmdbId: number;
55
+ }
56
+
57
+ const QUALITY_MAP: Record<number, string> = { 1: "480p", 2: "720p", 3: "1080p" };
58
+
59
+ // Castle ids (movieId/episodeId/id) are 16+ digit integers that overflow JS's
60
+ // safe integer range, so JSON.parse silently corrupts them. Quote any integer
61
+ // literal with 16+ digits as a value so it survives parsing as a string.
62
+ function safeJsonParse(text: string): any {
63
+ const safe = text.replace(/:(\s*)(-?\d{16,})(\s*[,}\]])/g, ':$1"$2"$3');
64
+ return JSON.parse(safe);
65
+ }
66
+
67
+ export class CastleScraper {
68
+ private tmdbKey =
69
+ process.env.CASTLE_TMDB_API_KEY ||
70
+ process.env.TMDB_API_KEY ||
71
+ "439c478a771f35c05022f9feabcca01c";
72
+ private securityKey: string | null = null;
73
+
74
+ // ---- AES-CBC (key derived from security key + suffix, key == IV) -------
75
+
76
+ private deriveKey(apiKeyB64: string): Buffer {
77
+ const apiKeyBytes = Buffer.from(apiKeyB64, "base64");
78
+ let keyMaterial = Buffer.concat([apiKeyBytes, Buffer.from(SUFFIX, "ascii")]);
79
+ if (keyMaterial.length < 16) {
80
+ keyMaterial = Buffer.concat([
81
+ keyMaterial,
82
+ Buffer.alloc(16 - keyMaterial.length),
83
+ ]);
84
+ } else if (keyMaterial.length > 16) {
85
+ keyMaterial = keyMaterial.subarray(0, 16);
86
+ }
87
+ return keyMaterial;
88
+ }
89
+
90
+ private decryptCastle(encryptedB64: string, securityKeyB64: string): string {
91
+ const aesKey = this.deriveKey(securityKeyB64);
92
+ const iv = aesKey; // key doubles as IV
93
+ const encrypted = Buffer.from(encryptedB64, "base64");
94
+ const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, iv);
95
+ decipher.setAutoPadding(true); // PKCS#7
96
+ return Buffer.concat([
97
+ decipher.update(encrypted),
98
+ decipher.final(),
99
+ ]).toString("utf-8");
100
+ }
101
+
102
+ // ---- HTTP -------------------------------------------------------------
103
+
104
+ private async request(
105
+ url: string,
106
+ init?: RequestInit & { headers?: Record<string, string> }
107
+ ): Promise<Response> {
108
+ const r = await fetch(url, {
109
+ ...init,
110
+ headers: { ...WORKING_HEADERS, ...(init?.headers || {}) },
111
+ });
112
+ if (!r.ok) throw new Error(`Castle request failed ${r.status} for ${url}`);
113
+ return r;
114
+ }
115
+
116
+ // Response is either {data: "<cipher>"} or a raw cipher string.
117
+ private async extractCipher(response: Response): Promise<string> {
118
+ const text = (await response.text()).trim();
119
+ if (!text) throw new Error("Empty response");
120
+ try {
121
+ const j = JSON.parse(text);
122
+ if (j && typeof j.data === "string") return j.data.trim();
123
+ } catch {
124
+ /* not JSON — raw cipher */
125
+ }
126
+ return text;
127
+ }
128
+
129
+ private async decryptedJson(response: Response, securityKey: string): Promise<any> {
130
+ const cipher = await this.extractCipher(response);
131
+ return safeJsonParse(this.decryptCastle(cipher, securityKey));
132
+ }
133
+
134
+ // ---- Castle API -------------------------------------------------------
135
+
136
+ private async getSecurityKey(): Promise<string> {
137
+ if (this.securityKey) return this.securityKey;
138
+ const url = `${CASTLE_BASE}/v0.1/system/getSecurityKey/1?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}`;
139
+ const r = await this.request(url);
140
+ const data = await r.json();
141
+ if (data.code !== 200 || !data.data) {
142
+ throw new Error(`Security key API error: ${JSON.stringify(data)}`);
143
+ }
144
+ this.securityKey = data.data as string;
145
+ return this.securityKey;
146
+ }
147
+
148
+ private async searchCastle(
149
+ securityKey: string,
150
+ keyword: string,
151
+ page = 1,
152
+ size = 30
153
+ ): Promise<any> {
154
+ const params = new URLSearchParams({
155
+ channel: CHANNEL,
156
+ clientType: CLIENT,
157
+ keyword,
158
+ lang: LANG,
159
+ mode: "1",
160
+ packageName: PKG,
161
+ page: String(page),
162
+ size: String(size),
163
+ });
164
+ const url = `${CASTLE_BASE}/film-api/v1.1.0/movie/searchByKeyword?${params}`;
165
+ return this.decryptedJson(await this.request(url), securityKey);
166
+ }
167
+
168
+ private async getDetails(securityKey: string, movieId: string): Promise<any> {
169
+ const url = `${CASTLE_BASE}/film-api/v1.1/movie?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}&movieId=${movieId}&packageName=${PKG}`;
170
+ return this.decryptedJson(await this.request(url), securityKey);
171
+ }
172
+
173
+ private async getVideo2(
174
+ securityKey: string,
175
+ movieId: string,
176
+ episodeId: string,
177
+ resolution = 2
178
+ ): Promise<any> {
179
+ const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`;
180
+ const body = {
181
+ mode: "1",
182
+ appMarket: "GuanWang",
183
+ clientType: "1",
184
+ woolUser: "false",
185
+ apkSignKey: APK_SIGN_KEY,
186
+ androidVersion: "13",
187
+ movieId: String(movieId),
188
+ episodeId: String(episodeId),
189
+ isNewUser: "true",
190
+ resolution: String(resolution),
191
+ packageName: PKG,
192
+ };
193
+ const r = await this.request(url, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify(body),
197
+ });
198
+ return this.decryptedJson(r, securityKey);
199
+ }
200
+
201
+ private async getVideoV1(
202
+ securityKey: string,
203
+ movieId: string,
204
+ episodeId: string,
205
+ languageId: string,
206
+ resolution = 2
207
+ ): Promise<any> {
208
+ const params = new URLSearchParams({
209
+ apkSignKey: APK_SIGN_KEY,
210
+ channel: CHANNEL,
211
+ clientType: CLIENT,
212
+ episodeId: String(episodeId),
213
+ lang: LANG,
214
+ languageId: String(languageId),
215
+ mode: "1",
216
+ movieId: String(movieId),
217
+ packageName: PKG,
218
+ resolution: String(resolution),
219
+ });
220
+ const url = `${CASTLE_BASE}/film-api/v1.9.1/movie/getVideo?${params}`;
221
+ return this.decryptedJson(await this.request(url), securityKey);
222
+ }
223
+
224
+ // ---- helpers ----------------------------------------------------------
225
+
226
+ private extractDataBlock(obj: any): any {
227
+ if (obj && typeof obj.data === "object" && obj.data) return obj.data;
228
+ return obj && typeof obj === "object" ? obj : {};
229
+ }
230
+
231
+ private qualityValue(quality?: string): number {
232
+ if (!quality) return 0;
233
+ let q = String(quality).toLowerCase();
234
+ q = q.replace(/^(sd|hd|fhd|uhd|4k)\s*/, "").replace(/p$/, "").trim();
235
+ if (q === "4k" || q === "2160") return 2160;
236
+ const known = [1440, 1080, 720, 480, 360, 240];
237
+ const n = parseInt(q, 10);
238
+ if (known.includes(n)) return n;
239
+ return n > 0 ? n : 0;
240
+ }
241
+
242
+ private formatSize(size: any): string {
243
+ if (typeof size === "number" && size > 0) {
244
+ return size > 1_000_000_000
245
+ ? `${(size / 1_000_000_000).toFixed(2)} GB`
246
+ : `${Math.round(size / 1_000_000)} MB`;
247
+ }
248
+ return "Unknown";
249
+ }
250
+
251
+ private async getTmdbDetails(
252
+ tmdbId: number,
253
+ mediaType: "movie" | "tv"
254
+ ): Promise<TmdbInfo> {
255
+ const endpoint = mediaType === "tv" ? "tv" : "movie";
256
+ const url = `${TMDB_BASE_URL}/${endpoint}/${tmdbId}?api_key=${this.tmdbKey}&append_to_response=external_ids`;
257
+ const r = await this.request(url);
258
+ const data = await r.json();
259
+ const title = mediaType === "tv" ? data.name : data.title;
260
+ const releaseDate =
261
+ mediaType === "tv" ? data.first_air_date : data.release_date;
262
+ const year = releaseDate ? parseInt(String(releaseDate).split("-")[0], 10) : null;
263
+ return { title, year, tmdbId };
264
+ }
265
+
266
+ private findMovieId(searchResult: any, tmdbInfo: TmdbInfo): string {
267
+ const data = this.extractDataBlock(searchResult);
268
+ const rows: any[] = data.rows || [];
269
+ if (!rows.length) throw new Error("No search results found");
270
+ const searchTitle = (tmdbInfo.title || "").toLowerCase();
271
+
272
+ for (const item of rows) {
273
+ const itemTitle = String(item.title || item.name || "").toLowerCase();
274
+ if (
275
+ itemTitle &&
276
+ (itemTitle.includes(searchTitle) || searchTitle.includes(itemTitle))
277
+ ) {
278
+ const id = item.id || item.redirectId || item.redirectIdStr;
279
+ if (id) return String(id);
280
+ }
281
+ }
282
+ const first = rows[0];
283
+ const id = first.id || first.redirectId || first.redirectIdStr;
284
+ if (id) return String(id);
285
+ throw new Error("Could not extract movie ID from search results");
286
+ }
287
+
288
+ private processVideoResponse(
289
+ videoData: any,
290
+ media: TmdbInfo,
291
+ season: number,
292
+ episode: number,
293
+ resolution: number,
294
+ languageInfo: string
295
+ ): CastleStream[] {
296
+ const streams: CastleStream[] = [];
297
+ const data = this.extractDataBlock(videoData);
298
+ const videoUrl: string | undefined = data.videoUrl;
299
+ if (!videoUrl) return streams;
300
+
301
+ let mediaTitle = media.title || "Unknown";
302
+ if (media.year) mediaTitle += ` (${media.year})`;
303
+ if (season && episode) {
304
+ mediaTitle = `${media.title} S${String(season).padStart(2, "0")}E${String(
305
+ episode
306
+ ).padStart(2, "0")}`;
307
+ }
308
+
309
+ const quality = QUALITY_MAP[resolution] || `${resolution}p`;
310
+
311
+ if (Array.isArray(data.videos)) {
312
+ for (const video of data.videos) {
313
+ let q = String(
314
+ video.resolutionDescription || video.resolution || quality
315
+ ).replace(/^(SD|HD|FHD)\s+/i, "");
316
+ const name = languageInfo
317
+ ? `Castle ${languageInfo} - ${q}`
318
+ : `Castle - ${q}`;
319
+ streams.push({
320
+ name,
321
+ title: mediaTitle,
322
+ url: video.url || videoUrl,
323
+ quality: q,
324
+ size: this.formatSize(video.size),
325
+ provider: "castle",
326
+ });
327
+ }
328
+ } else {
329
+ const name = languageInfo
330
+ ? `Castle ${languageInfo} - ${quality}`
331
+ : `Castle - ${quality}`;
332
+ streams.push({
333
+ name,
334
+ title: mediaTitle,
335
+ url: videoUrl,
336
+ quality,
337
+ size: this.formatSize(data.size),
338
+ provider: "castle",
339
+ });
340
+ }
341
+ return streams;
342
+ }
343
+
344
+ async fetchSources(
345
+ tmdbId: number,
346
+ season = 0,
347
+ episode = 0
348
+ ): Promise<CastleStream[]> {
349
+ const mediaType: "movie" | "tv" = season && episode ? "tv" : "movie";
350
+ try {
351
+ const tmdbInfo = await this.getTmdbDetails(tmdbId, mediaType);
352
+ const securityKey = await this.getSecurityKey();
353
+
354
+ const searchTerm = tmdbInfo.year
355
+ ? `${tmdbInfo.title} ${tmdbInfo.year}`
356
+ : tmdbInfo.title;
357
+ const searchResult = await this.searchCastle(securityKey, searchTerm);
358
+ let movieId = this.findMovieId(searchResult, tmdbInfo);
359
+
360
+ let details = await this.getDetails(securityKey, movieId);
361
+
362
+ if (mediaType === "tv") {
363
+ const data = this.extractDataBlock(details);
364
+ const seasons: any[] = data.seasons || [];
365
+ const s = seasons.find((x) => x.number === season);
366
+ if (s && s.movieId && String(s.movieId) !== movieId) {
367
+ movieId = String(s.movieId);
368
+ details = await this.getDetails(securityKey, movieId);
369
+ }
370
+ }
371
+
372
+ const data = this.extractDataBlock(details);
373
+ const episodes: any[] = data.episodes || [];
374
+
375
+ let episodeId: string | null = null;
376
+ if (mediaType === "tv") {
377
+ const ep = episodes.find((e) => e.number === episode);
378
+ if (ep && ep.id) episodeId = String(ep.id);
379
+ } else if (episodes.length) {
380
+ episodeId = String(episodes[0].id);
381
+ }
382
+ if (!episodeId) throw new Error("Could not find episode ID");
383
+
384
+ const epObj = episodes.find((e) => String(e.id) === episodeId);
385
+ const tracks: any[] = epObj?.tracks || [];
386
+
387
+ const resolution = 2;
388
+ let allStreams: CastleStream[] = [];
389
+
390
+ for (let i = 0; i < tracks.length; i++) {
391
+ const track = tracks[i];
392
+ const langName =
393
+ track.languageName || track.abbreviate || `Lang${i + 1}`;
394
+ if (track.existIndividualVideo && track.languageId) {
395
+ try {
396
+ const videoData = await this.getVideoV1(
397
+ securityKey,
398
+ movieId,
399
+ episodeId,
400
+ String(track.languageId),
401
+ resolution
402
+ );
403
+ const langStreams = this.processVideoResponse(
404
+ videoData,
405
+ tmdbInfo,
406
+ season,
407
+ episode,
408
+ resolution,
409
+ `[${langName}]`
410
+ );
411
+ allStreams.push(...langStreams);
412
+ } catch (e: any) {
413
+ console.error(`[Castle] ${langName}: v1 failed - ${e?.message || e}`);
414
+ }
415
+ }
416
+ }
417
+
418
+ if (!allStreams.length) {
419
+ const videoData = await this.getVideo2(
420
+ securityKey,
421
+ movieId,
422
+ episodeId,
423
+ resolution
424
+ );
425
+ allStreams = this.processVideoResponse(
426
+ videoData,
427
+ tmdbInfo,
428
+ season,
429
+ episode,
430
+ resolution,
431
+ "[Shared]"
432
+ );
433
+ }
434
+
435
+ allStreams.sort(
436
+ (a, b) => this.qualityValue(b.quality) - this.qualityValue(a.quality)
437
+ );
438
+ return allStreams;
439
+ } catch (e: any) {
440
+ console.error(`[Castle] Error: ${e?.message || e}`);
441
+ return [];
442
+ }
443
+ }
444
+ }
445
+
446
+ import type { SourceModule } from "./types";
447
+
448
+ const _castle = new CastleScraper();
449
+
450
+ // hlowb.com HLS streams play directly. De-dupe by url (the qualities often share
451
+ // one master playlist) and keep the highest first.
452
+ export const castle: SourceModule = {
453
+ id: "castle",
454
+ name: "Steve",
455
+ label: "HLS · multi-language",
456
+ active: true,
457
+ rank: 5,
458
+ async fetch(ctx) {
459
+ const streams = await _castle
460
+ .fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
461
+ .catch(() => []);
462
+ if (!streams.length) return null;
463
+ const seen = new Set<string>();
464
+ const out = [];
465
+ for (const s of streams) {
466
+ if (seen.has(s.url)) continue;
467
+ seen.add(s.url);
468
+ out.push({
469
+ file: s.url,
470
+ label: s.quality.replace(/p$/i, ""),
471
+ type: (s.url.includes(".m3u8")
472
+ ? "hls"
473
+ : s.url.includes(".mpd")
474
+ ? "dash"
475
+ : "mp4") as "hls" | "dash" | "mp4",
476
+ });
477
+ }
478
+ return { streams: out };
479
+ },
480
+ };
lib/scraper/cinesu.ts ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "crypto";
2
+ import type { SourceModule, SourceStream } from "./types";
3
+
4
+ // Server-only CineSu resolver (cine.su). Fetches an encrypted octet-stream,
5
+ // decrypts it with a salted-SHA256 keystream + per-byte mask, and rebuilds each
6
+ // HLS variant. The /v1/s path token rotates per site build, so it's scraped out
7
+ // of the page JS automatically (see getApiToken).
8
+
9
+ const BASE_URL = "https://cine.su";
10
+ // Last-known credentials, used only as a fallback if live extraction fails.
11
+ const FALLBACK_TOKEN = "4f069b6a3007";
12
+ const FALLBACK_SALT =
13
+ "ac99adcd819fb92558bbc706dc13d74ed73971fc3619056e56a63a08d1496099";
14
+ const UA =
15
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
16
+
17
+ // ---- Rotating credential extraction ------------------------------------
18
+ // Two per-build secrets live in a Next.js chunk and rotate on every deploy:
19
+ // • the path token — `…="<12 hex>"`, used as `/v1/s/${token}/m/…`
20
+ // • the hash salt — `` `<64 hex>:${e}` `` inside the salted-hash function
21
+ // The chunk, variable, and both values rotate, so we scrape them live + cache.
22
+ interface CineSuCreds {
23
+ token: string;
24
+ salt: string;
25
+ }
26
+ let credsCache: { creds: CineSuCreds; at: number } | null = null;
27
+ const CREDS_TTL = 6 * 60 * 60 * 1000; // 6h
28
+
29
+ function credsFromJs(js: string): CineSuCreds | null {
30
+ if (!js.includes("/v1/s/")) return null;
31
+ // token: the variable used in `/v1/s/${VAR}/m/`, then its literal.
32
+ let token: string | null = null;
33
+ const vm = js.match(/\/v1\/s\/\$\{(\w+)\}\/m\//);
34
+ if (vm) {
35
+ const t = js.match(new RegExp(`\\b${vm[1]}\\s*=\\s*["']([0-9a-f]{12})["']`));
36
+ if (t) token = t[1];
37
+ }
38
+ if (!token) {
39
+ const g = js.match(/["']([0-9a-f]{12})["']/); // lone 12-hex literal
40
+ if (g) token = g[1];
41
+ }
42
+ // salt: the 64-hex literal at the head of the `<salt>:${...}` template.
43
+ const sm = js.match(/([0-9a-f]{64}):\$\{/);
44
+ const salt = sm ? sm[1] : null;
45
+ if (token && salt) return { token, salt };
46
+ return null;
47
+ }
48
+
49
+ async function extractCreds(): Promise<CineSuCreds | null> {
50
+ const headers = { "User-Agent": UA };
51
+ const page = await fetch(`${BASE_URL}/en/watch-movie/1339713`, {
52
+ headers,
53
+ cache: "no-store",
54
+ });
55
+ if (!page.ok) return null;
56
+ const html = await page.text();
57
+ const chunks = [
58
+ ...new Set(
59
+ [...html.matchAll(/\/_next\/static\/chunks\/[a-z0-9]+\.js/g)].map(
60
+ (x) => x[0]
61
+ )
62
+ ),
63
+ ];
64
+ const bodies = await Promise.all(
65
+ chunks.map((p) =>
66
+ fetch(`${BASE_URL}${p}`, { headers, cache: "no-store" })
67
+ .then((r) => (r.ok ? r.text() : null))
68
+ .catch(() => null)
69
+ )
70
+ );
71
+ for (const js of bodies) {
72
+ const c = js && credsFromJs(js);
73
+ if (c) return c;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ async function getApiCreds(force = false): Promise<CineSuCreds> {
79
+ if (!force && credsCache && Date.now() - credsCache.at < CREDS_TTL) {
80
+ return credsCache.creds;
81
+ }
82
+ const c = await extractCreds().catch(() => null);
83
+ if (c) {
84
+ credsCache = { creds: c, at: Date.now() };
85
+ return c;
86
+ }
87
+ return credsCache?.creds ?? { token: FALLBACK_TOKEN, salt: FALLBACK_SALT };
88
+ }
89
+
90
+ function sha256(buf: Buffer): Buffer {
91
+ return crypto.createHash("sha256").update(buf).digest();
92
+ }
93
+
94
+ // Salted SHA-256 (salt rotates per build).
95
+ function hs(text: string, salt: string): Buffer {
96
+ return sha256(Buffer.concat([Buffer.from(`${salt}:`), Buffer.from(text, "utf-8")]));
97
+ }
98
+
99
+ // Keystream: SHA-256(baseHash || counter) blocks concatenated to `length`.
100
+ function hr(text: string, length: number, salt: string): Buffer {
101
+ const baseHash = hs(text, salt);
102
+ const out = Buffer.alloc(length);
103
+ let s = 0;
104
+ let a = 0;
105
+ while (s < length) {
106
+ const counter = Buffer.alloc(4);
107
+ counter.writeUInt32BE(a >>> 0, 0);
108
+ const h = sha256(Buffer.concat([baseHash, counter]));
109
+ const take = Math.min(h.length, length - s);
110
+ h.copy(out, s, 0, take);
111
+ s += h.length; // advance a full block even on the final partial copy
112
+ a += 1;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ // Decrypt the octet-stream: subtract the rolling mask, then XOR the keystream.
118
+ function hi(data: Buffer, tStr: string, salt: string): Buffer {
119
+ const iStream = hr(tStr, data.length, salt);
120
+ const rHash = hs(`mask:${tStr}`, salt);
121
+ const rLen = rHash.length;
122
+ const out = Buffer.alloc(data.length);
123
+ for (let t = 0; t < data.length; t++) {
124
+ const mask = (17 * t + rHash[t % rLen]) & 255;
125
+ let val = (data[t] - mask) & 255;
126
+ val = (val ^ iStream[t]) & 255;
127
+ out[t] = val;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ export interface CineSuVariant {
133
+ label: string;
134
+ base: string;
135
+ init?: string;
136
+ target: number;
137
+ segs: { n: string; d?: number; x?: boolean }[];
138
+ }
139
+
140
+ // Fetch + decrypt the artifact and return the raw HLS variants. Exported so the
141
+ // /api/cinesu playlist route can rebuild the M3U8 (the segments are PNG-wrapped
142
+ // MPEG-TS, so they must be proxied + de-wrapped — see that route).
143
+ export async function cineSuVariants(
144
+ tmdbId: number,
145
+ season: number,
146
+ episode: number
147
+ ): Promise<CineSuVariant[]> {
148
+ const isMovie = !season || !episode;
149
+ const mType = isMovie ? "movie" : "tv";
150
+ const sVal = isMovie ? 0 : season;
151
+ const eVal = isMovie ? 0 : episode;
152
+
153
+ const payload = { m: mType, t: tmdbId, s: sVal, e: eVal };
154
+ const blob = Buffer.from(JSON.stringify(payload)).toString("base64url");
155
+ const referer = `${BASE_URL}/en/watch-${mType}/${tmdbId}`;
156
+ const tStr = `artifact:${payload.m}:${payload.t}:${payload.s}:${payload.e}`;
157
+
158
+ const attempt = (apiToken: string) =>
159
+ fetch(`${BASE_URL}/v1/s/${apiToken}/m/${blob}`, {
160
+ headers: {
161
+ "User-Agent": UA,
162
+ accept: "application/octet-stream,*/*",
163
+ "accept-language": "en-GB,en;q=0.5",
164
+ Referer: referer,
165
+ },
166
+ cache: "no-store",
167
+ });
168
+
169
+ let creds = await getApiCreds();
170
+ let res = await attempt(creds.token);
171
+ // A 404 usually means the cached creds went stale (site redeployed) — scrape
172
+ // fresh ones and retry once.
173
+ if (res.status === 404) {
174
+ const fresh = await getApiCreds(true);
175
+ if (fresh.token !== creds.token) {
176
+ creds = fresh;
177
+ res = await attempt(creds.token);
178
+ }
179
+ }
180
+ if (!res.ok) return [];
181
+
182
+ const encData = Buffer.from(await res.arrayBuffer());
183
+ const decrypted = hi(encData, tStr, creds.salt);
184
+
185
+ let data: any;
186
+ try {
187
+ data = JSON.parse(decrypted.toString("utf-8"));
188
+ } catch {
189
+ return [];
190
+ }
191
+ // The decrypted payload echoes the token back as `tag`.
192
+ if (data.tag !== creds.token || !Array.isArray(data.variants)) return [];
193
+
194
+ return data.variants.map((v: any) => ({
195
+ label: cleanLabel(v.label),
196
+ base: v.base || "",
197
+ init: v.init || "",
198
+ target: v.target || 6,
199
+ segs: v.segs || [],
200
+ }));
201
+ }
202
+
203
+ // Strip the PNG-polyglot wrapper to expose the embedded MPEG-TS. The real TS
204
+ // starts right after the PNG IEND chunk (IEND + its 4-byte CRC).
205
+ export function stripPngWrapper(buf: Buffer): Buffer {
206
+ const i = buf.indexOf("IEND");
207
+ if (i >= 0) {
208
+ const ts = buf.subarray(i + 8);
209
+ if (ts[0] === 0x47) return ts; // valid TS sync byte
210
+ }
211
+ return buf;
212
+ }
213
+
214
+ // Build the M3U8 for one variant, routing each PNG-wrapped segment through a
215
+ // proxy that de-wraps it. `proxySeg(url)` returns a same-origin TS url.
216
+ export function buildVariantM3u8(
217
+ variant: CineSuVariant,
218
+ proxySeg: (url: string) => string
219
+ ): string {
220
+ const { base, init, target, segs } = variant;
221
+ const lines = [
222
+ "#EXTM3U",
223
+ "#EXT-X-VERSION:6",
224
+ `#EXT-X-TARGETDURATION:${Math.round(target)}`,
225
+ "#EXT-X-PLAYLIST-TYPE:VOD",
226
+ "#EXT-X-MEDIA-SEQUENCE:1",
227
+ ];
228
+ if (init) lines.push(`#EXT-X-MAP:URI="${proxySeg(base + init)}"`);
229
+ for (const seg of segs) {
230
+ if (seg.x) continue;
231
+ const dur = seg.d && seg.d > 0 ? seg.d : target;
232
+ lines.push(`#EXTINF:${dur.toFixed(3)},`);
233
+ lines.push(proxySeg(base + (seg.n || "")));
234
+ }
235
+ lines.push("#EXT-X-ENDLIST");
236
+ return lines.join("\n") + "\n";
237
+ }
238
+
239
+ // "1920x1080" -> "1080", "720p" -> "720", else the raw label (or "auto").
240
+ function cleanLabel(label: any): string {
241
+ const s = String(label || "auto");
242
+ const wxh = s.match(/\d+\s*[x×]\s*(\d+)/i);
243
+ if (wxh) return wxh[1];
244
+ return s.replace(/p$/i, "");
245
+ }
246
+
247
+ export const cinesu: SourceModule = {
248
+ id: "cinesu",
249
+ name: "Alex",
250
+ label: "HLS · reconstructed",
251
+ active: true,
252
+ rank: 3,
253
+ async fetch(ctx) {
254
+ const variants = await cineSuVariants(
255
+ ctx.tmdbId,
256
+ ctx.season,
257
+ ctx.episode
258
+ ).catch(() => []);
259
+ if (!variants.length) return null;
260
+ // Each variant's playlist (and its PNG-wrapped segments) is served by the
261
+ // /api/cinesu route, so the encrypted sources payload stays small.
262
+ const qs = (v: number) =>
263
+ `m=${ctx.season && ctx.episode ? "tv" : "movie"}&t=${ctx.tmdbId}&s=${
264
+ ctx.season || 0
265
+ }&e=${ctx.episode || 0}&v=${v}`;
266
+ const streams: SourceStream[] = variants.map((variant, i) => ({
267
+ file: `${ctx.origin}/api/cinesu?${qs(i)}`,
268
+ label: variant.label,
269
+ type: "hls" as const,
270
+ }));
271
+ streams.sort(
272
+ (a, b) => (parseInt(b.label, 10) || 0) - (parseInt(a.label, 10) || 0)
273
+ );
274
+ return { streams };
275
+ },
276
+ };
lib/scraper/flixhq.ts ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "crypto";
2
+
3
+ // Server-only FlixHQ source resolver. Searches flixhq.one for a TMDB title,
4
+ // locates the watch page (and episode for TV), picks a non-videasy/vidking
5
+ // server embed, then runs the megacloud/rabbitstream access flow
6
+ // (challenge → attest → captcha PoW → playback) and AES-GCM-decrypts the
7
+ // playback payload to recover the master HLS url + caption tracks.
8
+ //
9
+ // The resulting stream plays directly (no proxying required).
10
+
11
+ const MAIN_URL = "https://flixhq.one";
12
+ const UA =
13
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
14
+
15
+ export interface FlixHqSource {
16
+ file: string;
17
+ label: string;
18
+ type: string;
19
+ }
20
+
21
+ export interface FlixHqSub {
22
+ url: string;
23
+ label: string;
24
+ language: string;
25
+ }
26
+
27
+ export interface FlixHqResult {
28
+ sources: FlixHqSource[];
29
+ subtitles: FlixHqSub[];
30
+ provider: string;
31
+ }
32
+
33
+ interface TmdbInfo {
34
+ title: string;
35
+ mediaType: "movie" | "tv";
36
+ year: number | null;
37
+ runtime: number | null;
38
+ season: number;
39
+ episode: number;
40
+ }
41
+
42
+ interface SearchResult {
43
+ title: string;
44
+ link: string;
45
+ year: number;
46
+ runtime: number;
47
+ mediaType: string;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // base64url helpers
52
+ // ---------------------------------------------------------------------------
53
+ function decodeB64Url(s: string): Buffer {
54
+ return Buffer.from(s, "base64url");
55
+ }
56
+ function encodeB64Url(b: Buffer): string {
57
+ return b.toString("base64url");
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Proof-of-work hash (port of the megacloud captcha solver)
62
+ // ---------------------------------------------------------------------------
63
+ const BE = 512;
64
+ const LT = 511;
65
+ const DR = 2;
66
+ const LR = 2654435761;
67
+ const HR = 2246822519;
68
+
69
+ function yeFast(t: number[]): void {
70
+ t[0] = (t[0] + t[1]) >>> 0;
71
+ let x = (t[3] ^ t[0]) >>> 0;
72
+ t[3] = ((x << 16) | (x >>> 16)) >>> 0;
73
+ t[2] = (t[2] + t[3]) >>> 0;
74
+ x = (t[1] ^ t[2]) >>> 0;
75
+ t[1] = ((x << 12) | (x >>> 20)) >>> 0;
76
+ t[0] = (t[0] + t[1]) >>> 0;
77
+ x = (t[3] ^ t[0]) >>> 0;
78
+ t[3] = ((x << 8) | (x >>> 24)) >>> 0;
79
+ t[2] = (t[2] + t[3]) >>> 0;
80
+ x = (t[1] ^ t[2]) >>> 0;
81
+ t[1] = ((x << 7) | (x >>> 25)) >>> 0;
82
+ }
83
+
84
+ function gr(input: Buffer): number[] {
85
+ const e = [1779033703, 3144134277, 1013904242, 2773480762];
86
+ for (const b of input) {
87
+ e[0] = (e[0] + b) >>> 0;
88
+ e[0] = ((e[0] << 7) | (e[0] >>> 25)) >>> 0;
89
+ yeFast(e);
90
+ }
91
+ for (let i = 0; i < 8; i++) yeFast(e);
92
+
93
+ const r = new Array<number>(BE);
94
+ for (let i = 0; i < BE; i++) {
95
+ yeFast(e);
96
+ r[i] = (e[0] ^ e[2]) >>> 0;
97
+ }
98
+
99
+ for (let d = 0; d < DR; d++) {
100
+ for (let s = 0; s < BE; s++) {
101
+ const a = r[s] & LT;
102
+ let c = (r[s] + r[a]) >>> 0;
103
+ c = ((c << 13) | (c >>> 19)) >>> 0;
104
+ c = (c ^ (Math.imul(r[(s + 1) & LT], LR) >>> 0)) >>> 0;
105
+ r[s] = c;
106
+ e[0] = (e[0] ^ c) >>> 0;
107
+ yeFast(e);
108
+ }
109
+ }
110
+
111
+ const n = new Array<number>(8);
112
+ const o = BE / 8;
113
+ for (let i = 0; i < 8; i++) {
114
+ yeFast(e);
115
+ let sVal = e[0] >>> 0;
116
+ const a = i * o;
117
+ for (let c = 0; c < o; c++) {
118
+ const d = r[a + c];
119
+ sVal = (sVal + d) >>> 0;
120
+ sVal = ((sVal << 5) | (sVal >>> 27)) >>> 0;
121
+ sVal = (sVal ^ (Math.imul(d, HR) >>> 0)) >>> 0;
122
+ }
123
+ n[i] = (sVal ^ e[2]) >>> 0;
124
+ }
125
+ return n;
126
+ }
127
+
128
+ // Count leading zero bits across the 8-word digest (difficulty check).
129
+ function wr(t: number[]): number {
130
+ let eVal = 0;
131
+ for (const n of t) {
132
+ if (n === 0) eVal += 32;
133
+ else return eVal + Math.clz32(n);
134
+ }
135
+ return eVal;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Lightweight string similarity (Jaro-Winkler) for title scoring.
140
+ // ---------------------------------------------------------------------------
141
+ function jaro(a: string, b: string): number {
142
+ if (a === b) return 1;
143
+ const la = a.length;
144
+ const lb = b.length;
145
+ if (la === 0 || lb === 0) return 0;
146
+ const range = Math.max(0, Math.floor(Math.max(la, lb) / 2) - 1);
147
+ const aMatch = new Array<boolean>(la).fill(false);
148
+ const bMatch = new Array<boolean>(lb).fill(false);
149
+ let matches = 0;
150
+ for (let i = 0; i < la; i++) {
151
+ const start = Math.max(0, i - range);
152
+ const end = Math.min(i + range + 1, lb);
153
+ for (let j = start; j < end; j++) {
154
+ if (bMatch[j] || a[i] !== b[j]) continue;
155
+ aMatch[i] = true;
156
+ bMatch[j] = true;
157
+ matches++;
158
+ break;
159
+ }
160
+ }
161
+ if (matches === 0) return 0;
162
+ let t = 0;
163
+ let k = 0;
164
+ for (let i = 0; i < la; i++) {
165
+ if (!aMatch[i]) continue;
166
+ while (!bMatch[k]) k++;
167
+ if (a[i] !== b[k]) t++;
168
+ k++;
169
+ }
170
+ t /= 2;
171
+ return (matches / la + matches / lb + (matches - t) / matches) / 3;
172
+ }
173
+
174
+ function jaroWinkler(a: string, b: string): number {
175
+ const j = jaro(a, b);
176
+ let prefix = 0;
177
+ for (let i = 0; i < Math.min(4, a.length, b.length); i++) {
178
+ if (a[i] === b[i]) prefix++;
179
+ else break;
180
+ }
181
+ return j + prefix * 0.1 * (1 - j);
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Best-effort 2-letter language code from a caption track label.
186
+ // ---------------------------------------------------------------------------
187
+ function guessLang(label: string): string {
188
+ const n = (label || "").toLowerCase();
189
+ if (n.includes("english")) return "en";
190
+ if (n.includes("spanish") || n.includes("español")) return "es";
191
+ if (n.includes("french") || n.includes("français")) return "fr";
192
+ if (n.includes("german") || n.includes("deutsch")) return "de";
193
+ if (n.includes("portuguese") || n.includes("português")) return "pt";
194
+ if (n.includes("italian")) return "it";
195
+ if (n.includes("arabic")) return "ar";
196
+ if (n.includes("russian")) return "ru";
197
+ if (n.includes("japanese")) return "ja";
198
+ if (n.includes("korean")) return "ko";
199
+ if (n.includes("chinese") || n.includes("mandarin")) return "zh";
200
+ if (n.includes("hindi")) return "hi";
201
+ return "";
202
+ }
203
+
204
+ export class FlixHqScraper {
205
+ private tmdbKey =
206
+ process.env.TMDB_API_KEY || "";
207
+
208
+ async fetchSources(
209
+ tmdbId: number,
210
+ season = 0,
211
+ episode = 0
212
+ ): Promise<FlixHqResult | null> {
213
+ try {
214
+ const mediaType: "movie" | "tv" =
215
+ season > 0 && episode > 0 ? "tv" : "movie";
216
+
217
+ const tmdb = await this.fetchTmdbInfo(tmdbId, mediaType, season, episode);
218
+ const results = await this.search(tmdb.title);
219
+ const scored = this.scoreResults(tmdb, results);
220
+ if (!scored.length) return null;
221
+
222
+ let watchUrl = scored[0].link;
223
+
224
+ if (mediaType === "tv") {
225
+ const ep = await this.extractEpisodeLink(watchUrl, season, episode);
226
+ if (!ep) return null;
227
+ watchUrl = ep;
228
+ }
229
+
230
+ const servers = await this.getServers(watchUrl, mediaType === "tv");
231
+ let embed: string | null = null;
232
+ for (const srv of servers) {
233
+ const name = String(srv?.name || "").toLowerCase();
234
+ if (!name.includes("videasy") && !name.includes("vidking")) {
235
+ embed = srv?.link || null;
236
+ if (embed) break;
237
+ }
238
+ }
239
+ if (!embed) return null;
240
+
241
+ return await this.extractRabbitstream(embed);
242
+ } catch (e: any) {
243
+ console.error("FlixHqScraper Error:", e?.message || e);
244
+ return null;
245
+ }
246
+ }
247
+
248
+ // ---- internals -------------------------------------------------------
249
+
250
+ private async httpGet(url: string, init?: RequestInit): Promise<string> {
251
+ for (let i = 0; i < 2; i++) {
252
+ try {
253
+ const r = await fetch(url, {
254
+ ...init,
255
+ headers: { "User-Agent": UA, ...(init?.headers || {}) },
256
+ });
257
+ if (r.ok) return await r.text();
258
+ } catch {
259
+ /* retry */
260
+ }
261
+ await new Promise((res) => setTimeout(res, 500));
262
+ }
263
+ throw new Error(`Failed to fetch ${url}`);
264
+ }
265
+
266
+ private async fetchTmdbInfo(
267
+ tmdbId: number,
268
+ mediaType: "movie" | "tv",
269
+ season: number,
270
+ episode: number
271
+ ): Promise<TmdbInfo> {
272
+ const r = await fetch(
273
+ `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${this.tmdbKey}&language=en-US`
274
+ );
275
+ if (!r.ok) throw new Error("Failed to fetch data from TMDB.");
276
+ const d = await r.json();
277
+ const title: string = d.title || d.name || "";
278
+ const dateStr: string = d.release_date || d.first_air_date || "";
279
+ const year = dateStr.length >= 4 ? parseInt(dateStr.slice(0, 4), 10) : null;
280
+ const runtime =
281
+ mediaType === "movie"
282
+ ? d.runtime ?? null
283
+ : Array.isArray(d.episode_run_time)
284
+ ? d.episode_run_time[0] ?? null
285
+ : null;
286
+ return { title, mediaType, year, runtime, season, episode };
287
+ }
288
+
289
+ private async search(query: string): Promise<SearchResult[]> {
290
+ const html = await this.httpGet(
291
+ `${MAIN_URL}/search?keyword=${encodeURIComponent(query)}`
292
+ );
293
+ const results: SearchResult[] = [];
294
+
295
+ // Each result lives in a .flw-item block; split on the class marker.
296
+ const chunks = html.split("flw-item");
297
+ for (let i = 1; i < chunks.length; i++) {
298
+ const chunk = chunks[i];
299
+ const nameMatch = chunk.match(
300
+ /class="film-name"[^>]*>\s*<a\s+href="([^"]+)"[^>]*title="([^"]*)"/
301
+ );
302
+ if (!nameMatch) continue;
303
+ let link = nameMatch[1];
304
+ const title = decodeEntities(nameMatch[2]).trim();
305
+ if (!link.startsWith("http")) link = `${MAIN_URL}${link}`;
306
+
307
+ let year = 0;
308
+ let runtime = 0;
309
+ const infoRe = /class="fdi-item[^"]*"[^>]*>\s*([^<]+?)\s*</g;
310
+ let m: RegExpExecArray | null;
311
+ while ((m = infoRe.exec(chunk))) {
312
+ const txt = m[1].trim();
313
+ if (/^\d{4}$/.test(txt)) year = parseInt(txt, 10);
314
+ else if (/^\d+m$/.test(txt)) runtime = parseInt(txt.slice(0, -1), 10);
315
+ }
316
+
317
+ const typeMatch = chunk.match(/class="fdi-type"[^>]*>\s*([^<]+?)\s*</);
318
+ const mediaType = typeMatch ? typeMatch[1].trim().toLowerCase() : "movie";
319
+
320
+ results.push({ title, link, year, runtime, mediaType });
321
+ }
322
+ return results;
323
+ }
324
+
325
+ private scoreResults(tmdb: TmdbInfo, results: SearchResult[]): SearchResult[] {
326
+ const dur = tmdb.runtime || 0;
327
+ const scored: { score: number; res: SearchResult }[] = [];
328
+ results.forEach((res, idx) => {
329
+ if (
330
+ tmdb.mediaType === "movie" &&
331
+ dur > 0 &&
332
+ res.runtime > 0 &&
333
+ Math.abs(dur - res.runtime) > 60
334
+ ) {
335
+ return;
336
+ }
337
+ let score =
338
+ jaroWinkler(tmdb.title.toLowerCase(), res.title.toLowerCase()) * 2000;
339
+ if (tmdb.mediaType === res.mediaType) score += 500;
340
+ score += 100 - Math.min(idx, 50);
341
+ if (tmdb.year && res.year > 0) {
342
+ if (tmdb.year === res.year) score += 800;
343
+ else if (Math.abs(tmdb.year - res.year) <= 1) score += 300;
344
+ }
345
+ if (
346
+ tmdb.mediaType === "movie" &&
347
+ dur > 0 &&
348
+ res.runtime > 0 &&
349
+ Math.abs(dur - res.runtime) <= 10
350
+ ) {
351
+ score += 1000;
352
+ }
353
+ scored.push({ score, res });
354
+ });
355
+ scored.sort((a, b) => b.score - a.score);
356
+ return scored.map((s) => s.res);
357
+ }
358
+
359
+ private async extractEpisodeLink(
360
+ seriesUrl: string,
361
+ season: number,
362
+ episode: number
363
+ ): Promise<string | null> {
364
+ const html = await this.httpGet(seriesUrl);
365
+ const target = `s${String(season).padStart(2, "0")}-e${String(
366
+ episode
367
+ ).padStart(2, "0")}`;
368
+ const re = /class="eps-item[^"]*"[^>]*href="([^"]+)"|href="([^"]+)"[^>]*class="eps-item/g;
369
+ let m: RegExpExecArray | null;
370
+ while ((m = re.exec(html))) {
371
+ const link = m[1] || m[2];
372
+ if (link && link.includes(target)) {
373
+ return link.startsWith("http") ? link : `${MAIN_URL}${link}`;
374
+ }
375
+ }
376
+ return null;
377
+ }
378
+
379
+ private async getServers(
380
+ watchUrl: string,
381
+ isTv: boolean
382
+ ): Promise<any[]> {
383
+ const html = await this.httpGet(watchUrl);
384
+
385
+ let token: string | null = null;
386
+ const sel = isTv ? "series-player" : "main-wrapper";
387
+ const selMatch = html.match(
388
+ new RegExp(`id="${sel}"[^>]*data-token="([^"]+)"`)
389
+ );
390
+ if (selMatch) token = selMatch[1];
391
+ if (!token) {
392
+ const generic = html.match(/data-token=["']([^"']+)["']/);
393
+ if (generic) token = generic[1];
394
+ }
395
+ if (!token) throw new Error("No data-token found.");
396
+
397
+ const body = new URLSearchParams();
398
+ body.set(isTv ? "players_show" : "players", token);
399
+
400
+ const r = await fetch(`${MAIN_URL}/ajax/ajax.php`, {
401
+ method: "POST",
402
+ headers: {
403
+ "User-Agent": UA,
404
+ "X-Requested-With": "XMLHttpRequest",
405
+ Referer: watchUrl,
406
+ "Content-Type": "application/x-www-form-urlencoded",
407
+ },
408
+ body: body.toString(),
409
+ });
410
+ const data = await r.json();
411
+ return Array.isArray(data) ? data : [data];
412
+ }
413
+
414
+ private async extractRabbitstream(
415
+ embedUrl: string
416
+ ): Promise<FlixHqResult | null> {
417
+ const parsed = new URL(embedUrl);
418
+ const baseApi = `${parsed.protocol}//${parsed.host}`;
419
+ const idMatch = embedUrl.match(/\/e\/([a-zA-Z0-9_-]+)/);
420
+ if (!idMatch) throw new Error("No video id in embed url");
421
+ const videoId = idMatch[1];
422
+
423
+ // 1. Challenge
424
+ const r1 = await fetch(`${baseApi}/api/videos/access/challenge`, {
425
+ method: "POST",
426
+ headers: {
427
+ "User-Agent": UA,
428
+ Referer: embedUrl,
429
+ "X-Requested-With": "XMLHttpRequest",
430
+ "Content-Type": "application/json",
431
+ },
432
+ });
433
+ const j1 = await r1.json();
434
+ const nonce: string = j1.nonce || "";
435
+ const challengeId: string = j1.challenge_id || "";
436
+
437
+ // 2. Attest — ECDSA P-256 signature over the nonce + JWK public key.
438
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", {
439
+ namedCurve: "P-256",
440
+ });
441
+ const sigB64 = encodeB64Url(
442
+ crypto.sign("sha256", Buffer.from(nonce), privateKey)
443
+ );
444
+ const pubJwk = publicKey.export({ format: "jwk" }) as {
445
+ x: string;
446
+ y: string;
447
+ };
448
+ const jwk = {
449
+ crv: "P-256",
450
+ ext: true,
451
+ key_ops: ["verify"],
452
+ kty: "EC",
453
+ x: pubJwk.x,
454
+ y: pubJwk.y,
455
+ };
456
+
457
+ const ua = UA;
458
+ const clientPayload = {
459
+ user_agent: ua,
460
+ architecture: "x86",
461
+ bitness: "64",
462
+ platform: "Windows",
463
+ platform_version: "10.0.0",
464
+ model: "",
465
+ ua_full_version: "124.0.0.0",
466
+ brand_full_versions: [{ brand: "Chromium", version: "124.0.0.0" }],
467
+ pixel_ratio: 1,
468
+ screen_width: 1920,
469
+ screen_height: 1080,
470
+ color_depth: 24,
471
+ languages: ["en-US"],
472
+ timezone: "UTC",
473
+ hardware_concurrency: 8,
474
+ device_memory: 8,
475
+ touch_points: 0,
476
+ webgl_vendor: "Google Inc. (Google)",
477
+ webgl_renderer:
478
+ "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
479
+ canvas_hash: "_xjcrc8La-Vnxpr6a6vNFOOdnRcHHQ0tzgT_V3atRqo",
480
+ audio_hash: "RyBmlOc4cA7XhqmvkyO40eo8sOa5q-CFlrTnf70qADY",
481
+ pointer_type: "fine,hover",
482
+ extra: { vendor: "Google Inc." },
483
+ };
484
+
485
+ const vId = crypto.randomUUID().replace(/-/g, "");
486
+ const dId = crypto.randomUUID().replace(/-/g, "");
487
+
488
+ const r2 = await fetch(`${baseApi}/api/videos/access/attest`, {
489
+ method: "POST",
490
+ headers: {
491
+ "User-Agent": ua,
492
+ Referer: embedUrl,
493
+ "X-Requested-With": "XMLHttpRequest",
494
+ "Content-Type": "application/json",
495
+ },
496
+ body: JSON.stringify({
497
+ viewer_id: vId,
498
+ device_id: dId,
499
+ challenge_id: challengeId,
500
+ nonce,
501
+ signature: sigB64,
502
+ public_key: jwk,
503
+ client: clientPayload,
504
+ storage: {},
505
+ attributes: { entropy: "high" },
506
+ }),
507
+ });
508
+ const att = await r2.json();
509
+ const token: string = att.token || "";
510
+ const conf: number = att.confidence ?? 0;
511
+ const avId: string = att.viewer_id || "";
512
+ const adId: string = att.device_id || "";
513
+
514
+ // 3. Captcha — solve the proof-of-work challenge if present.
515
+ const sessionId = crypto.randomUUID().replace(/-/g, "");
516
+ const capHeaders = {
517
+ "User-Agent": ua,
518
+ Origin: baseApi,
519
+ Referer: embedUrl,
520
+ "X-Requested-With": "XMLHttpRequest",
521
+ "X-Playback-Session-Id": sessionId,
522
+ "Content-Type": "application/json",
523
+ };
524
+ const rCap = await fetch(
525
+ `${baseApi}/api/videos/${videoId}/embed/captcha`,
526
+ {
527
+ method: "POST",
528
+ headers: capHeaders,
529
+ body: JSON.stringify({
530
+ fingerprint: {
531
+ token,
532
+ viewer_id: vId,
533
+ device_id: dId,
534
+ confidence: conf,
535
+ },
536
+ }),
537
+ }
538
+ );
539
+ const cap = await rCap.json();
540
+
541
+ let newToken = token;
542
+ if (cap.pow_nonce && cap.pow_token) {
543
+ const difficulty = cap.pow_difficulty ?? 2;
544
+ let solution = 0;
545
+ while (wr(gr(Buffer.from(`${cap.pow_nonce}:${solution}`))) < difficulty) {
546
+ solution++;
547
+ }
548
+ const rVer = await fetch(
549
+ `${baseApi}/api/videos/${videoId}/embed/captcha/verify`,
550
+ {
551
+ method: "POST",
552
+ headers: capHeaders,
553
+ body: JSON.stringify({
554
+ pow_token: cap.pow_token,
555
+ solution: String(solution),
556
+ fingerprint: {
557
+ token,
558
+ viewer_id: avId,
559
+ device_id: adId,
560
+ confidence: conf,
561
+ },
562
+ }),
563
+ }
564
+ );
565
+ const ver = await rVer.json();
566
+ newToken = ver.token || token;
567
+ }
568
+
569
+ // 4. Playback
570
+ const pbHeaders: Record<string, string> = {
571
+ "User-Agent": ua,
572
+ Origin: baseApi,
573
+ Referer: embedUrl,
574
+ "X-Requested-With": "XMLHttpRequest",
575
+ "X-Playback-Session-Id": sessionId,
576
+ "Content-Type": "application/json",
577
+ };
578
+ if (newToken !== token) pbHeaders["X-Captcha-Token"] = newToken;
579
+
580
+ const r3 = await fetch(
581
+ `${baseApi}/api/videos/${videoId}/embed/playback`,
582
+ {
583
+ method: "POST",
584
+ headers: pbHeaders,
585
+ body: JSON.stringify({
586
+ fingerprint: {
587
+ token,
588
+ viewer_id: avId,
589
+ device_id: adId,
590
+ confidence: conf,
591
+ },
592
+ }),
593
+ }
594
+ );
595
+ const j3 = await r3.json();
596
+ const playback = j3.playback;
597
+ if (!playback) throw new Error("No playback in response");
598
+
599
+ // 5. Decrypt the AES-GCM payload.
600
+ const kParts: string[] = playback.key_parts || [];
601
+ const vStr = String(playback.version ?? "").trim();
602
+ let keyBytes: Buffer;
603
+ if (/^\d+$/.test(vStr) && kParts.length) {
604
+ const v = parseInt(vStr, 10);
605
+ if (v >= 1 && v <= kParts.length && 31 - v >= 1 && 31 - v <= kParts.length) {
606
+ keyBytes = Buffer.concat([
607
+ decodeB64Url(kParts[v - 1]),
608
+ decodeB64Url(kParts[31 - v - 1]),
609
+ ]);
610
+ } else {
611
+ keyBytes = Buffer.concat(kParts.map(decodeB64Url));
612
+ }
613
+ } else {
614
+ keyBytes = Buffer.concat(kParts.map(decodeB64Url));
615
+ }
616
+
617
+ const iv = decodeB64Url(playback.iv || "");
618
+ const payload = decodeB64Url(playback.payload || "");
619
+ const tag = payload.subarray(payload.length - 16);
620
+ const ciphertext = payload.subarray(0, payload.length - 16);
621
+
622
+ const decipher = crypto.createDecipheriv("aes-256-gcm", keyBytes, iv);
623
+ decipher.setAuthTag(tag);
624
+ const decrypted = Buffer.concat([
625
+ decipher.update(ciphertext),
626
+ decipher.final(),
627
+ ]);
628
+ const data = JSON.parse(decrypted.toString("utf-8"));
629
+
630
+ const masterUrl: string | undefined = data?.sources?.[0]?.url;
631
+ if (!masterUrl) throw new Error("No stream url in decrypted payload");
632
+
633
+ const type = masterUrl.includes(".m3u8")
634
+ ? "hls"
635
+ : masterUrl.includes(".mpd")
636
+ ? "dash"
637
+ : "mp4";
638
+
639
+ const subtitles: FlixHqSub[] = (data.tracks || [])
640
+ .filter((t: any) => t.kind === "captions" && t.file)
641
+ .map((t: any) => ({
642
+ url: t.file,
643
+ label: t.label || "",
644
+ language: guessLang(t.label || ""),
645
+ }));
646
+
647
+ return {
648
+ sources: [{ file: masterUrl, label: "auto", type }],
649
+ subtitles,
650
+ provider: "FlixHQ",
651
+ };
652
+ }
653
+ }
654
+
655
+ // Minimal HTML entity decode for titles pulled from attributes.
656
+ function decodeEntities(s: string): string {
657
+ return s
658
+ .replace(/&amp;/g, "&")
659
+ .replace(/&#039;/g, "'")
660
+ .replace(/&#39;/g, "'")
661
+ .replace(/&quot;/g, '"')
662
+ .replace(/&lt;/g, "<")
663
+ .replace(/&gt;/g, ">");
664
+ }
665
+
666
+ import type { SourceModule } from "./types";
667
+
668
+ const _flixhq = new FlixHqScraper();
669
+
670
+ // megacloud/rabbitstream streams + captions play directly (no proxy needed).
671
+ export const flixhq: SourceModule = {
672
+ id: "flixhq",
673
+ name: "Efe",
674
+ label: "HLS · built-in subs",
675
+ active: true,
676
+ rank: 6,
677
+ async fetch(ctx) {
678
+ const r = await _flixhq
679
+ .fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
680
+ .catch(() => null);
681
+ if (!r || !r.sources.length) return null;
682
+ return {
683
+ streams: r.sources as any,
684
+ subtitles: r.subtitles.map((s) => ({
685
+ url: s.url,
686
+ display: s.label,
687
+ language: s.language,
688
+ source: "Efe",
689
+ })),
690
+ };
691
+ },
692
+ };
lib/scraper/index.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Source registry
3
+ //
4
+ // To add a source: create a file here that exports a `SourceModule` (id, name,
5
+ // label, active, rank, fetch), then add one import + one array entry below.
6
+ // Everything else (the /api/sources route, the player's server list, failover)
7
+ // is driven off this list.
8
+ //
9
+ // Display names are overridable per-source via env, e.g.
10
+ // SOURCE_ONEROOM_NAME="Server 1" SOURCE_FLIXHQ_NAME="Server 2"
11
+ // (uppercase the source id). Handy for rebranding without touching code.
12
+ // ─────────────────────────────────────────────────────────────────────────
13
+
14
+ import type { SourceModule, SourceMeta } from "./types";
15
+ import { oneroom } from "./oneroom";
16
+ import { insertunit } from "./insertunit";
17
+ import { flixhq } from "./flixhq";
18
+ import { castle } from "./castle";
19
+ import { cinesu } from "./cinesu";
20
+ import { vidnest } from "./vidnest";
21
+ import { vidrock } from "./vidrock";
22
+ import { vixsrc } from "./vixsrc";
23
+
24
+ const REGISTERED: SourceModule[] = [
25
+ oneroom,
26
+ insertunit,
27
+ flixhq,
28
+ castle,
29
+ cinesu,
30
+ vidnest,
31
+ vidrock,
32
+ vixsrc,
33
+ ];
34
+
35
+ /** Env display-name override for a source id (SOURCE_<ID>_NAME). */
36
+ function nameFor(id: string, fallback: string): string {
37
+ return process.env[`SOURCE_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_NAME`] || fallback;
38
+ }
39
+
40
+ const byRank = (a: SourceModule, b: SourceModule) =>
41
+ (a.rank ?? 100) - (b.rank ?? 100);
42
+
43
+ /** All active sources, ordered by rank (used for failover + default order). */
44
+ export function activeSources(): SourceModule[] {
45
+ return REGISTERED.filter((s) => s.active).sort(byRank);
46
+ }
47
+
48
+ /**
49
+ * Resolve a source by id. Accepts a plain id ("oneroom"), a parent id, or a
50
+ * "parent:child" id. The returned module's name honours any env override.
51
+ */
52
+ export function getSource(id: string): SourceModule | undefined {
53
+ const wanted = id || activeSources()[0]?.id;
54
+ if (!wanted) return undefined;
55
+ const [parentId, childId] = wanted.split(":");
56
+ const parent = REGISTERED.find((s) => s.id === parentId && s.active);
57
+ if (!parent) return undefined;
58
+ if (!childId) {
59
+ return { ...parent, name: nameFor(parent.id, parent.name) };
60
+ }
61
+ const child = parent.children?.find((c) => c.id === childId);
62
+ if (!child) return undefined;
63
+ return {
64
+ id: wanted,
65
+ name: `${nameFor(parent.id, parent.name)} · ${child.name}`,
66
+ label: parent.label,
67
+ active: true,
68
+ fetch: child.fetch,
69
+ };
70
+ }
71
+
72
+ /** Client-safe metadata for building the player's server list. */
73
+ export function sourceList(): SourceMeta[] {
74
+ return activeSources().map((s) => ({
75
+ id: s.id,
76
+ name: nameFor(s.id, s.name),
77
+ label: s.label,
78
+ children: s.children?.map((c) => ({ id: c.id, name: c.name })),
79
+ }));
80
+ }
lib/scraper/insertunit.ts ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Server-only Insertunit source resolver (api.insertunit.ws). Resolves a TMDB
2
+ // id to an IMDB id, scrapes the embed HTML, and extracts the dash/hls streams,
3
+ // audio-track names and cc (subtitle) list. Streams live on interkh.com and
4
+ // require the insertunit Referer, so they must be proxied.
5
+
6
+ export interface InsertunitResult {
7
+ dash: string | null;
8
+ dasha: string | null;
9
+ hls: string | null;
10
+ audio: { names: string[]; order: number[] } | null;
11
+ cc: { url: string; name: string }[] | null;
12
+ }
13
+
14
+ const HEADERS: Record<string, string> = {
15
+ accept:
16
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
17
+ "accept-language": "en-GB,en;q=0.9",
18
+ "cache-control": "max-age=0",
19
+ "sec-ch-ua":
20
+ '"Chromium";v="148", "Brave";v="148", "Not/A)Brand";v="99"',
21
+ "sec-ch-ua-mobile": "?0",
22
+ "sec-ch-ua-platform": '"macOS"',
23
+ "sec-fetch-dest": "document",
24
+ "sec-fetch-mode": "navigate",
25
+ "sec-fetch-site": "none",
26
+ "sec-fetch-user": "?1",
27
+ "upgrade-insecure-requests": "1",
28
+ };
29
+
30
+ export const INSERTUNIT_REFERER = "https://api.insertunit.ws/";
31
+
32
+ function safeJsonLoad<T = any>(s: string): T | null {
33
+ try {
34
+ return JSON.parse(s) as T;
35
+ } catch {
36
+ // strip trailing commas from sloppy JS objects, retry
37
+ const clean = s.replace(/,\s*}/g, "}").replace(/,\s*\]/g, "]");
38
+ try {
39
+ return JSON.parse(clean) as T;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ }
45
+
46
+ export class InsertunitScraper {
47
+ private tmdbKey =
48
+ process.env.TMDB_API_KEY || "";
49
+
50
+ private async getImdbId(tmdbId: number, isTv: boolean): Promise<string | null> {
51
+ const mt = isTv ? "tv" : "movie";
52
+ try {
53
+ const r = await fetch(
54
+ `https://api.themoviedb.org/3/${mt}/${tmdbId}/external_ids?api_key=${this.tmdbKey}`
55
+ );
56
+ if (!r.ok) return null;
57
+ const d = await r.json();
58
+ return d.imdb_id || null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Finds the dynamic token suffix appended to media URLs to bypass 403s.
65
+ private extractDynamicToken(html: string): string {
66
+ const varMatch = html.match(
67
+ /o\[k\]\s*\+=\s*'&'\s*\+\s*([a-zA-Z0-9_]+);/
68
+ );
69
+ if (!varMatch) return "";
70
+ const name = varMatch[1];
71
+ const valMatch = html.match(
72
+ new RegExp(`${name}\\s*=\\s*"([^"]+)"`)
73
+ );
74
+ if (!valMatch) return "";
75
+ return `&${valMatch[1]}`;
76
+ }
77
+
78
+ async fetchSources(
79
+ tmdbId: number,
80
+ season = 0,
81
+ episode = 0
82
+ ): Promise<InsertunitResult | null> {
83
+ const isTv = !!(season && episode);
84
+ const imdb = await this.getImdbId(tmdbId, isTv);
85
+ if (!imdb) return null;
86
+
87
+ let html: string;
88
+ try {
89
+ const r = await fetch(`https://api.insertunit.ws/embed/imdb/${imdb}`, {
90
+ headers: HEADERS,
91
+ cache: "no-store", // fresh tokens each resolve
92
+ });
93
+ if (!r.ok) return null;
94
+ html = await r.text();
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ return isTv
100
+ ? this.parseTv(html, season, episode)
101
+ : this.parseMovie(html);
102
+ }
103
+
104
+ private parseMovie(html: string): InsertunitResult | null {
105
+ const m = html.match(/source:\s*({[\s\S]*?})/);
106
+ if (!m) return null;
107
+ const src = m[1];
108
+ const tok = this.extractDynamicToken(html);
109
+
110
+ const dash = src.match(/dash:\s*"(.*?)"/);
111
+ const dasha = src.match(/dasha:\s*"(.*?)"/);
112
+ const hls = src.match(/hls:\s*"(.*?)"/);
113
+ const audio = src.match(/audio:\s*({[\s\S]*?})/);
114
+ const cc = src.match(/cc:\s*(\[[\s\S]*?\])/);
115
+
116
+ return {
117
+ dash: dash ? dash[1] + tok : null,
118
+ dasha: dasha ? dasha[1] + tok : null,
119
+ hls: hls ? hls[1] + tok : null,
120
+ audio: audio ? safeJsonLoad(audio[1]) : null,
121
+ cc: cc ? safeJsonLoad(cc[1]) : null,
122
+ };
123
+ }
124
+
125
+ private parseTv(
126
+ html: string,
127
+ season: number,
128
+ episode: number
129
+ ): InsertunitResult | null {
130
+ // Isolate the season block to avoid cross-season episode mismatches.
131
+ const seasonRe = new RegExp(
132
+ `"season":\\s*${season}\\s*,\\s*"blocked"[\\s\\S]*?(?="season":\\s*\\d+\\s*,|$)`
133
+ );
134
+ const sm = html.match(seasonRe);
135
+ if (!sm) return null;
136
+ const seasonHtml = sm[0];
137
+
138
+ const epRe = new RegExp(
139
+ `"episode":\\s*"${episode}"[\\s\\S]*?(?="episode":\\s*"\\d+"|$)`
140
+ );
141
+ const em = seasonHtml.match(epRe);
142
+ if (!em) return null;
143
+ const eh = em[0];
144
+ const tok = this.extractDynamicToken(html);
145
+
146
+ const dash = eh.match(/"dash":\s*"(.*?)"/);
147
+ const dasha = eh.match(/"dasha":\s*"(.*?)"/);
148
+ const hls = eh.match(/"hls":\s*"(.*?)"/);
149
+ const audio = eh.match(/"audio":\s*({[\s\S]*?})/);
150
+ const cc = eh.match(/"cc":\s*(\[[\s\S]*?\])/);
151
+
152
+ return {
153
+ dash: dash ? dash[1] + tok : null,
154
+ dasha: dasha ? dasha[1] + tok : null,
155
+ hls: hls ? hls[1] + tok : null,
156
+ audio: audio ? safeJsonLoad(audio[1]) : null,
157
+ cc: cc ? safeJsonLoad(cc[1]) : null,
158
+ };
159
+ }
160
+ }
161
+
162
+ import type { SourceModule } from "./types";
163
+
164
+ // Best-effort language code from an Insertunit cc track name.
165
+ function guessLang(name: string): string {
166
+ const n = (name || "").toLowerCase();
167
+ if (n.includes("eng") || n.includes("english")) return "en";
168
+ if (n.includes("рус") || n.includes("rus")) return "ru";
169
+ if (n.includes("укр") || n.includes("ukr")) return "uk";
170
+ if (n.includes("span") || n.includes("esp")) return "es";
171
+ if (n.includes("fr")) return "fr";
172
+ if (n.includes("de") || n.includes("ger")) return "de";
173
+ return "";
174
+ }
175
+
176
+ const _insertunit = new InsertunitScraper();
177
+
178
+ // interkh streams need the insertunit Referer, so both streams and cc subs are
179
+ // proxied. Prefer DASH (its <BaseURL>[0] is a real CDN host; the HLS variant
180
+ // uses an "x-bc" placeholder host that 410s).
181
+ export const insertunit: SourceModule = {
182
+ id: "insertunit",
183
+ name: "Ari",
184
+ label: "Multi-audio · built-in subs",
185
+ active: true,
186
+ rank: 2,
187
+ async fetch(ctx) {
188
+ const r = await _insertunit
189
+ .fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
190
+ .catch(() => null);
191
+ if (!r) return null;
192
+ const ref = INSERTUNIT_REFERER;
193
+ const streams = [];
194
+ if (r.dash) {
195
+ streams.push({
196
+ file: ctx.proxyStream(r.dash, ref),
197
+ label: "auto",
198
+ type: "dash" as const,
199
+ });
200
+ } else if (r.hls) {
201
+ streams.push({
202
+ file: ctx.proxyStream(r.hls, ref),
203
+ label: "auto",
204
+ type: "hls" as const,
205
+ });
206
+ }
207
+ if (!streams.length) return null;
208
+ const subtitles = (r.cc || []).map((c) => ({
209
+ url: ctx.proxySub(c.url, ref),
210
+ display: c.name,
211
+ language: guessLang(c.name),
212
+ source: "Ari",
213
+ }));
214
+ return { streams, subtitles };
215
+ },
216
+ };
lib/scraper/oneroom.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SourceModule } from "./types";
2
+
3
+ // Server-only source resolver (MovieBox / aoneroom backend). The real stream
4
+ // URLs are hotlink-locked to a themoviebox.org referer, so they're routed
5
+ // through this app's own /api/stream proxy (ctx.proxyStream) — everything flows
6
+ // through your VPS, no third-party proxy or worker.
7
+ const MAIN_URL = "https://themoviebox.org";
8
+ const API_BASE = "https://h5-api.aoneroom.com";
9
+ const REFERER = "https://themoviebox.org/";
10
+ const TMDB_KEY = process.env.TMDB_API_KEY || "";
11
+
12
+ let bearerToken: string | null = null;
13
+
14
+ function clientTimeToken(): string {
15
+ const crypto = require("crypto") as typeof import("crypto");
16
+ const ts = Math.floor(Date.now() / 1000);
17
+ const rev = ts.toString().split("").reverse().join("");
18
+ const md5 = crypto.createHash("md5").update(rev).digest("hex");
19
+ return `${ts},${md5}`;
20
+ }
21
+
22
+ function baseHeaders() {
23
+ return {
24
+ Accept: "application/json",
25
+ "User-Agent":
26
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
27
+ "X-Client-Info": JSON.stringify({ timezone: "Asia/Jakarta" }),
28
+ "Content-Type": "application/json",
29
+ };
30
+ }
31
+
32
+ async function getBearerToken(): Promise<string> {
33
+ if (bearerToken) return bearerToken;
34
+ try {
35
+ const resp = await fetch(
36
+ `${API_BASE}/wefeed-h5api-bff/home?host=themoviebox.org`,
37
+ {
38
+ headers: {
39
+ ...baseHeaders(),
40
+ Authorization: "",
41
+ "X-Request-Lang": "en",
42
+ "X-Client-Token": clientTimeToken(),
43
+ Referer: `${MAIN_URL}/`,
44
+ },
45
+ }
46
+ );
47
+ const xUser = resp.headers.get("x-user");
48
+ if (xUser) {
49
+ const userData = JSON.parse(xUser);
50
+ if (userData.token) {
51
+ bearerToken = userData.token;
52
+ return bearerToken!;
53
+ }
54
+ }
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ return "";
59
+ }
60
+
61
+ async function fetchSources(tmdbId: number, season = 0, episode = 0) {
62
+ const isTv = season > 0;
63
+ const mediaType = isTv ? "tv" : "movie";
64
+ const tmdbResp = await fetch(
65
+ `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${TMDB_KEY}`
66
+ );
67
+ const tmdbData = await tmdbResp.json();
68
+ const title = tmdbData.title || tmdbData.name;
69
+ if (!title) return null;
70
+
71
+ const token = await getBearerToken();
72
+ const searchResp = await fetch(
73
+ `${API_BASE}/wefeed-h5api-bff/subject/search`,
74
+ {
75
+ method: "POST",
76
+ headers: {
77
+ ...baseHeaders(),
78
+ Authorization: token ? `Bearer ${token}` : "",
79
+ "X-Request-Lang": "en",
80
+ "X-Client-Token": clientTimeToken(),
81
+ Referer: `${MAIN_URL}/`,
82
+ },
83
+ body: JSON.stringify({ keyword: title, page: 1, perPage: 28, subjectType: 0 }),
84
+ }
85
+ );
86
+ const searchData = await searchResp.json();
87
+ const items = searchData?.data?.items || [];
88
+
89
+ const targetType = isTv ? [2, 3] : [1];
90
+ const match = items.find(
91
+ (item: any) =>
92
+ targetType.includes(parseInt(item.subjectType)) &&
93
+ item.title.toLowerCase().includes(title.toLowerCase())
94
+ );
95
+ if (!match) return null;
96
+
97
+ const detailPath = match.detailPath;
98
+ const playResp = await fetch(
99
+ `${API_BASE}/wefeed-h5api-bff/subject/play?subjectId=${match.subjectId}&se=${season}&ep=${episode}&detailPath=${detailPath}`,
100
+ {
101
+ headers: {
102
+ ...baseHeaders(),
103
+ "X-Request-Lang": "en",
104
+ "X-Client-Token": clientTimeToken(),
105
+ Referer: `${MAIN_URL}/movies/${detailPath}`,
106
+ },
107
+ }
108
+ );
109
+ const playData = await playResp.json();
110
+ if (!playData?.data?.hasResource) return null;
111
+
112
+ const streams = [
113
+ ...(playData.data.streams || []),
114
+ ...(playData.data.hls || []),
115
+ ...(playData.data.dash || []),
116
+ ];
117
+
118
+ return streams
119
+ .filter((s: any) => s.url?.startsWith("http"))
120
+ .map((item: any) => {
121
+ const res = item.resolutions || "720";
122
+ const url: string = item.url;
123
+ return {
124
+ url, // raw upstream URL — proxied through /api/stream below
125
+ label: res.toString().replace("p", ""),
126
+ type: url.includes(".m3u8") ? "hls" : url.includes(".mpd") ? "dash" : "mp4",
127
+ };
128
+ })
129
+ .sort((a: any, b: any) => parseInt(b.label) - parseInt(a.label));
130
+ }
131
+
132
+ export const oneroom: SourceModule = {
133
+ id: "oneroom",
134
+ name: "Sunny",
135
+ label: "Multi-quality",
136
+ active: true,
137
+ rank: 1,
138
+ async fetch(ctx) {
139
+ const raw = await fetchSources(ctx.tmdbId, ctx.season, ctx.episode).catch(
140
+ () => null
141
+ );
142
+ if (!raw || !raw.length) return null;
143
+ // Route every stream through the VPS proxy with the required referer.
144
+ const streams = raw.map((s: any) => ({
145
+ file: ctx.proxyStream(s.url, REFERER),
146
+ label: s.label,
147
+ type: s.type,
148
+ }));
149
+ return { streams };
150
+ },
151
+ };
lib/scraper/types.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Standard contract every source in this folder implements. Drop a new file
2
+ // that exports a `SourceModule`, register it in ./index.ts, and it shows up in
3
+ // the player automatically — no route or UI changes needed.
4
+
5
+ export interface SourceStream {
6
+ file: string;
7
+ /** Quality label, e.g. "1080", "720", or "auto". */
8
+ label: string;
9
+ type: "hls" | "dash" | "mp4";
10
+ }
11
+
12
+ export interface SourceSubtitle {
13
+ url: string;
14
+ display: string;
15
+ language: string;
16
+ hi?: boolean;
17
+ source?: string;
18
+ }
19
+
20
+ export interface SourceResult {
21
+ streams: SourceStream[];
22
+ /** The source's OWN built-in subtitles. Wyzie is added as a default on top
23
+ * by the route, so most sources can leave this empty. */
24
+ subtitles?: SourceSubtitle[];
25
+ }
26
+
27
+ export interface SourceContext {
28
+ tmdbId: number;
29
+ /** 0 for movies. */
30
+ season: number;
31
+ /** 0 for movies. */
32
+ episode: number;
33
+ /** This deployment's origin, e.g. "https://host" — for sources that need to
34
+ * point at their own custom proxy route. */
35
+ origin: string;
36
+ /** Wrap an upstream stream URL so it's fetched via our /api/stream proxy
37
+ * (use when the CDN needs a Referer or hides behind hotlink protection). */
38
+ proxyStream(url: string, referer?: string): string;
39
+ /** Wrap an upstream subtitle URL so it's served WebVTT via our /api/sub proxy. */
40
+ proxySub(url: string, referer?: string): string;
41
+ }
42
+
43
+ // A sub-provider inside a parent source (e.g. one of VidNest's upstreams).
44
+ export interface SubSource {
45
+ /** Stable id within the parent, e.g. "klikxxi". */
46
+ id: string;
47
+ name: string;
48
+ fetch(ctx: SourceContext): Promise<SourceResult | null>;
49
+ }
50
+
51
+ export interface SourceModule {
52
+ /** Stable id used in URLs + persistence, e.g. "oneroom". */
53
+ id: string;
54
+ /** Display name shown in the player, e.g. "MovieBox". */
55
+ name: string;
56
+ /** Short line under the name in the server list. */
57
+ label: string;
58
+ /** Flip to false to hide a source without deleting the file. */
59
+ active: boolean;
60
+ /** Lower = listed first and tried first during failover. Defaults to 100. */
61
+ rank?: number;
62
+ /** Resolve playable streams (+ optional built-in subs) for a title. For a
63
+ * parent source this aggregates/tries its children. */
64
+ fetch(ctx: SourceContext): Promise<SourceResult | null>;
65
+ /** Present on "parent" sources — selectable sub-providers. The parent's own
66
+ * fetch tries them all; children can also be picked individually. */
67
+ children?: SubSource[];
68
+ }
69
+
70
+ // Lightweight metadata sent to the client to build the server list.
71
+ export interface SourceMeta {
72
+ id: string;
73
+ name: string;
74
+ label: string;
75
+ children?: { id: string; name: string }[];
76
+ }
lib/scraper/vidnest.ts ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SourceModule, SubSource, SourceContext, SourceStream, SourceSubtitle } from "./types";
2
+
3
+ // Server-only VidNest resolver (vidnest.fun). VidNest is a *parent* source: it
4
+ // proxies ~11 upstream providers, each with its own JSON schema. Each provider
5
+ // is exposed as a sub-source; the parent tries them all (concurrently) and
6
+ // aggregates whatever resolves.
7
+
8
+ const BASE_URL = "https://vidnest.fun";
9
+ const API_BASE = "https://new.vidnest.fun";
10
+
11
+ const HEADERS: Record<string, string> = {
12
+ "User-Agent":
13
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/150 Safari/537.36",
14
+ Accept: "application/json, text/javascript, */*; q=0.01",
15
+ "Accept-Language": "en-US,en;q=0.9",
16
+ Referer: `${BASE_URL}/`,
17
+ Origin: BASE_URL,
18
+ };
19
+
20
+ // VidNest scrambles its base64 with a custom alphabet.
21
+ const VIDNEST_ALPHABET =
22
+ "RB0fpH8ZEyVLkv7c2i6MAJ5u3IKFDxlS1NTsnGaqmXYdUrtzjwObCgQP94hoeW+/";
23
+ const STANDARD_ALPHABET =
24
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
25
+ const TRANSLATE = new Map<string, string>();
26
+ for (let i = 0; i < VIDNEST_ALPHABET.length; i++) {
27
+ TRANSLATE.set(VIDNEST_ALPHABET[i], STANDARD_ALPHABET[i]);
28
+ }
29
+
30
+ interface ServerDef {
31
+ path: string;
32
+ name: string;
33
+ query: string;
34
+ audio?: string;
35
+ }
36
+
37
+ // Upstream providers wrapped by VidNest.
38
+ const SERVERS: ServerDef[] = [
39
+ { path: "moviebox", name: "MovieBox", query: "" },
40
+ { path: "allmovies", name: "AllMovies", query: "" },
41
+ { path: "catflix", name: "CatFlix", query: "" },
42
+ { path: "purstream", name: "PurStream", query: "", audio: "French" },
43
+ { path: "hollymoviehd", name: "HollyMovieHD", query: "" },
44
+ { path: "lamda", name: "Lamda", query: "" },
45
+ { path: "flixhq", name: "FlixHQ", query: "" },
46
+ { path: "vidlink", name: "VidLink", query: "" },
47
+ { path: "onehd", name: "OneHD", query: "?server=upcloud" },
48
+ { path: "klikxxi", name: "KlikXXI", query: "" },
49
+ { path: "delta", name: "Delta", query: "" },
50
+ ];
51
+
52
+ function decodePayload(payload: string): any {
53
+ let p = payload;
54
+ const pad = p.length % 4;
55
+ if (pad) p += "=".repeat(4 - pad);
56
+ let std = "";
57
+ for (const ch of p) std += TRANSLATE.get(ch) ?? ch;
58
+ const json = Buffer.from(std, "base64").toString("utf-8");
59
+ return JSON.parse(json);
60
+ }
61
+
62
+ // Wrap a raw stream URL in VidNest's omss proxy (carries the required headers).
63
+ function proxyUrl(url: string): string {
64
+ if (!url) return url;
65
+ const data = encodeURIComponent(
66
+ JSON.stringify({ url, headers: HEADERS })
67
+ );
68
+ return `https://omss.fstream.app/v1/proxy?data=${data}`;
69
+ }
70
+
71
+ function inferType(typeStr: string, url: string): SourceStream["type"] {
72
+ const t = String(typeStr || "").toLowerCase();
73
+ const u = (url || "").toLowerCase();
74
+ if (t === "hls" || u.includes(".m3u8")) return "hls";
75
+ if (t === "dash" || u.includes(".mpd")) return "dash";
76
+ if (u.includes(".mp4")) return "mp4";
77
+ return "hls";
78
+ }
79
+
80
+ function inferSubFmt(url: string): string {
81
+ const u = (url || "").toLowerCase();
82
+ if (u.includes(".srt")) return "srt";
83
+ if (u.includes(".ass")) return "ass";
84
+ return "vtt";
85
+ }
86
+
87
+ interface RawSource {
88
+ url: string;
89
+ type: string;
90
+ quality: string;
91
+ audio: string;
92
+ }
93
+
94
+ // Map a decrypted provider payload to a normalized {sources, subtitles} using
95
+ // that provider's specific schema (ported from the reference scraper).
96
+ function mapServer(path: string, data: any): {
97
+ sources: RawSource[];
98
+ subtitles: { url: string; lang: string }[];
99
+ } {
100
+ const sources: RawSource[] = [];
101
+ const subtitles: { url: string; lang: string }[] = [];
102
+ try {
103
+ if (path === "klikxxi") {
104
+ for (const s of data.sources || [])
105
+ sources.push({ url: s.url, type: inferType(s.type, s.url), quality: s.quality || "Auto", audio: "English" });
106
+ } else if (path === "allmovies" || path === "delta") {
107
+ for (const s of data.streams || [])
108
+ sources.push({ url: s.url, type: inferType(s.type, s.url), quality: "Auto", audio: s.language || "Unknown" });
109
+ } else if (path === "onehd") {
110
+ if (data.url) sources.push({ url: data.url, type: inferType("", data.url), quality: "Auto", audio: "English" });
111
+ for (const sub of data.subtitles || []) subtitles.push({ url: sub.url, lang: sub.lang });
112
+ } else if (path === "hollymoviehd") {
113
+ for (const s of data.sources || [])
114
+ sources.push({ url: s.file, type: inferType(s.type, s.file), quality: s.label || "Auto", audio: "English" });
115
+ } else if (path === "vidlink") {
116
+ const stream = data?.data?.stream || {};
117
+ if (stream.playlist)
118
+ sources.push({ url: stream.playlist, type: inferType(stream.type, stream.playlist), quality: "Auto", audio: "English" });
119
+ for (const cap of stream.captions || []) subtitles.push({ url: cap.url, lang: cap.language });
120
+ } else if (path === "purstream") {
121
+ for (const s of data.sources || [])
122
+ sources.push({ url: s.url, type: inferType(s.format, s.url), quality: s.name || "Auto", audio: "French" });
123
+ } else if (path === "moviebox") {
124
+ for (const u of data.url || [])
125
+ sources.push({ url: u.link, type: inferType(u.type, u.link), quality: "Auto", audio: u.lang || "Unknown" });
126
+ } else if (path === "catflix" || path === "lamda" || path === "flixhq") {
127
+ // Generic shapes seen across the simpler providers.
128
+ const list = data.sources || data.streams || [];
129
+ for (const s of list)
130
+ sources.push({
131
+ url: s.url || s.file || s.link,
132
+ type: inferType(s.type || s.format, s.url || s.file || s.link || ""),
133
+ quality: s.quality || s.label || s.name || "Auto",
134
+ audio: s.language || s.lang || "Unknown",
135
+ });
136
+ }
137
+ } catch {
138
+ /* unexpected payload shape — skip */
139
+ }
140
+ return { sources: sources.filter((s) => s.url), subtitles: subtitles.filter((s) => s.url) };
141
+ }
142
+
143
+ async function fetchProvider(
144
+ def: ServerDef,
145
+ ctx: SourceContext
146
+ ): Promise<{ sources: RawSource[]; subtitles: { url: string; lang: string }[] }> {
147
+ const isMovie = !ctx.season || !ctx.episode;
148
+ const path = isMovie
149
+ ? `${API_BASE}/${def.path}/movie/${ctx.tmdbId}${def.query}`
150
+ : `${API_BASE}/${def.path}/tv/${ctx.tmdbId}/${ctx.season}/${ctx.episode}${def.query}`;
151
+ try {
152
+ const res = await fetch(path, {
153
+ headers: HEADERS,
154
+ cache: "no-store",
155
+ signal: AbortSignal.timeout(8000),
156
+ });
157
+ if (!res.ok) return { sources: [], subtitles: [] };
158
+ const json = await res.json();
159
+ if (!json?.data) return { sources: [], subtitles: [] };
160
+ return mapServer(def.path, decodePayload(json.data));
161
+ } catch {
162
+ return { sources: [], subtitles: [] };
163
+ }
164
+ }
165
+
166
+ function toResult(
167
+ raw: { sources: RawSource[]; subtitles: { url: string; lang: string }[] },
168
+ label: (s: RawSource, i: number) => string,
169
+ serverName: string
170
+ ): { streams: SourceStream[]; subtitles: SourceSubtitle[] } {
171
+ const streams: SourceStream[] = raw.sources.map((s, i) => ({
172
+ file: proxyUrl(s.url),
173
+ label: label(s, i),
174
+ type: s.type as SourceStream["type"],
175
+ }));
176
+ const subtitles: SourceSubtitle[] = raw.subtitles.map((s) => ({
177
+ url: proxyUrl(s.url),
178
+ display: s.lang || "Subtitle",
179
+ language: (s.lang || "").slice(0, 2).toLowerCase(),
180
+ source: serverName,
181
+ }));
182
+ return { streams, subtitles };
183
+ }
184
+
185
+ // A meaningful per-stream label for a single provider's result.
186
+ function childLabel(s: RawSource, i: number): string {
187
+ if (/\d{3,4}/.test(s.quality)) return s.quality.replace(/p$/i, "");
188
+ if (s.audio && s.audio !== "Unknown") return s.audio;
189
+ return s.quality && s.quality !== "Auto" ? s.quality : `Source ${i + 1}`;
190
+ }
191
+
192
+ // Each provider becomes a selectable sub-source.
193
+ const children: SubSource[] = SERVERS.map((def) => ({
194
+ id: def.path,
195
+ name: def.name,
196
+ async fetch(ctx) {
197
+ const raw = await fetchProvider(def, ctx);
198
+ if (!raw.sources.length) return null;
199
+ return toResult(raw, childLabel, def.name);
200
+ },
201
+ }));
202
+
203
+ export const vidnest: SourceModule = {
204
+ id: "vidnest",
205
+ name: "Makena",
206
+ label: "11 providers · auto-pick",
207
+ active: true,
208
+ rank: 4,
209
+ children,
210
+ // Parent: query every provider concurrently and aggregate. Each stream is
211
+ // labelled by its provider so the quality menu doubles as a source picker.
212
+ async fetch(ctx) {
213
+ const results = await Promise.all(
214
+ SERVERS.map(async (def) => {
215
+ const raw = await fetchProvider(def, ctx);
216
+ return raw.sources.length ? { def, raw } : null;
217
+ })
218
+ );
219
+ const streams: SourceStream[] = [];
220
+ const subtitles: SourceSubtitle[] = [];
221
+ for (const r of results) {
222
+ if (!r) continue;
223
+ const mapped = toResult(r.raw, () => r.def.name, r.def.name);
224
+ streams.push(...mapped.streams);
225
+ subtitles.push(...mapped.subtitles);
226
+ }
227
+ if (!streams.length) return null;
228
+ return { streams, subtitles };
229
+ },
230
+ };
lib/scraper/vidrock.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "crypto";
2
+ import type { SourceModule, SourceStream, SourceSubtitle } from "./types";
3
+
4
+ // Server-only VidRock resolver (vidrock.net). The item id is AES-CBC encrypted
5
+ // (key + IV derived from a fixed passphrase) to build the API path; streams are
6
+ // then wrapped in VidRock's omss proxy. Some entries point at a CDN-selector
7
+ // (hls2.vdrk.site) that has to be expanded into per-resolution streams.
8
+
9
+ const BASE_URL = "https://vidrock.net/";
10
+ const SUB_BASE = "https://sub.vdrk.site";
11
+ const PROXY_PREFIX = "https://proxy.vidrock.store/";
12
+ const PASSPHRASE = "x7k9mPqT2rWvY8zA5bC3nF6hJ2lK4mN9"; // 32 bytes → AES-256
13
+
14
+ const HEADERS: Record<string, string> = {
15
+ "User-Agent":
16
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150 Safari/537.36",
17
+ Accept: "application/json, text/javascript, */*; q=0.01",
18
+ "Accept-Language": "en-US,en;q=0.9",
19
+ Referer: BASE_URL,
20
+ Origin: BASE_URL.replace(/\/$/, ""),
21
+ };
22
+
23
+ function encryptItemId(itemId: string): string {
24
+ const key = Buffer.from(PASSPHRASE, "utf-8");
25
+ const iv = key.subarray(0, 16);
26
+ const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); // PKCS7 default
27
+ const enc = Buffer.concat([cipher.update(itemId, "utf-8"), cipher.final()]);
28
+ return enc.toString("base64url"); // url-safe, no padding
29
+ }
30
+
31
+ // VidRock plays through its own omss proxy (carries the required headers).
32
+ function proxyUrl(url: string): string {
33
+ if (!url) return url;
34
+ const data = encodeURIComponent(JSON.stringify({ url, headers: HEADERS }));
35
+ return `https://omss.fstream.app/v1/proxy?data=${data}`;
36
+ }
37
+
38
+ interface RawSource {
39
+ url: string;
40
+ type: SourceStream["type"];
41
+ quality: string;
42
+ audio: string;
43
+ }
44
+
45
+ async function resolve(
46
+ tmdbId: number,
47
+ season: number,
48
+ episode: number
49
+ ): Promise<{ sources: RawSource[]; subs: { file: string; label: string }[] }> {
50
+ const isMovie = !season || !episode;
51
+ const itemId = isMovie ? `${tmdbId}` : `${tmdbId}_${season}_${episode}`;
52
+ const mediaType = isMovie ? "movie" : "tv";
53
+ const apiUrl = `${BASE_URL}api/${mediaType}/${encryptItemId(itemId)}`;
54
+
55
+ const sources: RawSource[] = [];
56
+ const subs = await fetchSubs(tmdbId, season, episode);
57
+
58
+ let data: any;
59
+ try {
60
+ const res = await fetch(apiUrl, {
61
+ headers: HEADERS,
62
+ cache: "no-store",
63
+ signal: AbortSignal.timeout(10000),
64
+ });
65
+ if (!res.ok) return { sources, subs };
66
+ data = await res.json();
67
+ } catch {
68
+ return { sources, subs };
69
+ }
70
+
71
+ for (const stream of Object.values<any>(data || {})) {
72
+ if (!stream || typeof stream !== "object") continue;
73
+ const streamUrl = stream.url;
74
+ if (!streamUrl) continue;
75
+ const audio = stream.language === "English" ? "eng" : stream.language || "Unknown";
76
+
77
+ if (String(streamUrl).includes("hls2.vdrk.site")) {
78
+ // CDN selector — expand into one stream per resolution.
79
+ try {
80
+ const cdnRes = await fetch(streamUrl, {
81
+ headers: HEADERS,
82
+ cache: "no-store",
83
+ signal: AbortSignal.timeout(8000),
84
+ });
85
+ if (!cdnRes.ok) continue;
86
+ const cdnData = await cdnRes.json();
87
+ for (const obj of cdnData || []) {
88
+ let finalUrl: string = obj.url || "";
89
+ if (finalUrl.startsWith(PROXY_PREFIX)) {
90
+ finalUrl = decodeURIComponent(
91
+ finalUrl.slice(PROXY_PREFIX.length).replace(/^\/+/, "")
92
+ );
93
+ }
94
+ if (!finalUrl) continue;
95
+ sources.push({
96
+ url: finalUrl,
97
+ type: finalUrl.toLowerCase().includes(".mp4") ? "mp4" : "hls",
98
+ quality: `${obj.resolution || "Unknown"}`,
99
+ audio,
100
+ });
101
+ }
102
+ } catch {
103
+ continue;
104
+ }
105
+ } else {
106
+ sources.push({ url: streamUrl, type: "hls", quality: "1080", audio });
107
+ }
108
+ }
109
+
110
+ return { sources, subs };
111
+ }
112
+
113
+ async function fetchSubs(
114
+ tmdbId: number,
115
+ season: number,
116
+ episode: number
117
+ ): Promise<{ file: string; label: string }[]> {
118
+ const isMovie = !season || !episode;
119
+ const url = isMovie
120
+ ? `${SUB_BASE}/v2/movie/${tmdbId}`
121
+ : `${SUB_BASE}/v2/tv/${tmdbId}/${season}/${episode}`;
122
+ try {
123
+ const res = await fetch(url, {
124
+ headers: HEADERS,
125
+ cache: "no-store",
126
+ signal: AbortSignal.timeout(8000),
127
+ });
128
+ if (!res.ok) return [];
129
+ const data = await res.json();
130
+ return (Array.isArray(data) ? data : [])
131
+ .filter((s: any) => s.file && s.label)
132
+ .map((s: any) => ({ file: s.file, label: s.label }));
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ function langCode(label: string): string {
139
+ const map: Record<string, string> = {
140
+ english: "en", arabic: "ar", bengali: "bn", filipino: "tl", french: "fr",
141
+ indonesian: "id", malay: "ms", portuguese: "pt", russian: "ru", spanish: "es",
142
+ german: "de", italian: "it", turkish: "tr", hindi: "hi", korean: "ko",
143
+ japanese: "ja", chinese: "zh", dutch: "nl", polish: "pl", thai: "th",
144
+ vietnamese: "vi",
145
+ };
146
+ return map[label.toLowerCase()] || label.slice(0, 2).toLowerCase();
147
+ }
148
+
149
+ export const vidrock: SourceModule = {
150
+ id: "vidrock",
151
+ name: "Kai",
152
+ label: "Multi-CDN · built-in subs",
153
+ active: true,
154
+ rank: 7,
155
+ async fetch(ctx) {
156
+ const { sources, subs } = await resolve(
157
+ ctx.tmdbId,
158
+ ctx.season,
159
+ ctx.episode
160
+ );
161
+ if (!sources.length) return null;
162
+ const streams: SourceStream[] = sources.map((s) => ({
163
+ file: proxyUrl(s.url),
164
+ label: /^\d{3,4}$/.test(s.quality) ? s.quality : "auto",
165
+ type: s.type,
166
+ }));
167
+ // De-dupe by resolution, keep highest first.
168
+ streams.sort(
169
+ (a, b) => (parseInt(b.label, 10) || 0) - (parseInt(a.label, 10) || 0)
170
+ );
171
+ const subtitles: SourceSubtitle[] = subs.map((s) => ({
172
+ url: ctx.proxySub(s.file),
173
+ display: s.label,
174
+ language: langCode(s.label),
175
+ source: "Kai",
176
+ }));
177
+ return { streams, subtitles };
178
+ },
179
+ };
lib/scraper/vixsrc.ts ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SourceModule, SourceStream } from "./types";
2
+
3
+ // Server-only VixSrc resolver (vixsrc.to). Hits the JSON API for a TMDB title to
4
+ // get the embed iframe path, scrapes a short-lived token/expires/playlist out of
5
+ // the embed HTML, then builds the signed master playlist URL. The master playlist
6
+ // is parsed for the best resolution and its CDN needs a Referer, so it's wrapped
7
+ // in our /api/stream proxy for playback.
8
+
9
+ const BASE_URL = "https://vixsrc.to";
10
+
11
+ const HEADERS: Record<string, string> = {
12
+ "User-Agent":
13
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150 Safari/537.36",
14
+ Accept: "application/json, text/javascript, */*; q=0.01",
15
+ "Accept-Language": "en-US,en;q=0.9",
16
+ Referer: BASE_URL,
17
+ Origin: BASE_URL,
18
+ };
19
+
20
+ interface TokenData {
21
+ token: string;
22
+ expires: string;
23
+ playlist: string;
24
+ }
25
+
26
+ // JS Date.now() works in ms; VixSrc's `expires` is a unix-seconds string.
27
+ function isTokenExpired(expires: string): boolean {
28
+ const expiration = parseInt(expires, 10);
29
+ if (!Number.isFinite(expiration)) return true;
30
+ return expiration * 1000 - 60000 < Date.now();
31
+ }
32
+
33
+ function extractTokenData(html: string): TokenData | null {
34
+ const token = html.match(/token["']\s*:\s*["']([^"']+)/)?.[1];
35
+ const expires = html.match(/expires["']\s*:\s*["']([^"']+)/)?.[1];
36
+ const playlist = html.match(/url\s*:\s*["']([^"']+)/)?.[1];
37
+
38
+ if (!token || !expires || !playlist) return null;
39
+ if (isTokenExpired(expires)) return null;
40
+
41
+ return { token, expires, playlist };
42
+ }
43
+
44
+ // Pull the highest RESOLUTION=NxH variant out of the HLS master manifest.
45
+ function bestResolution(manifest: string): number | null {
46
+ let best: number | null = null;
47
+ const re = /#EXT-X-STREAM-INF:[^\n]*RESOLUTION=\d+x(\d+)/g;
48
+ let m: RegExpExecArray | null;
49
+ while ((m = re.exec(manifest))) {
50
+ const h = parseInt(m[1], 10);
51
+ if (Number.isFinite(h) && (best === null || h > best)) best = h;
52
+ }
53
+ return best;
54
+ }
55
+
56
+ async function resolveMaster(
57
+ tmdbId: number,
58
+ season: number,
59
+ episode: number
60
+ ): Promise<{ masterUrl: string; referer: string; height: number | null } | null> {
61
+ const isMovie = !season || !episode;
62
+ const apiUrl = isMovie
63
+ ? `${BASE_URL}/api/movie/${tmdbId}`
64
+ : `${BASE_URL}/api/tv/${tmdbId}/${season}/${episode}`;
65
+
66
+ const get = (url: string, headers: Record<string, string> = HEADERS) =>
67
+ fetch(url, {
68
+ headers,
69
+ cache: "no-store",
70
+ signal: AbortSignal.timeout(8000),
71
+ });
72
+
73
+ // 1. API → embed iframe path.
74
+ const apiRes = await get(apiUrl);
75
+ if (!apiRes.ok) return null;
76
+ const apiData = await apiRes.json().catch(() => null);
77
+ const sublink: string | undefined = apiData?.src;
78
+ if (!sublink) return null;
79
+
80
+ // 2. Embed HTML → token/expires/playlist.
81
+ const embedUrl = BASE_URL + sublink;
82
+ const htmlRes = await get(embedUrl);
83
+ if (!htmlRes.ok) return null;
84
+ const tokenData = extractTokenData(await htmlRes.text());
85
+ if (!tokenData) return null;
86
+
87
+ // 3. Build the signed master playlist URL.
88
+ const sep = tokenData.playlist.includes("?") ? "&" : "?";
89
+ const masterUrl = `${tokenData.playlist}${sep}token=${tokenData.token}&expires=${tokenData.expires}&h=1`;
90
+
91
+ // 4. Fetch the master to confirm it resolves + pick the best resolution.
92
+ const playlistRes = await get(masterUrl, { ...HEADERS, Referer: apiUrl });
93
+ if (!playlistRes.ok) return null;
94
+ const height = bestResolution(await playlistRes.text());
95
+ if (height === null) return null;
96
+
97
+ return { masterUrl, referer: apiUrl, height };
98
+ }
99
+
100
+ // VixSrc's CDN requires the Referer used to mint the playlist, so playback goes
101
+ // through our /api/stream proxy. Subtitles are broken upstream, so we lean on the
102
+ // route's default Wyzie/OpenSubtitles layer instead.
103
+ export const vixsrc: SourceModule = {
104
+ id: "vixsrc",
105
+ name: "Noor",
106
+ label: "HLS · proxied",
107
+ active: true,
108
+ rank: 8,
109
+ async fetch(ctx) {
110
+ const resolved = await resolveMaster(
111
+ ctx.tmdbId,
112
+ ctx.season,
113
+ ctx.episode
114
+ ).catch(() => null);
115
+ if (!resolved) return null;
116
+
117
+ const stream: SourceStream = {
118
+ file: ctx.proxyStream(resolved.masterUrl, resolved.referer),
119
+ label: resolved.height ? String(resolved.height) : "auto",
120
+ type: "hls",
121
+ };
122
+ return { streams: [stream] };
123
+ },
124
+ };
lib/security/tokens.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * Server-side signing for the stream/subtitle proxy. Upstream URLs are wrapped
5
+ * in a short-lived HMAC-signed token so /api/stream only ever proxies URLs this
6
+ * server itself minted — it can't be abused as an open proxy. The secret
7
+ * (APP_SECRET) never leaves the server.
8
+ */
9
+ const SECRET =
10
+ process.env.APP_SECRET ||
11
+ "dev-only-insecure-secret-change-me-please-0123456789";
12
+
13
+ function b64url(buf: Buffer): string {
14
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
15
+ }
16
+ function b64urlToBuf(s: string): Buffer {
17
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
18
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
19
+ }
20
+ function hmac(data: string): string {
21
+ return b64url(crypto.createHmac("sha256", SECRET).update(data).digest());
22
+ }
23
+
24
+ export function sign(payload: Record<string, unknown>, ttlSeconds: number): string {
25
+ const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds };
26
+ const p = b64url(Buffer.from(JSON.stringify(body)));
27
+ return `${p}.${hmac(p)}`;
28
+ }
29
+
30
+ export function verify<T = Record<string, any>>(token: string | null): T | null {
31
+ if (!token || token.indexOf(".") < 0) return null;
32
+ const [p, sig] = token.split(".");
33
+ if (!p || !sig) return null;
34
+ const expected = hmac(p);
35
+ if (
36
+ sig.length !== expected.length ||
37
+ !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
38
+ ) {
39
+ return null;
40
+ }
41
+ let body: any;
42
+ try {
43
+ body = JSON.parse(b64urlToBuf(p).toString("utf8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ if (typeof body.exp !== "number" || body.exp < Math.floor(Date.now() / 1000)) {
48
+ return null;
49
+ }
50
+ return body as T;
51
+ }
52
+
53
+ export interface StreamClaim {
54
+ k: "stream";
55
+ url: string;
56
+ ref?: string; // Referer to send upstream (some CDNs hotlink-lock manifests)
57
+ exp: number;
58
+ }
59
+
60
+ /** Wrap a real upstream stream/subtitle URL so it's proxied, not exposed. */
61
+ export function mintStreamToken(url: string, ref?: string): string {
62
+ return sign({ k: "stream", url, ...(ref ? { ref } : {}) }, 60 * 60 * 3); // 3h
63
+ }
64
+
65
+ export interface DashBaseClaim {
66
+ k: "dbase";
67
+ url: string; // the real DASH <BaseURL>, ending in "/"
68
+ ref?: string;
69
+ exp: number;
70
+ }
71
+
72
+ /** Wrap a DASH BaseURL so segment paths can be appended and proxied. */
73
+ export function mintDashBase(base: string, ref?: string): string {
74
+ return sign({ k: "dbase", url: base, ...(ref ? { ref } : {}) }, 60 * 60 * 3);
75
+ }
lib/subtitles/flags.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Maps a subtitle language code (ISO 639-1, optionally with a region suffix
2
+ // like "pt-BR" / "en_US") to a country flag image served by flagsapi.com.
3
+
4
+ const LANG_TO_COUNTRY: Record<string, string> = {
5
+ en: "GB", es: "ES", pt: "PT", fr: "FR", de: "DE", it: "IT", ru: "RU",
6
+ ja: "JP", ko: "KR", zh: "CN", ar: "SA", nl: "NL", sv: "SE", no: "NO",
7
+ nb: "NO", nn: "NO", da: "DK", fi: "FI", pl: "PL", tr: "TR", el: "GR",
8
+ cs: "CZ", hu: "HU", ro: "RO", th: "TH", vi: "VN", id: "ID", he: "IL",
9
+ iw: "IL", hi: "IN", uk: "UA", sr: "RS", hr: "HR", sk: "SK", sl: "SI",
10
+ bg: "BG", is: "IS", et: "EE", lv: "LV", lt: "LT", fa: "IR", ms: "MY",
11
+ tl: "PH", fil: "PH", ca: "ES", eu: "ES", gl: "ES", ga: "IE", cy: "GB",
12
+ sq: "AL", mk: "MK", bs: "BA", be: "BY", ka: "GE", hy: "AM", az: "AZ",
13
+ kk: "KZ", uz: "UZ", mn: "MN", ne: "NP", si: "LK", ta: "IN", te: "IN",
14
+ ml: "IN", kn: "IN", bn: "BD", ur: "PK", pa: "IN", mr: "IN", gu: "IN",
15
+ af: "ZA", sw: "KE", am: "ET", zu: "ZA", la: "VA",
16
+ };
17
+
18
+ export type FlagSize = 16 | 24 | 32 | 48 | 64;
19
+
20
+ export function flagUrl(
21
+ language?: string | null,
22
+ size: FlagSize = 24
23
+ ): string | null {
24
+ if (!language) return null;
25
+ const lc = language.toLowerCase().trim();
26
+ // Explicit region suffix wins, e.g. "pt-br" -> BR, "en_us" -> US.
27
+ const m = lc.match(/[-_]([a-z]{2})$/);
28
+ let cc = m ? m[1].toUpperCase() : undefined;
29
+ if (!cc) cc = LANG_TO_COUNTRY[lc.slice(0, 2)];
30
+ if (!cc) return null;
31
+ return `https://flagsapi.com/${cc}/flat/${size}.png`;
32
+ }
lib/subtitles/opensubtitles.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stremio OpenSubtitles v3 add-on. Keyed by IMDB id, so we first resolve the
2
+ // TMDB id to an IMDB id. Returned as a shared subtitle layer (merged with Wyzie
3
+ // in /api/sources) — these are the "built-in" OpenSubtitles tracks.
4
+
5
+ const TMDB_KEY = process.env.TMDB_API_KEY || "";
6
+
7
+ export interface OsSub {
8
+ url: string;
9
+ display: string;
10
+ language: string; // 2-letter best-effort
11
+ source: string;
12
+ }
13
+
14
+ async function tmdbToImdb(
15
+ tmdbId: number,
16
+ isTv: boolean
17
+ ): Promise<string | null> {
18
+ try {
19
+ const mt = isTv ? "tv" : "movie";
20
+ const r = await fetch(
21
+ `https://api.themoviedb.org/3/${mt}/${tmdbId}/external_ids?api_key=${TMDB_KEY}`,
22
+ { cache: "no-store" }
23
+ );
24
+ if (!r.ok) return null;
25
+ const d = await r.json();
26
+ return d.imdb_id || null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // 3-letter (ISO 639-2) → 2-letter (ISO 639-1), best effort.
33
+ const THREE_TO_TWO: Record<string, string> = {
34
+ eng: "en", spa: "es", spl: "es", por: "pt", pob: "pt", fre: "fr", fra: "fr",
35
+ ger: "de", deu: "de", ita: "it", rus: "ru", ara: "ar", jpn: "ja", kor: "ko",
36
+ zho: "zh", chi: "zh", dut: "nl", nld: "nl", pol: "pl", tur: "tr", swe: "sv",
37
+ nor: "no", dan: "da", fin: "fi", hun: "hu", ron: "ro", rum: "ro", ell: "el",
38
+ ukr: "uk", bul: "bg", hrv: "hr", srp: "sr", slv: "sl", slo: "sk", slk: "sk",
39
+ cze: "cs", ces: "cs", heb: "he", hin: "hi", tha: "th", vie: "vi", ind: "id",
40
+ may: "ms", msa: "ms", fil: "tl", per: "fa", fas: "fa", est: "et", bos: "bs",
41
+ mac: "mk", mkd: "mk", alb: "sq", sqi: "sq", ben: "bn", sin: "si", khm: "km",
42
+ };
43
+
44
+ function langName(code2: string): string {
45
+ try {
46
+ return (
47
+ new Intl.DisplayNames(["en"], { type: "language" }).of(code2) || code2
48
+ );
49
+ } catch {
50
+ return code2;
51
+ }
52
+ }
53
+
54
+ export async function fetchOpenSubtitles(
55
+ tmdbId: number,
56
+ season?: number,
57
+ episode?: number
58
+ ): Promise<{ url: string; display: string; language: string }[]> {
59
+ const isTv = !!(season && episode);
60
+ const imdb = await tmdbToImdb(tmdbId, isTv);
61
+ if (!imdb) return [];
62
+
63
+ const url = isTv
64
+ ? `https://opensubtitles-v3.strem.io/subtitles/series/${imdb}:${season}:${episode}.json`
65
+ : `https://opensubtitles-v3.strem.io/subtitles/movie/${imdb}.json`;
66
+
67
+ try {
68
+ const r = await fetch(url, {
69
+ cache: "no-store",
70
+ signal: AbortSignal.timeout(8000),
71
+ });
72
+ if (!r.ok) return [];
73
+ const data = await r.json();
74
+ const list: any[] = data?.subtitles || [];
75
+ return list
76
+ .filter((s) => s.url && s.lang)
77
+ .map((s) => {
78
+ const two = THREE_TO_TWO[String(s.lang).toLowerCase()] || s.lang;
79
+ return {
80
+ url: s.url as string,
81
+ display: langName(two),
82
+ language: two,
83
+ };
84
+ });
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
lib/subtitles/vdrk.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Server-side subtitle lookup via sub.vdrk.site (keyed by TMDB id). Returns the
2
+ // raw upstream .vtt/.srt URLs; the caller is responsible for proxying them.
3
+ export interface VdrkSub {
4
+ label: string;
5
+ language: string;
6
+ file: string;
7
+ }
8
+
9
+ export async function fetchVdrkSubs(
10
+ type: "movie" | "tv",
11
+ tmdbId: number,
12
+ season = 0,
13
+ episode = 0
14
+ ): Promise<VdrkSub[]> {
15
+ const upstream =
16
+ type === "tv"
17
+ ? `https://sub.vdrk.site/v1/tv/${tmdbId}/${season || 1}/${episode || 1}`
18
+ : `https://sub.vdrk.site/v1/movie/${tmdbId}`;
19
+
20
+ try {
21
+ const res = await fetch(upstream, { cache: "no-store" });
22
+ if (!res.ok) return [];
23
+ const data = await res.json();
24
+ if (!Array.isArray(data)) return [];
25
+ return data
26
+ .filter((s: any) => s && s.file && s.label)
27
+ .map((s: any) => ({
28
+ label: s.label,
29
+ language: guessLang(s.label),
30
+ file: s.file,
31
+ }));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ // Best-effort language code (optionally with a region, e.g. "pt-BR") from a
38
+ // vdrk subtitle label. Region suffixes drive the flag shown in the player.
39
+ function guessLang(label: string): string {
40
+ const n = (label || "").toLowerCase();
41
+ if (n.includes("brazil")) return "pt-BR";
42
+ if (n.includes("english")) return "en";
43
+ if (n.includes("spanish") || n.includes("español") || n.includes("castilian"))
44
+ return "es";
45
+ if (n.includes("latino") || n.includes("latin")) return "es-MX";
46
+ if (n.includes("portuguese") || n.includes("português")) return "pt";
47
+ if (n.includes("french") || n.includes("français")) return "fr";
48
+ if (n.includes("german") || n.includes("deutsch")) return "de";
49
+ if (n.includes("italian")) return "it";
50
+ if (n.includes("russian")) return "ru";
51
+ if (n.includes("dutch")) return "nl";
52
+ if (n.includes("polish")) return "pl";
53
+ if (n.includes("turkish")) return "tr";
54
+ if (n.includes("arabic")) return "ar";
55
+ if (n.includes("chinese") || n.includes("mandarin")) return "zh";
56
+ if (n.includes("japanese")) return "ja";
57
+ if (n.includes("korean")) return "ko";
58
+ if (n.includes("hebrew")) return "he";
59
+ if (n.includes("hindi")) return "hi";
60
+ if (n.includes("thai")) return "th";
61
+ if (n.includes("vietnamese")) return "vi";
62
+ if (n.includes("indonesian")) return "id";
63
+ if (n.includes("persian") || n.includes("farsi")) return "fa";
64
+ if (n.includes("hungarian")) return "hu";
65
+ if (n.includes("romanian")) return "ro";
66
+ if (n.includes("ukrainian")) return "uk";
67
+ if (n.includes("bulgarian")) return "bg";
68
+ if (n.includes("croatian")) return "hr";
69
+ if (n.includes("slovak")) return "sk";
70
+ if (n.includes("danish")) return "da";
71
+ if (n.includes("swedish")) return "sv";
72
+ if (n.includes("norwegian")) return "no";
73
+ if (n.includes("finnish")) return "fi";
74
+ if (n.includes("greek")) return "el";
75
+ if (n.includes("czech")) return "cs";
76
+ if (n.includes("serbian")) return "sr";
77
+ if (n.includes("slovenian")) return "sl";
78
+ if (n.includes("icelandic")) return "is";
79
+ if (n.includes("estonian")) return "et";
80
+ if (n.includes("latvian")) return "lv";
81
+ if (n.includes("lithuanian")) return "lt";
82
+ return "";
83
+ }
lib/tmdb.ts ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TMDB helper. The API key is read server-side from env, with a fallback to the
2
+ // key provided for this demo so the player page works out of the box.
3
+ const TMDB_KEY =
4
+ process.env.TMDB_API_KEY || "";
5
+
6
+ const BASE = "https://api.themoviedb.org/3";
7
+
8
+ export const img = {
9
+ poster: (
10
+ path: string | null,
11
+ size: "w185" | "w342" | "w500" | "w780" | "original" = "w342"
12
+ ) => (path ? `https://image.tmdb.org/t/p/${size}${path}` : null),
13
+ backdrop: (path: string | null, size: "w780" | "w1280" | "original" = "w1280") =>
14
+ path ? `https://image.tmdb.org/t/p/${size}${path}` : null,
15
+ };
16
+
17
+ export type MediaType = "movie" | "tv";
18
+
19
+ export interface TmdbItem {
20
+ id: number;
21
+ title: string;
22
+ year: string;
23
+ poster: string | null;
24
+ backdrop: string | null;
25
+ overview: string;
26
+ rating: number;
27
+ runtime: number | null;
28
+ }
29
+
30
+ function normalize(r: any): TmdbItem {
31
+ const date = r.release_date || r.first_air_date || "";
32
+ return {
33
+ id: r.id,
34
+ title: r.title || r.name || "Untitled",
35
+ year: date ? date.slice(0, 4) : "",
36
+ poster: r.poster_path,
37
+ backdrop: r.backdrop_path,
38
+ overview: r.overview || "",
39
+ rating: r.vote_average || 0,
40
+ runtime: r.runtime ?? (Array.isArray(r.episode_run_time) ? r.episode_run_time[0] : null) ?? null,
41
+ };
42
+ }
43
+
44
+ // Title logo (transparent PNG) for the player overlay. Prefers an English logo.
45
+ export async function getLogo(
46
+ type: "movie" | "tv",
47
+ id: string
48
+ ): Promise<string | null> {
49
+ try {
50
+ const data = await tmdb(`/${type}/${id}/images`, {
51
+ include_image_language: "en,null",
52
+ });
53
+ const logos: any[] = data.logos || [];
54
+ const pick =
55
+ logos.find((l) => l.iso_639_1 === "en") || logos[0] || null;
56
+ return pick?.file_path
57
+ ? `https://image.tmdb.org/t/p/w500${pick.file_path}`
58
+ : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ async function tmdb(path: string, params: Record<string, string> = {}) {
65
+ const url = new URL(BASE + path);
66
+ url.searchParams.set("api_key", TMDB_KEY);
67
+ Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
68
+ const res = await fetch(url.toString(), { next: { revalidate: 3600 } });
69
+ if (!res.ok) throw new Error(`TMDB ${res.status}`);
70
+ return res.json();
71
+ }
72
+
73
+ export async function getPopular(type: "movie" | "tv"): Promise<TmdbItem[]> {
74
+ const data = await tmdb(`/${type}/popular`);
75
+ return (data.results || []).map(normalize);
76
+ }
77
+
78
+ export async function getById(
79
+ type: "movie" | "tv",
80
+ id: string
81
+ ): Promise<TmdbItem | null> {
82
+ try {
83
+ const data = await tmdb(`/${type}/${id}`);
84
+ return normalize(data);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export async function search(
91
+ type: "movie" | "tv",
92
+ query: string
93
+ ): Promise<TmdbItem[]> {
94
+ if (!query.trim()) return [];
95
+ const data = await tmdb(`/search/${type}`, { query });
96
+ return (data.results || []).map(normalize);
97
+ }
98
+
99
+ export interface TvSeasonInfo {
100
+ season: number;
101
+ episodeCount: number;
102
+ name: string;
103
+ }
104
+
105
+ // Season list for a TV show (used by the next-episode + episode-selector
106
+ // features). Excludes specials (season 0).
107
+ export async function getTvSeasons(id: string): Promise<TvSeasonInfo[]> {
108
+ try {
109
+ const data = await tmdb(`/tv/${id}`);
110
+ return (data.seasons || [])
111
+ .filter((s: any) => s.season_number >= 1 && s.episode_count > 0)
112
+ .map((s: any) => ({
113
+ season: s.season_number,
114
+ episodeCount: s.episode_count,
115
+ name: s.name || `Season ${s.season_number}`,
116
+ }));
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+
122
+ export interface TvEpisode {
123
+ episode: number;
124
+ name: string;
125
+ still: string | null;
126
+ overview: string;
127
+ runtime: number | null;
128
+ }
129
+
130
+ export async function getSeasonEpisodes(
131
+ id: string,
132
+ season: number
133
+ ): Promise<TvEpisode[]> {
134
+ try {
135
+ const data = await tmdb(`/tv/${id}/season/${season}`);
136
+ return (data.episodes || []).map((e: any) => ({
137
+ episode: e.episode_number,
138
+ name: e.name || `Episode ${e.episode_number}`,
139
+ still: e.still_path,
140
+ overview: e.overview || "",
141
+ runtime: e.runtime ?? null,
142
+ }));
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.mjs ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+ // Lean, self-contained output for Docker / simple VPS deploys.
5
+ output: "standalone",
6
+ images: {
7
+ remotePatterns: [{ protocol: "https", hostname: "image.tmdb.org" }],
8
+ },
9
+ };
10
+
11
+ export default nextConfig;
package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "vidsuper",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "dependencies": {
15
+ "dashjs": "^4.7.4",
16
+ "hls.js": "^1.5.13",
17
+ "next": "^16.2.9",
18
+ "react": "^19.2.7",
19
+ "react-dom": "^19.2.7"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20",
23
+ "@types/react": "^19.2.17",
24
+ "@types/react-dom": "^19.2.3",
25
+ "autoprefixer": "^10.4.19",
26
+ "postcss": "^8.4.39",
27
+ "tailwindcss": "^3.4.6",
28
+ "typescript": "^5"
29
+ }
30
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
8
+
9
+ export default config;
public/logo.svg ADDED
tailwind.config.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
6
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
7
+ ],
8
+ theme: {
9
+ extend: {
10
+ fontFamily: {
11
+ sans: [
12
+ "var(--font-montserrat)",
13
+ "var(--font-inter)",
14
+ "ui-sans-serif",
15
+ "system-ui",
16
+ "sans-serif",
17
+ ],
18
+ },
19
+ colors: {
20
+ brand: {
21
+ pink: "#f59ff1",
22
+ purple: "#8b5cf6",
23
+ violet: "#a855f7",
24
+ },
25
+ },
26
+ keyframes: {
27
+ "fade-in": {
28
+ "0%": { opacity: "0", transform: "translateY(12px)" },
29
+ "100%": { opacity: "1", transform: "translateY(0)" },
30
+ },
31
+ "fade-in-slow": {
32
+ "0%": { opacity: "0" },
33
+ "100%": { opacity: "1" },
34
+ },
35
+ float: {
36
+ "0%, 100%": { transform: "translateY(0)" },
37
+ "50%": { transform: "translateY(-8px)" },
38
+ },
39
+ "pulse-glow": {
40
+ "0%, 100%": { opacity: "0.5" },
41
+ "50%": { opacity: "0.9" },
42
+ },
43
+ shimmer: {
44
+ "100%": { transform: "translateX(100%)" },
45
+ },
46
+ },
47
+ animation: {
48
+ "fade-in": "fade-in 0.7s ease-out both",
49
+ "fade-in-slow": "fade-in-slow 1s ease-out both",
50
+ float: "float 6s ease-in-out infinite",
51
+ "pulse-glow": "pulse-glow 4s ease-in-out infinite",
52
+ shimmer: "shimmer 2s infinite",
53
+ },
54
+ },
55
+ },
56
+ plugins: [],
57
+ };
58
+
59
+ export default config;
tsconfig.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2020",
4
+ "downlevelIteration": true,
5
+ "lib": [
6
+ "dom",
7
+ "dom.iterable",
8
+ "esnext"
9
+ ],
10
+ "allowJs": true,
11
+ "skipLibCheck": true,
12
+ "strict": true,
13
+ "noEmit": true,
14
+ "esModuleInterop": true,
15
+ "module": "esnext",
16
+ "moduleResolution": "bundler",
17
+ "resolveJsonModule": true,
18
+ "isolatedModules": true,
19
+ "jsx": "react-jsx",
20
+ "incremental": true,
21
+ "plugins": [
22
+ {
23
+ "name": "next"
24
+ }
25
+ ],
26
+ "paths": {
27
+ "@/*": [
28
+ "./*"
29
+ ]
30
+ }
31
+ },
32
+ "include": [
33
+ "next-env.d.ts",
34
+ "**/*.ts",
35
+ "**/*.tsx",
36
+ ".next/types/**/*.ts",
37
+ ".next/dev/types/**/*.ts"
38
+ ],
39
+ "exclude": [
40
+ "node_modules",
41
+ "wasm"
42
+ ]
43
+ }