diff --git a/README.md b/README.md
index 4930d82..a59dbcb 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,49 @@ system rather than mouse hover.
> playlists, providers or credentials. You are responsible for making sure
> you have the rights to any streams you load into it.
+## Screenshots
+
+
+
+
+ Home: everything one press away, with the playlist's expiry and last update at the bottom.
+
+
+
+
+
+
+
Live TV Channel list with a live preview and what's on now and next.
+
+
+
+
TV Guide A full EPG grid, with catch-up on supported channels.
+
+
+
+
+
+
Series Details, seasons and episodes at a glance.
+
+
+
+
Player Audio and subtitle tracks, an episodes panel and Next Episode.
+
+
+
+
+
+
Search One search across channels, movies and series, typed with the remote.
+
+
+
+
Add a playlist Xtream Codes login or an M3U link.
+
+
+
+
+Screenshots use invented demo data from the bundled mock Xtream server: no real channels, titles or artwork.
+
## Features
- **Live TV** – category rail, channel list with a live preview and
@@ -70,9 +113,13 @@ Open in a desktop browser. The arrow keys act as
the remote's D-pad, `Enter` selects and `Escape` goes back. Add your own
Xtream Codes account or M3U URL on the "Add source" screen.
-For a quick playback check without a provider, you can use a public HLS
-test stream such as
-`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`.
+To try everything without a provider, run the bundled
+[mock Xtream server](mock-xtream/) (`pnpm mock:xtream`) and add
+`http://localhost:8787` with username and password `demo`: it serves an
+invented catalogue with channels, a TV guide, movies and series.
+
+For a quick playback check, you can also use a public HLS test stream such
+as `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`.
> **About CORS in development:** most IPTV providers don't send CORS
> headers, so a browser would normally block requests to them. The dev
@@ -112,7 +159,7 @@ commands as above.
**Debugging on device:** `pnpm inspect-device myTV` opens remote DevTools.
> **Forking?** Change the `id` in `webos-meta/appinfo.json` (currently
-> `com.stacktips.zenplay`) and the matching ID in the `launch-device` and
+> `com.spannable.zenplay`) and the matching ID in the `launch-device` and
> `inspect-device` scripts in `package.json`, so your build installs as a
> separate app.
diff --git a/index.html b/index.html
index 9852448..26e435c 100644
--- a/index.html
+++ b/index.html
@@ -47,7 +47,8 @@
min-height: 100%;
background: var(--bg);
color: var(--text);
- font-family: "Inter", system-ui, -apple-system, sans-serif;
+ /* "Inter Variable" is bundled with the app (see src/main.tsx), so every device — TVs included — renders the same font. */
+ font-family: "Inter Variable", "Inter", system-ui, -apple-system, sans-serif;
}
body {
@@ -94,9 +95,29 @@
width: 0;
height: 0;
}
+ /*
+ * The launch splash is part of the page itself, so it's on screen from
+ * the very first paint — webOS drops its own splash (the same image,
+ * webos-meta/splash.png) as soon as this page loads, well before the
+ * app's script has been parsed and run. boot-splash.ts fades it out
+ * once the app has painted underneath.
+ */
+ #boot-splash {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ background: #08090b;
+ transition: opacity 400ms ease;
+ }
+
+ #boot-splash.is-hidden {
+ opacity: 0;
+ pointer-events: none;
+ }
+
diff --git a/mock-xtream/.gitignore b/mock-xtream/.gitignore
new file mode 100644
index 0000000..07129c0
--- /dev/null
+++ b/mock-xtream/.gitignore
@@ -0,0 +1 @@
+media/
diff --git a/mock-xtream/README.md b/mock-xtream/README.md
new file mode 100644
index 0000000..6067b81
--- /dev/null
+++ b/mock-xtream/README.md
@@ -0,0 +1,88 @@
+# Mock Xtream server
+
+A local, fake Xtream Codes panel for running ZenPlay without a real IPTV
+subscription — for demos, development and app-store screenshots.
+
+Everything it serves is invented: film and series titles, channel names,
+people, plots and the TV guide. Posters, backdrops, episode stills and
+channel logos are drawn as SVG from each title, so there are no third-party
+images, brands or streams involved. The catalogue is seeded, so every run
+serves exactly the same content and screenshots can be retaken identically.
+
+## Run
+
+```sh
+pnpm mock:xtream # from the repo root
+# or
+cd mock-xtream && npm start
+```
+
+No dependencies — Node 20+ only. It prints the server URL (and the one to use
+from a TV on your network) with the login:
+
+| Field | Value |
+| -------- | ----------------------- |
+| Server | `http://localhost:8787` |
+| Username | `demo` |
+| Password | `demo` |
+
+Add it in ZenPlay as an **Xtream Codes** playlist with those details.
+
+Options (environment variables): `PORT` (default `8787`), `HOST` (default
+`0.0.0.0`), `MOCK_USER`, `MOCK_PASS`.
+
+- **webOS TV Simulator / desktop browser:** `http://localhost:8787`.
+- **A real TV:** use the LAN address the server prints, e.g.
+ `http://192.168.1.58:8787` (the Mac and TV must be on the same network).
+
+## What's in it
+
+- ~120 movies in 10 categories, ~60 series in 8 categories (1–4 seasons,
+ 6–10 episodes each), ~55 live channels in 8 groups.
+- Plots, cast, director, ratings, release years, durations.
+- A TV guide: `xmltv.php` (6 hours back to 3 days ahead) and `get_epg`.
+- Catch-up flags on News, Sports, Entertainment and Documentary channels.
+- "Animation Movies" and "Kids Series" categories, which the Kids profile's
+ bundled rules pick up.
+- Movie and series names come as "Title (Year)", the way many panels send them.
+
+## Video
+
+Pressing play shows a demo clip: an original animated sunset over drifting
+mountains, rendered on your Mac (no footage, text or logos). Render it once
+before starting the server:
+
+```sh
+cd mock-xtream && npm run make-video # ~1–2 minutes; needs the Xcode command line tools (swift)
+```
+
+It writes `media/` (git-ignored, ~65 MB):
+
+- `movie.mp4` — a 90-second 1080p clip, played for every film, episode and
+ catch-up programme (with seeking).
+- `hls/` — 60 seconds of HLS segments that every live channel plays as a
+ never-ending live stream, looping on the wall clock.
+
+Without `media/`, the catalogue still works and play shows the player's
+error state. Live channels are offered as HLS only, so leave **Live Stream
+Format** on Auto (or HLS).
+
+## Endpoints
+
+`/player_api.php` — sign-in (no action), `get_live_categories`,
+`get_vod_categories`, `get_series_categories`, `get_live_streams`,
+`get_vod_streams`, `get_series` (all accept `category_id`),
+`get_series_info`, `get_vod_info`, `get_epg` / `get_short_epg`.
+`/xmltv.php` — full guide. `/art/…` — generated SVG artwork.
+`/movie/…`, `/series/…`, `/streaming/timeshift.php` — the demo MP4.
+`/live/…/.m3u8` and `/hls/…` — the looping live stream.
+
+## Files
+
+- `catalog.mjs` — the invented content and the seeded generator.
+- `artwork.mjs` — SVG posters, backdrops, stills and logos.
+- `server.mjs` — the HTTP server.
+- `make-video.swift` — renders the demo clip into `media/`.
+
+Before publishing screenshots, check the titles on screen once more; they're
+invented, but a short generic title can coincide with a real work.
diff --git a/mock-xtream/artwork.mjs b/mock-xtream/artwork.mjs
new file mode 100644
index 0000000..4553422
--- /dev/null
+++ b/mock-xtream/artwork.mjs
@@ -0,0 +1,121 @@
+// Generated artwork for the mock server: posters, backdrops, episode stills
+// and channel logos drawn as SVG from the title alone — no real images, so
+// nothing to license. Same title, same picture, every time.
+
+import { rng } from "./catalog.mjs";
+
+/** Colour sets per category: [light, dark, accent] — saturated enough to read as artwork, dark enough at the bottom for white text. */
+const PALETTES = [
+ ["#3b6fd8", "#0d1633", "#ffc857"],
+ ["#e0533d", "#2a0c0c", "#ffd166"],
+ ["#f2a541", "#3a1a06", "#fff3c4"],
+ ["#2a9d8f", "#062320", "#e9f5a1"],
+ ["#7b5cd6", "#160f2e", "#ffd1f7"],
+ ["#3a86ff", "#081a3a", "#8ce1ff"],
+ ["#ef476f", "#2c0715", "#ffd6e0"],
+ ["#06d6a0", "#03261d", "#fef9c3"],
+ ["#118ab2", "#04202b", "#ffe8a3"],
+ ["#8d99ae", "#141821", "#ef233c"],
+ ["#5a189a", "#10031f", "#ff9e00"],
+];
+
+function escapeXml(text) {
+ return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
+}
+
+/** Splits a title into lines of roughly `width` characters. */
+function wrap(text, width) {
+ const lines = [];
+ let line = "";
+ for (const word of text.split(" ")) {
+ if (line && (line + " " + word).length > width) {
+ lines.push(line);
+ line = word;
+ } else line = line ? `${line} ${word}` : word;
+ }
+ if (line) lines.push(line);
+ return lines;
+}
+
+/** A central motif per title (sun, rings, peaks or bars), so a grid of posters reads as varied artwork rather than flat swatches. */
+function motif(r, w, h, accent) {
+ const cx = r.int(Math.round(w * 0.3), Math.round(w * 0.7));
+ const cy = r.int(Math.round(h * 0.22), Math.round(h * 0.38));
+ const size = r.int(Math.round(w * 0.22), Math.round(w * 0.34));
+ switch (r.int(0, 3)) {
+ case 0: // sun over a horizon
+ return ``;
+ case 1: // concentric rings
+ return [0, 1, 2, 3].map((i) => ``).join("");
+ case 2: { // mountain peaks
+ const base = cy + size;
+ return ``;
+ }
+ default: // diagonal light bars
+ return [0, 1, 2].map((i) => ``).join("");
+ }
+}
+
+/** Soft circles for texture. */
+function shapes(r, w, h, accent) {
+ const out = [];
+ for (let i = 0; i < 3; i++) {
+ out.push(``);
+ }
+ return out.join("") + motif(r, w, h, accent);
+}
+
+const FONT = "Helvetica Neue, Helvetica, Arial, sans-serif";
+
+/** 2:3 movie/series poster. */
+export function posterSvg({ title, year, genre, palette }) {
+ const [top, bottom, accent] = PALETTES[palette % PALETTES.length];
+ const r = rng(`poster:${title}`);
+ const w = 600;
+ const h = 900;
+ const lines = wrap(title.toUpperCase(), 12);
+ const size = lines.length > 3 ? 50 : lines.some((line) => line.length > 10) ? 56 : 64;
+ const startY = h - 170 - (lines.length - 1) * size;
+ const text = lines.map((line, i) => `${escapeXml(line)}`).join("");
+ return ``;
+}
+
+/** 16:9 backdrop (details page) or episode still. */
+export function backdropSvg({ title, palette, subtitle = "" }) {
+ const [top, bottom, accent] = PALETTES[palette % PALETTES.length];
+ const r = rng(`backdrop:${title}:${subtitle}`);
+ const w = 1280;
+ const h = 720;
+ return ``;
+}
+
+/** Square channel logo: initials in a rounded badge, with the name beneath. */
+export function logoSvg({ name, palette }) {
+ const [top, bottom, accent] = PALETTES[palette % PALETTES.length];
+ const words = name.replace(/[^A-Za-z0-9 ]/g, "").split(" ").filter(Boolean);
+ const numeric = words.find((word) => /^\d+$/.test(word));
+ const initials = (words.filter((word) => !/^\d+$/.test(word)).slice(0, 2).map((word) => word[0]).join("") + (numeric ?? "")).toUpperCase();
+ const label = wrap(name, 16).slice(0, 2);
+ return ``;
+}
diff --git a/mock-xtream/catalog.mjs b/mock-xtream/catalog.mjs
new file mode 100644
index 0000000..675ee8f
--- /dev/null
+++ b/mock-xtream/catalog.mjs
@@ -0,0 +1,328 @@
+// Invented content for the mock Xtream server: every title, channel, person
+// and plot here is made up for screenshots. Nothing refers to a real film,
+// show, broadcaster or brand. Output is seeded, so every run serves the same
+// catalogue and screenshots can be retaken identically.
+
+// --- Seeded randomness ---------------------------------------------------------
+
+function hash(text) {
+ let h = 2166136261;
+ for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
+ return h >>> 0;
+}
+
+/** A small deterministic PRNG (mulberry32) seeded from a string. */
+export function rng(seed) {
+ let a = hash(String(seed));
+ const next = () => {
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ return {
+ next,
+ int: (min, max) => min + Math.floor(next() * (max - min + 1)),
+ pick: (list) => list[Math.floor(next() * list.length)],
+ sample: (list, n) => [...list].sort(() => next() - 0.5).slice(0, n),
+ };
+}
+
+// --- People --------------------------------------------------------------------
+
+const FIRST = ["Maya", "Elias", "Noor", "Theo", "Ines", "Rafael", "Hana", "Jonah", "Priya", "Luca", "Amara", "Felix", "Sofia", "Kai", "Leila", "Owen", "Mei", "Daniel", "Zara", "Tomas", "Aisha", "Callum", "Yara", "Mateo", "Freya", "Idris", "Clara", "Ravi", "Nadia", "Oscar"];
+const LAST = ["Hart", "Okafor", "Lindqvist", "Moreau", "Castellano", "Brennan", "Sato", "Ellery", "Varga", "Achterberg", "Quinn", "Delacroix", "Nakamura", "Oyelaran", "Whitlock", "Santoro", "Kowalczyk", "Ashdown", "Ferreira", "Halloran", "Iverson", "Marchetti", "Rahman", "Thorne", "Vance", "Abernathy", "Kessler", "Lowell", "Petrov", "Sinclair"];
+
+function person(r) {
+ return `${r.pick(FIRST)} ${r.pick(LAST)}`;
+}
+
+// --- Movies --------------------------------------------------------------------
+
+/** Movie categories and their titles. "Animation Movies" is one of the Kids rules' bundled category names, so a Kids profile has something to show. */
+export const MOVIE_GENRES = [
+ {
+ name: "New Releases",
+ genre: "Drama, Thriller",
+ titles: ["The Quiet Hour", "Glass Harbor", "Northbound", "Paper Lanterns", "The Long Weekend", "Salt & Stone", "After the Rain", "Borrowed Time", "Midnight Ferry", "The Last Good Summer", "Cold Open", "Wildflower Road"],
+ },
+ {
+ name: "Action & Adventure",
+ genre: "Action, Adventure",
+ titles: ["Iron Meridian", "Redline Protocol", "Coastline Run", "The Vault at Kestrel Bay", "Hard Frontier", "Blackwater Run", "Zero Hour Heist", "Falcon Canyon", "Parcel Run", "Steel Horizon", "Firebreak", "Last Stand at Dunmore", "Rogue Signal", "Crossfire Express"],
+ },
+ {
+ name: "Comedy",
+ genre: "Comedy",
+ titles: ["My Neighbor's Wedding", "Totally Fine", "The Worst Best Man", "Road Trip to Nowhere", "Kitchen Nightshift", "Office Hours", "Party of One", "The Inheritance Game", "Weekend at Grandma's", "Accidental Tourists", "Second Date", "Supply Teacher"],
+ },
+ {
+ name: "Drama",
+ genre: "Drama",
+ titles: ["The Lighthouse Keeper's Daughter", "Letters from Alder Street", "Undertow", "A Year in Portland", "The Weight of Snow", "Small Mercies", "The Piano Room", "Harvest Hymn", "What We Carry", "River of Names", "The Understudy", "Stillhouse Pond"],
+ },
+ {
+ name: "Thriller & Mystery",
+ genre: "Thriller, Mystery",
+ titles: ["The Ninth Guest", "Dead Reckoning", "Dead Angle", "The Harrow Affair", "The 3 A.M. Shift", "Missing in Marlow", "The Silent Witness Room", "Echo Point", "Fault Lines", "The Stranger Upstairs", "Cold Case 1987", "Room 414"],
+ },
+ {
+ name: "Sci-Fi & Fantasy",
+ genre: "Science Fiction, Fantasy",
+ titles: ["Orbit Nine", "The Last Colony", "Starlight Drift", "Paradox Engine", "Emberfall", "The Clockmaker's Gate", "Signal from Kepler", "Halcyon Station", "The Dreaming Forest", "Tidebreaker", "Chronos Divide", "Nova Frontier"],
+ },
+ {
+ name: "Romance",
+ genre: "Romance, Comedy",
+ titles: ["Love, Unscripted", "The Bookshop on Ivy Lane", "Summer in Lisbon", "Two Tickets to Paris", "Meet Me at the Gallery", "The Wedding Planner's Rules", "Autumn Letters", "Coffee for Two", "Starlight Serenade", "Right Place, Wrong Time"],
+ },
+ {
+ name: "Animation Movies",
+ genre: "Animation, Family, Adventure",
+ titles: ["Pip and the Moon Balloon", "The Brave Little Tugboat", "Dino Dentist", "Kip's Big Snow Day", "The Lost Kite Kingdom", "Robo Pals", "Owl Academy", "Penguin Parade", "The Cloud Painter", "Captain Whiskers", "Pocket Heroes", "The Magic Crayon"],
+ },
+ {
+ name: "Documentaries",
+ genre: "Documentary",
+ titles: ["Wild Coasts", "The Last Glaciers", "Deep Blue Cities", "Coffee: A Global Story", "Night Skies", "The Beekeepers", "Built to Last", "Rivers of the World", "The Secret Forest Year", "Street Food Journeys", "Voices of the Desert", "Engines of Change"],
+ },
+ {
+ name: "Horror",
+ genre: "Horror",
+ titles: ["The Hollow House", "Whisperwood", "Don't Answer", "The Seventh Floor", "Lanterns in the Fog", "Nightwatch Motel", "The Late Visitor", "Below Deck", "Static Hour", "The Harvest Festival"],
+ },
+];
+
+const MOVIE_PLOTS = {
+ "New Releases": [
+ "{a} returns to a small coastal town after ten years away and finds that the family secret everyone buried is about to surface.",
+ "Two strangers stranded by a cancelled train spend one long night crossing the city together — and neither is who they claim to be.",
+ ],
+ "Action & Adventure": [
+ "Ex-agent {a} is pulled back for one last job: recover a stolen drive before it reaches the highest bidder in 48 hours.",
+ "When a routine convoy is ambushed in the mountains, rookie pilot {a} has to lead the survivors home through hostile ground.",
+ ],
+ Comedy: [
+ "{a} agrees to fake-date an old friend for a family wedding weekend. What could possibly go wrong? Everything.",
+ "A chaotic family road trip goes spectacularly off-course when {a} insists on taking the scenic route.",
+ ],
+ Drama: [
+ "After her mother's death, {a} moves into the old family house and uncovers letters that change everything she knew.",
+ "A gifted but troubled pianist and the teacher who refuses to give up on him spend one winter preparing for the audition of a lifetime.",
+ ],
+ "Thriller & Mystery": [
+ "Detective {a} investigates a disappearance at a remote hotel where every guest has something to hide.",
+ "A night-shift security guard notices the same stranger on the cameras every night at 3:14 a.m. — and then the stranger looks back.",
+ ],
+ "Sci-Fi & Fantasy": [
+ "The crew of a deep-space station receives a message from a ship that vanished forty years ago.",
+ "{a} discovers a door in an old clock shop that opens onto a different city every hour.",
+ ],
+ Romance: [
+ "A bookshop owner and a travelling architect keep meeting by accident — until it stops being an accident.",
+ "{a} has one summer in Lisbon to finish a novel. Falling in love wasn't part of the plan.",
+ ],
+ "Animation Movies": [
+ "A curious little inventor builds a balloon to visit the Moon and makes friends with everyone she meets along the way.",
+ "A brave little tugboat sets out across the big harbour to find her way home before the storm.",
+ ],
+ Documentaries: [
+ "A breathtaking journey across five continents following the people and wildlife who depend on them.",
+ "Filmed over three years, this documentary follows the scientists racing to understand a changing planet.",
+ ],
+ Horror: [
+ "A family moves into a farmhouse at the edge of the woods and starts hearing voices in the walls.",
+ "Five friends spend the night in an abandoned motel. Only four check out.",
+ ],
+};
+
+// --- Series --------------------------------------------------------------------
+
+/** "Kids Series" is one of the Kids rules' bundled category names. */
+export const SERIES_GENRES = [
+ { name: "Trending Now", genre: "Drama, Mystery", titles: ["Harbor Lights", "The Kingsley Files", "Northern Line", "Blue Hour", "The Firm on Carver Street", "Deep Water Point", "Crown & Anchor", "Last Orders at the Rook"] },
+ { name: "Crime & Mystery", genre: "Crime, Mystery", titles: ["Precinct 12", "The Marlow Murders", "Cold Trail", "Chain of Evidence", "The Night Desk", "Undercover Blue", "Shadow Docket", "Double Blind"] },
+ { name: "Drama Series", genre: "Drama", titles: ["The Ashford Family", "Saint Clair Hospital", "Riverside", "Homeward", "Everything We Were", "The Vineyard", "Second Chances", "Main Street"] },
+ { name: "Comedy Series", genre: "Comedy", titles: ["Flat 4B", "The Night Kitchen", "Mixed Doubles", "Office Politics", "Grandpa Moves In", "Startup Life", "Next Door Down", "Weekend Warriors"] },
+ { name: "Sci-Fi Series", genre: "Science Fiction", titles: ["Outer Rim", "Parallel", "Signal Lost", "Colony Seven", "Tomorrow's Children", "Deep Orbit", "The Archivists", "After the Fall"] },
+ { name: "Kids Series", genre: "Animation, Kids", titles: ["Robo Pals", "Owl Academy Adventures", "Captain Whiskers Sets Sail", "The Busy Builders", "Dino Detectives", "Little Explorers", "Bubble Bay", "Rainbow Club"] },
+ { name: "Docuseries", genre: "Documentary", titles: ["Ocean Worlds", "Great Kitchens of the World", "The Makers", "Wild Islands", "Mountains", "Inside the Workshop", "Night Cities", "Hidden Trails"] },
+ { name: "Reality & Lifestyle", genre: "Reality, Lifestyle", titles: ["Home Rescue", "Bake Along", "Garden Makeover", "Road to the Runway", "Tiny House Dreams", "Weekend Chef", "Treasure Hunters", "Island Escape"] },
+];
+
+/** Plots per series category, in SERIES_GENRES order. */
+const SERIES_PLOTS = [
+ ["In a close-knit harbour town, {a} returns home to take over the family business and gets caught up in a mystery that divides the community.", "A young lawyer, {a}, takes on the case nobody else will — and the city's most powerful family takes notice."],
+ ["Each season follows a new case for detective {a} and a small team willing to bend the rules to get to the truth.", "When a body turns up in a quiet village, {a} has one week to find the killer before the trail goes cold."],
+ ["Three generations of the same family share one house, one business and far too many secrets.", "The doctors and nurses of a busy city hospital juggle life-and-death decisions with lives of their own."],
+ ["An ensemble of friends, flatmates and neighbours navigate love, ambition and the occasional disaster.", "{a} moves back in with the family at thirty-five. Everyone has opinions."],
+ ["The crew of a distant colony discovers they aren't the first people to arrive.", "A signal from deep space changes everything {a} thought they knew about the universe."],
+ ["Big adventures for small heroes — every episode is a new friendship, a new problem and a new way to solve it together.", "A team of curious little explorers sets out to discover how the world works, one question at a time."],
+ ["Stunning, intimate storytelling from the places and people that shape our world.", "Travelling to kitchens, workshops and wild places to meet the people who keep traditions alive."],
+ ["Everyday people, big transformations — and a few surprises along the way.", "Talented amateurs compete over eight weeks for the title — and bragging rights."],
+];
+
+const EPISODE_WORDS = ["Homecoming", "The Storm", "Crossroads", "Old Friends", "The Offer", "Night Moves", "Fault Lines", "The Letter", "Open House", "Loose Ends", "The Late Visitor", "High Tide", "Dead Angle", "Second Chances", "The Deal", "Last Light", "New Beginnings", "The Test", "Lost & Found", "The Big Day", "Fresh Start", "Runaway", "The Long Road", "Full Circle"];
+
+// --- Live TV -------------------------------------------------------------------
+
+/** Invented channel names — deliberately not any real broadcaster. */
+export const LIVE_GENRES = [
+ { name: "News", archive: true, channels: ["Northwind News 24", "Global Wire", "City Desk Live", "Market Pulse", "Weather Now", "World Report", "Parliament Live", "Evening Edition"] },
+ { name: "Sports", archive: true, channels: ["Stadium Sports 1", "Stadium Sports 2", "Pitchside", "Fairway Golf", "Paddock TV", "Court Side Tennis", "Ringside Live", "Extreme Outdoors", "Sports Replay HD"] },
+ { name: "Entertainment", archive: true, channels: ["Aurora One", "Lantern Stories", "Storyline TV", "Brightside TV", "Chuckle TV", "Laugh Track", "Hits Drama", "Replay Drama"] },
+ { name: "Movies", archive: false, channels: ["Silver Screen 1", "Silver Screen Action", "Silver Screen Classics", "Big Screen Box", "Indie Screen", "Family Movies", "Thriller Zone", "Romance Channel"] },
+ { name: "Kids", archive: false, channels: ["Kids Club", "Doodle TV", "Little Learners", "Junior Adventures", "Cartoon Corner", "Tiny Tales"] },
+ { name: "Documentary", archive: true, channels: ["Explorer Earth", "Timeline TV", "Wild Frontier", "Science Now", "Travel & Tales", "Engineering Wonders"] },
+ { name: "Music", archive: false, channels: ["Hit Radio TV", "Classic Rock Live", "Chill Lounge", "Country Roads", "Jazz Club", "Pop Charts 40"] },
+ { name: "Lifestyle", archive: false, channels: ["Homestead TV", "Kitchen Table TV", "Wardrobe TV", "Fit Life", "Auto World"] },
+];
+
+const PROGRAMMES = {
+ News: ["Morning Briefing", "World Report", "Business Today", "Headlines Now", "Weather Watch", "Newsroom Live", "The Evening Edition", "Late Night News", "Global Markets", "In Conversation"],
+ Sports: ["Matchday Live", "Top Flight Football", "Sports Desk", "Golf Highlights", "Tennis Open: Quarter-Final", "Racing Weekend", "Ringside Countdown", "Extreme Sports Weekly", "The Big Match Replay", "Sports Tonight"],
+ Entertainment: ["Late Night Live", "Celebrity Kitchen", "Talent Search", "Harbor Lights", "Flat 4B", "Saint Clair Hospital", "Comedy Hour", "Quiz Night", "Weekend Live", "Behind the Scenes"],
+ Movies: ["Iron Meridian", "The Quiet Hour", "Orbit Nine", "My Neighbor's Wedding", "Glass Harbor", "The Ninth Guest", "Starlight Drift", "Summer in Lisbon", "Coastline Run", "Undertow"],
+ Kids: ["Robo Pals", "Dino Detectives", "Little Explorers", "Bubble Bay", "The Busy Builders", "Owl Academy Adventures", "Story Time", "Captain Whiskers Sets Sail", "Rainbow Club", "Draw With Me"],
+ Documentary: ["Wild Coasts", "Engines of Change", "The Last Glaciers", "Deep Blue Cities", "Ancient Empires", "Factory Floor", "Ocean Worlds", "Night Skies", "The Beekeepers", "Mountains"],
+ Music: ["Top 40 Countdown", "Rock Classics", "Live Sessions", "Chill Mix", "Country Hour", "Jazz at Midnight", "Video Hits", "Acoustic Evenings", "Artist Spotlight", "Dance Floor"],
+ Lifestyle: ["Home Rescue", "Bake Along", "Garden Makeover", "Weekend Chef", "Style Files", "Fit in 30", "Tiny House Dreams", "Road Test", "Island Escape", "Treasure Hunters"],
+};
+
+const PROGRAMME_DESCRIPTIONS = {
+ News: "The latest headlines, analysis and interviews from around the world.",
+ Sports: "Live coverage, highlights and expert analysis.",
+ Entertainment: "Your favourite shows, stars and stories.",
+ Movies: "Feature film presentation.",
+ Kids: "Fun, friendly adventures for younger viewers.",
+ Documentary: "An in-depth look at the people and places shaping our world.",
+ Music: "Non-stop music and live performances.",
+ Lifestyle: "Ideas and inspiration for home, food and life.",
+};
+
+// --- Build ---------------------------------------------------------------------
+
+const DAY = 86400;
+const NOW = Math.floor(Date.now() / 1000);
+
+function slug(text) {
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
+}
+
+function build() {
+ const vodCategories = [];
+ const movies = [];
+ let movieId = 10001;
+ MOVIE_GENRES.forEach((g, gi) => {
+ const categoryId = String(100 + gi);
+ vodCategories.push({ category_id: categoryId, category_name: g.name, parent_id: 0 });
+ for (const title of g.titles) {
+ const r = rng(`movie:${title}`);
+ const year = g.name === "New Releases" ? r.int(2025, 2026) : r.int(1998, 2025);
+ const plot = r.pick(MOVIE_PLOTS[g.name]).replace("{a}", person(r));
+ movies.push({
+ id: movieId++,
+ title,
+ year,
+ categoryId,
+ genre: g.genre,
+ plot,
+ rating: (r.int(58, 89) / 10).toFixed(1),
+ durationSecs: r.int(84, 148) * 60,
+ director: person(r),
+ cast: [person(r), person(r), person(r), person(r)].join(", "),
+ // Most panels add titles over time — newer releases carry a newer timestamp.
+ added: NOW - (g.name === "New Releases" ? r.int(1, 20) : r.int(30, 900)) * DAY,
+ slug: slug(title),
+ palette: gi,
+ });
+ }
+ });
+
+ const seriesCategories = [];
+ const series = [];
+ let seriesId = 5001;
+ let episodeId = 900001;
+ SERIES_GENRES.forEach((g, gi) => {
+ const categoryId = String(200 + gi);
+ seriesCategories.push({ category_id: categoryId, category_name: g.name, parent_id: 0 });
+ for (const title of g.titles) {
+ const r = rng(`series:${title}`);
+ const firstYear = r.int(2012, 2024);
+ const seasonCount = r.int(1, 4);
+ const seasons = {};
+ for (let s = 1; s <= seasonCount; s++) {
+ const count = r.int(6, 10);
+ seasons[s] = Array.from({ length: count }, (_, i) => {
+ const er = rng(`ep:${title}:${s}:${i}`);
+ return {
+ id: String(episodeId++),
+ episode_num: i + 1,
+ season: s,
+ title: `${title} - S${String(s).padStart(2, "0")}E${String(i + 1).padStart(2, "0")} - ${er.pick(EPISODE_WORDS)}`,
+ plot: `${er.pick(["A surprise visitor", "An old rivalry", "A missing file", "An unexpected offer", "A storm warning", "A long-kept secret"])} ${er.pick(["turns the week upside down", "forces a difficult choice", "brings everyone together", "puts the plan at risk", "changes everything"])}.`,
+ durationSecs: (g.name === "Kids Series" ? er.int(11, 24) : er.int(38, 58)) * 60,
+ airDate: `${firstYear + s - 1}-${String(er.int(1, 12)).padStart(2, "0")}-${String(er.int(1, 28)).padStart(2, "0")}`,
+ rating: (er.int(66, 92) / 10).toFixed(1),
+ };
+ });
+ }
+ series.push({
+ id: seriesId++,
+ title,
+ year: firstYear,
+ categoryId,
+ genre: g.genre,
+ plot: r.pick(SERIES_PLOTS[gi]).replace("{a}", person(r)),
+ rating: (r.int(65, 91) / 10).toFixed(1),
+ director: person(r),
+ cast: [person(r), person(r), person(r), person(r)].join(", "),
+ lastModified: NOW - r.int(1, 200) * DAY,
+ seasons,
+ slug: slug(title),
+ palette: gi + 3,
+ });
+ }
+ });
+
+ const liveCategories = [];
+ const channels = [];
+ let streamId = 1;
+ let number = 101;
+ LIVE_GENRES.forEach((g, gi) => {
+ const categoryId = String(300 + gi);
+ liveCategories.push({ category_id: categoryId, category_name: g.name, parent_id: 0 });
+ for (const name of g.channels) {
+ channels.push({
+ id: streamId++,
+ num: number++,
+ name,
+ categoryId,
+ group: g.name,
+ epgId: `${slug(name)}.mock`,
+ archive: g.archive,
+ palette: gi,
+ });
+ }
+ number = Math.ceil(number / 100) * 100 + 1; // each group starts a new hundred
+ });
+
+ return { vodCategories, movies, seriesCategories, series, liveCategories, channels };
+}
+
+export const catalog = build();
+
+/** A day of programmes for one channel, from `from` to `to` (unix seconds), on 30/60/90-minute slots. */
+export function programmesFor(channel, from, to) {
+ const titles = PROGRAMMES[channel.group];
+ const slotStart = Math.floor(from / 1800) * 1800;
+ const list = [];
+ for (let start = slotStart; start < to; ) {
+ const r = rng(`${channel.id}:${start}`);
+ const length = channel.group === "Movies" ? r.pick([90, 120]) : r.pick([30, 30, 60, 60, 90]);
+ const stop = start + length * 60;
+ list.push({ title: r.pick(titles), description: PROGRAMME_DESCRIPTIONS[channel.group], start, stop, category: channel.group });
+ start = stop;
+ }
+ return list;
+}
diff --git a/mock-xtream/make-video.swift b/mock-xtream/make-video.swift
new file mode 100644
index 0000000..9603f98
--- /dev/null
+++ b/mock-xtream/make-video.swift
@@ -0,0 +1,192 @@
+// Renders the mock server's demo video: an original, text-free animated
+// scene (a sunset over drifting mountain ridges) drawn frame by frame with
+// CoreGraphics and encoded with AVFoundation — no footage, no third-party
+// content, nothing to install beyond the Xcode command line tools.
+//
+// swift make-video.swift (from mock-xtream/, or `npm run make-video`)
+//
+// Writes into media/:
+// movie.mp4 — H.264, progressive (moov first), for films, episodes and catch-up
+// hls/init.mp4 — fMP4 HLS init segment, for live channels
+// hls/segN.m4s — fMP4 HLS media segments
+// hls/segments.json — each segment's duration, read by server.mjs to build a looping live playlist
+
+import AVFoundation
+import CoreGraphics
+import Foundation
+import UniformTypeIdentifiers
+
+let width = 1920
+let height = 1080
+let fps: Int32 = 30
+let movieSeconds = 90
+let liveSeconds = 60
+let segmentSeconds = 4.0
+
+let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("media")
+let hlsDir = root.appendingPathComponent("hls")
+try? FileManager.default.removeItem(at: root)
+try FileManager.default.createDirectory(at: hlsDir, withIntermediateDirectories: true)
+
+// --- The scene -------------------------------------------------------------------
+
+func color(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> CGColor {
+ CGColor(srgbRed: r, green: g, blue: b, alpha: a)
+}
+
+func mix(_ a: [CGFloat], _ b: [CGFloat], _ t: CGFloat) -> CGColor {
+ color(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
+}
+
+/** A ridge line: a sum of sines, so it's smooth, varied and scrolls seamlessly. */
+func ridge(_ x: CGFloat, seed: CGFloat) -> CGFloat {
+ sin(x * 0.0021 + seed) * 70 + sin(x * 0.0047 + seed * 2.3) * 38 + sin(x * 0.0113 + seed * 0.7) * 14
+}
+
+/** Draws frame `t` (seconds). The sky drifts from dusk gold to evening violet over a 60s cycle and back. */
+func draw(_ ctx: CGContext, t: Double) {
+ let w = CGFloat(width)
+ let h = CGFloat(height)
+ let phase = CGFloat((1 - cos(t / 60 * 2 * .pi)) / 2) // 0 → 1 → 0 every 60s
+
+ // Sky (CoreGraphics' origin is bottom-left).
+ let skyTop = mix([0.16, 0.20, 0.45], [0.10, 0.07, 0.25], phase)
+ let skyMid = mix([0.93, 0.52, 0.36], [0.62, 0.30, 0.50], phase)
+ let skyLow = mix([1.00, 0.80, 0.45], [0.95, 0.55, 0.40], phase)
+ let sky = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [skyLow, skyMid, skyTop] as CFArray, locations: [0.25, 0.55, 1])!
+ ctx.drawLinearGradient(sky, start: CGPoint(x: 0, y: 0), end: CGPoint(x: 0, y: h), options: [])
+
+ // Sun: slowly sinking, with a soft glow.
+ let sunY = h * 0.52 - CGFloat(t) * 1.2
+ let sunX = w * 0.62
+ let glow = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [color(1, 0.93, 0.7, 0.55), color(1, 0.8, 0.5, 0)] as CFArray, locations: [0, 1])!
+ ctx.drawRadialGradient(glow, startCenter: CGPoint(x: sunX, y: sunY), startRadius: 0, endCenter: CGPoint(x: sunX, y: sunY), endRadius: 420, options: [])
+ ctx.setFillColor(color(1, 0.95, 0.78))
+ ctx.fillEllipse(in: CGRect(x: sunX - 95, y: sunY - 95, width: 190, height: 190))
+
+ // Drifting haze bands.
+ for i in 0..<3 {
+ let y = h * (0.42 + CGFloat(i) * 0.08)
+ let x = CGFloat((t * Double(18 + i * 9)).truncatingRemainder(dividingBy: Double(w * 2))) - w * 0.6
+ ctx.setFillColor(color(1, 0.9, 0.85, 0.07))
+ ctx.fillEllipse(in: CGRect(x: x, y: y, width: w * 0.8, height: 60))
+ }
+
+ // Mountain layers, far to near: darker and faster (parallax).
+ let layers: [(base: CGFloat, speed: CGFloat, seed: CGFloat, far: [CGFloat], near: [CGFloat])] = [
+ (0.40, 6, 1.1, [0.55, 0.36, 0.48], [0.40, 0.26, 0.42]),
+ (0.30, 14, 4.2, [0.36, 0.22, 0.36], [0.26, 0.16, 0.30]),
+ (0.19, 28, 7.9, [0.20, 0.12, 0.22], [0.14, 0.09, 0.18]),
+ (0.09, 50, 2.6, [0.09, 0.06, 0.12], [0.06, 0.04, 0.09]),
+ ]
+ for layer in layers {
+ let path = CGMutablePath()
+ path.move(to: CGPoint(x: 0, y: 0))
+ var x: CGFloat = 0
+ while x <= w {
+ path.addLine(to: CGPoint(x: x, y: h * layer.base + ridge(x + CGFloat(t) * layer.speed, seed: layer.seed)))
+ x += 8
+ }
+ path.addLine(to: CGPoint(x: w, y: 0))
+ path.closeSubpath()
+ ctx.addPath(path)
+ ctx.setFillColor(mix(layer.far, layer.near, phase))
+ ctx.fillPath()
+ }
+
+ // Birds: a small flock crossing slowly.
+ ctx.setStrokeColor(color(0.12, 0.08, 0.15, 0.8))
+ ctx.setLineWidth(3)
+ for i in 0..<5 {
+ let bx = CGFloat((t * 40 + Double(i) * 55).truncatingRemainder(dividingBy: Double(w + 400))) - 200
+ let by = h * 0.68 + CGFloat(i % 2) * 30 + CGFloat(sin(t * 1.3 + Double(i))) * 10
+ let flap = CGFloat(sin(t * 8 + Double(i))) * 8
+ ctx.move(to: CGPoint(x: bx - 16, y: by + flap))
+ ctx.addQuadCurve(to: CGPoint(x: bx, y: by), control: CGPoint(x: bx - 8, y: by + 4))
+ ctx.addQuadCurve(to: CGPoint(x: bx + 16, y: by + flap), control: CGPoint(x: bx + 8, y: by + 4))
+ ctx.strokePath()
+ }
+}
+
+// --- Encoding ----------------------------------------------------------------------
+
+func videoInput(bitrate: Int) -> AVAssetWriterInput {
+ let input = AVAssetWriterInput(mediaType: .video, outputSettings: [
+ AVVideoCodecKey: AVVideoCodecType.h264,
+ AVVideoWidthKey: width,
+ AVVideoHeightKey: height,
+ AVVideoCompressionPropertiesKey: [
+ AVVideoAverageBitRateKey: bitrate,
+ AVVideoMaxKeyFrameIntervalKey: Int(fps) * 2,
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
+ AVVideoAllowFrameReorderingKey: false,
+ ],
+ ])
+ input.expectsMediaDataInRealTime = false
+ return input
+}
+
+func render(into writer: AVAssetWriter, input: AVAssetWriterInput, seconds: Int) {
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: input, sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey as String: width,
+ kCVPixelBufferHeightKey as String: height,
+ ])
+ writer.add(input)
+ guard writer.startWriting() else { fatalError("Couldn't start writing: \(String(describing: writer.error))") }
+ writer.startSession(atSourceTime: .zero)
+
+ let frames = seconds * Int(fps)
+ for frame in 0..=20"
+ },
+ "scripts": {
+ "start": "node server.mjs",
+ "make-video": "swift make-video.swift"
+ }
+}
diff --git a/mock-xtream/server.mjs b/mock-xtream/server.mjs
new file mode 100644
index 0000000..1da21a0
--- /dev/null
+++ b/mock-xtream/server.mjs
@@ -0,0 +1,385 @@
+// A local, fake Xtream Codes panel for demos and store screenshots.
+// No dependencies: `node server.mjs` (Node 20+). See README.md.
+
+import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
+import http from "node:http";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { catalog, programmesFor } from "./catalog.mjs";
+import { backdropSvg, logoSvg, posterSvg } from "./artwork.mjs";
+
+const PORT = Number(process.env.PORT ?? 8787);
+const HOST = process.env.HOST ?? "0.0.0.0";
+const USERNAME = process.env.MOCK_USER ?? "demo";
+const PASSWORD = process.env.MOCK_PASS ?? "demo";
+
+const { vodCategories, movies, seriesCategories, series, liveCategories, channels } = catalog;
+const movieById = new Map(movies.map((m) => [String(m.id), m]));
+const seriesById = new Map(series.map((s) => [String(s.id), s]));
+const channelById = new Map(channels.map((c) => [String(c.id), c]));
+
+const now = () => Math.floor(Date.now() / 1000);
+
+// Demo video, rendered once by make-video.swift (see README) — absent until then.
+const MEDIA = path.join(path.dirname(fileURLToPath(import.meta.url)), "media");
+const MOVIE_FILE = path.join(MEDIA, "movie.mp4");
+const HLS_DIR = path.join(MEDIA, "hls");
+const hasVideo = () => existsSync(MOVIE_FILE) && existsSync(path.join(HLS_DIR, "segments.json"));
+
+// --- Responses -----------------------------------------------------------------
+
+function send(res, status, body, type) {
+ res.writeHead(status, {
+ "Content-Type": type,
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "*",
+ "Cache-Control": type.startsWith("image/") ? "public, max-age=86400" : "no-store",
+ });
+ res.end(body);
+}
+
+const json = (res, body) => send(res, 200, JSON.stringify(body), "application/json; charset=utf-8");
+const svg = (res, body) => send(res, 200, body, "image/svg+xml");
+
+/** Absolute URLs built from the request's own Host, so the same data works on localhost, a LAN IP or the simulator. */
+function baseOf(req) {
+ return `http://${req.headers.host ?? `localhost:${PORT}`}`;
+}
+
+// --- player_api.php ------------------------------------------------------------
+
+function userInfo(req) {
+ const host = (req.headers.host ?? "localhost").split(":");
+ return {
+ user_info: {
+ username: USERNAME,
+ password: PASSWORD,
+ message: "Welcome to the ZenPlay demo server",
+ auth: 1,
+ status: "Active",
+ exp_date: String(now() + 365 * 86400),
+ is_trial: "0",
+ active_cons: "0",
+ created_at: String(now() - 200 * 86400),
+ max_connections: "2",
+ allowed_output_formats: ["m3u8", "ts"],
+ },
+ server_info: {
+ url: host[0],
+ port: host[1] ?? "80",
+ https_port: "443",
+ server_protocol: "http",
+ timezone: "UTC",
+ timestamp_now: now(),
+ time_now: new Date().toISOString().replace("T", " ").slice(0, 19),
+ },
+ };
+}
+
+const byCategory = (list, categoryId) => (categoryId ? list.filter((item) => item.categoryId === categoryId) : list);
+
+function vodStream(base, m, index) {
+ return {
+ num: index + 1,
+ name: `${m.title} (${m.year})`,
+ stream_type: "movie",
+ stream_id: m.id,
+ stream_icon: `${base}/art/poster/movie/${m.id}.svg`,
+ rating: m.rating,
+ rating_5based: Number((Number(m.rating) / 2).toFixed(1)),
+ added: String(m.added),
+ category_id: m.categoryId,
+ container_extension: "mp4",
+ };
+}
+
+function seriesEntry(base, s, index) {
+ return {
+ num: index + 1,
+ name: `${s.title} (${s.year})`,
+ series_id: s.id,
+ cover: `${base}/art/poster/series/${s.id}.svg`,
+ plot: s.plot,
+ cast: s.cast,
+ director: s.director,
+ genre: s.genre,
+ releaseDate: `${s.year}-01-01`,
+ last_modified: String(s.lastModified),
+ rating: s.rating,
+ rating_5based: Number((Number(s.rating) / 2).toFixed(1)),
+ backdrop_path: [`${base}/art/backdrop/series/${s.id}.svg`],
+ category_id: s.categoryId,
+ };
+}
+
+function liveStream(base, c) {
+ return {
+ num: c.num,
+ name: c.name,
+ stream_type: "live",
+ stream_id: c.id,
+ stream_icon: `${base}/art/logo/${c.id}.svg`,
+ epg_channel_id: c.epgId,
+ added: String(now() - 400 * 86400),
+ category_id: c.categoryId,
+ tv_archive: c.archive ? 1 : 0,
+ tv_archive_duration: c.archive ? 7 : 0,
+ };
+}
+
+function seriesInfo(base, s) {
+ const episodes = {};
+ for (const [season, list] of Object.entries(s.seasons)) {
+ episodes[season] = list.map((ep) => ({
+ id: ep.id,
+ episode_num: ep.episode_num,
+ title: ep.title,
+ container_extension: "mp4",
+ season: ep.season,
+ info: {
+ movie_image: `${base}/art/still/${s.id}/${ep.id}.svg`,
+ plot: ep.plot,
+ releasedate: ep.airDate,
+ rating: ep.rating,
+ duration_secs: ep.durationSecs,
+ duration: new Date(ep.durationSecs * 1000).toISOString().slice(11, 19),
+ },
+ }));
+ }
+ return {
+ seasons: Object.keys(s.seasons).map((n) => ({ season_number: Number(n), name: `Season ${n}`, episode_count: s.seasons[n].length, cover: `${base}/art/poster/series/${s.id}.svg` })),
+ info: { ...seriesEntry(base, s, 0), name: s.title },
+ episodes,
+ };
+}
+
+function vodInfo(base, m) {
+ return {
+ info: {
+ name: m.title,
+ movie_image: `${base}/art/poster/movie/${m.id}.svg`,
+ backdrop_path: [`${base}/art/backdrop/movie/${m.id}.svg`],
+ plot: m.plot,
+ description: m.plot,
+ genre: m.genre,
+ director: m.director,
+ cast: m.cast,
+ releasedate: `${m.year}-${String((m.id % 12) + 1).padStart(2, "0")}-15`,
+ rating: m.rating,
+ duration_secs: m.durationSecs,
+ duration: new Date(m.durationSecs * 1000).toISOString().slice(11, 19),
+ },
+ movie_data: { stream_id: m.id, name: m.title, added: String(m.added), category_id: m.categoryId, container_extension: "mp4" },
+ };
+}
+
+function shortEpg(c, limit) {
+ const t = now();
+ const list = programmesFor(c, t - 3 * 3600, t + 24 * 3600).filter((p) => p.stop > t - 3 * 3600);
+ return {
+ epg_listings: list.slice(0, limit).map((p, i) => ({
+ id: String(c.id * 10000 + i),
+ epg_id: c.epgId,
+ channel_id: c.epgId,
+ title: Buffer.from(p.title).toString("base64"),
+ description: Buffer.from(p.description).toString("base64"),
+ lang: "en",
+ start: new Date(p.start * 1000).toISOString().replace("T", " ").slice(0, 19),
+ end: new Date(p.stop * 1000).toISOString().replace("T", " ").slice(0, 19),
+ start_timestamp: String(p.start),
+ stop_timestamp: String(p.stop),
+ has_archive: c.archive && p.stop < t ? 1 : 0,
+ })),
+ };
+}
+
+function playerApi(req, res, query) {
+ if (query.get("username") !== USERNAME || query.get("password") !== PASSWORD) {
+ return json(res, { user_info: { auth: 0 } });
+ }
+ const base = baseOf(req);
+ const categoryId = query.get("category_id") ?? undefined;
+ switch (query.get("action")) {
+ case null:
+ case "":
+ return json(res, userInfo(req));
+ case "get_live_categories":
+ return json(res, liveCategories);
+ case "get_vod_categories":
+ return json(res, vodCategories);
+ case "get_series_categories":
+ return json(res, seriesCategories);
+ case "get_live_streams":
+ return json(res, byCategory(channels, categoryId).map((c) => liveStream(base, c)));
+ case "get_vod_streams":
+ return json(res, byCategory(movies, categoryId).map((m, i) => vodStream(base, m, i)));
+ case "get_series":
+ return json(res, byCategory(series, categoryId).map((s, i) => seriesEntry(base, s, i)));
+ case "get_series_info": {
+ const s = seriesById.get(query.get("series_id") ?? "");
+ return s ? json(res, seriesInfo(base, s)) : json(res, { info: {}, episodes: {} });
+ }
+ case "get_vod_info": {
+ const m = movieById.get(query.get("vod_id") ?? "");
+ return m ? json(res, vodInfo(base, m)) : json(res, { info: {} });
+ }
+ case "get_short_epg":
+ case "get_simple_data_table":
+ case "get_epg": {
+ const c = channelById.get(query.get("stream_id") ?? "");
+ return json(res, c ? shortEpg(c, Number(query.get("limit") ?? 50)) : { epg_listings: [] });
+ }
+ default:
+ return json(res, []);
+ }
+}
+
+// --- xmltv.php -----------------------------------------------------------------
+
+function xmlEscape(text) {
+ return String(text).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
+}
+
+function xmltvTime(seconds) {
+ return new Date(seconds * 1000).toISOString().replace(/[-:T]/g, "").slice(0, 14) + " +0000";
+}
+
+function xmltv(req) {
+ const base = baseOf(req);
+ const t = now();
+ const from = t - 6 * 3600;
+ const to = t + 3 * 86400;
+ const parts = ['', ''];
+ for (const c of channels) {
+ parts.push(`${xmlEscape(c.name)}`);
+ }
+ for (const c of channels) {
+ for (const p of programmesFor(c, from, to)) {
+ parts.push(
+ `${xmlEscape(p.title)}${xmlEscape(p.description)}${xmlEscape(p.category)}`,
+ );
+ }
+ }
+ parts.push("");
+ return parts.join("\n");
+}
+
+// --- Artwork -------------------------------------------------------------------
+
+function artwork(res, path) {
+ const [, , kind, a, b] = path.replace(/\.svg$/, "").split("/");
+ if (kind === "poster") {
+ const item = a === "movie" ? movieById.get(b) : seriesById.get(b);
+ if (item) return svg(res, posterSvg({ title: item.title, year: item.year, genre: item.genre, palette: item.palette }));
+ }
+ if (kind === "backdrop") {
+ const item = a === "movie" ? movieById.get(b) : seriesById.get(b);
+ if (item) return svg(res, backdropSvg({ title: item.title, palette: item.palette }));
+ }
+ if (kind === "still") {
+ const item = seriesById.get(a);
+ if (item) return svg(res, backdropSvg({ title: item.title, palette: item.palette, subtitle: b }));
+ }
+ if (kind === "logo") {
+ const c = channelById.get(a);
+ if (c) return svg(res, logoSvg({ name: c.name, palette: c.palette }));
+ }
+ return send(res, 404, "Not found", "text/plain");
+}
+
+// --- Video -------------------------------------------------------------------------
+
+const NO_VIDEO = "No demo video yet: run `npm run make-video` in mock-xtream/ (see README).";
+
+/** Serves a file with HTTP Range support — the player seeks and starts playback with range requests. */
+function sendFile(req, res, file, type) {
+ const size = statSync(file).size;
+ const range = /bytes=(\d*)-(\d*)/.exec(req.headers.range ?? "");
+ const headers = { "Content-Type": type, "Accept-Ranges": "bytes", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" };
+ if (!range) {
+ res.writeHead(200, { ...headers, "Content-Length": size });
+ return createReadStream(file).pipe(res);
+ }
+ const start = range[1] ? Number(range[1]) : size - Number(range[2]);
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
+ if (start >= size || start > end) {
+ res.writeHead(416, { ...headers, "Content-Range": `bytes */${size}` });
+ return res.end();
+ }
+ res.writeHead(206, { ...headers, "Content-Length": end - start + 1, "Content-Range": `bytes ${start}-${end}/${size}` });
+ createReadStream(file, { start, end }).pipe(res);
+}
+
+/**
+ * A never-ending live playlist: the rendered segments played on a loop,
+ * clocked to wall time so every channel is "live" at the same point. Each
+ * wrap back to the first segment is marked as a discontinuity, since its
+ * timestamps start again from zero.
+ */
+function livePlaylist() {
+ const { durations } = JSON.parse(readFileSync(path.join(HLS_DIR, "segments.json"), "utf8"));
+ const count = durations.length;
+ const segment = Math.max(...durations);
+ const window = 6;
+ const current = Math.floor(Date.now() / 1000 / segment);
+ const first = current - window + 1;
+ const lines = [
+ "#EXTM3U",
+ "#EXT-X-VERSION:7",
+ `#EXT-X-TARGETDURATION:${Math.ceil(segment)}`,
+ "#EXT-X-INDEPENDENT-SEGMENTS",
+ `#EXT-X-MEDIA-SEQUENCE:${first}`,
+ `#EXT-X-DISCONTINUITY-SEQUENCE:${Math.floor((first - 1) / count)}`,
+ '#EXT-X-MAP:URI="/hls/init.mp4"',
+ ];
+ for (let g = first; g <= current; g++) {
+ const index = g % count;
+ if (index === 0) lines.push("#EXT-X-DISCONTINUITY");
+ lines.push(`#EXTINF:${durations[index].toFixed(3)},`, `/hls/seg${index}.m4s`);
+ }
+ return lines.join("\n") + "\n";
+}
+
+function video(req, res, pathname) {
+ if (!hasVideo()) return send(res, 404, NO_VIDEO, "text/plain");
+ // Live channels (HLS). The .ts form isn't offered, so the app's "Auto" format settles on HLS.
+ if (/^\/live\/[^/]+\/[^/]+\/\d+\.m3u8$/.test(pathname)) return send(res, 200, livePlaylist(), "application/vnd.apple.mpegurl");
+ const hls = /^\/hls\/(init\.mp4|seg\d+\.m4s)$/.exec(pathname);
+ if (hls) {
+ const file = path.join(HLS_DIR, hls[1]);
+ return existsSync(file) ? sendFile(req, res, file, "video/mp4") : send(res, 404, "Not found", "text/plain");
+ }
+ // Films, episodes and catch-up all play the same clip.
+ if (/^\/(movie|series)\/[^/]+\/[^/]+\/\d+\.\w+$/.test(pathname) || pathname === "/streaming/timeshift.php") return sendFile(req, res, MOVIE_FILE, "video/mp4");
+ return send(res, 404, "No stream here.", "text/plain");
+}
+
+// --- Server --------------------------------------------------------------------
+
+const server = http.createServer((req, res) => {
+ const url = new URL(req.url ?? "/", "http://localhost");
+ if (req.method === "OPTIONS") return send(res, 204, "", "text/plain");
+ if (url.pathname === "/player_api.php") return playerApi(req, res, url.searchParams);
+ if (url.pathname === "/xmltv.php") {
+ if (url.searchParams.get("username") !== USERNAME || url.searchParams.get("password") !== PASSWORD) return send(res, 401, "Unauthorized", "text/plain");
+ return send(res, 200, xmltv(req), "application/xml; charset=utf-8");
+ }
+ if (url.pathname.startsWith("/art/")) return artwork(res, url.pathname);
+ // /live/, /movie/, /series/, /hls/ and catch-up: the rendered demo clip.
+ return video(req, res, url.pathname);
+});
+
+server.listen(PORT, HOST, () => {
+ const lan = Object.values(os.networkInterfaces())
+ .flat()
+ .find((i) => i && i.family === "IPv4" && !i.internal)?.address;
+ console.log(`ZenPlay mock Xtream server
+ Movies ${movies.length} · Series ${series.length} · Channels ${channels.length}
+
+ Server URL: http://localhost:${PORT}${lan ? ` (TV on your network: http://${lan}:${PORT})` : ""}
+ Username: ${USERNAME}
+ Password: ${PASSWORD}
+
+ Video: ${hasVideo() ? "demo clip ready (movies, episodes, catch-up and live)" : NO_VIDEO}`);
+});
diff --git a/package.json b/package.json
index 88abe2b..23366f5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "zenplay",
"private": true,
- "version": "0.1.0",
+ "version": "1.0.1",
"description": "An open-source IPTV player for LG webOS TV: Live TV, EPG, movies and series from your own Xtream Codes or M3U playlists.",
"keywords": [
"iptv",
@@ -38,11 +38,13 @@
"test": "vitest run",
"package": "pnpm build && ares-package dist -o webos-dist",
"install-device": "node scripts/install-device.mjs",
- "launch-device": "ares-launch com.stacktips.zenplay --device",
+ "launch-device": "ares-launch com.spannable.zenplay --device",
"launch-hosted": "ares-launch --hosted dist --device",
- "inspect-device": "ares-inspect --app com.stacktips.zenplay --open --device"
+ "inspect-device": "ares-inspect --app com.spannable.zenplay --open --device",
+ "mock:xtream": "node mock-xtream/server.mjs"
},
"dependencies": {
+ "@fontsource-variable/inter": "^5.3.0",
"hls.js": "^1.5.17",
"lucide-react": "^1.47.0",
"react": "^18.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 662cd28..7f60493 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@fontsource-variable/inter':
+ specifier: ^5.3.0
+ version: 5.3.0
hls.js:
specifier: ^1.5.17
version: 1.7.3
@@ -415,6 +418,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@fontsource-variable/inter@5.3.0':
+ resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1402,6 +1408,8 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
+ '@fontsource-variable/inter@5.3.0': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
diff --git a/screenshots/1.png b/screenshots/1.png
new file mode 100644
index 0000000..b5a3e09
Binary files /dev/null and b/screenshots/1.png differ
diff --git a/screenshots/2.png b/screenshots/2.png
new file mode 100644
index 0000000..fdd6076
Binary files /dev/null and b/screenshots/2.png differ
diff --git a/screenshots/3.png b/screenshots/3.png
new file mode 100644
index 0000000..d24d83d
Binary files /dev/null and b/screenshots/3.png differ
diff --git a/screenshots/4.png b/screenshots/4.png
new file mode 100644
index 0000000..48bd1d5
Binary files /dev/null and b/screenshots/4.png differ
diff --git a/screenshots/5.png b/screenshots/5.png
new file mode 100644
index 0000000..cc2275b
Binary files /dev/null and b/screenshots/5.png differ
diff --git a/screenshots/6.png b/screenshots/6.png
new file mode 100644
index 0000000..99f37ac
Binary files /dev/null and b/screenshots/6.png differ
diff --git a/screenshots/7.png b/screenshots/7.png
new file mode 100644
index 0000000..e954fcf
Binary files /dev/null and b/screenshots/7.png differ
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 83ec804..aa453e0 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -2,7 +2,9 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PlaylistSource, Profile } from "@core";
import { addPlaylistSource, setActivePlaylistSourceId } from "./playlist-store.js";
-import { addProfile } from "./profile-store.js";
+import { addProfile, setActiveProfileId } from "./profile-store.js";
+import { __resetBootSplashForTests } from "./boot-splash.js";
+import { getLocalLiveMeta } from "./live-store.js";
import { App } from "./App.js";
/**
@@ -41,6 +43,9 @@ vi.mock("./screens/HomeScreen.js", () => ({
switch to {s.name}
))}
+
@@ -59,6 +64,29 @@ vi.mock("./screens/FavouritesScreen.js", () => ({
vi.mock("./screens/HistoryScreen.js", () => ({
HistoryScreen: ({ profileId, source }: { profileId: string; source: PlaylistSource }) =>
+ ),
+}));
vi.mock("./sync/sync-scheduler.js", () => ({ startSyncScheduler: () => () => {} }));
// Every playlist counts as already downloaded, so the first-sync screen never gets in the way.
vi.mock("./live-store.js", async (importOriginal) => ({
@@ -144,3 +172,63 @@ describe("App: playlists per profile", () => {
expect(await screen.findByText("home on Provider A")).toBeTruthy();
});
});
+
+describe("App: Search", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ addPlaylistSource(providerA);
+ setActivePlaylistSourceId("a");
+ addProfile(mum);
+ });
+
+ it("Home's Search opens the Search screen, and Back from a series opened there returns to Search", async () => {
+ render();
+ await pickProfile("Mum");
+ click("search");
+ expect(await screen.findByText("search screen")).toBeTruthy();
+
+ click("open series from search");
+ expect(await screen.findByText("series detail s-1")).toBeTruthy();
+ click("back from series");
+ expect(await screen.findByText("search screen")).toBeTruthy();
+
+ click("leave search");
+ expect(await screen.findByText(/^home on /)).toBeTruthy();
+ });
+});
+
+describe("App: launch splash", () => {
+ let splash: HTMLElement;
+ beforeEach(() => {
+ localStorage.clear();
+ addPlaylistSource(providerA);
+ setActivePlaylistSourceId("a");
+ addProfile(mum);
+ __resetBootSplashForTests();
+ document.getElementById("boot-splash")?.remove();
+ splash = document.createElement("div");
+ splash.id = "boot-splash";
+ document.body.appendChild(splash);
+ });
+
+ it("on a relaunch, stays up through restoring the profile and the first-download check, and fades once Home is showing", async () => {
+ setActiveProfileId(mum.id);
+ let answer!: (meta: Awaited>) => void;
+ vi.mocked(getLocalLiveMeta).mockReturnValueOnce(new Promise((resolve) => (answer = resolve)));
+
+ render();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(screen.queryByText(/^home on /)).toBeNull(); // still checking
+ expect(splash.classList.contains("is-hidden")).toBe(false);
+
+ await act(async () => answer({ sourceId: "a", lastSyncedAt: Date.now(), generation: 1, channelCount: 1 }));
+ expect(await screen.findByText(/^home on /)).toBeTruthy();
+ await vi.waitFor(() => expect(splash.classList.contains("is-hidden")).toBe(true));
+ });
+
+ it("with no saved profile, fades once the profile picker is showing", async () => {
+ render();
+ expect(screen.getByRole("button", { name: "pick Mum" })).toBeTruthy();
+ await vi.waitFor(() => expect(splash.classList.contains("is-hidden")).toBe(true));
+ });
+});
diff --git a/src/App.tsx b/src/App.tsx
index 4f3659e..a862962 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -28,6 +28,8 @@ import { getLocalLiveMeta } from "./live-store.js";
import { liveStreamUrl } from "./live-stream-url.js";
import { purgeSourceData } from "./sync/purge.js";
import { startSyncScheduler } from "./sync/sync-scheduler.js";
+import { dismissBootSplash } from "./boot-splash.js";
+import { setSearchIndexPlaybackActive, startSearchIndexScheduler } from "./search/search-index-scheduler.js";
import { useSourceSyncState } from "./sync/sync-store.js";
import { describeRunningSync } from "./sync/sync-summary.js";
import { FirstSyncScreen } from "./screens/FirstSyncScreen.js";
@@ -40,6 +42,7 @@ import { KidsHomeScreen } from "./screens/KidsHomeScreen.js";
import { LiveTvScreen } from "./screens/LiveTvScreen.js";
import { VodScreen } from "./screens/VodScreen.js";
import { SeriesScreen, type EpisodePlayContext } from "./screens/SeriesScreen.js";
+import { SearchScreen } from "./screens/SearchScreen.js";
import { yearFromDate, type PlaybackInfo } from "./screens/PlayerOverlays.js";
import { GuideScreen } from "./screens/GuideScreen.js";
import { SettingsScreen } from "./screens/SettingsScreen.js";
@@ -52,6 +55,7 @@ import { detectPlatform } from "./platform.js";
const TABS = [
{ id: "home", label: "Home" },
+ { id: "search", label: "Search" },
{ id: "live", label: "Live TV" },
{ id: "guide", label: "Guide" },
{ id: "movies", label: "Movies" },
@@ -131,6 +135,9 @@ export function App(): JSX.Element {
// always drop the user back at the shelf browser instead of where they
// left off. Reported via SeriesScreen's onSelectionChange.
const [seriesSelectionId, setSeriesSelectionId] = useState(null);
+ // Set when a series is opened from another screen that Back should return
+ // to (Search), rather than to the Series browse page.
+ const [seriesReturnTab, setSeriesReturnTab] = useState(null);
// Bumped every time playback closes, so SeriesScreen/VodScreen can re-read
// Continue Watching (upsertContinueWatching persists to localStorage
// during playback, which isn't itself reactive state — same shape as the
@@ -179,10 +186,18 @@ export function App(): JSX.Element {
setActiveProfile(profile);
}
+ // True until the mount effect below has restored the profile saved from
+ // last time — the render before it has no active profile, and mustn't be
+ // mistaken for "nobody's chosen yet" (see isShowingRealScreen).
+ const [isRestoringProfile, setIsRestoringProfile] = useState(() => {
+ const savedId = getActiveProfileId();
+ return savedId !== null && profiles.some((p) => p.id === savedId);
+ });
useEffect(() => {
const savedId = getActiveProfileId();
const saved = savedId ? profiles.find((p) => p.id === savedId) : undefined;
if (saved) activateProfile(saved);
+ setIsRestoringProfile(false);
// Only re-check localStorage-persisted active profile once, on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -207,6 +222,28 @@ export function App(): JSX.Element {
return startSyncScheduler(source);
}, [activeSource?.id, hasActiveProfile]);
+ // Builds the search index in the background, only while the app is idle
+ // (see search/search-index-scheduler.ts): the active playlist first, then
+ // the others. Restarted when the active playlist changes so it goes first.
+ const sourcesRef = useRef(sources);
+ sourcesRef.current = sources;
+ useEffect(() => {
+ if (!hasActiveProfile) return;
+ return startSearchIndexScheduler({
+ getJobs: () => {
+ const active = activeSourceRef.current;
+ const ordered = active ? [active, ...sourcesRef.current.filter((s) => s.id !== active.id)] : sourcesRef.current;
+ // Series, Movies, then Live TV — the sync order. An M3U playlist has no series.
+ return ordered.flatMap((s) => [
+ ...(s.kind === "xtream" ? [{ sourceId: s.id, kind: "series" as const }] : []),
+ { sourceId: s.id, kind: "vod" as const },
+ { sourceId: s.id, kind: "live" as const },
+ ]);
+ },
+ });
+ }, [activeSource?.id, hasActiveProfile]);
+ useEffect(() => setSearchIndexPlaybackActive(playbackUrl !== null), [playbackUrl]);
+
// Whether each source still needs its first download, which gets its own
// screen (FirstSyncScreen) instead of dropping the user onto empty tabs.
// "done" once its live channels have synced before, or the user continued.
@@ -219,9 +256,9 @@ export function App(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSourceId_]);
- // Browse screens show a small corner badge while the active source syncs in the background.
+ // Home shows a small corner badge while the active source syncs in the background; the browse screens keep showing what's stored meanwhile.
const activeSyncState = useSourceSyncState(activeSource?.id ?? "");
- const syncPillLabel = activeSource ? describeRunningSync(activeSyncState, activeSource) : null;
+ const syncPillLabel = activeSource ? describeRunningSync(activeSyncState) : null;
function handleSourceAdded(source: PlaylistSource): void {
const updated = addPlaylistSource(source);
@@ -275,6 +312,16 @@ export function App(): JSX.Element {
if (getActiveProfileId() === profileId) clearActiveProfile();
}
+ // The launch splash stays up until one of the screens below is actually
+ // showing — not the passing states before it (restoring the saved profile,
+ // checking whether the playlist needs its first download), which would
+ // otherwise flash between the splash and Home. See boot-splash.ts.
+ const firstSyncState = activeSource ? firstSyncBySource[activeSource.id] : undefined;
+ const isShowingRealScreen = !activeSource || (!activeProfile && !isRestoringProfile) || (activeProfile !== null && firstSyncState !== undefined && firstSyncState !== "checking");
+ useEffect(() => {
+ if (isShowingRealScreen) dismissBootSplash();
+ }, [isShowingRealScreen]);
+
if (!activeSource) {
return ;
}
@@ -446,8 +493,8 @@ export function App(): JSX.Element {
setPlaybackSubtitle(undefined);
setNextEpisode(null);
setIsPlaybackLive(true);
- // In the Live Stream Format from App Settings (Xtream only — see live-stream-url.ts).
- setPlaybackUrl(liveStreamUrl(channel));
+ // In the Live Stream Format from App Settings, or on Auto the one that last worked for this playlist (Xtream only — see live-stream-url.ts).
+ setPlaybackUrl(liveStreamUrl(channel, activeSource.id));
};
// Recently Watched → a series: play its saved episode with the full episode
// list (for Next Episode and the Episodes panel). If the episode is gone
@@ -516,8 +563,10 @@ export function App(): JSX.Element {
const goHome = () => {
setPendingSeriesId(null);
+ setSeriesReturnTab(null);
setActiveTab("home");
};
+ const openSearch = () => setActiveTab("search");
return (
@@ -548,6 +597,27 @@ export function App(): JSX.Element {
isPlaybackOpen={Boolean(playbackUrl)}
/>
)}
+ {activeTab === "search" && (
+ {
+ setPendingSeriesId(seriesId);
+ setSeriesReturnTab("search");
+ setActiveTab("series");
+ }}
+ onContinueSeries={(entry) => {
+ // Plays the next episode over Search; if it can't, the series page opens — and its Back returns here.
+ setSeriesReturnTab("search");
+ continueSeries(entry);
+ }}
+ onBack={goHome}
+ isPlaybackOpen={Boolean(playbackUrl)}
+ />
+ )}
{activeTab === "live" && (
)}
@@ -575,6 +646,7 @@ export function App(): JSX.Element {
profile={activeProfile}
onPlay={playMovie}
onBack={goHome}
+ onOpenSearch={openSearch}
isPlaybackOpen={Boolean(playbackUrl)}
/>
)}
@@ -587,6 +659,17 @@ export function App(): JSX.Element {
onBack={goHome}
initialSelectedId={pendingSeriesId ?? seriesSelectionId ?? undefined}
onSelectionChange={setSeriesSelectionId}
+ onCloseDetail={
+ seriesReturnTab
+ ? () => {
+ setPendingSeriesId(null);
+ setSeriesSelectionId(null);
+ setSeriesReturnTab(null);
+ setActiveTab(seriesReturnTab);
+ }
+ : undefined
+ }
+ onOpenSearch={openSearch}
continueWatchingVersion={playbackCloseVersion}
isPlaybackOpen={Boolean(playbackUrl)}
/>
@@ -638,7 +721,7 @@ export function App(): JSX.Element {
/>
)}
- {!playbackUrl && syncPillLabel && }
+ {!playbackUrl && activeTab === "home" && syncPillLabel && }
{!playbackUrl && kidsNotice && }
{playbackUrl && (
@@ -657,7 +740,7 @@ export function App(): JSX.Element {
channelLineup={playerLineup}
watchTarget={watchTarget}
onTuneChannel={(channel) => playLive(channel)}
- onPlayAlternateStream={(url) => setPlaybackUrl(url)}
+ liveSourceId={isPlaybackLive ? activeSource.id : undefined}
resumeFrom={playbackResume}
autoResume={playbackAutoResume}
info={playbackInfo}
diff --git a/src/boot-splash.ts b/src/boot-splash.ts
new file mode 100644
index 0000000..aac7558
--- /dev/null
+++ b/src/boot-splash.ts
@@ -0,0 +1,39 @@
+/** Matches #boot-splash's opacity transition in index.html. */
+const FADE_OUT_MS = 400;
+/** The longest the splash stays up, whatever happens — it must never become a blocking screen. */
+const MAX_SPLASH_MS = 4000;
+
+let dismissed = false;
+
+/**
+ * Fades out and removes the launch splash that index.html paints before any
+ * script runs (see its #boot-splash comment). App calls this once it's
+ * showing a real screen — Home, the profile picker, Add Playlist or the
+ * first-download screen — not on its first render, which on a relaunch is
+ * a passing state (the saved profile not yet restored, the first-download
+ * check not yet answered) that would otherwise flash between the splash and
+ * Home. Waits two frames so that screen has painted underneath. Safe to
+ * call more than once.
+ */
+export function dismissBootSplash(): void {
+ if (dismissed) return;
+ dismissed = true;
+ const splash = document.getElementById("boot-splash");
+ if (!splash) return;
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => {
+ splash.classList.add("is-hidden");
+ setTimeout(() => splash.remove(), FADE_OUT_MS);
+ }),
+ );
+}
+
+/** Started at boot: dismisses the splash after MAX_SPLASH_MS if App hasn't by then. Never tied to a data fetch. */
+export function startBootSplashTimeout(): void {
+ setTimeout(dismissBootSplash, MAX_SPLASH_MS);
+}
+
+/** Test-only. */
+export function __resetBootSplashForTests(): void {
+ dismissed = false;
+}
diff --git a/src/catalog-records.test.ts b/src/catalog-records.test.ts
new file mode 100644
index 0000000..024f958
--- /dev/null
+++ b/src/catalog-records.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from "vitest";
+import { sortNewestFirst } from "./catalog-records.js";
+
+describe("sortNewestFirst", () => {
+ it("orders by descending numeric stream id, not string order", () => {
+ const sorted = sortNewestFirst([{ id: "9" }, { id: "100000" }, { id: "10" }, { id: "99999" }]);
+ expect(sorted.map((item) => item.id)).toEqual(["100000", "99999", "10", "9"]);
+ });
+});
diff --git a/src/catalog-records.ts b/src/catalog-records.ts
index 6326cce..7984bdd 100644
--- a/src/catalog-records.ts
+++ b/src/catalog-records.ts
@@ -1,5 +1,5 @@
import { getDefaultKidsRules, type Channel, type SeriesInfo } from "@core";
-import type { CatalogRecord } from "./core/storage/catalog-db.js";
+import { catalogRecordId, streamIdSortKey, type CatalogRecord } from "./core/storage/catalog-db.js";
/**
* Channel/series → catalog-db row mappers, shared by catalog-sync.ts
@@ -24,7 +24,7 @@ function kidsFields(sourceId: string, text: string): Pick,
): CatalogRecord {
return {
- id: `${sourceId}:${item.id}`,
+ id: catalogRecordId(sourceId, item.id),
sourceId,
streamId: item.id,
name: item.name,
@@ -55,3 +55,11 @@ export function seriesToRecord(
generation,
};
}
+
+/** Newest first — descending stream id, the same order the local table is read in (see catalog-db.ts's queryPage) — for lists fetched straight from the provider. Returns a new array. */
+export function sortNewestFirst(items: readonly T[]): T[] {
+ return items
+ .map((item) => ({ item, key: streamIdSortKey(item.id) }))
+ .sort((a, b) => (a.key < b.key ? 1 : a.key > b.key ? -1 : 0))
+ .map(({ item }) => item);
+}
diff --git a/src/catalog-store.ts b/src/catalog-store.ts
index ed4fe3d..8e38ce2 100644
--- a/src/catalog-store.ts
+++ b/src/catalog-store.ts
@@ -1,5 +1,6 @@
-import { KIDS_TAGS, type Category, type Channel, type SeriesInfo } from "@core";
+import { cleanTitle, KIDS_TAGS, type Category, type Channel, type SeriesInfo } from "@core";
import {
+ catalogRecordId,
countRecords,
getCategoryIds,
getRecordsByIds as getCatalogDbRecordsByIds,
@@ -37,10 +38,10 @@ export interface CatalogPageQuery {
/** Upper bound for counting the More for Kids category — it's walked, not counted by an index. */
const MORE_FOR_KIDS_COUNT_LIMIT = 5000;
-function recordToChannel(record: CatalogRecord, kind: "movie"): Channel {
+export function recordToChannel(record: CatalogRecord, kind: "movie"): Channel {
return {
id: record.streamId,
- name: record.name,
+ name: cleanTitle(record.name), // rows stored before titles were cleaned at sync time
logoUrl: record.logoUrl,
groupTitle: record.groupTitle,
streamUrl: record.streamUrl ?? "",
@@ -48,10 +49,10 @@ function recordToChannel(record: CatalogRecord, kind: "movie"): Channel {
};
}
-function recordToSeriesSummary(record: CatalogRecord): Pick {
+export function recordToSeriesSummary(record: CatalogRecord): Pick {
return {
id: record.streamId,
- name: record.name,
+ name: cleanTitle(record.name),
posterUrl: record.posterUrl,
groupTitle: record.groupTitle,
...(record.genre ? { genre: record.genre } : {}),
@@ -68,7 +69,7 @@ function recordToSeriesSummary(record: CatalogRecord): Pick {
const { filter } = query;
if (filter && query.categoryId === PARENT_PICKS_CATEGORY_ID) {
- const records = await getCatalogDbRecordsByIds(catalogDb, kind, filter.pickedIds.map((id) => `${sourceId}:${id}`));
+ const records = await getCatalogDbRecordsByIds(catalogDb, kind, filter.pickedIds.map((id) => catalogRecordId(sourceId, id)));
return records.slice(query.offset, query.offset + query.limit);
}
if (filter && query.categoryId === MORE_FOR_KIDS_CATEGORY_ID) {
@@ -109,7 +110,7 @@ export async function getCatalogCount(sourceId: string, kind: CatalogKind, query
const catalogDb = await openCatalogDb();
const { filter } = query;
if (filter && query.categoryId === PARENT_PICKS_CATEGORY_ID) {
- return (await getCatalogDbRecordsByIds(catalogDb, kind, filter.pickedIds.map((id) => `${sourceId}:${id}`))).length;
+ return (await getCatalogDbRecordsByIds(catalogDb, kind, filter.pickedIds.map((id) => catalogRecordId(sourceId, id)))).length;
}
if (filter && query.categoryId === MORE_FOR_KIDS_CATEGORY_ID) {
if (!filter.moreForKids) return 0;
@@ -139,7 +140,7 @@ export async function getRecordsByIds(
*/
export async function getRecordsByIds(sourceId: string, kind: CatalogKind, streamIds: string[]): Promise {
const catalogDb = await openCatalogDb();
- const ids = streamIds.map((streamId) => `${sourceId}:${streamId}`);
+ const ids = streamIds.map((streamId) => catalogRecordId(sourceId, streamId));
const records = await getCatalogDbRecordsByIds(catalogDb, kind, ids);
return kind === "vod" ? records.map((r) => recordToChannel(r, "movie")) : records.map(recordToSeriesSummary);
}
diff --git a/src/catalog-sync-core.test.ts b/src/catalog-sync-core.test.ts
new file mode 100644
index 0000000..6d944b9
--- /dev/null
+++ b/src/catalog-sync-core.test.ts
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { KIDS_RULES_VERSION, type Channel } from "@core";
+import {
+ __clearCatalogDbForTests,
+ __resetCatalogDbForTests,
+ catalogSyncMetaKey,
+ getSyncMeta,
+ openCatalogDb,
+ putSyncMeta,
+ queryPage,
+ SEARCH_TOKENS_STORE,
+ type CatalogDb,
+} from "./core/storage/catalog-db.js";
+import { runIndexJobs } from "./search/search-indexer.js";
+import { searchCatalog } from "./search/search-index.js";
+import { CatalogEmptyError, writeCatalog } from "./catalog-sync-core.js";
+
+const SOURCE = "src-1";
+const movie = (id: string, name: string, extra: Partial = {}): Channel => ({ id, name, groupTitle: "cat-1", streamUrl: `http://x/${id}.mp4`, kind: "movie", ...extra });
+
+async function stored(catalogDb: CatalogDb) {
+ return queryPage(catalogDb, "vod", { sourceId: SOURCE, offset: 0, limit: 100 });
+}
+async function names(query: string): Promise {
+ return (await searchCatalog(SOURCE, "vod", query, { limit: 20 })).items.map((m) => m.name);
+}
+function countSearchRows(catalogDb: CatalogDb): Promise {
+ return new Promise((resolve) => {
+ const request = catalogDb.db.transaction(SEARCH_TOKENS_STORE, "readonly").objectStore(SEARCH_TOKENS_STORE).count();
+ request.onsuccess = () => resolve(request.result);
+ });
+}
+
+describe("writeCatalog", () => {
+ beforeEach(async () => {
+ __resetCatalogDbForTests();
+ await __clearCatalogDbForTests();
+ });
+
+ it("rewrites only new and changed titles, and deletes the ones that are gone", async () => {
+ const catalogDb = await openCatalogDb();
+ await writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Heat"), movie("2", "Alien"), movie("3", "Gone")]);
+
+ const onProgress = vi.fn();
+ const result = await writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Heat"), movie("2", "Alien", { logoUrl: "http://img/2.jpg" }), movie("4", "Brand New")], {
+ batchSize: 2,
+ onProgress,
+ });
+
+ expect(result).toEqual({ recordCount: 3, written: 2, deleted: 1 });
+ expect(onProgress.mock.calls.map(([n]) => n)).toEqual([2, 3]);
+ const rows = await stored(catalogDb);
+ expect(rows.map((r) => r.name)).toEqual(["Brand New", "Alien", "Heat"]);
+ expect(rows.find((r) => r.name === "Alien")?.logoUrl).toBe("http://img/2.jpg");
+ });
+
+ it("rewrites every title once when the stored tags came from older Kids rules", async () => {
+ const catalogDb = await openCatalogDb();
+ await writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Heat"), movie("2", "Alien")]);
+ const meta = await getSyncMeta(catalogDb, catalogSyncMetaKey(SOURCE, "vod"));
+ await putSyncMeta(catalogDb, { ...meta!, rulesVersion: KIDS_RULES_VERSION - 1 });
+
+ await expect(writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Heat"), movie("2", "Alien")])).resolves.toMatchObject({ written: 2 });
+ expect(await getSyncMeta(catalogDb, catalogSyncMetaKey(SOURCE, "vod"))).toMatchObject({ rulesVersion: KIDS_RULES_VERSION });
+ await expect(writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Heat"), movie("2", "Alien")])).resolves.toMatchObject({ written: 0 });
+ });
+
+ it("keeps an already-built search index current instead of starting it over", async () => {
+ const catalogDb = await openCatalogDb();
+ await writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Old Film"), movie("2", "Kept Film"), movie("3", "Wrong Name")]);
+ await runIndexJobs(catalogDb, [{ sourceId: SOURCE, kind: "vod" }], { paceMs: 0, shouldStop: () => false, sleep: async () => {} });
+ expect((await names("film")).sort()).toEqual(["Kept Film", "Old Film"]);
+
+ await writeCatalog(catalogDb, "vod", SOURCE, [movie("2", "Kept Film"), movie("3", "Right Film"), movie("4", "New Film")]);
+
+ expect((await names("film")).sort()).toEqual(["Kept Film", "New Film", "Right Film"]);
+ expect(await names("wrong")).toEqual([]);
+ expect(await countSearchRows(catalogDb)).toBe(3);
+ expect(await getSyncMeta(catalogDb, catalogSyncMetaKey(SOURCE, "vod"))).toMatchObject({ generation: 1 });
+ });
+
+ it("refuses an empty list over a stored catalog, unless told it's real", async () => {
+ const catalogDb = await openCatalogDb();
+ await writeCatalog(catalogDb, "vod", SOURCE, [movie("1", "Heat")]);
+
+ await expect(writeCatalog(catalogDb, "vod", SOURCE, [])).rejects.toBeInstanceOf(CatalogEmptyError);
+ expect(await stored(catalogDb)).toHaveLength(1);
+
+ await writeCatalog(catalogDb, "vod", SOURCE, [], { allowEmpty: true });
+ expect(await stored(catalogDb)).toHaveLength(0);
+ });
+});
diff --git a/src/catalog-sync-core.ts b/src/catalog-sync-core.ts
new file mode 100644
index 0000000..33b18d7
--- /dev/null
+++ b/src/catalog-sync-core.ts
@@ -0,0 +1,215 @@
+import { KIDS_RULES_VERSION, mapSeriesEntry, mapVodStream, type Channel, type PlaylistSource, type XtreamSeriesRaw, type XtreamVodStreamRaw } from "@core";
+import {
+ catalogSyncMetaKey,
+ countRecords,
+ getSyncMeta,
+ openCatalogDb,
+ putSyncMeta,
+ readCategoryRecords,
+ readRecordsAfter,
+ writeCatalogChanges,
+ type CatalogDb,
+ type CatalogKind,
+ type CatalogRecord,
+} from "./core/storage/catalog-db.js";
+import { getSearchIndexMeta, searchIndexMetaKey, searchKey, type SearchTokenRow } from "./core/storage/search-index-db.js";
+import { channelToRecord, seriesToRecord } from "./catalog-records.js";
+import { indexTokens } from "./search/normalize.js";
+import { SyncStorageUnavailableError, type SyncJobOptions } from "./sync-job.js";
+
+/**
+ * The Xtream movie/series catalog sync: download the provider's list, map
+ * it, and bring the local table (core/storage/catalog-db.ts) in line with
+ * it. DOM-free, so it runs inside src/workers/sync-worker.ts — the download,
+ * JSON parse, Kids tagging and every IndexedDB write stay off the main thread.
+ *
+ * A refresh only writes what changed. Each downloaded title is compared
+ * with the stored row (the fields a screen shows, and the Kids tags when
+ * the bundled rules are newer); only new and changed titles are written,
+ * and titles the provider dropped are deleted. A daily refresh of a
+ * 130k-title catalog is typically a few hundred writes instead of 130k
+ * writes plus 130k deletes, and the table's generation stays the same, so
+ * the search index isn't rebuilt: this sync updates the search rows of
+ * new, renamed and deleted titles itself, in the same transaction.
+ *
+ * New and changed titles are written before anything is deleted, so a sync
+ * that dies partway never loses a title that's still offered; sync_meta is
+ * only updated at the end, so the next check still finds it due.
+ *
+ * With `categoryId`, only that category is fetched and compared (a
+ * screen's "Refresh" on one category) — the rest of the table is untouched
+ * and the catalog's sync time isn't moved.
+ */
+
+export interface CatalogSyncRequest {
+ source: PlaylistSource;
+ kind: CatalogKind;
+ /** Refresh one category only. Needs a table that has been fully synced at least once. */
+ categoryId?: string;
+}
+
+export interface CatalogSyncResult {
+ /** Titles now stored for the source (whole catalog). */
+ recordCount: number;
+ /** Titles written because they were new or changed. */
+ written: number;
+ /** Titles removed because the provider no longer lists them. */
+ deleted: number;
+}
+
+/** The provider returned no titles where there were some — kept separate so a good catalog isn't wiped by an empty response. */
+export class CatalogEmptyError extends Error {
+ constructor() {
+ super("The provider returned an empty list.");
+ this.name = "CatalogEmptyError";
+ }
+}
+
+export const DEFAULT_CATALOG_BATCH_SIZE = 2000;
+
+type CatalogItem = Channel | ReturnType;
+
+function stripTrailingSlash(url: string): string {
+ return url.endsWith("/") ? url.slice(0, -1) : url;
+}
+
+async function fetchCatalog(request: CatalogSyncRequest, fetchImpl: typeof fetch): Promise {
+ const { source, kind, categoryId } = request;
+ if (source.kind !== "xtream") throw new Error("Only Xtream playlists have a separate catalog to sync.");
+ const url = new URL(`${stripTrailingSlash(source.baseUrl)}/player_api.php`);
+ url.searchParams.set("username", source.username);
+ url.searchParams.set("password", source.password);
+ url.searchParams.set("action", kind === "vod" ? "get_vod_streams" : "get_series");
+ if (categoryId) url.searchParams.set("category_id", categoryId);
+ const response = await fetchImpl(url.toString());
+ if (!response.ok) throw new Error(`Xtream request failed: HTTP ${response.status}`);
+ const raw = (await response.json()) as unknown;
+ // A panel that rejects the login answers with an object, not a list.
+ if (!Array.isArray(raw)) throw new Error("The provider didn't return a list.");
+ return kind === "vod" ? (raw as XtreamVodStreamRaw[]).map((s) => mapVodStream(source, s)) : (raw as XtreamSeriesRaw[]).map(mapSeriesEntry);
+}
+
+/** What's compared to decide whether a stored row needs rewriting — everything a screen shows. The Kids tags follow from name and genre. */
+function signature(record: CatalogRecord): string {
+ return JSON.stringify([record.name, record.groupTitle, record.streamUrl, record.logoUrl, record.posterUrl, record.genre]);
+}
+
+interface StoredRow {
+ signature: string;
+ name: string;
+}
+
+const READ_CHUNK = 2000;
+
+async function readStored(catalogDb: CatalogDb, kind: CatalogKind, sourceId: string, categoryId?: string): Promise