# VoidXDevs

**Live sports, zero friction.** Static hub + multi-source player + one-channel embeds, with
the feed URLs and stream URLs kept **server-side** behind Cloudflare Pages Functions.

- Hub — five feeds merged into one grid, live matches with working servers first
- Player — HLS via JW Player, DASH via Shaka, ClearKey, auto-retry, stall recovery, Fit/Fill/Zoom
- Embed — one opaque URL per server that shows only that channel + the watermark
- API — `/api/feed/<source>` and `/api/play?p=…`; no upstream URL ever reaches the browser
- No build step, no framework; works locally on `file://` or `localhost`

---

## 1. Files

```
VoidXDevs/
├── index.html                 Hub
├── watch.html                 Full player page
├── embed.html                 Embed page (player + watermark only)
├── assets/
│   ├── css/  base.css  hub.css  player.css  embed.css
│   ├── js/   config.js  feeds.js  hub.js  player.js
│   │         embed.js  sandbox.js  watermark.js  ui.js  token.js
│   └── img/  logo.svg  favicon.ico
├── functions/                 Cloudflare Pages Functions (server side)
│   ├── _middleware.js         CORS + origin guard + security headers
│   ├── lib/core.js            KV read, stream mapping, HMAC sign/verify
│   └── api/
│       ├── feed/[source].js   GET /api/feed/football  → sanitised feed
│       ├── feeds.js           GET /api/feeds          → all sources
│       ├── play.js            GET /api/play?p=…       → 302 to the real manifest
│       └── health.js          GET /api/health         → deploy check
├── worker/
│   ├── refresh.js             Cron worker: upstream → KV (raw JSON)
│   └── wrangler.toml
├── _worker.js                 Dashboard path: site + /api/* in one file
├── wrangler.toml              Pages config for the terminal path
├── _routes.json               Pages: run Functions for /api/*
├── _headers                   Security + cache headers
├── .assetsignore              Never upload dev config / worker / docs
├── assets/js/config.dev.js    Local-only feed URLs (excluded from uploads)
├── dev-config.example.js      Template for local direct-feed mode
├── DEPLOY.md                  Step-by-step Cloudflare Pages checklist
├── README.md
└── LICENSE
```

---

## 2. How the data flows

```
cron worker ──► upstream feeds ──► KV  (vx:raw:football …)
                                     │
browser ──► /api/feed/football ──────┘  strip stream URLs → signed pointers
        ◄── { v, data:[… link:"/api/play?p=…" …] }

player  ──► /api/play?p=… ──► verify HMAC ──► 302 ──► real manifest (CDN)
```

* The browser never sees an upstream feed URL.
* The browser never sees a manifest URL until it is actually playing — and then it is a
  **short-lived, signed pointer** that expires (`PLAY_TTL`, default 15 min).
* ClearKey values stay in the payload (they are part of the DRM, not a secret).
* Poster images, team flags and titles stay public so cards can render.

---

## 3. Local development

```bash
python3 -m http.server 8080          # or just open index.html
```

For local runs the site needs a feed source. Two options:

**A. Direct mode (no Cloudflare account needed) — already set up**

The zip ships `assets/js/config.dev.js` with the five feed URLs, so opening the folder on
`localhost` or via VS Code Live Server works immediately. A yellow **DEV** badge appears
next to the logo, which means "feeds are being fetched directly, not through `/api`".

* This file is loaded **only** on `localhost`, `127.0.0.1`, `0.0.0.0`, `*.local` or
  `file://` — never on a real domain.
* It is listed in `.assetsignore`, so Cloudflare Pages never uploads it.
* Working from a git clone instead of the zip? `cp dev-config.example.js assets/js/config.dev.js`.
* Want to test the real API on localhost (`wrangler pages dev`)? Add `?api=1` to the URL.

> Deploying somewhere that ignores `.assetsignore` (GitHub Pages, Netlify, a plain bucket)?
> Delete `assets/js/config.dev.js` before you upload.

**B. Functions mode**

```bash
wrangler pages dev . --kv VX_KV --binding SIGN_SECRET=devsecret --binding FEED_URLS='{"football":"…"}'
```

---

## 4. Deploying to Cloudflare Pages (feeds kept secret)

Two paths, both documented in **[`DEPLOY.md`](DEPLOY.md)**:

* **Dashboard only (no installs)** — delete `assets/js/config.dev.js`, drag the folder's
  contents into *Workers & Pages → Create → Pages → Upload assets*, then add the two secrets
  (`FEED_URLS`, `SIGN_SECRET`) and redeploy. The API comes from `_worker.js` (Pages advanced
  mode), because a dashboard upload cannot compile a `functions/` folder.
* **Terminal** — `npx wrangler@latest pages deploy`, which uses `functions/` instead.

Either way you add the same two secrets:

| Secret | Value |
|---|---|
| `FEED_URLS` | one line of JSON: `{"football":"https://…","cricket":"https://…",…}` |
| `SIGN_SECRET` | any long random string |

Then check `/api/health` — `feedUrls` and `signSecret` must both be `true`.

### What is actually protected

| Hidden | Still visible |
|---|---|
| The upstream feed JSON URLs | The final manifest URL **while a stream is playing** (browser network tab) |
| The stream URL inventory (nothing to scrape) | That a redirect happened (`/api/play?p=…` → CDN) |
| Long-lived, shareable manifest links | Nothing else — the page source holds no feed data |

No video site can hide a URL from the browser that is playing it. What this design gives you:
nobody can pull your whole feed list, links expire after `PLAY_TTL`, and your sources are
never named in your HTML or JS. Optional extras: `ALLOWED_ORIGINS` (origin lock),
a shorter `PLAY_TTL`, or Turnstile on `/api/feed`.

## 5. Player notes

| Concern | Behaviour |
|---|---|
| HLS | JW Player 8, `type:"hls"`, autostart + muted, tap to unmute |
| HLS fallback | if the JW CDN is blocked and the browser supports HLS natively (Safari/iOS), a plain `<video>` takes over automatically |
| DASH | Shaka 5.2.4 + UI overlay, script fetched **only** when a DASH server is chosen |
| ClearKey | `drm.clearKeys[kid] = key` before `load()` |
| Switching servers | the previous engine is fully destroyed (`remove()` / `destroy()`) before the next mounts — no zombie players |
| Quality | device tier caps `maxHeight` 480/720/1080 and `maxBandwidth` |
| Retry | 4 attempts, 1.2 s → 2.4 s → 4.8 s → 9.6 s, then a Retry card |
| Stall | 9 s without progress → `goToLive()` / seek to the live edge → reload |
| Fit | Fit → Fill → Zoom (stage button, JW custom button, Shaka control panel) |
| Signed links | `/api/play?p=…` is resolved to an absolute URL before mounting |

---

## 6. Embedding one channel

The **Embed** button on each server row gives an opaque URL:

```
https://your-project.pages.dev/embed.html?id=CSQPYX32C5P6R09H60RG2C8
```

```html
<iframe
  src="https://your-project.pages.dev/embed.html?id=CSQPYX32C5P6R09H60RG2C8"
  width="100%" height="500" frameborder="0" allowfullscreen
  allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
  referrerpolicy="no-referrer"
></iframe>
```

The token is feed + event + server index in base32 (`assets/js/token.js`), decoded in the
browser. `embed.html` renders the video and the watermark — no header, no server list, no
buttons, no popups.

Never add `sandbox` to the iframe: `sandbox.js` detects it and shows a refusal screen.

---

## 7. Feeds

`VX.FEEDS` in `assets/js/config.js` only holds `label`, `icon` and `type`:

```js
VX.FEEDS = {
  football: { label: "Football", icon: "⚽", type: "teamvs" },
  willow:   { label: "Willow",   icon: "🌿", type: "image"  }
};
```

URLs are **not** here — they live in the `FEED_URLS` secret. Adding a source means:

1. add the key to `VX.FEEDS` and `VX.ORDER` in `config.js`
2. add it to `FEED_KEYS` in `functions/lib/core.js` and `KEYS` in `worker/refresh.js`
3. add its URL to the `FEED_URLS` secret (worker **and** Pages)
4. redeploy both

`type: "teamvs"` reads `event.startTimeIST / teamA / teamB / teamAFlag / teamBFlag` and
`streams[]`; `type: "image"` probes `matches / events / items / data`, `title / event_name /
match_name`, `image / cover_image / poster / thumbnail` and pulls streams from
`streams[] / sources[] / links[]` or a direct `link / url / hls_url / dash_url`.

---

## 8. Troubleshooting

| Symptom | Cause / fix |
|---|---|
| Feed chips say “failed” | `/api/feed/…` returned 502 → KV empty (run the worker) or `FEED_URLS` missing |
| `api/health` shows `kv:false` | KV binding name is not `VX_KV`, or you didn’t redeploy after adding it |
| Stream shows “Reconnecting…” then fails | upstream manifest is dead, or the CDN blocks CORS for XHR-based loaders — try another server |
| DASH never starts | browser without MSE (older iOS) — use an HLS server; the UI says so |
| “token expired — reload the player” | `PLAY_TTL` elapsed (tab was paused) — the player retries automatically |
| “feed changed — reload the player” | the worker refreshed KV mid-session — reload the page |
| Embed shows “Sandbox Detected” | remove `sandbox` from your iframe |
| **0 events, 0 live, 0 upcoming on localhost** | the site cannot reach `/api/feed/…` and there is no dev config. Keep `assets/js/config.dev.js` in `assets/js/` (it ships in the zip) — the header then shows a **DEV** badge |
| Everything fails locally | same as above — see §3 |

Console helpers:

```js
VX.CFG                        // tunables (apiBase, direct mode, TTL…)
VX.feedUrl("football")        // what the client will request
VX.feeds.load("willow")       // one feed, normalised
VX.player.state               // player runtime
VX.token.encode({ id:"football:101", source:"football", stream:1 })
VX.sandbox.detect()           // { blocked, reason }
```

---

## 9. Legal

VoidXDevs hosts no media. It displays links supplied by third-party feeds. All trademarks,
logos and broadcast rights belong to their respective owners. MIT licensed — see `LICENSE`.
